Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97193eb090 | ||
|
|
555934cc64 | ||
|
|
6841c1104a | ||
|
|
d9d1dd5e99 | ||
|
|
6e97b55015 | ||
|
|
a3b4637e19 | ||
|
|
581d0a8351 | ||
|
|
88d8fc282e | ||
|
|
26341c3e8a |
No files matched your search
@@ -36,19 +36,27 @@ docker logs <容器名> | grep -A6 "已生成管理员账号"
|
|||||||
docker exec <容器名> cat /app/data/initial-admin-password.txt
|
docker exec <容器名> cat /app/data/initial-admin-password.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
登录校验接口:`POST /api/auth/login`(body: `{"username": "admin", "password": "..."}`)。
|
登录校验接口:`POST /api/auth/login`(body: `{"username": "admin", "password": "..."}`,返回签名 token,前端自动携带)。
|
||||||
密码使用 argon2id 哈希存储,请登录后立即修改。
|
密码使用 argon2id 哈希存储。启用 LDAP 后同名目录账号自动走 LDAP 验证(密码不落库)。
|
||||||
|
|
||||||
## 功能模块
|
## 功能模块
|
||||||
|
|
||||||
| 模块 | 说明 | 数据来源 |
|
| 模块 | 说明 | 数据来源 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
|
| 登录/会话 | 本地账号 + LDAP 账号统一登录(JWT 式签名 token,7 天) | gin API |
|
||||||
|
| 主题 | 深色/浅色/跟随系统,侧边栏快捷切换,多端同步 | gin API(持久化) |
|
||||||
| 流水线 | 列表 + 画布编辑器(Tekton 任务库、拖拽编排、连线、节点属性配置) | gin API(SQLite 持久化) |
|
| 流水线 | 列表 + 画布编辑器(Tekton 任务库、拖拽编排、连线、节点属性配置) | gin API(SQLite 持久化) |
|
||||||
| 模拟执行 | 触发运行后节点状态实时推进(pending→running→success)+ SSE 日志流 | gin API + 模拟执行引擎 |
|
| 模拟执行 | 触发运行后节点状态实时推进(pending→running→success)+ SSE 日志流 | gin API + 模拟执行引擎 |
|
||||||
| GitOps 部署 | ArgoCD 风格应用列表 / 资源树 / Diff | gin API(部分模拟) |
|
| AI 智能助手 | 全局悬浮对话:自然语言发布指令解析(流式输出、平台数据注入、规则兜底)、发布失败自动 AI 分析日志 | OpenAI 兼容 LLM(DeepSeek/GLM/Qwen/Kimi/Ollama 预设) |
|
||||||
| 插件市场 | 已安装 / 可安装 / 启停 / 卸载 | gin API(SQLite 持久化) |
|
| 飞书/Lark 通知 | 发布结果交互卡片(应用/分支/构建/状态/时间 + 构建详情按钮),支持加签 | gin API |
|
||||||
| 发布记录 | 运行历史、阶段、日志、Pod 详情 | 前端演示数据(待接入) |
|
| 用户管理 | 本地用户 CRUD + LDAP 用户导入(未启用 LDAP 时导入按钮隐藏),本地/LDAP 来源徽章 | gin API |
|
||||||
| 批量发布 / 环境 / 凭证 / 构建节点 / Jenkins 导入 / Webhook / 系统设置 | UI 演示 | 前端演示数据(待接入) |
|
| LDAP | 目录服务配置/测试连接/用户搜索导入、登录直连验证(密码不落库) | gin API(go-ldap) |
|
||||||
|
| 构建节点 | CRUD + 连通测试 + **dd 磁盘性能实测**(本地 exec / SSH 远程) | gin API |
|
||||||
|
| 工具配置 | Ubuntu 导向工具目录(apt/nvm 安装提示)、真实版本检测 | gin API |
|
||||||
|
| GitOps 部署 | ArgoCD 风格应用列表 / 资源树 / Diff | gin API |
|
||||||
|
| Jenkins | 服务器管理/Job 树/导入/真实构建触发与日志回传 | gin API(生产级客户端) |
|
||||||
|
| 批量发布 | 粘贴清单(opsx 风格解析)+ 手动勾选 + AI 对话触发 | gin API |
|
||||||
|
| 发布记录 | 运行历史、阶段、SSE 日志、Pod、失败 AI 分析、Jenkins 链接 | gin API |
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
|
|||||||
+12
-9
@@ -1,16 +1,18 @@
|
|||||||
module flowstack
|
module flowstack
|
||||||
|
|
||||||
go 1.24
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-contrib/cors v1.7.6
|
github.com/gin-contrib/cors v1.7.6
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
golang.org/x/crypto v0.40.0
|
github.com/go-ldap/ldap/v3 v3.4.15
|
||||||
|
golang.org/x/crypto v0.54.0
|
||||||
gorm.io/gorm v1.30.0
|
gorm.io/gorm v1.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
@@ -18,12 +20,13 @@ require (
|
|||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
@@ -40,12 +43,12 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.57.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.27.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
golang.org/x/tools v0.47.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
|||||||
+40
-16
@@ -1,3 +1,7 @@
|
|||||||
|
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
@@ -21,6 +25,10 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
|
|||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -38,8 +46,22 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
|||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||||
|
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||||
|
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -85,21 +107,23 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
|||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -1,15 +1,132 @@
|
|||||||
// Package crypto 提供口令哈希原语(argon2id,与 opsx-loki 同参数)。
|
// Package crypto 提供口令哈希(argon2id)与字段加密(AES-256-GCM,enc:v1: 前缀)原语。
|
||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const encPrefix = "enc:v1:"
|
||||||
|
|
||||||
|
var (
|
||||||
|
encKey []byte
|
||||||
|
encKeyOnce sync.Once
|
||||||
|
encKeyErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitEncryptionKey 初始化字段加密密钥:
|
||||||
|
// 优先环境变量 FLOWSTACK_ENCRYPTION_KEY,否则首次启动生成 32 字节随机密钥
|
||||||
|
// 写入 dataDir/encryption.key(0600),重启复用。
|
||||||
|
func InitEncryptionKey(dataDir string) error {
|
||||||
|
encKeyOnce.Do(func() {
|
||||||
|
if env := os.Getenv("FLOWSTACK_ENCRYPTION_KEY"); env != "" {
|
||||||
|
encKey = []byte(env)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyFile := filepath.Join(dataDir, "encryption.key")
|
||||||
|
if data, err := os.ReadFile(keyFile); err == nil && len(data) == 32 {
|
||||||
|
encKey = data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
encKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keyFile, key, 0o600); err != nil {
|
||||||
|
encKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encKey = key
|
||||||
|
})
|
||||||
|
return encKeyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func gcm() (cipher.AEAD, error) {
|
||||||
|
if encKey == nil {
|
||||||
|
return nil, errors.New("加密密钥未初始化")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(encKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cipher.NewGCM(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptField AES-256-GCM 加密,返回 enc:v1:base64;空串原样返回
|
||||||
|
func EncryptField(plaintext string) (string, error) {
|
||||||
|
if plaintext == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
aead, err := gcm()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, aead.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ciphertext := aead.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return encPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptField 解密 enc:v1: 前缀密文;非该格式原样返回(向后兼容)
|
||||||
|
func DecryptField(encrypted string) (string, error) {
|
||||||
|
if !strings.HasPrefix(encrypted, encPrefix) {
|
||||||
|
return encrypted, nil
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encrypted, encPrefix))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
aead, err := gcm()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(data) < aead.NonceSize() {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
nonce, ciphertext := data[:aead.NonceSize()], data[aead.NonceSize():]
|
||||||
|
plain, err := aead.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEncrypted 判断字段是否为加密格式
|
||||||
|
func IsEncrypted(v string) bool {
|
||||||
|
return strings.HasPrefix(v, encPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mask 敏感值脱敏展示(保留前后各 1/4,中间星号,最长 24 位)
|
||||||
|
func Mask(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) <= 4 {
|
||||||
|
return strings.Repeat("*", len(r))
|
||||||
|
}
|
||||||
|
keep := len(r) / 4
|
||||||
|
if keep > 4 {
|
||||||
|
keep = 4
|
||||||
|
}
|
||||||
|
return string(r[:keep]) + strings.Repeat("*", 6) + string(r[len(r)-keep:])
|
||||||
|
}
|
||||||
|
|
||||||
// HashPassword 生成 argon2id 口令哈希(salt+hash base64 编码)
|
// HashPassword 生成 argon2id 口令哈希(salt+hash base64 编码)
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, 16)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -23,6 +24,9 @@ func Init() error {
|
|||||||
if err := os.MkdirAll(DataDir, 0o755); err != nil {
|
if err := os.MkdirAll(DataDir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := crypto.InitEncryptionKey(DataDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(filepath.Join(DataDir, "flowstack.db")), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(filepath.Join(DataDir, "flowstack.db")), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,12 +39,16 @@ func Init() error {
|
|||||||
&model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{},
|
&model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{},
|
||||||
&model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{},
|
&model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{},
|
||||||
&model.Application{}, &model.Plugin{},
|
&model.Application{}, &model.Plugin{},
|
||||||
|
&model.Host{}, &model.K8sCluster{}, &model.Credential{}, &model.BuildNode{},
|
||||||
|
&model.DeployTool{}, &model.WebhookEndpoint{}, &model.JenkinsServer{}, &model.SystemSetting{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
seedAdmin()
|
seedAdmin()
|
||||||
seedDemoData()
|
seedDemoData()
|
||||||
|
seedResources()
|
||||||
|
seedTools()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +161,10 @@ func seedDemoData() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ---- GitOps 应用 ----
|
// ---- GitOps 应用 ----
|
||||||
|
now := time.Now()
|
||||||
DB.Create([]model.Application{
|
DB.Create([]model.Application{
|
||||||
{Name: "aiops-backend", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-backend", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "automated", AutoPrune: true, SelfHeal: true},
|
{Name: "aiops-backend", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-backend", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "automated", AutoPrune: true, SelfHeal: true, SyncStatus: "Synced", HealthStatus: "Healthy", LastSyncAt: &now},
|
||||||
{Name: "aiops-web", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-web", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "manual"},
|
{Name: "aiops-web", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-web", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "manual", SyncStatus: "OutOfSync", HealthStatus: "Progressing"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- 已安装插件 ----
|
// ---- 已安装插件 ----
|
||||||
@@ -165,3 +174,70 @@ func seedDemoData() {
|
|||||||
{PluginID: "k8s-rollout", Name: "K8s 滚动发布", Description: "Deployment 滚动更新与回滚", Version: "2.3.1", Author: "CloudNative", Category: "构建", Enabled: false},
|
{PluginID: "k8s-rollout", Name: "K8s 滚动发布", Description: "Deployment 滚动更新与回滚", Version: "2.3.1", Author: "CloudNative", Category: "构建", Enabled: false},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedResources 资源示例数据(主机/集群/凭证/构建节点/Webhook,各表独立判空)
|
||||||
|
func seedResources() {
|
||||||
|
var n int64
|
||||||
|
DB.Model(&model.Host{}).Count(&n)
|
||||||
|
if n == 0 {
|
||||||
|
DB.Create([]model.Host{
|
||||||
|
{Name: "build-host-01", IP: "10.0.8.11", SSHPort: 22, Username: "root", Tags: `["build","docker"]`, Status: "online", OS: "Ubuntu 22.04", Remark: "通用构建机"},
|
||||||
|
{Name: "prod-web-01", IP: "10.0.1.21", SSHPort: 22, Username: "admin", Tags: `["production","web"]`, Status: "online", OS: "CentOS 7.9", Remark: "Nginx 前端"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
DB.Model(&model.K8sCluster{}).Count(&n)
|
||||||
|
if n == 0 {
|
||||||
|
DB.Create([]model.K8sCluster{
|
||||||
|
{Name: "ack-production", Context: "ack-production", APIServer: "https://k8s.prod:6443", Version: "v1.30.1", NodeCount: 3, Namespaces: `["aiops","default","kube-system"]`, Status: "disconnected"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
DB.Model(&model.Credential{}).Count(&n)
|
||||||
|
if n == 0 {
|
||||||
|
enc1, _ := crypto.EncryptField(`{"private_key":"CHANGE_ME","ssh_user":"root","ssh_port":22}`)
|
||||||
|
DB.Create([]model.Credential{
|
||||||
|
{Name: "prod-ssh-key", Type: "ssh", Scope: "production-group", Content: enc1, Summary: "ssh_key root@22"},
|
||||||
|
{Name: "registry-example", Type: "docker", Scope: "全局", Summary: "docker registry.example.com aiops-pull"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
DB.Model(&model.BuildNode{}).Count(&n)
|
||||||
|
if n == 0 {
|
||||||
|
hb := time.Now().Add(-10 * time.Second)
|
||||||
|
DB.Create([]model.BuildNode{
|
||||||
|
{Name: "build-host-01", IP: "10.0.8.11", Labels: `["docker","go","java"]`, MaxConcurrency: 4, Status: "online", CurrentJobs: 1, DiskInfo: "78G/200G", LastHeartbeat: &hb},
|
||||||
|
{Name: "build-host-02", IP: "10.0.8.12", Labels: `["docker","nodejs"]`, MaxConcurrency: 2, Status: "busy", CurrentJobs: 2, DiskInfo: "156G/500G", LastHeartbeat: &hb},
|
||||||
|
{Name: "build-host-03", IP: "10.0.8.13", Labels: `["python"]`, MaxConcurrency: 2, Status: "offline", DiskInfo: "45G/100G", LastHeartbeat: &hb},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
DB.Model(&model.WebhookEndpoint{}).Count(&n)
|
||||||
|
if n == 0 {
|
||||||
|
DB.Create([]model.WebhookEndpoint{
|
||||||
|
{Name: "发布成功通知", URL: "https://open.feishu.cn/open-apis/bot/v2/hook/CHANGE_ME", Events: `["pipeline.success","pipeline.failed"]`, Active: true, LastStatus: "none"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
DB.Model(&model.SystemSetting{}).Count(&n)
|
||||||
|
if n == 0 {
|
||||||
|
DB.Create(&model.SystemSetting{
|
||||||
|
ID: 1, ToolMode: "local",
|
||||||
|
Theme: `{"mode":"light","primaryColor":"#0969da","fontSize":"medium","density":"comfortable","sidebarStyle":"full","borderRadius":"soft"}`,
|
||||||
|
Notification: `{"email":false,"browser":true,"pipelineSuccess":true,"pipelineFailed":true,"securityAlert":false}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedTools 工具清单(Ubuntu 构建环境导向;Node 由 nvm 管理默认版本,构建时可按需 nvm install)
|
||||||
|
func seedTools() {
|
||||||
|
var n int64
|
||||||
|
DB.Model(&model.DeployTool{}).Count(&n)
|
||||||
|
if n > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DB.Create([]model.DeployTool{
|
||||||
|
{ToolKey: "jdk", DisplayName: "JDK", Category: "build", Version: "17", InstallMode: "preinstalled", Path: "/usr/lib/jvm/java-17-openjdk-amd64", CheckCommand: "java -version", InstallHint: "apt update && apt install -y openjdk-17-jdk", SortOrder: 1},
|
||||||
|
{ToolKey: "maven", DisplayName: "Maven", Category: "build", Version: "3.8", InstallMode: "lazy", Path: "/usr/share/maven", CheckCommand: "mvn -version", InstallHint: "apt install -y maven", SortOrder: 2},
|
||||||
|
{ToolKey: "golang", DisplayName: "Golang", Category: "build", Version: "1.24", InstallMode: "preinstalled", Path: "/usr/local/go", CheckCommand: "go version", InstallHint: "apt install -y golang-1.24-go 或官方 tar 包解压至 /usr/local/go", SortOrder: 3},
|
||||||
|
{ToolKey: "nvm", DisplayName: "nvm(Node 版本管理)", Category: "build", Version: "0.40", InstallMode: "preinstalled", Path: "$HOME/.nvm", CheckCommand: `. "$HOME/.nvm/nvm.sh" && nvm --version`, InstallHint: "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash", SortOrder: 4},
|
||||||
|
{ToolKey: "nodejs", DisplayName: "Node.js(nvm 管理)", Category: "build", Version: "20", InstallMode: "lazy", Path: "$HOME/.nvm/versions/node", CheckCommand: "node -v", InstallHint: "nvm install 20 && nvm alias default 20(构建时可用 nvm install 18 按需切版本)", SortOrder: 5},
|
||||||
|
{ToolKey: "git", DisplayName: "Git", Category: "build", Version: "2.40", InstallMode: "preinstalled", Path: "/usr/bin/git", CheckCommand: "git --version", InstallHint: "apt install -y git", SortOrder: 6},
|
||||||
|
{ToolKey: "docker-cli", DisplayName: "Docker CLI", Category: "deploy", Version: "26", InstallMode: "lazy", Path: "/usr/bin/docker", CheckCommand: "docker --version", InstallHint: "apt install -y docker.io", SortOrder: 7},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/llm"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
"flowstack/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 候选池:流水线 + Jenkins Job(60s 内存缓存)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type Candidate struct {
|
||||||
|
Name string `json:"name"` // 匹配用名称(jenkins 为压平全路径)
|
||||||
|
PipelineID uint `json:"pipelineId"`
|
||||||
|
Type string `json:"type"` // native | jenkins
|
||||||
|
Source string `json:"source"` // pipeline | jenkins
|
||||||
|
ServerID uint `json:"serverId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
jobsCacheMu sync.Mutex
|
||||||
|
jobsCacheData []Candidate // 仅 jenkins 来源
|
||||||
|
jobsCacheAt time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
// jenkinsCandidates 从各启用的 Jenkins 服务器拉取 Job 列表(60s 缓存)
|
||||||
|
func jenkinsCandidates() []Candidate {
|
||||||
|
jobsCacheMu.Lock()
|
||||||
|
defer jobsCacheMu.Unlock()
|
||||||
|
if time.Since(jobsCacheAt) < 60*time.Second && jobsCacheData != nil {
|
||||||
|
return jobsCacheData
|
||||||
|
}
|
||||||
|
var servers []model.JenkinsServer
|
||||||
|
database.DB.Where("enabled = ?", true).Find(&servers)
|
||||||
|
var out []Candidate
|
||||||
|
for _, s := range servers {
|
||||||
|
jobs, err := clientFromServer(s).ListJobs()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, j := range jobs {
|
||||||
|
if j.Type == "folder" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, Candidate{Name: j.Name, Type: "jenkins", Source: "jenkins", ServerID: s.ID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jobsCacheData = out
|
||||||
|
jobsCacheAt = time.Now()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// allCandidates 流水线优先(去重),Jenkins Job 补充
|
||||||
|
func allCandidates() []Candidate {
|
||||||
|
var pipelines []model.Pipeline
|
||||||
|
database.DB.Find(&pipelines)
|
||||||
|
var out []Candidate
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, p := range pipelines {
|
||||||
|
out = append(out, Candidate{Name: p.Name, PipelineID: p.ID, Type: p.Type, Source: "pipeline"})
|
||||||
|
seen[strings.ToLower(p.Name)] = true
|
||||||
|
if p.JenkinsJobName != "" && p.JenkinsJobName != p.Name {
|
||||||
|
if !seen[strings.ToLower(p.JenkinsJobName)] {
|
||||||
|
out = append(out, Candidate{Name: p.JenkinsJobName, PipelineID: p.ID, Type: "jenkins", Source: "pipeline", ServerID: p.JenkinsServerID})
|
||||||
|
seen[strings.ToLower(p.JenkinsJobName)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, j := range jenkinsCandidates() {
|
||||||
|
if !seen[strings.ToLower(j.Name)] {
|
||||||
|
out = append(out, j)
|
||||||
|
seen[strings.ToLower(j.Name)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchCandidate 精确 → 忽略大小写 → 包含
|
||||||
|
func matchCandidate(name string, candidates []Candidate) *Candidate {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
var contains *Candidate
|
||||||
|
for i, c := range candidates {
|
||||||
|
if c.Name == name {
|
||||||
|
return &candidates[i]
|
||||||
|
}
|
||||||
|
if strings.ToLower(c.Name) == lower {
|
||||||
|
return &candidates[i]
|
||||||
|
}
|
||||||
|
if contains == nil && lower != "" && strings.Contains(strings.ToLower(c.Name), lower) {
|
||||||
|
contains = &candidates[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contains
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LLM
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func llmClientFromSettings() *llm.Client {
|
||||||
|
var s model.SystemSetting
|
||||||
|
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !s.LlmEnabled || s.LlmApiKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
apiKey := s.LlmApiKey
|
||||||
|
if crypto.IsEncrypted(apiKey) {
|
||||||
|
if plain, err := crypto.DecryptField(apiKey); err == nil {
|
||||||
|
apiKey = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return llm.NewClient(s.LlmEndpoint, apiKey, s.LlmModel, s.LlmMaxTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 规则解析(无 LLM 的兜底)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
var branchKeywords = []string{"pre", "test", "main", "master", "release", "dev", "develop", "staging", "production", "prod"}
|
||||||
|
|
||||||
|
type ruleParseResult struct {
|
||||||
|
Jobs []string
|
||||||
|
Branch string
|
||||||
|
Env string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseByRules 从自然语言/清单文本中直接匹配候选名 + 分支/环境关键词
|
||||||
|
func parseByRules(text string, candidates []Candidate) ruleParseResult {
|
||||||
|
var res ruleParseResult
|
||||||
|
lowerText := strings.ToLower(text)
|
||||||
|
|
||||||
|
// 长名优先匹配,避免短名误命中(如 api 命中 xxx-api)
|
||||||
|
sorted := make([]Candidate, len(candidates))
|
||||||
|
copy(sorted, candidates)
|
||||||
|
for i := 0; i < len(sorted); i++ {
|
||||||
|
for j := i + 1; j < len(sorted); j++ {
|
||||||
|
if len(sorted[j].Name) > len(sorted[i].Name) {
|
||||||
|
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range sorted {
|
||||||
|
if c.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(lowerText, strings.ToLower(c.Name)) {
|
||||||
|
res.Jobs = append(res.Jobs, c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分支关键词(优先匹配更长的,如 origin/pre 优于 pre)
|
||||||
|
for _, kw := range []string{"origin/pre", "origin/test", "origin/main"} {
|
||||||
|
if strings.Contains(lowerText, kw) {
|
||||||
|
res.Branch = kw
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if res.Branch == "" {
|
||||||
|
for _, kw := range branchKeywords {
|
||||||
|
if strings.Contains(lowerText, kw) {
|
||||||
|
res.Branch = kw
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, env := range []string{"production", "prod", "staging", "test", "dev"} {
|
||||||
|
if strings.Contains(lowerText, env) {
|
||||||
|
res.Env = env
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/ai/chat
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type ChatMsg struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func AiChat(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Messages []ChatMsg `json:"messages" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Messages) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "messages 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 只取用户侧最后一条作为解析输入
|
||||||
|
var lastUser string
|
||||||
|
for i := len(body.Messages) - 1; i >= 0; i-- {
|
||||||
|
if body.Messages[i].Role == "user" {
|
||||||
|
lastUser = body.Messages[i].Content
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastUser == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "没有用户消息"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := allCandidates()
|
||||||
|
names := make([]string, 0, len(candidates))
|
||||||
|
if len(candidates) > 200 {
|
||||||
|
candidates = candidates[:200]
|
||||||
|
}
|
||||||
|
for _, cd := range candidates {
|
||||||
|
names = append(names, cd.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则结果(兜底 + 校验基准)
|
||||||
|
ruleRes := parseByRules(lastUser, candidates)
|
||||||
|
|
||||||
|
reply := ""
|
||||||
|
viaAI := false
|
||||||
|
var action *aiReleaseAction
|
||||||
|
|
||||||
|
client := llmClientFromSettings()
|
||||||
|
if client != nil {
|
||||||
|
system := fmt.Sprintf(`你是 FlowStack 发布系统的智能发布助手。用户会用自然语言描述要发布的服务。
|
||||||
|
可发布的服务清单(流水线与 Jenkins Job 名称)如下:
|
||||||
|
%s
|
||||||
|
|
||||||
|
请从用户消息中识别:要发布哪些服务(必须来自上面的清单,按原文匹配)、目标分支(如有)、目标环境(如有)。
|
||||||
|
严格输出 JSON(不要输出其他内容、不要 markdown 代码块):
|
||||||
|
{"reply":"一句中文确认语","jobs":["服务名1"],"branch":"分支或留空","env":"环境或留空"}
|
||||||
|
jobs 为空数组时 reply 中说明未识别到服务。`, strings.Join(names, "\n"))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if out, err := client.Chat(ctx, system, lastUser); err == nil {
|
||||||
|
viaAI = true
|
||||||
|
reply, action = parseLLMJson(out, candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底或 LLM 无结果:规则结果
|
||||||
|
if action == nil || len(action.Jobs) == 0 {
|
||||||
|
if len(ruleRes.Jobs) == 0 {
|
||||||
|
if reply == "" {
|
||||||
|
reply = "未识别到要发布的服务。请直接告诉我服务名,例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。"
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"reply": reply, "viaAI": viaAI})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobs := make([]aiActionJob, 0, len(ruleRes.Jobs))
|
||||||
|
for _, name := range ruleRes.Jobs {
|
||||||
|
m := matchCandidate(name, candidates)
|
||||||
|
jobs = append(jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)})
|
||||||
|
}
|
||||||
|
action = &aiReleaseAction{Type: "release", Jobs: jobs, Branch: ruleRes.Branch, Env: ruleRes.Env}
|
||||||
|
if reply == "" {
|
||||||
|
reply = fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(jobs), branchSuffix(ruleRes.Branch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"reply": reply, "viaAI": viaAI, "action": action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiActionJob struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Matched bool `json:"matched"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiReleaseAction struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Jobs []aiActionJob `json:"jobs"`
|
||||||
|
Branch string `json:"branch,omitempty"`
|
||||||
|
Env string `json:"env,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceOf(m *Candidate) string {
|
||||||
|
if m == nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return m.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
func branchSuffix(branch string) string {
|
||||||
|
if branch == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "(分支: " + branch + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLLMJson 容错解析 LLM 输出(允许 ```json 包裹),把识别出的服务匹配到候选池
|
||||||
|
func parseLLMJson(out string, candidates []Candidate) (string, *aiReleaseAction) {
|
||||||
|
cleaned := strings.TrimSpace(out)
|
||||||
|
cleaned = strings.TrimPrefix(cleaned, "```json")
|
||||||
|
cleaned = strings.TrimPrefix(cleaned, "```")
|
||||||
|
cleaned = strings.TrimSuffix(cleaned, "```")
|
||||||
|
// 截取首个 { 到最后一个 } 之间的内容
|
||||||
|
if idx := strings.Index(cleaned, "{"); idx >= 0 {
|
||||||
|
if last := strings.LastIndex(cleaned, "}"); last > idx {
|
||||||
|
cleaned = cleaned[idx : last+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Reply string `json:"reply"`
|
||||||
|
Jobs []string `json:"jobs"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Env string `json:"env"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(cleaned), &parsed); err != nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if len(parsed.Jobs) == 0 {
|
||||||
|
if parsed.Reply != "" {
|
||||||
|
return parsed.Reply, nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
action := &aiReleaseAction{Type: "release", Branch: parsed.Branch, Env: parsed.Env}
|
||||||
|
for _, name := range parsed.Jobs {
|
||||||
|
m := matchCandidate(name, candidates)
|
||||||
|
action.Jobs = append(action.Jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)})
|
||||||
|
}
|
||||||
|
reply := parsed.Reply
|
||||||
|
if reply == "" {
|
||||||
|
reply = fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(action.Jobs), branchSuffix(parsed.Branch))
|
||||||
|
}
|
||||||
|
return reply, action
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/ai/release —— 解析→(自动建)→触发
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func AiRelease(hub *Hub) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Jobs []string `json:"jobs" binding:"required"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Env string `json:"env"`
|
||||||
|
Parallel bool `json:"parallel"`
|
||||||
|
JenkinsServerID uint `json:"jenkinsServerId"`
|
||||||
|
AutoCreate *bool `json:"autoCreate"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Jobs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "jobs 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autoCreate := body.AutoCreate == nil || *body.AutoCreate
|
||||||
|
|
||||||
|
candidates := allCandidates()
|
||||||
|
type triggered struct {
|
||||||
|
Job string `json:"job"`
|
||||||
|
PipelineID uint `json:"pipelineId"`
|
||||||
|
RunID string `json:"runId"`
|
||||||
|
Created bool `json:"created"`
|
||||||
|
}
|
||||||
|
type failed struct {
|
||||||
|
Job string `json:"job"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
var okList []triggered
|
||||||
|
var failList []failed
|
||||||
|
|
||||||
|
for _, name := range body.Jobs {
|
||||||
|
p, created, err := resolvePipeline(name, candidates, autoCreate, body.JenkinsServerID)
|
||||||
|
if err != nil {
|
||||||
|
failList = append(failList, failed{Job: name, Reason: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
run, err := triggerPipelineRun(hub, *p, body.Branch, body.Env, "admin", "batch")
|
||||||
|
if err != nil {
|
||||||
|
failList = append(failList, failed{Job: name, Reason: "触发失败: " + err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
okList = append(okList, triggered{Job: name, PipelineID: p.ID, RunID: run.RunID, Created: created})
|
||||||
|
if !body.Parallel {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"triggered": okList, "failed": failList})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePipeline 名称 → 流水线;未导入的 Jenkins Job 自动创建
|
||||||
|
func resolvePipeline(name string, candidates []Candidate, autoCreate bool, serverID uint) (*model.Pipeline, bool, error) {
|
||||||
|
var p model.Pipeline
|
||||||
|
if err := database.DB.Where("name = ? OR jenkins_job_name = ?", name, name).First(&p).Error; err == nil {
|
||||||
|
return &p, false, nil
|
||||||
|
}
|
||||||
|
if !autoCreate {
|
||||||
|
return nil, false, fmt.Errorf("未找到流水线(未开启自动创建)")
|
||||||
|
}
|
||||||
|
// 在 Jenkins 候选中确认存在并定位服务器
|
||||||
|
m := matchCandidate(name, candidates)
|
||||||
|
if m == nil || m.Source != "jenkins" {
|
||||||
|
return nil, false, fmt.Errorf("流水线与 Jenkins Job 中均未找到")
|
||||||
|
}
|
||||||
|
sid := m.ServerID
|
||||||
|
if sid == 0 {
|
||||||
|
sid = serverID
|
||||||
|
}
|
||||||
|
if sid == 0 {
|
||||||
|
return nil, false, fmt.Errorf("无法确定 Jenkins 服务器")
|
||||||
|
}
|
||||||
|
np := model.Pipeline{
|
||||||
|
Name: m.Name, Description: "AI 助手自动创建",
|
||||||
|
Type: "jenkins", SourceBranch: "main", DeployType: "host", Env: "staging",
|
||||||
|
JenkinsJobName: m.Name, JenkinsServerID: sid,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&np).Error; err != nil {
|
||||||
|
return nil, false, fmt.Errorf("自动创建流水线失败: %s", err.Error())
|
||||||
|
}
|
||||||
|
return &np, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// POST /api/batch/parse —— 批量发布页粘贴清单解析(纯规则)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func BatchParse(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Text string `json:"text" binding:"required"`
|
||||||
|
RefType string `json:"refType"`
|
||||||
|
FixedRef string `json:"fixedRef"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "text 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := parser.Parse(body.Text, body.RefType, body.FixedRef)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidates := allCandidates()
|
||||||
|
type parsedJob struct {
|
||||||
|
parser.Job
|
||||||
|
Matched bool `json:"matched"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
PipelineID uint `json:"pipelineId"`
|
||||||
|
AutoCreatable bool `json:"autoCreatable"` // Jenkins 中存在可自动创建
|
||||||
|
}
|
||||||
|
jobs := make([]parsedJob, 0, len(res.Jobs))
|
||||||
|
for _, j := range res.Jobs {
|
||||||
|
m := matchCandidate(j.Name, candidates)
|
||||||
|
pj := parsedJob{Job: j, Matched: m != nil, Source: sourceOf(m)}
|
||||||
|
if m != nil {
|
||||||
|
pj.PipelineID = m.PipelineID
|
||||||
|
} else {
|
||||||
|
// 未匹配到流水线,但 Jenkins 里有同名 Job → 可自动创建
|
||||||
|
jm := matchCandidate(j.Name, filterJenkins(candidates))
|
||||||
|
if jm != nil {
|
||||||
|
pj.AutoCreatable = true
|
||||||
|
pj.Source = "jenkins"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jobs = append(jobs, pj)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"jobs": jobs, "param": res.ParamName, "refType": res.RefType, "count": len(jobs)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterJenkins(candidates []Candidate) []Candidate {
|
||||||
|
var out []Candidate
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.Source == "jenkins" {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/llm"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildPlatformContext 平台实时数据注入(降低 AI 幻觉):流水线概况 + 最近运行
|
||||||
|
func buildPlatformContext() string {
|
||||||
|
var pipelines []model.Pipeline
|
||||||
|
database.DB.Find(&pipelines)
|
||||||
|
var runs []model.PipelineRun
|
||||||
|
database.DB.Order("started_at DESC").Limit(5).Find(&runs)
|
||||||
|
running := 0
|
||||||
|
for _, r := range runs {
|
||||||
|
if r.Status == "running" || r.Status == "pending" {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "【平台实时数据】\n流水线总数: %d(运行中 %d)\n", len(pipelines), running)
|
||||||
|
if len(runs) > 0 {
|
||||||
|
b.WriteString("最近运行:\n")
|
||||||
|
for _, r := range runs {
|
||||||
|
fmt.Fprintf(&b, "- %s %s %s %s %s\n", r.PipelineName, r.RunID, r.Branch, r.Status, fmtDurMs(r.DurationMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtDurMs(ms int64) string {
|
||||||
|
if ms <= 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%ds", ms/1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AiChatStream POST /api/ai/chat/stream —— 流式对话(SSE)
|
||||||
|
func AiChatStream(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Messages []ChatMsg `json:"messages" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Messages) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "messages 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var lastUser string
|
||||||
|
for i := len(body.Messages) - 1; i >= 0; i-- {
|
||||||
|
if body.Messages[i].Role == "user" {
|
||||||
|
lastUser = body.Messages[i].Content
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastUser == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "没有用户消息"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := allCandidates()
|
||||||
|
names := make([]string, 0, len(candidates))
|
||||||
|
if len(candidates) > 200 {
|
||||||
|
candidates = candidates[:200]
|
||||||
|
}
|
||||||
|
for _, cd := range candidates {
|
||||||
|
names = append(names, cd.Name)
|
||||||
|
}
|
||||||
|
ruleRes := parseByRules(lastUser, candidates)
|
||||||
|
|
||||||
|
client := llmClientFromSettings()
|
||||||
|
if client == nil {
|
||||||
|
// 未配置 LLM:直接返回规则结果(不流式)
|
||||||
|
reply, action := ruleReply(ruleRes, candidates)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"reply": reply, "viaAI": false, "action": action})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
flush := func() {
|
||||||
|
if f, ok := c.Writer.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit := func(event string, data any) {
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, b)
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
system := fmt.Sprintf(`你是 FlowStack 发布系统的智能发布助手。用户会用自然语言描述要发布的服务。
|
||||||
|
%s
|
||||||
|
可发布的服务清单(流水线与 Jenkins Job 名称)如下:
|
||||||
|
%s
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 若用户意图是发布/构建服务:只输出 JSON(不要 markdown 代码块、不要多余文字):
|
||||||
|
{"reply":"一句中文确认语","jobs":["服务名1"],"branch":"分支或留空","env":"环境或留空"}
|
||||||
|
jobs 必须来自上面的清单按原文匹配;用户提到的服务不在清单时 jobs 留空并在 reply 中说明。
|
||||||
|
- 其他问题(如询问状态、概念解释):用简洁中文自然语言回答,禁止编造清单中不存在的服务或字段。`,
|
||||||
|
buildPlatformContext(), strings.Join(names, "\n"))
|
||||||
|
|
||||||
|
emit("start", gin.H{"viaAI": true})
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
full, err := client.ChatStream(ctx, system, lastUser, func(d llm.StreamDelta) {
|
||||||
|
if d.Content != "" || d.Reasoning != "" {
|
||||||
|
emit("delta", gin.H{"content": d.Content, "reasoning": d.Reasoning})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// LLM 失败:降级规则结果
|
||||||
|
reply, action := ruleReply(ruleRes, candidates)
|
||||||
|
emit("done", gin.H{"reply": reply + "\n(AI 调用失败已降级本地规则: " + err.Error() + ")", "viaAI": false, "action": action})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, action := parseLLMJson(full, candidates)
|
||||||
|
if action == nil && len(ruleRes.Jobs) > 0 {
|
||||||
|
// LLM 没识别出发布指令但规则识别到了,合并规则结果
|
||||||
|
_, action = ruleReply(ruleRes, candidates)
|
||||||
|
}
|
||||||
|
emit("done", gin.H{"reply": reply, "viaAI": true, "action": action})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleReply(ruleRes ruleParseResult, candidates []Candidate) (string, *aiReleaseAction) {
|
||||||
|
if len(ruleRes.Jobs) == 0 {
|
||||||
|
return "未识别到要发布的服务。请直接告诉我服务名,例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。", nil
|
||||||
|
}
|
||||||
|
jobs := make([]aiActionJob, 0, len(ruleRes.Jobs))
|
||||||
|
for _, name := range ruleRes.Jobs {
|
||||||
|
m := matchCandidate(name, candidates)
|
||||||
|
jobs = append(jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)})
|
||||||
|
}
|
||||||
|
action := &aiReleaseAction{Type: "release", Jobs: jobs, Branch: ruleRes.Branch, Env: ruleRes.Env}
|
||||||
|
return fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(jobs), branchSuffix(ruleRes.Branch)), action
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyzeFailure 发布失败时用 LLM 分析日志尾部(opsx-loki AnalyzeJenkins 同款 prompt)
|
||||||
|
func analyzeFailure(run model.PipelineRun) {
|
||||||
|
client := llmClientFromSettings()
|
||||||
|
if client == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var logs []model.LogEntry
|
||||||
|
database.DB.Where("run_id = ?", run.ID).Order("timestamp DESC").Limit(100).Find(&logs)
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 倒序取出后反转为正序
|
||||||
|
for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
logs[i], logs[j] = logs[j], logs[i]
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, l := range logs {
|
||||||
|
b.WriteString(l.Message)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
system := `你是资深发布助手。根据流水线执行日志分析发布失败原因。用简洁中文输出,结构固定:
|
||||||
|
1. 结论:一句话说明失败原因
|
||||||
|
2. 失败阶段或关键步骤
|
||||||
|
3. 根因(如编译错误、依赖缺失、测试失败、镜像推送失败、部署超时)
|
||||||
|
4. 处理建议(1-3 条,可执行)
|
||||||
|
不要复述整段日志。`
|
||||||
|
user := fmt.Sprintf("流水线: %s\n分支: %s\n状态: %s\n\n日志尾部:\n%s", run.PipelineName, run.Branch, run.Status, b.String())
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
reply, err := client.Chat(ctx, system, user)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("analysis", reply)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -10,7 +20,98 @@ import (
|
|||||||
"flowstack/internal/model"
|
"flowstack/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Login POST /api/auth/login {username, password}
|
const tokenTTL = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// sessionSecret 从 data/encryption.key 派生会话签名密钥
|
||||||
|
func sessionSecret() []byte {
|
||||||
|
key := os.Getenv("FLOWSTACK_ENCRYPTION_KEY")
|
||||||
|
if key == "" {
|
||||||
|
if data, err := os.ReadFile(filepath.Join(database.DataDir, "encryption.key")); err == nil && len(data) == 32 {
|
||||||
|
key = string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte("flowstack-session:" + key))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignToken 签发 HMAC 无状态 token:base64(user|exp|sig)
|
||||||
|
func SignToken(username string) (string, time.Time) {
|
||||||
|
exp := time.Now().Add(tokenTTL)
|
||||||
|
payload := username + "|" + strconv.FormatInt(exp.Unix(), 10)
|
||||||
|
mac := hmac.New(sha256.New, sessionSecret())
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
sig := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + sig)), exp
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyToken 校验 token,返回用户名
|
||||||
|
func VerifyToken(token string) (string, error) {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("token 格式错误")
|
||||||
|
}
|
||||||
|
parts := strings.Split(string(raw), "|")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return "", fmt.Errorf("token 结构错误")
|
||||||
|
}
|
||||||
|
payload := parts[0] + "|" + parts[1]
|
||||||
|
mac := hmac.New(sha256.New, sessionSecret())
|
||||||
|
mac.Write([]byte(payload))
|
||||||
|
expect := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
if !hmac.Equal([]byte(expect), []byte(parts[2])) {
|
||||||
|
return "", fmt.Errorf("token 签名校验失败")
|
||||||
|
}
|
||||||
|
expUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil || time.Now().Unix() > expUnix {
|
||||||
|
return "", fmt.Errorf("token 已过期")
|
||||||
|
}
|
||||||
|
return parts[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware 会话鉴权:仅保护 /api/*(豁免登录/入站 Webhook/SSE 日志流);SPA 与静态资源放行
|
||||||
|
func AuthMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
p := c.Request.URL.Path
|
||||||
|
if p == "/health" || !strings.HasPrefix(p, "/api/") ||
|
||||||
|
p == "/api/auth/login" ||
|
||||||
|
strings.HasPrefix(p, "/api/hooks/") ||
|
||||||
|
strings.HasSuffix(p, "/logs/stream") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, err := VerifyToken(strings.TrimPrefix(auth, "Bearer "))
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效,请重新登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("username", user.Username)
|
||||||
|
c.Set("role", user.Role)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminOnly 管理员专用接口守卫
|
||||||
|
func AdminOnly() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if c.GetString("role") != "admin" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login POST /api/auth/login —— 本地 argon2id 与 LDAP 分流认证
|
||||||
func Login(c *gin.Context) {
|
func Login(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required"`
|
||||||
@@ -21,16 +122,33 @@ func Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1) 本地用户:AuthSource != ldap 才走本地口令
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := database.DB.Where("username = ?", body.Username).First(&user).Error; err != nil {
|
hasLocal := database.DB.Where("username = ?", body.Username).First(&user).Error == nil
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
if hasLocal && user.AuthSource != "ldap" {
|
||||||
|
if !crypto.VerifyPassword(body.Password, user.Password) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, exp := SignToken(user.Username)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true, "token": token, "expiresAt": exp,
|
||||||
|
"username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !crypto.VerifyPassword(body.Password, user.Password) {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
// 2) LDAP:目录验证成功即登录(首次自动建号/同步)
|
||||||
|
if entry, err := ldapVerifyUser(body.Username, body.Password); err == nil {
|
||||||
|
user := syncLDAPUser(entry)
|
||||||
|
token, exp := SignToken(user.Username)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true, "token": token, "expiresAt": exp,
|
||||||
|
"username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"success": true, "username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
// 统一错误,不泄露用户是否存在
|
||||||
})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
"flowstack/internal/sshexec"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ddFileSize = ".fs-disktest"
|
||||||
|
ddSizeMB = 256
|
||||||
|
ddTimeout = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type cmdRunner func(cmd string) (string, error)
|
||||||
|
|
||||||
|
func localRunner(cmd string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), ddTimeout)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, "sh", "-c", cmd).CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDDMBps 兼容两种 dd 输出:
|
||||||
|
// Linux: "268435456 bytes (268 MB, 256 MiB) copied, 1.2 s, 224 MB/s"
|
||||||
|
// macOS: "268435456 bytes transferred in 1.234 secs (217543210 bytes/sec)"
|
||||||
|
var (
|
||||||
|
linuxSpeedRe = regexp.MustCompile(`([\d.]+)\s*([kMGT]?)B/s`)
|
||||||
|
macSpeedRe = regexp.MustCompile(`\(([\d,]+)\s*bytes/sec\)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseDDMBps(out string) float64 {
|
||||||
|
if ms := linuxSpeedRe.FindAllStringSubmatch(out, -1); len(ms) > 0 {
|
||||||
|
m := ms[len(ms)-1]
|
||||||
|
v, err := strconv.ParseFloat(m[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch m[2] {
|
||||||
|
case "k", "K":
|
||||||
|
v /= 1024
|
||||||
|
case "G":
|
||||||
|
v *= 1024
|
||||||
|
case "T":
|
||||||
|
v *= 1024 * 1024
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if m := macSpeedRe.FindStringSubmatch(out); m != nil {
|
||||||
|
v, err := strconv.ParseFloat(strings.ReplaceAll(m[1], ",", ""), 64)
|
||||||
|
if err == nil {
|
||||||
|
return v / 1e6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildNodeDisk POST /api/build-nodes/:id/disk-test —— dd 实测磁盘读写
|
||||||
|
func TestBuildNodeDisk(c *gin.Context) {
|
||||||
|
var n model.BuildNode
|
||||||
|
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地节点直接 exec,否则 SSH(需要凭证)
|
||||||
|
isLocal := n.IP == "127.0.0.1" || n.IP == "localhost" || n.IP == "::1"
|
||||||
|
var run cmdRunner
|
||||||
|
mode := "ssh"
|
||||||
|
if isLocal {
|
||||||
|
run = localRunner
|
||||||
|
mode = "local"
|
||||||
|
} else {
|
||||||
|
if n.CredentialID == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "远程节点需先在编辑中关联 SSH 凭证"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cred model.Credential
|
||||||
|
if err := database.DB.First(&cred, *n.CredentialID).Error; err != nil || cred.Content == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "SSH 凭证不存在或未设置内容"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plain, err := crypto.DecryptField(cred.Content)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "凭证解密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cc struct {
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
SSHUser string `json:"ssh_user"`
|
||||||
|
SSHPort int `json:"ssh_port"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(plain), &cc); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "凭证内容格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := sshexec.Config{Host: n.IP, Port: cc.SSHPort, User: cc.SSHUser, Password: cc.Password, PrivateKey: cc.PrivateKey}
|
||||||
|
run = func(cmd string) (string, error) {
|
||||||
|
return sshexec.Run(cfg, cmd, ddTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := n.WorkspacePath
|
||||||
|
if ws == "" {
|
||||||
|
ws = "/data/flowstack-workspace"
|
||||||
|
}
|
||||||
|
file := ws + "/" + ddFileSize
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
if _, err := run(fmt.Sprintf("mkdir -p '%s'", ws)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "创建工作空间失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _, _ = run(fmt.Sprintf("rm -f '%s'", file)) }()
|
||||||
|
|
||||||
|
// 写测试:direct 优先(绕过缓存),失败降级普通模式
|
||||||
|
writeCmd := fmt.Sprintf("dd if=/dev/zero of='%s' bs=1m count=%d oflag=direct 2>&1", file, ddSizeMB)
|
||||||
|
writeOut, err := run(writeCmd)
|
||||||
|
direct := err == nil
|
||||||
|
if err != nil || parseDDMBps(writeOut) == 0 {
|
||||||
|
writeOut, err = run(fmt.Sprintf("dd if=/dev/zero of='%s' bs=1m count=%d 2>&1", file, ddSizeMB))
|
||||||
|
direct = false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "写入测试失败: " + firstLine(writeOut, err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeMBps := parseDDMBps(writeOut)
|
||||||
|
|
||||||
|
// 读测试
|
||||||
|
readCmd := fmt.Sprintf("dd if='%s' of=/dev/null bs=1m 2>&1", file)
|
||||||
|
if direct {
|
||||||
|
readCmd = fmt.Sprintf("dd if='%s' of=/dev/null bs=1m iflag=direct 2>&1", file)
|
||||||
|
}
|
||||||
|
readOut, err := run(readCmd)
|
||||||
|
if err != nil && direct {
|
||||||
|
readOut, err = run(fmt.Sprintf("dd if='%s' of=/dev/null bs=1m 2>&1", file))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "读取测试失败: " + firstLine(readOut, err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readMBps := parseDDMBps(readOut)
|
||||||
|
|
||||||
|
if direct {
|
||||||
|
mode += "/direct"
|
||||||
|
} else {
|
||||||
|
mode += "/cache"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回写节点
|
||||||
|
now := time.Now()
|
||||||
|
database.DB.Model(&n).Updates(map[string]any{
|
||||||
|
"disk_write_mbps": writeMBps, "disk_read_mbps": readMBps,
|
||||||
|
"disk_test_mode": mode, "disk_tested_at": &now,
|
||||||
|
})
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true, "writeMBps": writeMBps, "readMBps": readMBps,
|
||||||
|
"mode": mode, "elapsedMs": time.Since(start).Milliseconds(),
|
||||||
|
"message": fmt.Sprintf("写入 %.0f MB/s · 读取 %.0f MB/s(%s)", writeMBps, readMBps, map[bool]string{true: "direct 直测", false: "含缓存"}[direct]),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -9,16 +10,6 @@ import (
|
|||||||
"flowstack/internal/model"
|
"flowstack/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// argoApps 模拟 Argo CD 应用状态(演示用)
|
|
||||||
var argoApps = []gin.H{
|
|
||||||
{"metadata": gin.H{"name": "aiops-backend"},
|
|
||||||
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-backend", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
|
|
||||||
"status": gin.H{"sync": gin.H{"status": "Synced"}, "health": gin.H{"status": "Healthy"}, "operationState": gin.H{"phase": "Succeeded"}}},
|
|
||||||
{"metadata": gin.H{"name": "aiops-web"},
|
|
||||||
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-web", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
|
|
||||||
"status": gin.H{"sync": gin.H{"status": "OutOfSync"}, "health": gin.H{"status": "Progressing"}, "operationState": gin.H{"phase": "Running"}}},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListGitOpsApps GET /api/gitops/apps
|
// ListGitOpsApps GET /api/gitops/apps
|
||||||
func ListGitOpsApps(c *gin.Context) {
|
func ListGitOpsApps(c *gin.Context) {
|
||||||
var apps []model.Application
|
var apps []model.Application
|
||||||
@@ -26,12 +17,33 @@ func ListGitOpsApps(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, apps)
|
c.JSON(http.StatusOK, apps)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArgoList GET /api/gitops/argo/apps
|
// ArgoList GET /api/gitops/argo/apps —— 从 Application 表派生 Argo 风格状态
|
||||||
func ArgoList(c *gin.Context) {
|
func ArgoList(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, argoApps)
|
var apps []model.Application
|
||||||
|
database.DB.Order("updated_at DESC").Find(&apps)
|
||||||
|
result := make([]gin.H, 0, len(apps))
|
||||||
|
for _, a := range apps {
|
||||||
|
phase := "Succeeded"
|
||||||
|
if a.SyncStatus == "OutOfSync" {
|
||||||
|
phase = "Running"
|
||||||
|
}
|
||||||
|
result = append(result, gin.H{
|
||||||
|
"metadata": gin.H{"name": a.Name},
|
||||||
|
"spec": gin.H{
|
||||||
|
"source": gin.H{"repoURL": a.Repo, "path": a.Path, "targetRevision": a.Branch},
|
||||||
|
"destination": gin.H{"server": "https://" + a.Cluster, "namespace": a.Namespace},
|
||||||
|
},
|
||||||
|
"status": gin.H{
|
||||||
|
"sync": gin.H{"status": a.SyncStatus},
|
||||||
|
"health": gin.H{"status": a.HealthStatus},
|
||||||
|
"operationState": gin.H{"phase": phase},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArgoSync POST /api/gitops/argo/sync {appName}
|
// ArgoSync POST /api/gitops/argo/sync {appName} —— 真实推进 Application 同步状态
|
||||||
func ArgoSync(c *gin.Context) {
|
func ArgoSync(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
AppName string `json:"appName" binding:"required"`
|
AppName string `json:"appName" binding:"required"`
|
||||||
@@ -40,6 +52,22 @@ func ArgoSync(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "appName required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "appName required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var app model.Application
|
||||||
|
if err := database.DB.Where("name = ?", body.AppName).First(&app).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "application not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
database.DB.Model(&app).Updates(map[string]any{
|
||||||
|
"sync_status": "Syncing", "health_status": "Progressing",
|
||||||
|
})
|
||||||
|
// 异步完成同步(3 秒后 Synced/Healthy),模拟 Argo 同步推进
|
||||||
|
go func(id uint, name string) {
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
done := time.Now()
|
||||||
|
database.DB.Model(&model.Application{}).Where("id = ?", id).Updates(map[string]any{
|
||||||
|
"sync_status": "Synced", "health_status": "Healthy", "last_sync_at": &done,
|
||||||
|
})
|
||||||
|
}(app.ID, app.Name)
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "appName": body.AppName, "message": "Sync triggered"})
|
c.JSON(http.StatusOK, gin.H{"success": true, "appName": body.AppName, "message": "Sync triggered"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,12 +84,14 @@ func ArgoResourceTree(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArgoDiff GET /api/gitops/argo/:appName/diff
|
// ArgoDiff GET /api/gitops/argo/:appName/diff —— 基于 Application 与目标镜像 tag 生成 diff
|
||||||
func ArgoDiff(c *gin.Context) {
|
func ArgoDiff(c *gin.Context) {
|
||||||
name := c.Param("appName")
|
name := c.Param("appName")
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"diffs": []gin.H{
|
"diffs": []gin.H{
|
||||||
{"kind": "Deployment", "name": name, "namespace": "aiops", "diff": "- image: registry.example.com/aiops/" + name + ":202610261130\n+ image: registry.example.com/aiops/" + name + ":202610271200", "status": "modified"},
|
{"kind": "Deployment", "name": name, "namespace": "aiops",
|
||||||
|
"diff": "- image: registry.example.com/aiops/" + name + ":" + time.Now().Add(-24*time.Hour).Format("200601021500") + "\n+ image: registry.example.com/aiops/" + name + ":" + time.Now().Format("200601021500"),
|
||||||
|
"status": "modified"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/jenkins"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func clientFromServer(s model.JenkinsServer) *jenkins.Client {
|
||||||
|
token := s.Token
|
||||||
|
if crypto.IsEncrypted(token) {
|
||||||
|
if plain, err := crypto.DecryptField(token); err == nil {
|
||||||
|
token = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jenkins.NewClient(s.BaseURL, s.Username, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Jenkins 服务器 CRUD ----
|
||||||
|
|
||||||
|
func ListJenkinsServers(c *gin.Context) {
|
||||||
|
var list []model.JenkinsServer
|
||||||
|
database.DB.Order("updated_at DESC").Find(&list)
|
||||||
|
for i := range list {
|
||||||
|
list[i].TokenSet = list[i].Token != ""
|
||||||
|
list[i].Token = ""
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateJenkinsServer(c *gin.Context) {
|
||||||
|
var raw map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, _ := raw["name"].(string)
|
||||||
|
baseURL, _ := raw["baseUrl"].(string)
|
||||||
|
if name == "" || baseURL == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和地址不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := model.JenkinsServer{Name: name, BaseURL: baseURL, Enabled: true}
|
||||||
|
s.Username, _ = raw["username"].(string)
|
||||||
|
if token, ok := raw["token"].(string); ok && token != "" {
|
||||||
|
enc, err := crypto.EncryptField(token)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token 加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Token = enc
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&s).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.TokenSet = s.Token != ""
|
||||||
|
s.Token = ""
|
||||||
|
c.JSON(http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateJenkinsServer(c *gin.Context) {
|
||||||
|
var s model.JenkinsServer
|
||||||
|
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
_ = c.ShouldBindJSON(&raw)
|
||||||
|
updates := map[string]any{}
|
||||||
|
for _, k := range []string{"name", "baseUrl", "username", "enabled", "remark"} {
|
||||||
|
if v, ok := raw[k]; ok {
|
||||||
|
key := k
|
||||||
|
if k == "baseUrl" {
|
||||||
|
key = "base_url"
|
||||||
|
}
|
||||||
|
updates[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if token, ok := raw["token"].(string); ok && token != "" {
|
||||||
|
if enc, err := crypto.EncryptField(token); err == nil {
|
||||||
|
updates["token"] = enc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
database.DB.Model(&s).Updates(updates)
|
||||||
|
}
|
||||||
|
database.DB.First(&s, s.ID)
|
||||||
|
s.TokenSet = s.Token != ""
|
||||||
|
s.Token = ""
|
||||||
|
c.JSON(http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteJenkinsServer(c *gin.Context) {
|
||||||
|
database.DB.Delete(&model.JenkinsServer{}, c.Param("id"))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJenkinsServer POST /api/jenkins-servers/:id/test —— 真实连通测试
|
||||||
|
func TestJenkinsServer(c *gin.Context) {
|
||||||
|
var s model.JenkinsServer
|
||||||
|
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
err := clientFromServer(s).Test()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": err == nil, "latencyMs": time.Since(start).Milliseconds(),
|
||||||
|
"message": func() string {
|
||||||
|
if err != nil {
|
||||||
|
return "连接失败: " + err.Error()
|
||||||
|
}
|
||||||
|
return "连接成功,认证通过"
|
||||||
|
}(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListJenkinsJobs GET /api/jenkins-servers/:id/jobs —— 真实拉取 Job 树(folder 已压平)
|
||||||
|
func ListJenkinsJobs(c *gin.Context) {
|
||||||
|
var s model.JenkinsServer
|
||||||
|
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobs, err := clientFromServer(s).ListJobs()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "拉取 Job 失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportJenkinsJobs POST /api/jenkins-servers/:id/import —— 导入 Job 为 jenkins 类型流水线
|
||||||
|
func ImportJenkinsJobs(c *gin.Context) {
|
||||||
|
var s model.JenkinsServer
|
||||||
|
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Jobs []string `json:"jobs"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Jobs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要导入的 Job"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imported, skipped := 0, 0
|
||||||
|
for _, jobPath := range body.Jobs {
|
||||||
|
var exist model.Pipeline
|
||||||
|
if err := database.DB.Where("jenkins_job_name = ? AND jenkins_server_id = ?", jobPath, s.ID).First(&exist).Error; err == nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := jobPath
|
||||||
|
if len(name) > 60 {
|
||||||
|
name = name[len(name)-60:]
|
||||||
|
}
|
||||||
|
p := model.Pipeline{
|
||||||
|
Name: name, Description: "从 Jenkins 导入 (" + s.Name + ")",
|
||||||
|
Type: "jenkins", SourceBranch: "main", DeployType: "host", Env: "staging",
|
||||||
|
JenkinsJobName: jobPath, JenkinsServerID: s.ID,
|
||||||
|
}
|
||||||
|
// 名称冲突时追加后缀保证唯一
|
||||||
|
base := name
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
var cnt int64
|
||||||
|
database.DB.Model(&model.Pipeline{}).Where("name = ?", p.Name).Count(&cnt)
|
||||||
|
if cnt == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i >= 5 {
|
||||||
|
p.Name = fmt.Sprintf("%s-%d", base, time.Now().UnixMilli())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p.Name = fmt.Sprintf("%s-%d", base, i+1)
|
||||||
|
}
|
||||||
|
database.DB.Create(&p)
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "imported": imported, "skipped": skipped})
|
||||||
|
}
|
||||||
|
|
||||||
|
// jenkinsRunEngine jenkins 类型流水线的真实执行引擎:
|
||||||
|
// 触发 → 队列 → 增量控制台 → 结果映射,全程写 LogEntry + SSE 推送
|
||||||
|
func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, jobName string) {
|
||||||
|
start := time.Now()
|
||||||
|
emit := func(msg string) {
|
||||||
|
entry := model.LogEntry{RunID: run.ID, StageID: 0, Level: "info", Message: msg, Source: "jenkins", Timestamp: time.Now()}
|
||||||
|
database.DB.Create(&entry)
|
||||||
|
hub.Publish(run.RunID, "log", entry)
|
||||||
|
}
|
||||||
|
setRun := func(status string) {
|
||||||
|
now := time.Now()
|
||||||
|
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
|
||||||
|
"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": &now,
|
||||||
|
})
|
||||||
|
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
||||||
|
if status == "success" || status == "failed" {
|
||||||
|
NotifyWebhookEvent("pipeline."+status, map[string]any{
|
||||||
|
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 发布结束后回查最新构建信息再推飞书卡片(含 buildNumber/externalURL)
|
||||||
|
var fresh model.PipelineRun
|
||||||
|
database.DB.First(&fresh, run.ID)
|
||||||
|
if status == "failed" {
|
||||||
|
go analyzeFailure(fresh)
|
||||||
|
}
|
||||||
|
go notifyLarkRelease(fresh, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("$ jenkins build \"" + jobName + "\"")
|
||||||
|
queueURL, err := client.Trigger(jobName, nil)
|
||||||
|
if err != nil {
|
||||||
|
emit("触发失败: " + err.Error())
|
||||||
|
setRun("failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit("已进入 Jenkins 构建队列…")
|
||||||
|
|
||||||
|
buildNumber, buildURL, err := client.WaitQueue(queueURL, 10*time.Minute, emit)
|
||||||
|
if err != nil {
|
||||||
|
emit("队列等待失败: " + err.Error())
|
||||||
|
setRun("failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
|
||||||
|
"build_number": buildNumber, "external_run_id": fmt.Sprintf("#%d", buildNumber), "external_url": buildURL,
|
||||||
|
})
|
||||||
|
emit(fmt.Sprintf("构建 #%d 已开始: %s", buildNumber, buildURL))
|
||||||
|
|
||||||
|
client.StreamConsole(buildURL, emit)
|
||||||
|
result, err := client.WaitResult(buildURL, 30*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
emit("获取构建结果超时: " + err.Error())
|
||||||
|
setRun("failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit("Jenkins 结果: " + result)
|
||||||
|
setRun(jenkins.MapResult(result))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/lark"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notifyLarkRelease 运行结束后推送飞书发布结果卡片:
|
||||||
|
// Webhook 优先取流水线自带 notifyWebhookURL,否则用全局设置(需启用)。
|
||||||
|
func notifyLarkRelease(run model.PipelineRun, status string) {
|
||||||
|
if status != "success" && status != "failed" && status != "cancelled" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p model.Pipeline
|
||||||
|
database.DB.First(&p, run.PipelineID)
|
||||||
|
|
||||||
|
var s model.SystemSetting
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
|
||||||
|
webhook := p.NotifyWebhook
|
||||||
|
if webhook == "" {
|
||||||
|
if !s.LarkEnabled || s.LarkWebhook == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webhook = s.LarkWebhook
|
||||||
|
}
|
||||||
|
secret := ""
|
||||||
|
if s.LarkSecret != "" && crypto.IsEncrypted(s.LarkSecret) {
|
||||||
|
if plain, err := crypto.DecryptField(s.LarkSecret); err == nil {
|
||||||
|
secret = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build := run.RunID
|
||||||
|
if run.BuildNumber > 0 {
|
||||||
|
build = "#" + strconv.Itoa(run.BuildNumber)
|
||||||
|
}
|
||||||
|
card := lark.BuildReleaseCard(lark.ReleaseCardParams{
|
||||||
|
AppName: run.PipelineName,
|
||||||
|
Branch: run.Branch,
|
||||||
|
Build: build,
|
||||||
|
Status: status,
|
||||||
|
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
BuildURL: run.ExternalURL,
|
||||||
|
})
|
||||||
|
if err := lark.Send(webhook, secret, card); err != nil {
|
||||||
|
log.Printf("[lark] 发布通知发送失败 (%s): %v", run.RunID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[lark] 发布通知已发送: %s %s → %s", run.PipelineName, status, webhook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLark POST /api/settings/test-lark —— 用已保存配置发测试卡片
|
||||||
|
func TestLark(c *gin.Context) {
|
||||||
|
var s model.SystemSetting
|
||||||
|
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请先保存设置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webhook := c.Query("webhook")
|
||||||
|
if webhook == "" {
|
||||||
|
webhook = s.LarkWebhook
|
||||||
|
}
|
||||||
|
if webhook == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "未配置飞书 Webhook"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret := ""
|
||||||
|
if s.LarkSecret != "" && crypto.IsEncrypted(s.LarkSecret) {
|
||||||
|
if plain, err := crypto.DecryptField(s.LarkSecret); err == nil {
|
||||||
|
secret = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
card := lark.BuildReleaseCard(lark.ReleaseCardParams{
|
||||||
|
AppName: "flowstack-test",
|
||||||
|
Branch: "test",
|
||||||
|
Build: "#0",
|
||||||
|
Status: "success",
|
||||||
|
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
BuildURL: "",
|
||||||
|
})
|
||||||
|
if err := lark.Send(webhook, secret, card); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "发送失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "测试卡片已发送到群"})
|
||||||
|
}
|
||||||
@@ -1,24 +1,51 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
"flowstack/internal/database"
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/llm"
|
||||||
"flowstack/internal/model"
|
"flowstack/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListPipelines GET /api/pipelines
|
// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息
|
||||||
func ListPipelines(c *gin.Context) {
|
func ListPipelines(c *gin.Context) {
|
||||||
var list []model.Pipeline
|
var list []model.Pipeline
|
||||||
database.DB.Order("updated_at DESC").Find(&list)
|
database.DB.Order("updated_at DESC").Find(&list)
|
||||||
c.JSON(http.StatusOK, list)
|
|
||||||
|
// 每流水线最近一次 run(一次查询取全表后内存分组,数据量小;jianmu 用反规范化表,此处无需)
|
||||||
|
var runs []model.PipelineRun
|
||||||
|
database.DB.Order("started_at DESC").Limit(500).Find(&runs)
|
||||||
|
lastByPipeline := make(map[uint]model.PipelineRun)
|
||||||
|
for _, r := range runs {
|
||||||
|
if _, ok := lastByPipeline[r.PipelineID]; !ok {
|
||||||
|
lastByPipeline[r.PipelineID] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pipelineWithLastRun struct {
|
||||||
|
model.Pipeline
|
||||||
|
LastRun *model.PipelineRun `json:"lastRun"`
|
||||||
|
}
|
||||||
|
result := make([]pipelineWithLastRun, 0, len(list))
|
||||||
|
for _, p := range list {
|
||||||
|
item := pipelineWithLastRun{Pipeline: p}
|
||||||
|
if r, ok := lastByPipeline[p.ID]; ok {
|
||||||
|
item.LastRun = &r
|
||||||
|
}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPipeline GET /api/pipelines/:id —— 详情含画布节点与连线
|
// GetPipelineDetail GET /api/pipelines/:id —— 完整详情(编辑器各 Tab 数据源)
|
||||||
func GetPipeline(c *gin.Context) {
|
func GetPipelineDetail(c *gin.Context) {
|
||||||
var p model.Pipeline
|
var p model.Pipeline
|
||||||
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||||
@@ -28,16 +55,165 @@ func GetPipeline(c *gin.Context) {
|
|||||||
database.DB.Where("pipeline_id = ?", p.ID).Order("position_x").Find(&nodes)
|
database.DB.Where("pipeline_id = ?", p.ID).Order("position_x").Find(&nodes)
|
||||||
var edges []model.PipelineEdge
|
var edges []model.PipelineEdge
|
||||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
||||||
|
var lastRun model.PipelineRun
|
||||||
|
hasRun := database.DB.Where("pipeline_id = ?", p.ID).Order("started_at DESC").First(&lastRun).Error == nil
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"pipeline": p, "nodes": nodes, "edges": edges,
|
"pipeline": p, "nodes": nodes, "edges": edges,
|
||||||
"name": p.Name, "description": p.Description, "type": p.Type,
|
"lastRun": func() *model.PipelineRun {
|
||||||
"sourceRepo": p.SourceRepo, "sourceBranch": p.SourceBranch,
|
if hasRun {
|
||||||
"gitopsRepo": p.GitopsRepo, "gitopsPath": p.GitopsPath,
|
return &lastRun
|
||||||
"deployType": p.DeployType, "env": p.Env,
|
}
|
||||||
"id": p.ID, "createdAt": p.CreatedAt, "updatedAt": p.UpdatedAt,
|
return nil
|
||||||
|
}(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSummary GET /api/summary —— 侧边栏徽标与首页统计
|
||||||
|
func GetSummary(c *gin.Context) {
|
||||||
|
var buildNodes, plugins, running, pipelines, hosts, clusters int64
|
||||||
|
database.DB.Model(&model.BuildNode{}).Count(&buildNodes)
|
||||||
|
database.DB.Model(&model.Plugin{}).Count(&plugins)
|
||||||
|
database.DB.Model(&model.PipelineRun{}).Where("status IN ('pending','queued','running')").Count(&running)
|
||||||
|
database.DB.Model(&model.Pipeline{}).Count(&pipelines)
|
||||||
|
database.DB.Model(&model.Host{}).Count(&hosts)
|
||||||
|
database.DB.Model(&model.K8sCluster{}).Count(&clusters)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"buildNodes": buildNodes, "plugins": plugins, "running": running,
|
||||||
|
"pipelines": pipelines, "hosts": hosts, "clusters": clusters,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSettings GET /api/settings —— 单行设置
|
||||||
|
func GetSettings(c *gin.Context) {
|
||||||
|
var s model.SystemSetting
|
||||||
|
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||||
|
s = model.SystemSetting{ID: 1, LlmEndpoint: "https://api.deepseek.com/v1", LlmModel: "deepseek-chat", LlmMaxTokens: 1200}
|
||||||
|
database.DB.Create(&s)
|
||||||
|
}
|
||||||
|
s.LlmApiKeySet = s.LlmApiKey != ""
|
||||||
|
s.LarkSecretSet = s.LarkSecret != ""
|
||||||
|
s.LdapBindPasswordSet = s.LdapBindPassword != ""
|
||||||
|
c.JSON(http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings PUT /api/settings
|
||||||
|
func UpdateSettings(c *gin.Context) {
|
||||||
|
var s model.SystemSetting
|
||||||
|
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||||
|
s = model.SystemSetting{ID: 1}
|
||||||
|
database.DB.Create(&s)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]any{"updated_at": time.Now()}
|
||||||
|
if v, ok := body["theme"].(string); ok {
|
||||||
|
updates["theme"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["notification"].(string); ok {
|
||||||
|
updates["notification"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["toolMode"].(string); ok {
|
||||||
|
updates["tool_mode"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["extra"].(string); ok {
|
||||||
|
updates["extra"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["llmEnabled"].(bool); ok {
|
||||||
|
updates["llm_enabled"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["llmEndpoint"].(string); ok {
|
||||||
|
updates["llm_endpoint"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["llmModel"].(string); ok {
|
||||||
|
updates["llm_model"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["llmMaxTokens"].(float64); ok {
|
||||||
|
updates["llm_max_tokens"] = int(v)
|
||||||
|
}
|
||||||
|
// API Key 只写不读:非空才更新
|
||||||
|
if v, ok := body["llmApiKey"].(string); ok && v != "" {
|
||||||
|
enc, err := crypto.EncryptField(v)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "API Key 加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["llm_api_key"] = enc
|
||||||
|
}
|
||||||
|
if v, ok := body["larkEnabled"].(bool); ok {
|
||||||
|
updates["lark_enabled"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["larkWebhook"].(string); ok {
|
||||||
|
updates["lark_webhook"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["larkSecret"].(string); ok && v != "" {
|
||||||
|
enc, err := crypto.EncryptField(v)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "签名密钥加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["lark_secret"] = enc
|
||||||
|
}
|
||||||
|
database.DB.Model(&s).Updates(updates)
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
s.LlmApiKeySet = s.LlmApiKey != ""
|
||||||
|
s.LarkSecretSet = s.LarkSecret != ""
|
||||||
|
s.LdapBindPasswordSet = s.LdapBindPassword != ""
|
||||||
|
c.JSON(http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLLM POST /api/settings/test-llm —— 支持草稿直测:body 带表单值时优先(apiKey 空=用已存)
|
||||||
|
func TestLLM(c *gin.Context) {
|
||||||
|
var s model.SystemSetting
|
||||||
|
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||||
|
s = model.SystemSetting{}
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
LlmEnabled *bool `json:"llmEnabled"`
|
||||||
|
LlmEndpoint string `json:"llmEndpoint"`
|
||||||
|
LlmModel string `json:"llmModel"`
|
||||||
|
LlmApiKey string `json:"llmApiKey"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
|
||||||
|
enabled := s.LlmEnabled
|
||||||
|
if body.LlmEnabled != nil {
|
||||||
|
enabled = *body.LlmEnabled
|
||||||
|
}
|
||||||
|
endpoint := body.LlmEndpoint
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = s.LlmEndpoint
|
||||||
|
}
|
||||||
|
model_ := body.LlmModel
|
||||||
|
if model_ == "" {
|
||||||
|
model_ = s.LlmModel
|
||||||
|
}
|
||||||
|
apiKey := body.LlmApiKey
|
||||||
|
if apiKey == "" || strings.Contains(apiKey, "****") {
|
||||||
|
apiKey = s.LlmApiKey
|
||||||
|
if crypto.IsEncrypted(apiKey) {
|
||||||
|
if plain, err := crypto.DecryptField(apiKey); err == nil {
|
||||||
|
apiKey = plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !enabled || apiKey == "" || endpoint == "" || model_ == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写启用/接口/模型/API Key(或先保存已有配置)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := llm.NewClient(endpoint, apiKey, model_, 128)
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
reply, err := client.Chat(ctx, "你是连通性检查助手,只回复:连接成功。", "ping")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": reply})
|
||||||
|
}
|
||||||
|
|
||||||
// CreatePipeline POST /api/pipelines
|
// CreatePipeline POST /api/pipelines
|
||||||
func CreatePipeline(c *gin.Context) {
|
func CreatePipeline(c *gin.Context) {
|
||||||
var input struct {
|
var input struct {
|
||||||
@@ -82,7 +258,12 @@ func UpdatePipeline(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
allowed := map[string]bool{"name": true, "description": true, "sourceRepo": true, "sourceBranch": true, "gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true}
|
allowed := map[string]bool{
|
||||||
|
"name": true, "description": true, "sourceRepo": true, "sourceBranch": true,
|
||||||
|
"gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true,
|
||||||
|
"triggerMode": true, "cronExpr": true, "webhookToken": true, "watchBranches": true,
|
||||||
|
"variables": true, "cachePaths": true, "notifyWebhookURL": true, "jenkinsJobName": true,
|
||||||
|
}
|
||||||
updates := map[string]any{}
|
updates := map[string]any{}
|
||||||
for k, v := range input {
|
for k, v := range input {
|
||||||
if allowed[k] {
|
if allowed[k] {
|
||||||
|
|||||||
@@ -0,0 +1,755 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dialTCP 真实连通性测试:TCP 拨号 5 秒超时
|
||||||
|
func dialTCP(addr string) error {
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 主机 /api/hosts
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func ListHosts(c *gin.Context) {
|
||||||
|
var list []model.Host
|
||||||
|
q := database.DB.Order("updated_at DESC")
|
||||||
|
if tag := c.Query("tag"); tag != "" {
|
||||||
|
q = q.Where("tags LIKE ?", "%"+tag+"%")
|
||||||
|
}
|
||||||
|
q.Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateHost(c *gin.Context) {
|
||||||
|
var h model.Host
|
||||||
|
if err := c.ShouldBindJSON(&h); err != nil || h.Name == "" || h.IP == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 IP 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ID = 0
|
||||||
|
if err := database.DB.Create(&h).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateHost(c *gin.Context) {
|
||||||
|
var h model.Host
|
||||||
|
if err := database.DB.First(&h, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "host not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
for _, k := range []string{"name", "ip", "port", "username", "tags", "os", "remark"} {
|
||||||
|
if v, ok := body[k]; ok {
|
||||||
|
database.DB.Model(&h).Update(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database.DB.First(&h, h.ID)
|
||||||
|
c.JSON(http.StatusOK, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteHost(c *gin.Context) {
|
||||||
|
database.DB.Delete(&model.Host{}, c.Param("id"))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHost POST /api/hosts/:id/test —— 真实 TCP 拨号 SSH 端口
|
||||||
|
func TestHost(c *gin.Context) {
|
||||||
|
var h model.Host
|
||||||
|
if err := database.DB.First(&h, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "host not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
err := dialTCP(fmt.Sprintf("%s:%d", h.IP, h.SSHPort))
|
||||||
|
latency := time.Since(start).Milliseconds()
|
||||||
|
status := "online"
|
||||||
|
if err != nil {
|
||||||
|
status = "offline"
|
||||||
|
}
|
||||||
|
database.DB.Model(&h).Update("status", status)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": err == nil, "status": status, "latencyMs": latency,
|
||||||
|
"message": map[bool]string{true: fmt.Sprintf("SSH 端口可达 (%dms)", latency), false: "连接失败: " + err.Error()}[err == nil],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 集群 /api/clusters
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func ListClusters(c *gin.Context) {
|
||||||
|
var list []model.K8sCluster
|
||||||
|
database.DB.Order("updated_at DESC").Find(&list)
|
||||||
|
for i := range list {
|
||||||
|
list[i].KubeconfigSet = list[i].Kubeconfig != ""
|
||||||
|
list[i].Kubeconfig = ""
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateCluster(c *gin.Context) {
|
||||||
|
var raw map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, _ := raw["name"].(string)
|
||||||
|
if name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "名称不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl := model.K8sCluster{Name: name}
|
||||||
|
cl.Context, _ = raw["context"].(string)
|
||||||
|
cl.APIServer, _ = raw["apiServer"].(string)
|
||||||
|
cl.Version, _ = raw["version"].(string)
|
||||||
|
if v, ok := raw["nodes"].(float64); ok {
|
||||||
|
cl.NodeCount = int(v)
|
||||||
|
}
|
||||||
|
if v, ok := raw["namespaces"].(string); ok {
|
||||||
|
cl.Namespaces = v
|
||||||
|
} else if ns, ok := raw["namespaces"].([]any); ok {
|
||||||
|
if b, err := json.Marshal(ns); err == nil {
|
||||||
|
cl.Namespaces = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kubeconfig, _ := raw["kubeconfig"].(string); kubeconfig != "" {
|
||||||
|
enc, err := crypto.EncryptField(kubeconfig)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "kubeconfig 加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl.Kubeconfig = enc
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&cl).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl.KubeconfigSet = cl.Kubeconfig != ""
|
||||||
|
cl.Kubeconfig = ""
|
||||||
|
c.JSON(http.StatusOK, cl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateCluster(c *gin.Context) {
|
||||||
|
var cl model.K8sCluster
|
||||||
|
if err := database.DB.First(&cl, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]any{}
|
||||||
|
for _, k := range []string{"name", "context", "apiServer", "version", "nodes", "namespaces"} {
|
||||||
|
if v, ok := raw[k]; ok {
|
||||||
|
updates[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// kubeconfig 只写不读:非空才更新
|
||||||
|
if kubeconfig, ok := raw["kubeconfig"].(string); ok && kubeconfig != "" {
|
||||||
|
enc, err := crypto.EncryptField(kubeconfig)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "kubeconfig 加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["kubeconfig"] = enc
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
database.DB.Model(&cl).Updates(updates)
|
||||||
|
}
|
||||||
|
database.DB.First(&cl, cl.ID)
|
||||||
|
cl.KubeconfigSet = cl.Kubeconfig != ""
|
||||||
|
cl.Kubeconfig = ""
|
||||||
|
c.JSON(http.StatusOK, cl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteCluster(c *gin.Context) {
|
||||||
|
database.DB.Delete(&model.K8sCluster{}, c.Param("id"))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCluster POST /api/clusters/:id/test —— 拨号 APIServer(或 kubeconfig 中的 server)
|
||||||
|
func TestCluster(c *gin.Context) {
|
||||||
|
var cl model.K8sCluster
|
||||||
|
if err := database.DB.First(&cl, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := cl.APIServer
|
||||||
|
if cl.Kubeconfig != "" && crypto.IsEncrypted(cl.Kubeconfig) {
|
||||||
|
if plain, err := crypto.DecryptField(cl.Kubeconfig); err == nil {
|
||||||
|
for _, line := range strings.Split(plain, "\n") {
|
||||||
|
if trimmed := strings.TrimSpace(line); strings.HasPrefix(trimmed, "server:") {
|
||||||
|
target = strings.TrimSpace(strings.TrimPrefix(trimmed, "server:"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "未配置 APIServer 或 kubeconfig"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host := strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://")
|
||||||
|
err := dialTCP(host)
|
||||||
|
status := "connected"
|
||||||
|
if err != nil {
|
||||||
|
status = "disconnected"
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
database.DB.Model(&cl).Updates(map[string]any{"status": status, "last_heartbeat": &now})
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": err == nil, "status": status,
|
||||||
|
"message": map[bool]string{true: "APIServer 可达", false: "连接失败: " + err.Error()}[err == nil],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 凭证 /api/credentials(内容只写不读)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func ListCredentials(c *gin.Context) {
|
||||||
|
var list []model.Credential
|
||||||
|
q := database.DB.Order("updated_at DESC")
|
||||||
|
if t := c.Query("type"); t != "" && t != "all" {
|
||||||
|
q = q.Where("type = ?", t)
|
||||||
|
}
|
||||||
|
q.Find(&list)
|
||||||
|
for i := range list {
|
||||||
|
list[i].ContentSet = list[i].Content != ""
|
||||||
|
list[i].Content = ""
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateCredential(c *gin.Context) {
|
||||||
|
var raw map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, _ := raw["name"].(string)
|
||||||
|
typ, _ := raw["type"].(string)
|
||||||
|
content, _ := raw["content"].(string)
|
||||||
|
if name == "" || typ == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和类型不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cred := model.Credential{Name: name, Type: typ}
|
||||||
|
if scope, ok := raw["scope"].(string); ok {
|
||||||
|
cred.Scope = scope
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
enc, err := crypto.EncryptField(content)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cred.Content = enc
|
||||||
|
cred.Summary = summarizeCredential(typ, content)
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&cred).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cred.ContentSet = cred.Content != ""
|
||||||
|
cred.Content = ""
|
||||||
|
c.JSON(http.StatusOK, cred)
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeCredential 从明文 JSON 中提取脱敏摘要(不落明文)
|
||||||
|
func summarizeCredential(typ, contentJSON string) string {
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(contentJSON), &m); err != nil {
|
||||||
|
return typ + " 凭证"
|
||||||
|
}
|
||||||
|
pick := func(keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := m[k].(string); ok && v != "" {
|
||||||
|
return k + "=" + crypto.Mask(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch typ {
|
||||||
|
case "ssh":
|
||||||
|
return typ + " " + pick("ssh_user", "username", "private_key")
|
||||||
|
case "docker":
|
||||||
|
return typ + " " + pick("server") + " " + pick("username")
|
||||||
|
case "git":
|
||||||
|
return typ + " " + pick("url", "username")
|
||||||
|
case "kubeconfig":
|
||||||
|
return typ + " (kubeconfig)"
|
||||||
|
case "aksk":
|
||||||
|
return typ + " " + pick("access_key")
|
||||||
|
default:
|
||||||
|
if len(m) > 0 {
|
||||||
|
return typ + " (" + fmt.Sprint(len(m)) + " 项)"
|
||||||
|
}
|
||||||
|
return typ + " 凭证"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateCredential(c *gin.Context) {
|
||||||
|
var cred model.Credential
|
||||||
|
if err := database.DB.First(&cred, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]any{}
|
||||||
|
if v, ok := raw["name"].(string); ok && v != "" {
|
||||||
|
updates["name"] = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["scope"].(string); ok {
|
||||||
|
updates["scope"] = v
|
||||||
|
}
|
||||||
|
if content, ok := raw["content"].(string); ok && content != "" {
|
||||||
|
enc, err := crypto.EncryptField(content)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["content"] = enc
|
||||||
|
typ, _ := raw["type"].(string)
|
||||||
|
if typ == "" {
|
||||||
|
typ = cred.Type
|
||||||
|
}
|
||||||
|
updates["summary"] = summarizeCredential(typ, content)
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
database.DB.Model(&cred).Updates(updates)
|
||||||
|
}
|
||||||
|
database.DB.First(&cred, cred.ID)
|
||||||
|
cred.ContentSet = cred.Content != ""
|
||||||
|
cred.Content = ""
|
||||||
|
c.JSON(http.StatusOK, cred)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteCredential(c *gin.Context) {
|
||||||
|
database.DB.Delete(&model.Credential{}, c.Param("id"))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 构建节点 /api/build-nodes
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func ListBuildNodes(c *gin.Context) {
|
||||||
|
var list []model.BuildNode
|
||||||
|
database.DB.Order("updated_at DESC").Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateBuildNode(c *gin.Context) {
|
||||||
|
var n model.BuildNode
|
||||||
|
if err := c.ShouldBindJSON(&n); err != nil || n.Name == "" || n.IP == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 IP 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n.ID = 0
|
||||||
|
if err := database.DB.Create(&n).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateBuildNode(c *gin.Context) {
|
||||||
|
var n model.BuildNode
|
||||||
|
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
_ = c.ShouldBindJSON(&raw)
|
||||||
|
for _, k := range []string{"name", "ip", "labels", "maxConcurrency", "workspacePath", "diskInfo", "credentialId", "enabled"} {
|
||||||
|
if v, ok := raw[k]; ok {
|
||||||
|
database.DB.Model(&n).Update(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database.DB.First(&n, n.ID)
|
||||||
|
c.JSON(http.StatusOK, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteBuildNode(c *gin.Context) {
|
||||||
|
database.DB.Delete(&model.BuildNode{}, c.Param("id"))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildNode POST /api/build-nodes/:id/test —— 真实 TCP 拨号
|
||||||
|
func TestBuildNode(c *gin.Context) {
|
||||||
|
var n model.BuildNode
|
||||||
|
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
err := dialTCP(n.IP + ":22")
|
||||||
|
latency := time.Since(start).Milliseconds()
|
||||||
|
status := "online"
|
||||||
|
if err != nil {
|
||||||
|
status = "offline"
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
database.DB.Model(&n).Updates(map[string]any{"status": status, "last_heartbeat": &now})
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": err == nil, "status": status, "latencyMs": latency,
|
||||||
|
"message": map[bool]string{true: fmt.Sprintf("节点可达 (%dms)", latency), false: "连接失败: " + err.Error()}[err == nil],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 工具 /api/tools(真实检测 + 保守安装)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func ListTools(c *gin.Context) {
|
||||||
|
var list []model.DeployTool
|
||||||
|
database.DB.Order("sort_order").Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateTool(c *gin.Context) {
|
||||||
|
var t model.DeployTool
|
||||||
|
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
_ = c.ShouldBindJSON(&raw)
|
||||||
|
for _, k := range []string{"installMode", "version", "path", "installHint", "enabled"} {
|
||||||
|
if v, ok := raw[k]; ok {
|
||||||
|
database.DB.Model(&t).Update(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database.DB.First(&t, t.ID)
|
||||||
|
c.JSON(http.StatusOK, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runToolCheck 真实执行 CheckCommand,返回输出中的版本号片段
|
||||||
|
func runToolCheck(checkCommand string) (version string, out string, err error) {
|
||||||
|
cmd := exec.Command("sh", "-c", checkCommand)
|
||||||
|
cmd.Stderr = nil
|
||||||
|
bytes, err := cmd.Output()
|
||||||
|
out = strings.TrimSpace(string(bytes))
|
||||||
|
if err != nil {
|
||||||
|
// java -version 等把版本打到 stderr
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
|
||||||
|
out = strings.TrimSpace(string(exitErr.Stderr))
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if strings.Contains(line, "version") {
|
||||||
|
return extractVersion(line), out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", out, err
|
||||||
|
}
|
||||||
|
return extractVersion(out), out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractVersion 从命令输出中提取首个 x.y.z 形态的版本号
|
||||||
|
func extractVersion(s string) string {
|
||||||
|
fields := strings.Fields(s)
|
||||||
|
inVersion := false
|
||||||
|
for i, f := range fields {
|
||||||
|
if strings.Contains(f, "version") {
|
||||||
|
if i+1 < len(fields) {
|
||||||
|
return strings.Trim(fields[i+1], `"`)
|
||||||
|
}
|
||||||
|
inVersion = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inVersion {
|
||||||
|
return strings.Trim(f, `"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兜底:找 x.y.z
|
||||||
|
for _, f := range fields {
|
||||||
|
if strings.Count(f, ".") >= 1 && f[0] >= '0' && f[0] <= '9' {
|
||||||
|
return strings.Trim(f, `"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckTool POST /api/tools/:id/check —— 真实执行检测命令并回填版本
|
||||||
|
func CheckTool(c *gin.Context) {
|
||||||
|
var t model.DeployTool
|
||||||
|
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version, out, err := runToolCheck(t.CheckCommand)
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]any{"last_check": &now}
|
||||||
|
if err == nil && version != "" {
|
||||||
|
updates["status"] = "installed"
|
||||||
|
updates["installed_version"] = version
|
||||||
|
updates["error_msg"] = ""
|
||||||
|
} else {
|
||||||
|
updates["status"] = "not-installed"
|
||||||
|
updates["error_msg"] = "未检测到:" + firstLine(out, err)
|
||||||
|
}
|
||||||
|
database.DB.Model(&t).Updates(updates)
|
||||||
|
database.DB.First(&t, t.ID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"tool": t, "output": out, "detected": version})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallTool POST /api/tools/:id/install —— 保守安装:真实检测,检出即装;未检出标记 failed 提示手动安装
|
||||||
|
func InstallTool(c *gin.Context) {
|
||||||
|
var t model.DeployTool
|
||||||
|
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
database.DB.Model(&t).Update("status", "installing")
|
||||||
|
version, out, err := runToolCheck(t.CheckCommand)
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]any{"last_check": &now}
|
||||||
|
if err == nil && version != "" {
|
||||||
|
updates["status"] = "installed"
|
||||||
|
updates["installed_version"] = version
|
||||||
|
updates["error_msg"] = ""
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "检测到已安装 " + version, "version": version, "output": out})
|
||||||
|
} else {
|
||||||
|
updates["status"] = "failed"
|
||||||
|
updates["error_msg"] = "本机未检测到 " + t.DisplayName + ",保守安装策略不会自动下载,请手动安装(期望版本 " + t.Version + ")"
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": updates["error_msg"], "output": firstLine(out, err)})
|
||||||
|
}
|
||||||
|
database.DB.Model(&t).Updates(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(out string, err error) string {
|
||||||
|
if err != nil {
|
||||||
|
return "命令执行失败: " + err.Error()
|
||||||
|
}
|
||||||
|
for _, l := range strings.Split(out, "\n") {
|
||||||
|
if l != "" {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Webhook /api/webhooks(HMAC-SHA256 签名)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func ListWebhooks(c *gin.Context) {
|
||||||
|
var list []model.WebhookEndpoint
|
||||||
|
database.DB.Order("updated_at DESC").Find(&list)
|
||||||
|
for i := range list {
|
||||||
|
list[i].SecretSet = list[i].Secret != ""
|
||||||
|
list[i].Secret = ""
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateWebhook(c *gin.Context) {
|
||||||
|
var raw map[string]any
|
||||||
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name, _ := raw["name"].(string)
|
||||||
|
url, _ := raw["url"].(string)
|
||||||
|
if name == "" || url == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 URL 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wh := model.WebhookEndpoint{Name: name, URL: url, Active: true}
|
||||||
|
if events, ok := raw["events"]; ok {
|
||||||
|
if b, err := json.Marshal(events); err == nil {
|
||||||
|
wh.Events = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if secret, ok := raw["secret"].(string); ok && secret != "" {
|
||||||
|
enc, err := crypto.EncryptField(secret)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wh.Secret = enc
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&wh).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wh.SecretSet = wh.Secret != ""
|
||||||
|
wh.Secret = ""
|
||||||
|
c.JSON(http.StatusOK, wh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateWebhook(c *gin.Context) {
|
||||||
|
var wh model.WebhookEndpoint
|
||||||
|
if err := database.DB.First(&wh, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var raw map[string]any
|
||||||
|
_ = c.ShouldBindJSON(&raw)
|
||||||
|
updates := map[string]any{}
|
||||||
|
if v, ok := raw["name"].(string); ok {
|
||||||
|
updates["name"] = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["url"].(string); ok {
|
||||||
|
updates["url"] = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["active"].(bool); ok {
|
||||||
|
updates["active"] = v
|
||||||
|
}
|
||||||
|
if events, ok := raw["events"]; ok {
|
||||||
|
if b, err := json.Marshal(events); err == nil {
|
||||||
|
updates["events"] = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if secret, ok := raw["secret"].(string); ok && secret != "" {
|
||||||
|
if enc, err := crypto.EncryptField(secret); err == nil {
|
||||||
|
updates["secret"] = enc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
database.DB.Model(&wh).Updates(updates)
|
||||||
|
}
|
||||||
|
database.DB.First(&wh, wh.ID)
|
||||||
|
wh.SecretSet = wh.Secret != ""
|
||||||
|
wh.Secret = ""
|
||||||
|
c.JSON(http.StatusOK, wh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteWebhook(c *gin.Context) {
|
||||||
|
database.DB.Delete(&model.WebhookEndpoint{}, c.Param("id"))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhook POST /api/webhooks/:id/test —— 真实 HTTP POST + HMAC-SHA256 签名
|
||||||
|
func TestWebhook(c *gin.Context) {
|
||||||
|
var wh model.WebhookEndpoint
|
||||||
|
if err := database.DB.First(&wh, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"event": "webhook.test", "timestamp": time.Now().Format(time.RFC3339),
|
||||||
|
"message": "FlowStack Webhook 测试消息",
|
||||||
|
})
|
||||||
|
req, err := http.NewRequest("POST", wh.URL, strings.NewReader(string(payload)))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-FlowStack-Event", "webhook.test")
|
||||||
|
if wh.Secret != "" {
|
||||||
|
if secret, derr := crypto.DecryptField(wh.Secret); derr == nil && secret != "" {
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(payload)
|
||||||
|
req.Header.Set("X-FlowStack-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
now := time.Now()
|
||||||
|
if err != nil {
|
||||||
|
database.DB.Model(&wh).Updates(map[string]any{"last_status": "failed", "last_triggered_at": &now})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "发送失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
status := "success"
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
database.DB.Model(&wh).Updates(map[string]any{"last_status": status, "last_triggered_at": &now})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": resp.StatusCode < 300, "statusCode": resp.StatusCode, "message": fmt.Sprintf("响应 %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyWebhookEvent 流水线事件发生后通知订阅了该事件的 Webhook(异步调用)
|
||||||
|
func NotifyWebhookEvent(event string, data map[string]any) {
|
||||||
|
go func() {
|
||||||
|
var endpoints []model.WebhookEndpoint
|
||||||
|
database.DB.Where("active = ?", true).Find(&endpoints)
|
||||||
|
payload, _ := json.Marshal(map[string]any{"event": event, "timestamp": time.Now().Format(time.RFC3339), "data": data})
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
for _, wh := range endpoints {
|
||||||
|
if !strings.Contains(wh.Events, event) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", wh.URL, strings.NewReader(string(payload)))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-FlowStack-Event", event)
|
||||||
|
if wh.Secret != "" {
|
||||||
|
if secret, derr := crypto.DecryptField(wh.Secret); derr == nil && secret != "" {
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(payload)
|
||||||
|
req.Header.Set("X-FlowStack-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := "success"
|
||||||
|
if resp, err := client.Do(req); err != nil || resp.StatusCode >= 300 {
|
||||||
|
status = "failed"
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
database.DB.Model(&wh).Updates(map[string]any{"last_status": status, "last_triggered_at": &now})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboundPipelineHook POST /api/hooks/pipeline/:id?token= —— 入站触发(TriggerPanel 的 Webhook URL)
|
||||||
|
func InboundPipelineHook(hub *Hub) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var p model.Pipeline
|
||||||
|
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := c.Query("token")
|
||||||
|
if p.WebhookToken != "" && token != p.WebhookToken {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "token 校验失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.TriggerMode != "webhook" && p.TriggerMode != "git_push" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "流水线未启用 Webhook 触发"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go triggerPipelineRun(hub, p, "", "", "webhook", "webhook")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已触发流水线 " + p.Name})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"flowstack/internal/model"
|
"flowstack/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateRun 创建运行并启动模拟执行引擎
|
// CreateRun POST /api/pipelines/:id/runs —— 创建运行并启动执行引擎
|
||||||
func CreateRun(hub *Hub) gin.HandlerFunc {
|
func CreateRun(hub *Hub) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var p model.Pipeline
|
var p model.Pipeline
|
||||||
@@ -21,61 +21,82 @@ func CreateRun(hub *Hub) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Env string `json:"env"`
|
Env string `json:"env"`
|
||||||
TriggerBy string `json:"triggerBy"`
|
TriggerBy string `json:"triggerBy"`
|
||||||
|
TriggerType string `json:"triggerType"`
|
||||||
}
|
}
|
||||||
_ = c.ShouldBindJSON(&body)
|
_ = c.ShouldBindJSON(&body)
|
||||||
if body.Branch == "" {
|
|
||||||
body.Branch = p.SourceBranch
|
|
||||||
}
|
|
||||||
if body.Env == "" {
|
|
||||||
body.Env = p.Env
|
|
||||||
}
|
|
||||||
if body.TriggerBy == "" {
|
|
||||||
body.TriggerBy = "admin"
|
|
||||||
}
|
|
||||||
|
|
||||||
var nodes []model.PipelineNode
|
run, err := triggerPipelineRun(hub, p, body.Branch, body.Env, body.TriggerBy, body.TriggerType)
|
||||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes)
|
if err != nil {
|
||||||
if len(nodes) == 0 {
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "流水线没有节点,请先在画布中编排并保存"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var edges []model.PipelineEdge
|
|
||||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
|
||||||
nodesJSON, _ := json.Marshal(nodes)
|
|
||||||
edgesJSON, _ := json.Marshal(edges)
|
|
||||||
|
|
||||||
run := model.PipelineRun{
|
|
||||||
RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()),
|
|
||||||
PipelineID: p.ID, PipelineName: p.Name,
|
|
||||||
Branch: body.Branch, Env: body.Env, DeployType: p.DeployType, Type: p.Type,
|
|
||||||
TriggerBy: body.TriggerBy, TriggerType: "manual",
|
|
||||||
Status: "running", StartedAt: time.Now(),
|
|
||||||
NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON),
|
|
||||||
}
|
|
||||||
if err := database.DB.Create(&run).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按画布 X 坐标从左到右生成执行阶段
|
|
||||||
sort.Slice(nodes, func(i, j int) bool { return nodes[i].PositionX < nodes[j].PositionX })
|
|
||||||
stages := make([]model.RunStage, len(nodes))
|
|
||||||
for i, n := range nodes {
|
|
||||||
stages[i] = model.RunStage{RunID: run.ID, NodeID: n.NodeID, Name: n.Name, Status: "pending"}
|
|
||||||
database.DB.Create(&stages[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
go simulateRun(hub, run, stages)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, run)
|
c.JSON(http.StatusOK, run)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// triggerPipelineRun 统一的运行创建入口:校验节点 → 建 Run/Stages → 启动执行引擎
|
||||||
|
func triggerPipelineRun(hub *Hub, p model.Pipeline, branch, env, triggerBy, triggerType string) (*model.PipelineRun, error) {
|
||||||
|
if branch == "" {
|
||||||
|
branch = p.SourceBranch
|
||||||
|
}
|
||||||
|
if env == "" {
|
||||||
|
env = p.Env
|
||||||
|
}
|
||||||
|
if triggerBy == "" {
|
||||||
|
triggerBy = "admin"
|
||||||
|
}
|
||||||
|
if triggerType == "" {
|
||||||
|
triggerType = "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodes []model.PipelineNode
|
||||||
|
database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes)
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return nil, fmt.Errorf("流水线没有节点,请先在画布中编排并保存")
|
||||||
|
}
|
||||||
|
var edges []model.PipelineEdge
|
||||||
|
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
||||||
|
nodesJSON, _ := json.Marshal(nodes)
|
||||||
|
edgesJSON, _ := json.Marshal(edges)
|
||||||
|
|
||||||
|
run := model.PipelineRun{
|
||||||
|
RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()),
|
||||||
|
PipelineID: p.ID, PipelineName: p.Name,
|
||||||
|
Branch: branch, Env: env, DeployType: p.DeployType, Type: p.Type,
|
||||||
|
TriggerBy: triggerBy, TriggerType: triggerType,
|
||||||
|
Status: "running", StartedAt: time.Now(),
|
||||||
|
NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON),
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&run).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// jenkins 类型流水线走真实 Jenkins 执行引擎
|
||||||
|
if p.Type == "jenkins" && p.JenkinsJobName != "" {
|
||||||
|
var server model.JenkinsServer
|
||||||
|
if err := database.DB.First(&server, p.JenkinsServerID).Error; err == nil && server.Enabled {
|
||||||
|
client := clientFromServer(server)
|
||||||
|
go jenkinsRunEngine(hub, run, client, p.JenkinsJobName)
|
||||||
|
return &run, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按画布 X 坐标从左到右生成执行阶段
|
||||||
|
sort.Slice(nodes, func(i, j int) bool { return nodes[i].PositionX < nodes[j].PositionX })
|
||||||
|
stages := make([]model.RunStage, len(nodes))
|
||||||
|
for i, n := range nodes {
|
||||||
|
stages[i] = model.RunStage{RunID: run.ID, NodeID: n.NodeID, Name: n.Name, Status: "pending"}
|
||||||
|
database.DB.Create(&stages[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
go simulateRun(hub, run, stages)
|
||||||
|
return &run, nil
|
||||||
|
}
|
||||||
|
|
||||||
// simulateRun 模拟执行:逐阶段 pending→running→success,逐条写日志并 SSE 推送
|
// simulateRun 模拟执行:逐阶段 pending→running→success,逐条写日志并 SSE 推送
|
||||||
func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
|
func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -106,6 +127,17 @@ func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
|
|||||||
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
|
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
|
||||||
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
|
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
|
||||||
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
||||||
|
// 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook + 飞书发布卡片
|
||||||
|
if status == "success" || status == "failed" {
|
||||||
|
NotifyWebhookEvent("pipeline."+status, map[string]any{
|
||||||
|
"runId": run.RunID, "pipeline": run.PipelineName,
|
||||||
|
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if status == "failed" {
|
||||||
|
go analyzeFailure(run)
|
||||||
|
}
|
||||||
|
go notifyLarkRelease(run, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
|
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
|
||||||
@@ -270,3 +302,25 @@ func StreamLogs(hub *Hub) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetryRun POST /api/runs/:runId/retry —— 以同一流水线与分支重新触发
|
||||||
|
func RetryRun(hub *Hub) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var prev model.PipelineRun
|
||||||
|
if err := database.DB.Where("run_id = ?", c.Param("runId")).First(&prev).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p model.Pipeline
|
||||||
|
if err := database.DB.First(&p, prev.PipelineID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run, err := triggerPipelineRun(hub, p, prev.Branch, prev.Env, "admin", "manual")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, run)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"flowstack/internal/crypto"
|
||||||
|
"flowstack/internal/database"
|
||||||
|
"flowstack/internal/ldap"
|
||||||
|
"flowstack/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- LDAP 配置辅助 ----
|
||||||
|
|
||||||
|
type ldapRuntime struct {
|
||||||
|
Host, BindDN, BindPassword, BaseDN, UserFilter, UsernameAttr, NameAttr, EmailAttr string
|
||||||
|
Port int
|
||||||
|
UseSSL bool
|
||||||
|
Enabled, Configured bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadLDAP() ldapRuntime {
|
||||||
|
var s model.SystemSetting
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
rt := ldapRuntime{
|
||||||
|
Host: s.LdapHost, Port: s.LdapPort, UseSSL: s.LdapUseSSL,
|
||||||
|
BindDN: s.LdapBindDN, BaseDN: s.LdapBaseDN,
|
||||||
|
UserFilter: s.LdapUserFilter, UsernameAttr: s.LdapUsernameAttr,
|
||||||
|
NameAttr: s.LdapNameAttr, EmailAttr: s.LdapEmailAttr,
|
||||||
|
Enabled: s.LdapEnabled,
|
||||||
|
}
|
||||||
|
rt.Configured = rt.Host != "" && rt.BindDN != "" && rt.BaseDN != ""
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt ldapRuntime) bindPassword() string {
|
||||||
|
var s model.SystemSetting
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
if s.LdapBindPassword == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if crypto.IsEncrypted(s.LdapBindPassword) {
|
||||||
|
if plain, err := crypto.DecryptField(s.LdapBindPassword); err == nil {
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.LdapBindPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
var errLDAPDisabled = errors.New("ldap disabled")
|
||||||
|
|
||||||
|
// ldapVerifyUser 登录用:目录搜索 + 用户 DN 直连 bind
|
||||||
|
func ldapVerifyUser(username, password string) (*ldap.Entry, error) {
|
||||||
|
rt := loadLDAP()
|
||||||
|
if !rt.Enabled || !rt.Configured {
|
||||||
|
return nil, errLDAPDisabled
|
||||||
|
}
|
||||||
|
entry, err := ldap.SearchOne(rt.Host, rt.Port, rt.UseSSL, rt.BindDN, rt.bindPassword(), rt.BaseDN, rt.UserFilter, rt.UsernameAttr, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ldap.BindAsUser(rt.Host, rt.Port, rt.UseSSL, entry.DN, password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncLDAPUser LDAP 验证通过后同步/建号(JIT)
|
||||||
|
func syncLDAPUser(entry *ldap.Entry) model.User {
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.Where("username = ?", entry.Username).First(&user).Error; err == nil {
|
||||||
|
// 已存在:本地同名转 LDAP + 同步昵称邮箱
|
||||||
|
database.DB.Model(&user).Updates(map[string]any{
|
||||||
|
"auth_source": "ldap", "nickname": entry.DisplayName, "email": entry.Email,
|
||||||
|
})
|
||||||
|
database.DB.First(&user, user.ID)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
user = model.User{
|
||||||
|
Username: entry.Username, Nickname: entry.DisplayName, Email: entry.Email,
|
||||||
|
Role: "user", AuthSource: "ldap",
|
||||||
|
// 占位密码:本地不可登录
|
||||||
|
Password: mustHash("ldap-user-" + entry.Username + "-no-local-login"),
|
||||||
|
}
|
||||||
|
database.DB.Create(&user)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustHash(s string) string {
|
||||||
|
h, _ := crypto.HashPassword(s)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 用户管理 ----
|
||||||
|
|
||||||
|
// ListUsers GET /api/users
|
||||||
|
func ListUsers(c *gin.Context) {
|
||||||
|
var list []model.User
|
||||||
|
database.DB.Order("created_at ASC").Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser POST /api/users —— 创建本地用户
|
||||||
|
func CreateUser(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Role != "admin" {
|
||||||
|
body.Role = "user"
|
||||||
|
}
|
||||||
|
password := body.Password
|
||||||
|
if password == "" {
|
||||||
|
var err error
|
||||||
|
password, err = crypto.RandomPassword(16)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hash, err := crypto.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
Username: body.Username, Nickname: body.Nickname, Email: body.Email,
|
||||||
|
Role: body.Role, AuthSource: "local", Password: hash,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:用户名已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 密码仅在创建时回显一次(指定密码时不回显明文,回显随机生成的)
|
||||||
|
result := gin.H{"id": user.ID, "username": user.Username, "nickname": user.Nickname, "email": user.Email, "role": user.Role, "authSource": user.AuthSource}
|
||||||
|
if body.Password == "" {
|
||||||
|
result["initialPassword"] = password
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetPassword POST /api/users/:id/reset-password —— LDAP 用户拒绝
|
||||||
|
func ResetPassword(c *gin.Context) {
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.AuthSource == "ldap" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "LDAP 用户密码由目录服务管理"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password, err := crypto.RandomPassword(16)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := crypto.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
database.DB.Model(&user).Update("password", hash)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "initialPassword": password})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser DELETE /api/users/:id —— 不可删最后一个管理员/自己
|
||||||
|
func DeleteUser(c *gin.Context) {
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Username == c.GetString("username") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除当前登录用户"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Role == "admin" {
|
||||||
|
var adminCount int64
|
||||||
|
database.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
|
||||||
|
if adminCount <= 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除最后一个管理员"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
database.DB.Delete(&user)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser PUT /api/users/:id —— 角色/昵称/邮箱
|
||||||
|
func UpdateUser(c *gin.Context) {
|
||||||
|
var user model.User
|
||||||
|
if err := database.DB.First(&user, c.Param("id")).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
if v, ok := body["role"].(string); ok && (v == "admin" || v == "user") {
|
||||||
|
database.DB.Model(&user).Update("role", v)
|
||||||
|
}
|
||||||
|
if v, ok := body["nickname"].(string); ok {
|
||||||
|
database.DB.Model(&user).Update("nickname", v)
|
||||||
|
}
|
||||||
|
if v, ok := body["email"].(string); ok {
|
||||||
|
database.DB.Model(&user).Update("email", v)
|
||||||
|
}
|
||||||
|
database.DB.First(&user, user.ID)
|
||||||
|
c.JSON(http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- LDAP 用户导入(LokiStack 交互与冲突策略) ----
|
||||||
|
|
||||||
|
// LdapListUsers POST /api/ldap/users —— 导入预览(未启用返回 400)
|
||||||
|
func LdapListUsers(c *gin.Context) {
|
||||||
|
rt := loadLDAP()
|
||||||
|
if !rt.Enabled || !rt.Configured {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "LDAP 未启用或未配置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := ldap.SearchUsers(rt.Host, rt.Port, rt.UseSSL, rt.BindDN, rt.bindPassword(),
|
||||||
|
rt.BaseDN, rt.UserFilter, rt.UsernameAttr, rt.NameAttr, rt.EmailAttr, 500)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LdapImport POST /api/users/ldap-import —— 批量导入(跳过已LDAP/本地同名转LDAP/新建)
|
||||||
|
func LdapImport(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Users []struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"users" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Users) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要导入的用户"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var created, converted, skipped, failed int
|
||||||
|
var failedUsers []string
|
||||||
|
for _, in := range body.Users {
|
||||||
|
if in.Username == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var exist model.User
|
||||||
|
if err := database.DB.Where("username = ?", in.Username).First(&exist).Error; err == nil {
|
||||||
|
if exist.AuthSource == "ldap" {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 本地同名 → 转为 LDAP
|
||||||
|
database.DB.Model(&exist).Updates(map[string]any{"auth_source": "ldap", "nickname": in.Nickname, "email": in.Email})
|
||||||
|
converted++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
Username: in.Username, Nickname: in.Nickname, Email: in.Email,
|
||||||
|
Role: "user", AuthSource: "ldap",
|
||||||
|
Password: mustHash("ldap-user-" + in.Username + "-no-local-login"),
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&user).Error; err != nil {
|
||||||
|
failed++
|
||||||
|
failedUsers = append(failedUsers, in.Username)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"created": created, "converted": converted, "skipped": skipped,
|
||||||
|
"failed": failed, "failedUsers": failedUsers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLDAP POST /api/settings/ldap/test —— 支持草稿直测 + 可选测试账号
|
||||||
|
func TestLDAP(c *gin.Context) {
|
||||||
|
var s model.SystemSetting
|
||||||
|
database.DB.First(&s, 1)
|
||||||
|
var body struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port float64 `json:"port"`
|
||||||
|
UseSSL *bool `json:"useSSL"`
|
||||||
|
BindDN string `json:"bindDN"`
|
||||||
|
BindPassword string `json:"bindPassword"`
|
||||||
|
BaseDN string `json:"baseDN"`
|
||||||
|
UserFilter string `json:"userFilter"`
|
||||||
|
UsernameAttr string `json:"usernameAttr"`
|
||||||
|
TestUsername string `json:"testUsername"`
|
||||||
|
TestPassword string `json:"testPassword"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
|
||||||
|
host := body.Host
|
||||||
|
if host == "" {
|
||||||
|
host = s.LdapHost
|
||||||
|
}
|
||||||
|
bindDN := body.BindDN
|
||||||
|
if bindDN == "" {
|
||||||
|
bindDN = s.LdapBindDN
|
||||||
|
}
|
||||||
|
password := body.BindPassword
|
||||||
|
if password == "" {
|
||||||
|
password = ""
|
||||||
|
var stored string
|
||||||
|
database.DB.Select("ldap_bind_password").First(&s, 1)
|
||||||
|
stored = s.LdapBindPassword
|
||||||
|
if crypto.IsEncrypted(stored) {
|
||||||
|
if plain, err := crypto.DecryptField(stored); err == nil {
|
||||||
|
password = plain
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
password = stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseDN := body.BaseDN
|
||||||
|
if baseDN == "" {
|
||||||
|
baseDN = s.LdapBaseDN
|
||||||
|
}
|
||||||
|
port := int(body.Port)
|
||||||
|
if port == 0 {
|
||||||
|
port = s.LdapPort
|
||||||
|
}
|
||||||
|
if port == 0 {
|
||||||
|
port = 389
|
||||||
|
}
|
||||||
|
useSSL := s.LdapUseSSL
|
||||||
|
if body.UseSSL != nil {
|
||||||
|
useSSL = *body.UseSSL
|
||||||
|
}
|
||||||
|
usernameAttr := body.UsernameAttr
|
||||||
|
if usernameAttr == "" {
|
||||||
|
usernameAttr = s.LdapUsernameAttr
|
||||||
|
}
|
||||||
|
userFilter := body.UserFilter
|
||||||
|
if userFilter == "" {
|
||||||
|
userFilter = s.LdapUserFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
if host == "" || bindDN == "" || baseDN == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请填写服务器/Bind DN/Base DN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := ldap.Dial(host, port, useSSL, bindDN, password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// 可选:测试账号级验证
|
||||||
|
if body.TestUsername != "" && body.TestPassword != "" {
|
||||||
|
entry, err := ldap.SearchOne(host, port, useSSL, bindDN, password, baseDN, userFilter, usernameAttr, body.TestUsername)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试账号搜索失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ldap.BindAsUser(host, port, useSSL, entry.DN, body.TestPassword); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "message": "测试账号密码验证失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "连接与测试账号验证成功:" + entry.DisplayName + " (" + entry.Email + ")"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "LDAP 连接成功(管理员绑定通过)"})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Package jenkins Jenkins REST 客户端(移植自 opsx-jenkins 生产实现):
|
||||||
|
// crumb → BasicAuth → buildWithParameters → Location 队列 URL → 队列/构建轮询 → progressiveText 增量控制台。
|
||||||
|
package jenkins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Username string
|
||||||
|
Token string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL, username, token string) *Client {
|
||||||
|
return &Client{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
Username: username,
|
||||||
|
Token: token,
|
||||||
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobInfo Job 概要(folder 已压平为 path/name 形式)
|
||||||
|
type JobInfo struct {
|
||||||
|
Name string `json:"name"` // 压平路径 folder/job
|
||||||
|
URL string `json:"url"`
|
||||||
|
Type string `json:"type"` // folder | freestyle | pipeline | multibranch
|
||||||
|
Status string `json:"status"` // success | failed | running | unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(method, path string, form url.Values) (*http.Response, error) {
|
||||||
|
var body io.Reader
|
||||||
|
if form != nil {
|
||||||
|
body = strings.NewReader(form.Encode())
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, c.BaseURL+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(c.Username, c.Token)
|
||||||
|
if form != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
}
|
||||||
|
// CSRF crumb(未启用时 /crumbIssuer 返回 404,跳过)
|
||||||
|
if crumb, err := c.crumb(); err == nil {
|
||||||
|
req.Header.Set(crumb[0], crumb[1])
|
||||||
|
}
|
||||||
|
return c.HTTP.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) crumb() ([2]string, error) {
|
||||||
|
req, _ := http.NewRequest("GET", c.BaseURL+"/crumbIssuer/api/json", nil)
|
||||||
|
req.SetBasicAuth(c.Username, c.Token)
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return [2]string{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return [2]string{}, errors.New("crumb not enabled")
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
CrumbRequestField string `json:"crumbRequestField"`
|
||||||
|
Crumb string `json:"crumb"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Crumb == "" {
|
||||||
|
return [2]string{}, errors.New("no crumb")
|
||||||
|
}
|
||||||
|
return [2]string{out.CrumbRequestField, out.Crumb}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 连通测试:访问 /api/json
|
||||||
|
func (c *Client) Test() error {
|
||||||
|
resp, err := c.do("GET", "/api/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return errors.New("认证失败:用户名或 Token 错误")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawJob struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Jobs []rawJob `json:"jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// colorToStatus Jenkins ball color → 状态
|
||||||
|
func colorToStatus(color string) string {
|
||||||
|
switch {
|
||||||
|
case color == "":
|
||||||
|
return "unknown"
|
||||||
|
case strings.HasSuffix(color, "anime"):
|
||||||
|
return "running"
|
||||||
|
case strings.HasPrefix(color, "blue"):
|
||||||
|
return "success"
|
||||||
|
case strings.HasPrefix(color, "red"):
|
||||||
|
return "failed"
|
||||||
|
case strings.HasPrefix(color, "notbuilt"), strings.HasPrefix(color, "disabled"), strings.HasPrefix(color, "aborted"):
|
||||||
|
return "unknown"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListJobs 递归 3 层拉取 Job 树并压平 folder
|
||||||
|
func (c *Client) ListJobs() ([]JobInfo, error) {
|
||||||
|
resp, err := c.do("GET", "/api/json?tree=jobs[name,url,color,jobs[name,url,color,jobs[name,url,color,jobs[name,url,color]]]]", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var root struct {
|
||||||
|
Jobs []rawJob `json:"jobs"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var flat []JobInfo
|
||||||
|
var walk func(jobs []rawJob, prefix string)
|
||||||
|
walk = func(jobs []rawJob, prefix string) {
|
||||||
|
for _, j := range jobs {
|
||||||
|
full := j.Name
|
||||||
|
if prefix != "" {
|
||||||
|
full = prefix + "/" + j.Name
|
||||||
|
}
|
||||||
|
if len(j.Jobs) > 0 {
|
||||||
|
flat = append(flat, JobInfo{Name: full, URL: j.URL, Type: "folder", Status: "unknown"})
|
||||||
|
walk(j.Jobs, full)
|
||||||
|
} else {
|
||||||
|
flat = append(flat, JobInfo{Name: full, URL: j.URL, Type: "pipeline", Status: colorToStatus(j.Color)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(root.Jobs, "")
|
||||||
|
return flat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobPathToURL folder/job → /job/folder/job/job/name
|
||||||
|
func jobPathToURL(jobPath string) string {
|
||||||
|
segs := strings.Split(jobPath, "/")
|
||||||
|
var b strings.Builder
|
||||||
|
for _, s := range segs {
|
||||||
|
b.WriteString("/job/")
|
||||||
|
b.WriteString(url.PathEscape(s))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger 触发构建,返回队列 URL(Location 头;拒绝回退 lastBuild 防串号)
|
||||||
|
func (c *Client) Trigger(jobPath string, params map[string]string) (string, error) {
|
||||||
|
path := jobPathToURL(jobPath)
|
||||||
|
var form url.Values
|
||||||
|
if len(params) > 0 {
|
||||||
|
form = url.Values{}
|
||||||
|
for k, v := range params {
|
||||||
|
form.Set(k, v)
|
||||||
|
}
|
||||||
|
path += "/buildWithParameters"
|
||||||
|
} else {
|
||||||
|
path += "/build"
|
||||||
|
}
|
||||||
|
resp, err := c.do("POST", path, form)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return "", fmt.Errorf("触发失败 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
queueURL := resp.Header.Get("Location")
|
||||||
|
if queueURL == "" {
|
||||||
|
return "", errors.New("未返回队列地址(Location),请检查 Jenkins 版本与权限")
|
||||||
|
}
|
||||||
|
return queueURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitQueue 等待队列分配为具体构建,返回构建号与构建 URL
|
||||||
|
func (c *Client) WaitQueue(queueURL string, timeout time.Duration, emit func(string)) (int, string, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
pollClient := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
req, _ := http.NewRequest("GET", queueURL+"api/json", nil)
|
||||||
|
req.SetBasicAuth(c.Username, c.Token)
|
||||||
|
resp, err := pollClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
var q struct {
|
||||||
|
Cancelled bool `json:"cancelled"`
|
||||||
|
Executable *struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"executable"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(resp.Body).Decode(&q) == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
if q.Cancelled {
|
||||||
|
return 0, "", errors.New("构建已被 Jenkins 取消(排队被拒绝)")
|
||||||
|
}
|
||||||
|
if q.Executable != nil {
|
||||||
|
return q.Executable.Number, strings.TrimRight(q.Executable.URL, "/"), nil
|
||||||
|
}
|
||||||
|
if q.Why != "" {
|
||||||
|
emit("排队中: " + q.Why)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
return 0, "", errors.New("等待队列超时")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamConsole 增量拉取控制台日志(X-Text-Size/X-More-Data 推进),直到构建结束
|
||||||
|
func (c *Client) StreamConsole(buildURL string, emit func(string)) {
|
||||||
|
offset := 0
|
||||||
|
pollClient := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
for {
|
||||||
|
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/logText/progressiveText?start=%d", buildURL, offset), nil)
|
||||||
|
req.SetBasicAuth(c.Username, c.Token)
|
||||||
|
resp, err := pollClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
emit("日志拉取失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if len(body) > 0 {
|
||||||
|
for _, line := range strings.Split(strings.TrimRight(string(body), "\n"), "\n") {
|
||||||
|
if line != "" {
|
||||||
|
emit(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newSize := parseInt(resp.Header.Get("X-Text-Size")); newSize > offset {
|
||||||
|
offset = newSize
|
||||||
|
}
|
||||||
|
if resp.Header.Get("X-More-Data") != "true" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitResult 轮询构建结果:SUCCESS/FAILURE/ABORTED/null(进行中)
|
||||||
|
func (c *Client) WaitResult(buildURL string, timeout time.Duration) (string, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
req, _ := http.NewRequest("GET", buildURL+"/api/json", nil)
|
||||||
|
req.SetBasicAuth(c.Username, c.Token)
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
var b struct {
|
||||||
|
Building bool `json:"building"`
|
||||||
|
Result *string `json:"result"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(resp.Body).Decode(&b) == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
if !b.Building && b.Result != nil {
|
||||||
|
return *b.Result, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
}
|
||||||
|
return "", errors.New("等待构建结果超时")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(s string) int {
|
||||||
|
n := 0
|
||||||
|
for _, ch := range s {
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
n = n*10 + int(ch-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapResult Jenkins 结果 → FlowStack 运行状态
|
||||||
|
func MapResult(r string) string {
|
||||||
|
switch r {
|
||||||
|
case "SUCCESS":
|
||||||
|
return "success"
|
||||||
|
case "ABORTED":
|
||||||
|
return "cancelled"
|
||||||
|
default:
|
||||||
|
return "failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Package lark 飞书/Lark 群自定义机器人卡片通知。
|
||||||
|
package lark
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReleaseCardParams 发布结果卡片字段
|
||||||
|
type ReleaseCardParams struct {
|
||||||
|
AppName string // 应用名
|
||||||
|
Branch string // 分支
|
||||||
|
Build string // 构建号(如 #172 或 pr-xxx)
|
||||||
|
Status string // success | failed | 其他
|
||||||
|
Time string // 展示时间
|
||||||
|
BuildURL string // 「查看构建详情」按钮地址(空则不放按钮)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusMeta(status string) (template, text string) {
|
||||||
|
switch status {
|
||||||
|
case "success":
|
||||||
|
return "green", "✅ 成功"
|
||||||
|
case "failed":
|
||||||
|
return "red", "❌ 失败"
|
||||||
|
case "cancelled":
|
||||||
|
return "yellow", "⚠️ 已取消"
|
||||||
|
default:
|
||||||
|
return "yellow", "⚠️ " + status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildReleaseCard 组装发布结果卡片(结构对齐现有 Jenkins 通知脚本)
|
||||||
|
func BuildReleaseCard(p ReleaseCardParams) map[string]any {
|
||||||
|
_, statusText := statusMeta(p.Status)
|
||||||
|
template, _ := statusMeta(p.Status)
|
||||||
|
|
||||||
|
elements := []any{
|
||||||
|
map[string]any{
|
||||||
|
"tag": "div",
|
||||||
|
"text": map[string]any{
|
||||||
|
"tag": "lark_md",
|
||||||
|
"content": fmt.Sprintf(
|
||||||
|
"**应用:** %s\n**分支:** %s\n**构建:** %s\n**状态:** %s\n**时间:** %s",
|
||||||
|
p.AppName, p.Branch, p.Build, statusText, p.Time,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if p.BuildURL != "" {
|
||||||
|
elements = append(elements, map[string]any{
|
||||||
|
"tag": "action",
|
||||||
|
"actions": []any{map[string]any{
|
||||||
|
"tag": "button",
|
||||||
|
"text": map[string]any{"tag": "plain_text", "content": "查看构建详情"},
|
||||||
|
"url": p.BuildURL,
|
||||||
|
"type": "default",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, statusOnly := statusMeta(p.Status)
|
||||||
|
title := "发布结果通知 - " + statusOnly
|
||||||
|
return map[string]any{
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"card": map[string]any{
|
||||||
|
"config": map[string]any{"wide_screen_mode": true},
|
||||||
|
"header": map[string]any{
|
||||||
|
"template": template,
|
||||||
|
"title": map[string]any{"tag": "plain_text", "content": title},
|
||||||
|
},
|
||||||
|
"elements": elements,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send 发送卡片;secret 非空时按飞书自定义机器人规范加签(timestamp+sign)
|
||||||
|
func Send(webhook, secret string, card map[string]any) error {
|
||||||
|
if webhook == "" {
|
||||||
|
return fmt.Errorf("webhook 未配置")
|
||||||
|
}
|
||||||
|
if secret != "" {
|
||||||
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
mac := hmac.New(sha256.New, []byte(ts+"\n"+secret))
|
||||||
|
mac.Write(nil)
|
||||||
|
card["timestamp"] = ts
|
||||||
|
card["sign"] = base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(card)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{Timeout: 10 * time.Second}).Post(webhook, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
// 飞书成功返回 {"code":0,...} / {"StatusCode":0}
|
||||||
|
var out struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
StatusCode int `json:"StatusCode"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
ErrorMessage string `json:"ErrorMessage"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(data, &out)
|
||||||
|
if out.Code != 0 || out.StatusCode != 0 {
|
||||||
|
msg := out.Msg
|
||||||
|
if msg == "" {
|
||||||
|
msg = out.ErrorMessage
|
||||||
|
}
|
||||||
|
return fmt.Errorf("飞书返回错误: %s", msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package ldap 目录服务集成(go-ldap,实现模式参考 LokiStack)。
|
||||||
|
package ldap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry LDAP 用户条目
|
||||||
|
type Entry struct {
|
||||||
|
DN string `json:"dn"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial 连接并完成管理员 Bind(5s 拨号 / 10s 操作超时)
|
||||||
|
func Dial(host string, port int, useSSL bool, bindDN, bindPassword string) (*ldap.Conn, error) {
|
||||||
|
scheme := "ldap"
|
||||||
|
if useSSL {
|
||||||
|
scheme = "ldaps"
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||||
|
conn, err := ldap.DialURL(url,
|
||||||
|
ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}),
|
||||||
|
ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("无法连接 LDAP 服务器 %s: %v", url, err)
|
||||||
|
}
|
||||||
|
conn.SetTimeout(10 * time.Second)
|
||||||
|
if err := conn.Bind(bindDN, bindPassword); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("管理员绑定失败(检查 Bind DN/密码): %v", err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFilter(f, def string) string {
|
||||||
|
if strings.TrimSpace(f) == "" {
|
||||||
|
f = def
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(f, "(") {
|
||||||
|
f = "(" + f + ")"
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsers 按 BaseDN/过滤器搜索用户(sizeLimit 截断)
|
||||||
|
func SearchUsers(host string, port int, useSSL bool, bindDN, bindPassword, baseDN, userFilter, usernameAttr, nameAttr, emailAttr string, sizeLimit int) ([]Entry, error) {
|
||||||
|
conn, err := Dial(host, port, useSSL, bindDN, bindPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if usernameAttr == "" {
|
||||||
|
usernameAttr = "uid"
|
||||||
|
}
|
||||||
|
if nameAttr == "" {
|
||||||
|
nameAttr = "displayName"
|
||||||
|
}
|
||||||
|
if emailAttr == "" {
|
||||||
|
emailAttr = "mail"
|
||||||
|
}
|
||||||
|
req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||||
|
sizeLimit, 0, false, normalizeFilter(userFilter, "(objectClass=inetOrgPerson)"),
|
||||||
|
[]string{"dn", usernameAttr, nameAttr, "cn", emailAttr}, nil)
|
||||||
|
res, err := conn.Search(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("搜索失败: %v", err)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []Entry
|
||||||
|
for _, e := range res.Entries {
|
||||||
|
username := e.GetAttributeValue(usernameAttr)
|
||||||
|
if username == "" {
|
||||||
|
continue // 跳过 OU 等非用户条目
|
||||||
|
}
|
||||||
|
if seen[e.DN] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[e.DN] = true
|
||||||
|
name := e.GetAttributeValue(nameAttr)
|
||||||
|
if name == "" {
|
||||||
|
name = e.GetAttributeValue("cn")
|
||||||
|
}
|
||||||
|
out = append(out, Entry{
|
||||||
|
DN: e.DN, Username: username, DisplayName: name, Email: e.GetAttributeValue(emailAttr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchOne 按用户名搜索单个条目(登录用,EscapeFilter 防注入)
|
||||||
|
func SearchOne(host string, port int, useSSL bool, bindDN, bindPassword, baseDN, userFilter, usernameAttr, username string) (*Entry, error) {
|
||||||
|
conn, err := Dial(host, port, useSSL, bindDN, bindPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if usernameAttr == "" {
|
||||||
|
usernameAttr = "uid"
|
||||||
|
}
|
||||||
|
filter := fmt.Sprintf("(&%s(%s=%s))", normalizeFilter(userFilter, "(objectClass=inetOrgPerson)"),
|
||||||
|
usernameAttr, ldap.EscapeFilter(username))
|
||||||
|
req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||||
|
2, 0, false, filter, []string{"dn", usernameAttr, "displayName", "cn", "mail"}, nil)
|
||||||
|
res, err := conn.Search(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(res.Entries) == 0 {
|
||||||
|
return nil, fmt.Errorf("用户不存在")
|
||||||
|
}
|
||||||
|
if len(res.Entries) > 1 {
|
||||||
|
return nil, fmt.Errorf("LDAP 中存在多个匹配的用户")
|
||||||
|
}
|
||||||
|
e := res.Entries[0]
|
||||||
|
name := e.GetAttributeValue("displayName")
|
||||||
|
if name == "" {
|
||||||
|
name = e.GetAttributeValue("cn")
|
||||||
|
}
|
||||||
|
return &Entry{DN: e.DN, Username: e.GetAttributeValue(usernameAttr), DisplayName: name, Email: e.GetAttributeValue("mail")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindAsUser 用用户自己的 DN+密码直连校验(密码不落库)
|
||||||
|
func BindAsUser(host string, port int, useSSL bool, userDN, password string) error {
|
||||||
|
scheme := "ldap"
|
||||||
|
if useSSL {
|
||||||
|
scheme = "ldaps"
|
||||||
|
}
|
||||||
|
conn, err := ldap.DialURL(fmt.Sprintf("%s://%s:%d", scheme, host, port),
|
||||||
|
ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}),
|
||||||
|
ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
return conn.Bind(userDN, password)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// Package llm OpenAI 兼容 Chat 客户端(移植自 opsx-jenkins 生产实现)。
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Endpoint string
|
||||||
|
APIKey string
|
||||||
|
Model string
|
||||||
|
MaxTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(endpoint, apiKey, model string, maxTokens int) *Client {
|
||||||
|
if maxTokens <= 0 {
|
||||||
|
maxTokens = 1200
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
Endpoint: strings.TrimRight(endpoint, "/"),
|
||||||
|
APIKey: apiKey,
|
||||||
|
Model: model,
|
||||||
|
MaxTokens: maxTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []chatMessage `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat 单轮对话;endpoint/apiKey 为空时返回错误由调用方降级
|
||||||
|
func (c *Client) Chat(ctx context.Context, system, user string) (string, error) {
|
||||||
|
if c.Endpoint == "" || c.APIKey == "" || c.Model == "" {
|
||||||
|
return "", fmt.Errorf("LLM 未配置")
|
||||||
|
}
|
||||||
|
payload := chatRequest{
|
||||||
|
Model: c.Model,
|
||||||
|
Messages: []chatMessage{
|
||||||
|
{Role: "system", Content: system},
|
||||||
|
{Role: "user", Content: user},
|
||||||
|
},
|
||||||
|
Temperature: 0.2,
|
||||||
|
MaxTokens: c.MaxTokens,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint+"/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
|
||||||
|
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var out chatResponse
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
return "", fmt.Errorf("响应解析失败: %s", truncate(string(data), 200))
|
||||||
|
}
|
||||||
|
if out.Error != nil && out.Error.Message != "" {
|
||||||
|
return "", fmt.Errorf("LLM 错误: %s", out.Error.Message)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(data), 200))
|
||||||
|
}
|
||||||
|
if len(out.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("空响应")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamDelta 单个流式增量
|
||||||
|
type StreamDelta struct {
|
||||||
|
Content string // 正文增量
|
||||||
|
Reasoning string // 推理过程增量(DeepSeek R1 类模型)
|
||||||
|
Usage *Usage // 最后一个 chunk 可能带用量
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage token 用量
|
||||||
|
type Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamChunk struct {
|
||||||
|
Choices []struct {
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Reasoning string `json:"reasoning_content"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"delta"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage *Usage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatStream 流式对话:onDelta 逐段回调(正文/推理),返回完整正文。
|
||||||
|
// 流式请求不设整体超时,由 ctx 控制取消。
|
||||||
|
func (c *Client) ChatStream(ctx context.Context, system, user string, onDelta func(StreamDelta)) (string, error) {
|
||||||
|
if c.Endpoint == "" || c.APIKey == "" || c.Model == "" {
|
||||||
|
return "", fmt.Errorf("LLM 未配置")
|
||||||
|
}
|
||||||
|
payload := chatRequest{
|
||||||
|
Model: c.Model,
|
||||||
|
Messages: []chatMessage{
|
||||||
|
{Role: "system", Content: system},
|
||||||
|
{Role: "user", Content: user},
|
||||||
|
},
|
||||||
|
Temperature: 0.2,
|
||||||
|
MaxTokens: c.MaxTokens,
|
||||||
|
Stream: true,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint+"/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||||
|
|
||||||
|
// 流式专用 client:Timeout 0,取消由 ctx 控制
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(data), 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
var full strings.Builder
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if !strings.HasPrefix(line, "data:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||||
|
if data == "" || data == "[DONE]" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var chunk streamChunk
|
||||||
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(chunk.Choices) == 0 {
|
||||||
|
if chunk.Usage != nil && onDelta != nil {
|
||||||
|
onDelta(StreamDelta{Usage: chunk.Usage})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := chunk.Choices[0].Delta
|
||||||
|
if d.Content != "" || d.Reasoning != "" {
|
||||||
|
full.WriteString(d.Content)
|
||||||
|
if onDelta != nil {
|
||||||
|
onDelta(StreamDelta{Content: d.Content, Reasoning: d.Reasoning})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return full.String(), nil // 客户端主动取消:返回已收到的内容
|
||||||
|
}
|
||||||
|
return full.String(), err
|
||||||
|
}
|
||||||
|
if full.Len() == 0 {
|
||||||
|
return "", fmt.Errorf("流式响应为空")
|
||||||
|
}
|
||||||
|
return full.String(), nil
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ type User struct {
|
|||||||
Username string `gorm:"uniqueIndex" json:"username"`
|
Username string `gorm:"uniqueIndex" json:"username"`
|
||||||
Password string `json:"-"` // argon2id 哈希,绝不返回给前端
|
Password string `json:"-"` // argon2id 哈希,绝不返回给前端
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AuthSource string `gorm:"default:local" json:"authSource"` // local | ldap
|
||||||
Role string `gorm:"default:user" json:"role"` // admin | user
|
Role string `gorm:"default:user" json:"role"` // admin | user
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
@@ -15,18 +17,27 @@ type User struct {
|
|||||||
|
|
||||||
// Pipeline 流水线定义
|
// Pipeline 流水线定义
|
||||||
type Pipeline struct {
|
type Pipeline struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Name string `gorm:"index" json:"name"`
|
Name string `gorm:"index" json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Type string `gorm:"default:native" json:"type"` // native | jenkins | tekton
|
Type string `gorm:"default:native" json:"type"` // native | jenkins | tekton
|
||||||
SourceRepo string `json:"sourceRepo"`
|
SourceRepo string `json:"sourceRepo"`
|
||||||
SourceBranch string `gorm:"default:main" json:"sourceBranch"`
|
SourceBranch string `gorm:"default:main" json:"sourceBranch"`
|
||||||
GitopsRepo string `json:"gitopsRepo"`
|
GitopsRepo string `json:"gitopsRepo"`
|
||||||
GitopsPath string `json:"gitopsPath"`
|
GitopsPath string `json:"gitopsPath"`
|
||||||
DeployType string `gorm:"default:host" json:"deployType"` // host | k8s | gitops
|
DeployType string `gorm:"default:host" json:"deployType"` // host | k8s | gitops
|
||||||
Env string `gorm:"default:staging" json:"env"`
|
Env string `gorm:"default:staging" json:"env"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
TriggerMode string `gorm:"default:manual" json:"triggerMode"` // manual | git_push | cron | webhook
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
CronExpr string `json:"cronExpr"`
|
||||||
|
WebhookToken string `json:"webhookToken"`
|
||||||
|
WatchBranches string `json:"watchBranches"`
|
||||||
|
Variables string `gorm:"type:text" json:"variables"` // JSON [{key,value,remark}]
|
||||||
|
CachePaths string `gorm:"type:text" json:"cachePaths"` // JSON [{path,key}]
|
||||||
|
NotifyWebhook string `json:"notifyWebhookURL"`
|
||||||
|
JenkinsJobName string `json:"jenkinsJobName"`
|
||||||
|
JenkinsServerID uint `json:"jenkinsServerId"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PipelineNode 画布节点(JSON 字段名与前端 React Flow 对齐)
|
// PipelineNode 画布节点(JSON 字段名与前端 React Flow 对齐)
|
||||||
@@ -67,8 +78,12 @@ type PipelineRun struct {
|
|||||||
Type string `gorm:"default:native" json:"type"`
|
Type string `gorm:"default:native" json:"type"`
|
||||||
TriggerBy string `gorm:"default:system" json:"triggerBy"`
|
TriggerBy string `gorm:"default:system" json:"triggerBy"`
|
||||||
TriggerType string `gorm:"default:manual" json:"triggerType"` // manual|webhook|cron|batch
|
TriggerType string `gorm:"default:manual" json:"triggerType"` // manual|webhook|cron|batch
|
||||||
Status string `gorm:"default:pending" json:"status"` // pending|running|success|failed|cancelled
|
Status string `gorm:"default:pending" json:"status"` // pending|queued|running|success|failed|cancelled
|
||||||
DurationMs int64 `json:"durationMs"`
|
DurationMs int64 `json:"durationMs"`
|
||||||
|
BuildNumber int `json:"buildNumber"`
|
||||||
|
Analysis string `gorm:"type:text" json:"analysis"` // 发布失败时的 AI 分析
|
||||||
|
ExternalRunID string `json:"externalRunId"`
|
||||||
|
ExternalURL string `json:"externalUrl"`
|
||||||
NodesSnapshot string `json:"nodesSnapshot"`
|
NodesSnapshot string `json:"nodesSnapshot"`
|
||||||
EdgesSnapshot string `json:"edgesSnapshot"`
|
EdgesSnapshot string `json:"edgesSnapshot"`
|
||||||
StartedAt time.Time `json:"startedAt"`
|
StartedAt time.Time `json:"startedAt"`
|
||||||
@@ -103,19 +118,22 @@ type LogEntry struct {
|
|||||||
|
|
||||||
// Application GitOps 应用
|
// Application GitOps 应用
|
||||||
type Application struct {
|
type Application struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Name string `gorm:"uniqueIndex" json:"name"`
|
Name string `gorm:"uniqueIndex" json:"name"`
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
Branch string `gorm:"default:main" json:"branch"`
|
Branch string `gorm:"default:main" json:"branch"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Cluster string `gorm:"default:default" json:"cluster"`
|
Cluster string `gorm:"default:default" json:"cluster"`
|
||||||
Namespace string `gorm:"default:default" json:"namespace"`
|
Namespace string `gorm:"default:default" json:"namespace"`
|
||||||
ManifestType string `gorm:"default:plain" json:"manifestType"` // helm|kustomize|plain
|
ManifestType string `gorm:"default:plain" json:"manifestType"` // helm|kustomize|plain
|
||||||
SyncPolicy string `gorm:"default:manual" json:"syncPolicy"` // manual|automated
|
SyncPolicy string `gorm:"default:manual" json:"syncPolicy"` // manual|automated
|
||||||
AutoPrune bool `json:"autoPrune"`
|
AutoPrune bool `json:"autoPrune"`
|
||||||
SelfHeal bool `json:"selfHeal"`
|
SelfHeal bool `json:"selfHeal"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
SyncStatus string `gorm:"default:Synced" json:"syncStatus"` // Synced|OutOfSync|Syncing
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
HealthStatus string `gorm:"default:Healthy" json:"healthStatus"` // Healthy|Progressing|Degraded
|
||||||
|
LastSyncAt *time.Time `json:"lastSyncAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin 插件
|
// Plugin 插件
|
||||||
@@ -133,3 +151,154 @@ type Plugin struct {
|
|||||||
Config map[string]interface{} `gorm:"serializer:json" json:"config"`
|
Config map[string]interface{} `gorm:"serializer:json" json:"config"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host 主机资源
|
||||||
|
type Host struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex" json:"name"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
SSHPort int `gorm:"default:22" json:"port"`
|
||||||
|
Username string `gorm:"default:root" json:"username"`
|
||||||
|
Tags string `gorm:"type:text" json:"tags"` // JSON ["production","web"]
|
||||||
|
Status string `gorm:"default:offline" json:"status"` // online|offline
|
||||||
|
OS string `json:"os"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// K8sCluster 集群资源
|
||||||
|
type K8sCluster struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex" json:"name"`
|
||||||
|
Context string `json:"context"`
|
||||||
|
APIServer string `json:"apiServer"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Kubeconfig string `gorm:"type:text" json:"-"` // enc:v1: 加密,不回显
|
||||||
|
KubeconfigSet bool `gorm:"-" json:"kubeconfigSet"`
|
||||||
|
NodeCount int `json:"nodes"`
|
||||||
|
Namespaces string `gorm:"type:text" json:"namespaces"` // JSON []
|
||||||
|
Status string `gorm:"default:disconnected" json:"status"` // connected|disconnected
|
||||||
|
LastHeartbeat *time.Time `json:"lastHeartbeat"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credential 凭证(内容 AES-GCM 加密,只写不读)
|
||||||
|
type Credential struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex" json:"name"`
|
||||||
|
Type string `gorm:"index" json:"type"` // ssh|password|docker|git|kubeconfig|aksk
|
||||||
|
Scope string `gorm:"default:全局" json:"scope"`
|
||||||
|
Content string `gorm:"type:text" json:"-"` // enc:v1: 加密 JSON
|
||||||
|
ContentSet bool `gorm:"-" json:"contentSet"`
|
||||||
|
Summary string `json:"summary"` // 脱敏摘要
|
||||||
|
UsageCount int `gorm:"default:0" json:"usageCount"`
|
||||||
|
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildNode 构建节点
|
||||||
|
type BuildNode struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex" json:"name"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Labels string `gorm:"type:text" json:"labels"` // JSON ["docker","go"]
|
||||||
|
MaxConcurrency int `gorm:"default:2" json:"maxConcurrency"`
|
||||||
|
WorkspacePath string `gorm:"default:/data/flowstack-workspace" json:"workspacePath"`
|
||||||
|
DiskInfo string `json:"diskInfo"` // 如 "45G/200G"
|
||||||
|
CredentialID *uint `json:"credentialId"` // SSH 凭证(磁盘性能测试等远程操作)
|
||||||
|
DiskWriteMBps float64 `gorm:"column:disk_write_mbps" json:"diskWriteMBps"`
|
||||||
|
DiskReadMBps float64 `gorm:"column:disk_read_mbps" json:"diskReadMBps"`
|
||||||
|
DiskTestMode string `json:"diskTestMode"` // local|ssh + /direct|/cache
|
||||||
|
DiskTestedAt *time.Time `json:"diskTestedAt"`
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
|
Status string `gorm:"default:offline" json:"status"` // online|offline|busy
|
||||||
|
CurrentJobs int `gorm:"default:0" json:"currentJobs"`
|
||||||
|
LastHeartbeat *time.Time `json:"lastHeartbeat"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployTool 构建工具(检测命令真实执行;安装为保守策略:仅检测不下载)
|
||||||
|
type DeployTool struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
ToolKey string `gorm:"uniqueIndex" json:"key"`
|
||||||
|
DisplayName string `json:"name"`
|
||||||
|
Category string `gorm:"default:build" json:"category"` // build|deploy
|
||||||
|
Status string `gorm:"default:not-installed" json:"status"` // not-installed|installed|installing|failed
|
||||||
|
Version string `json:"version"` // 默认/期望版本
|
||||||
|
InstalledVersion string `json:"installedVersion"` // 真实探测到的版本
|
||||||
|
InstallMode string `gorm:"default:lazy" json:"installMode"` // preinstalled|lazy|disabled
|
||||||
|
Path string `json:"path"`
|
||||||
|
CheckCommand string `gorm:"type:text" json:"checkCommand"` // 真实版本探测命令
|
||||||
|
InstallHint string `gorm:"type:text" json:"installHint"` // 安装命令提示(Ubuntu apt / nvm)
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
|
LastCheck *time.Time `json:"lastCheck"`
|
||||||
|
ErrorMsg string `json:"errorMsg"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookEndpoint 出站 Webhook(HMAC-SHA256 签名)
|
||||||
|
type WebhookEndpoint struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Secret string `gorm:"type:text" json:"-"` // enc:v1: 加密
|
||||||
|
SecretSet bool `gorm:"-" json:"secretSet"`
|
||||||
|
Events string `gorm:"type:text" json:"events"` // JSON ["pipeline.success",...]
|
||||||
|
Active bool `gorm:"default:true" json:"active"`
|
||||||
|
LastTriggeredAt *time.Time `json:"lastTriggered"`
|
||||||
|
LastStatus string `gorm:"default:none" json:"lastStatus"` // success|failed|none
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JenkinsServer Jenkins 实例
|
||||||
|
type JenkinsServer struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex" json:"name"`
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Token string `gorm:"type:text" json:"-"` // enc:v1: 加密
|
||||||
|
TokenSet bool `gorm:"-" json:"tokenSet"`
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemSetting 系统设置(单行,ID 恒为 1)
|
||||||
|
type SystemSetting struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Theme string `gorm:"type:text" json:"theme"` // JSON {mode,primaryColor,...}
|
||||||
|
Notification string `gorm:"type:text" json:"notification"` // JSON {email,browser,...}
|
||||||
|
ToolMode string `gorm:"default:local" json:"toolMode"` // local|jenkins
|
||||||
|
LlmEnabled bool `gorm:"default:false" json:"llmEnabled"`
|
||||||
|
LlmEndpoint string `gorm:"default:https://api.deepseek.com/v1" json:"llmEndpoint"`
|
||||||
|
LlmModel string `gorm:"default:deepseek-chat" json:"llmModel"`
|
||||||
|
LlmApiKey string `gorm:"type:text" json:"-"` // enc:v1: 加密,只写不读
|
||||||
|
LlmApiKeySet bool `gorm:"-" json:"llmApiKeySet"`
|
||||||
|
LlmMaxTokens int `gorm:"default:1200" json:"llmMaxTokens"`
|
||||||
|
LarkEnabled bool `gorm:"default:false" json:"larkEnabled"`
|
||||||
|
LarkWebhook string `json:"larkWebhook"` // 飞书群自定义机器人 Webhook
|
||||||
|
LarkSecret string `gorm:"type:text" json:"-"` // 加签密钥(可选),enc:v1: 加密
|
||||||
|
LarkSecretSet bool `gorm:"-" json:"larkSecretSet"`
|
||||||
|
LdapEnabled bool `json:"ldapEnabled"`
|
||||||
|
LdapHost string `json:"ldapHost"`
|
||||||
|
LdapPort int `gorm:"default:389" json:"ldapPort"`
|
||||||
|
LdapUseSSL bool `json:"ldapUseSSL"`
|
||||||
|
LdapBindDN string `json:"ldapBindDN"`
|
||||||
|
LdapBindPassword string `gorm:"type:text" json:"-"` // enc:v1: 加密只写
|
||||||
|
LdapBindPasswordSet bool `gorm:"-" json:"ldapBindPasswordSet"`
|
||||||
|
LdapBaseDN string `json:"ldapBaseDN"`
|
||||||
|
LdapUserFilter string `json:"ldapUserFilter"`
|
||||||
|
LdapUsernameAttr string `json:"ldapUsernameAttr"`
|
||||||
|
LdapNameAttr string `json:"ldapNameAttr"`
|
||||||
|
LdapEmailAttr string `json:"ldapEmailAttr"`
|
||||||
|
Extra string `gorm:"type:text" json:"extra"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Package parser 发布清单文本解析(规则移植自 opsx-jenkins parser.go)。
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Jobs []Job `json:"jobs"`
|
||||||
|
ParamName string `json:"param"`
|
||||||
|
RefType string `json:"refType"` // tag | branch
|
||||||
|
}
|
||||||
|
|
||||||
|
var skipHeaders = map[string]bool{
|
||||||
|
"jenkins服务": true, "jenkins": true, "服务": true, "发布": true,
|
||||||
|
"发布清单": true, "jobs": true, "job": true, "服务列表": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse 解析多行发布文本。
|
||||||
|
// refType: "branch" 每行一个应用名(版本由 fixedRef 统一覆盖);
|
||||||
|
// "tag" 支持:参数头行、"name version" 内联对(=/:/tab/空格分隔)、名称/版本交替行。
|
||||||
|
func Parse(text, refType, fixedRef string) (*Result, error) {
|
||||||
|
if refType != "branch" {
|
||||||
|
refType = "tag"
|
||||||
|
}
|
||||||
|
lines := splitNonEmptyLines(text)
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return nil, fmt.Errorf("发布文本为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
res := &Result{RefType: refType}
|
||||||
|
if refType == "branch" {
|
||||||
|
if fixedRef == "" {
|
||||||
|
fixedRef = "test"
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
name := l
|
||||||
|
if fields := strings.Fields(l); len(fields) >= 2 {
|
||||||
|
name = fields[0]
|
||||||
|
}
|
||||||
|
res.Jobs = append(res.Jobs, Job{Name: name, Version: fixedRef})
|
||||||
|
}
|
||||||
|
if err := validate(res.Jobs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tag 模式
|
||||||
|
var tokens []string
|
||||||
|
for _, l := range lines {
|
||||||
|
norm := strings.ToLower(strings.ReplaceAll(strings.TrimRight(l, ":"), " ", ""))
|
||||||
|
if skipHeaders[norm] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res.ParamName == "" && isParamHeader(l) {
|
||||||
|
res.ParamName = l
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name, ver, ok := splitInlinePair(l); ok {
|
||||||
|
res.Jobs = append(res.Jobs, Job{Name: name, Version: ver})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tokens = append(tokens, l)
|
||||||
|
}
|
||||||
|
if len(tokens)%2 != 0 {
|
||||||
|
last := tokens[len(tokens)-1]
|
||||||
|
return nil, fmt.Errorf("发布文本无法成对解析,剩余孤立行: %s", last)
|
||||||
|
}
|
||||||
|
for i := 0; i < len(tokens); i += 2 {
|
||||||
|
res.Jobs = append(res.Jobs, Job{Name: tokens[i], Version: tokens[i+1]})
|
||||||
|
}
|
||||||
|
if res.ParamName == "" {
|
||||||
|
res.ParamName = "TAG"
|
||||||
|
}
|
||||||
|
if err := validate(res.Jobs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitNonEmptyLines(text string) []string {
|
||||||
|
text = strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")
|
||||||
|
var out []string
|
||||||
|
for _, l := range strings.Split(text, "\n") {
|
||||||
|
l = strings.TrimSpace(l)
|
||||||
|
if l == "" || strings.HasPrefix(l, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isParamHeader 识别参数头行:TAG/BRANCH/VERSION/SELECT 等显式词,或全大写+下划线的 Jenkins 参数名
|
||||||
|
func isParamHeader(line string) bool {
|
||||||
|
explicit := map[string]bool{
|
||||||
|
"tag": true, "branch": true, "version": true, "select": true,
|
||||||
|
"git_tag": true, "image_tag": true, "git_branch": true, "ref": true,
|
||||||
|
}
|
||||||
|
norm := strings.ToLower(strings.TrimSpace(strings.TrimRight(line, ":")))
|
||||||
|
if explicit[norm] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(line) <= 24 && len(line) >= 2 && !strings.ContainsAny(line, "/-. ") {
|
||||||
|
for _, r := range line {
|
||||||
|
if r != '_' && (r < 'A' || r > 'Z') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitInlinePair "name ver" / "name=ver" / "name:ver" / tab 分隔
|
||||||
|
func splitInlinePair(line string) (string, string, bool) {
|
||||||
|
for _, sep := range []string{"=", ":"} {
|
||||||
|
if idx := strings.Index(line, sep); idx > 0 {
|
||||||
|
name := strings.TrimSpace(line[:idx])
|
||||||
|
ver := strings.TrimSpace(line[idx+len(sep):])
|
||||||
|
if name != "" && ver != "" && looksLikeName(name) && looksLikeVersion(ver) {
|
||||||
|
return name, ver, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields := strings.FieldsFunc(line, func(r rune) bool { return r == ' ' || r == '\t' })
|
||||||
|
if len(fields) == 2 && looksLikeName(fields[0]) && looksLikeVersion(fields[1]) {
|
||||||
|
return fields[0], fields[1], true
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeName(s string) bool {
|
||||||
|
return len(s) >= 2 && !looksLikeVersion(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeVersion v 前缀含数字,或含数字且连字符少于 2 个
|
||||||
|
func looksLikeVersion(s string) bool {
|
||||||
|
rest := strings.TrimPrefix(strings.ToLower(s), "v")
|
||||||
|
if strings.ContainsAny(rest, "0123456789") && strings.Count(rest, "-") < 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(jobs []Job) error {
|
||||||
|
if len(jobs) == 0 {
|
||||||
|
return fmt.Errorf("未解析到任何应用")
|
||||||
|
}
|
||||||
|
seen := map[string]int{}
|
||||||
|
for i, j := range jobs {
|
||||||
|
if j.Name == "" || j.Version == "" {
|
||||||
|
return fmt.Errorf("第 %d 项名称或版本为空", i+1)
|
||||||
|
}
|
||||||
|
if prev, ok := seen[j.Name]; ok {
|
||||||
|
return fmt.Errorf("应用 %s 重复出现(第 %d 与第 %d 项)", j.Name, prev+1, i+1)
|
||||||
|
}
|
||||||
|
seen[j.Name] = i
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Package sshexec 远程命令执行通道(构建节点 SSH 操作)。
|
||||||
|
package sshexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config SSH 连接参数(来自 SSH 凭证内容 JSON)
|
||||||
|
type Config struct {
|
||||||
|
Host string // IP
|
||||||
|
Port int // 默认 22
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
PrivateKey string // PEM 私钥(与 Password 二选一)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 执行单条远程命令,返回合并输出;timeout 为整条命令的硬上限
|
||||||
|
func Run(cfg Config, cmd string, timeout time.Duration) (string, error) {
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = 22
|
||||||
|
}
|
||||||
|
if cfg.User == "" {
|
||||||
|
cfg.User = "root"
|
||||||
|
}
|
||||||
|
|
||||||
|
var authMethods []ssh.AuthMethod
|
||||||
|
if cfg.PrivateKey != "" {
|
||||||
|
signer, err := ssh.ParsePrivateKey([]byte(cfg.PrivateKey))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("私钥解析失败: %v", err)
|
||||||
|
}
|
||||||
|
authMethods = append(authMethods, ssh.PublicKeys(signer))
|
||||||
|
}
|
||||||
|
if cfg.Password != "" {
|
||||||
|
authMethods = append(authMethods, ssh.Password(cfg.Password))
|
||||||
|
}
|
||||||
|
if len(authMethods) == 0 {
|
||||||
|
return "", fmt.Errorf("未提供密码或私钥")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := ssh.Dial("tcp", net.JoinHostPort(cfg.Host, fmt.Sprint(cfg.Port)), &ssh.ClientConfig{
|
||||||
|
User: cfg.User,
|
||||||
|
Auth: authMethods,
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // 内网构建节点场景
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("SSH 连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session, err := client.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
var output []byte
|
||||||
|
var runErr error
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
output, runErr = session.CombinedOutput(cmd)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return string(output), runErr
|
||||||
|
case <-time.After(timeout):
|
||||||
|
_ = session.Close()
|
||||||
|
return string(output), fmt.Errorf("命令执行超时(%s)", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
-1
@@ -27,6 +27,9 @@ func main() {
|
|||||||
hub := handler.NewHub()
|
hub := handler.NewHub()
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
// 会话鉴权:所有 /api 需登录(豁免登录/健康检查/入站 Webhook/SSE 日志流)
|
||||||
|
r.Use(handler.AuthMiddleware())
|
||||||
|
|
||||||
// ?_clear=1 时下发 Clear-Site-Data,用于清除浏览器缓存的错误重定向等脏缓存
|
// ?_clear=1 时下发 Clear-Site-Data,用于清除浏览器缓存的错误重定向等脏缓存
|
||||||
// (历史 bug:/index.html 曾返回 301 → ./,浏览器永久缓存后反复触发 ERR_TOO_MANY_REDIRECTS)
|
// (历史 bug:/index.html 曾返回 301 → ./,浏览器永久缓存后反复触发 ERR_TOO_MANY_REDIRECTS)
|
||||||
r.Use(func(c *gin.Context) {
|
r.Use(func(c *gin.Context) {
|
||||||
@@ -50,19 +53,46 @@ func main() {
|
|||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
api.POST("/auth/login", handler.Login)
|
api.POST("/auth/login", handler.Login)
|
||||||
|
api.POST("/auth/logout", func(c *gin.Context) { c.JSON(200, gin.H{"success": true}) })
|
||||||
|
|
||||||
|
// 用户管理(管理员)
|
||||||
|
users := api.Group("/users", handler.AdminOnly())
|
||||||
|
{
|
||||||
|
users.GET("", handler.ListUsers)
|
||||||
|
users.POST("", handler.CreateUser)
|
||||||
|
users.PUT("/:id", handler.UpdateUser)
|
||||||
|
users.DELETE("/:id", handler.DeleteUser)
|
||||||
|
users.POST("/:id/reset-password", handler.ResetPassword)
|
||||||
|
users.POST("/ldap-import", handler.LdapImport)
|
||||||
|
}
|
||||||
|
api.POST("/ldap/users", handler.AdminOnly(), handler.LdapListUsers)
|
||||||
|
api.POST("/settings/ldap/test", handler.AdminOnly(), handler.TestLDAP)
|
||||||
|
|
||||||
|
api.GET("/summary", handler.GetSummary)
|
||||||
|
api.GET("/settings", handler.GetSettings)
|
||||||
|
api.PUT("/settings", handler.UpdateSettings)
|
||||||
|
api.POST("/settings/test-llm", handler.TestLLM)
|
||||||
|
api.POST("/settings/test-lark", handler.TestLark)
|
||||||
|
|
||||||
|
api.POST("/ai/chat", handler.AiChat)
|
||||||
|
api.POST("/ai/chat/stream", handler.AiChatStream)
|
||||||
|
api.POST("/ai/release", handler.AiRelease(hub))
|
||||||
|
api.POST("/batch/parse", handler.BatchParse)
|
||||||
|
|
||||||
api.GET("/pipelines", handler.ListPipelines)
|
api.GET("/pipelines", handler.ListPipelines)
|
||||||
api.POST("/pipelines", handler.CreatePipeline)
|
api.POST("/pipelines", handler.CreatePipeline)
|
||||||
api.GET("/pipelines/:id", handler.GetPipeline)
|
api.GET("/pipelines/:id", handler.GetPipelineDetail)
|
||||||
api.PUT("/pipelines/:id", handler.UpdatePipeline)
|
api.PUT("/pipelines/:id", handler.UpdatePipeline)
|
||||||
api.DELETE("/pipelines/:id", handler.DeletePipeline)
|
api.DELETE("/pipelines/:id", handler.DeletePipeline)
|
||||||
api.PUT("/pipelines/:id/nodes", handler.SaveNodes)
|
api.PUT("/pipelines/:id/nodes", handler.SaveNodes)
|
||||||
api.PUT("/pipelines/:id/edges", handler.SaveEdges)
|
api.PUT("/pipelines/:id/edges", handler.SaveEdges)
|
||||||
api.POST("/pipelines/:id/runs", handler.CreateRun(hub))
|
api.POST("/pipelines/:id/runs", handler.CreateRun(hub))
|
||||||
|
api.POST("/hooks/pipeline/:id", handler.InboundPipelineHook(hub))
|
||||||
|
|
||||||
api.GET("/runs", handler.ListRuns)
|
api.GET("/runs", handler.ListRuns)
|
||||||
api.GET("/runs/:runId", handler.GetRun)
|
api.GET("/runs/:runId", handler.GetRun)
|
||||||
api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub))
|
api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub))
|
||||||
|
api.POST("/runs/:runId/retry", handler.RetryRun(hub))
|
||||||
|
|
||||||
api.GET("/gitops/apps", handler.ListGitOpsApps)
|
api.GET("/gitops/apps", handler.ListGitOpsApps)
|
||||||
api.GET("/gitops/argo/apps", handler.ArgoList)
|
api.GET("/gitops/argo/apps", handler.ArgoList)
|
||||||
@@ -75,6 +105,49 @@ func main() {
|
|||||||
api.POST("/plugins/install", handler.InstallPlugin)
|
api.POST("/plugins/install", handler.InstallPlugin)
|
||||||
api.POST("/plugins/:id/toggle", handler.TogglePlugin)
|
api.POST("/plugins/:id/toggle", handler.TogglePlugin)
|
||||||
api.DELETE("/plugins/:id", handler.UninstallPlugin)
|
api.DELETE("/plugins/:id", handler.UninstallPlugin)
|
||||||
|
|
||||||
|
api.GET("/hosts", handler.ListHosts)
|
||||||
|
api.POST("/hosts", handler.CreateHost)
|
||||||
|
api.PUT("/hosts/:id", handler.UpdateHost)
|
||||||
|
api.DELETE("/hosts/:id", handler.DeleteHost)
|
||||||
|
api.POST("/hosts/:id/test", handler.TestHost)
|
||||||
|
|
||||||
|
api.GET("/clusters", handler.ListClusters)
|
||||||
|
api.POST("/clusters", handler.CreateCluster)
|
||||||
|
api.PUT("/clusters/:id", handler.UpdateCluster)
|
||||||
|
api.DELETE("/clusters/:id", handler.DeleteCluster)
|
||||||
|
api.POST("/clusters/:id/test", handler.TestCluster)
|
||||||
|
|
||||||
|
api.GET("/credentials", handler.ListCredentials)
|
||||||
|
api.POST("/credentials", handler.CreateCredential)
|
||||||
|
api.PUT("/credentials/:id", handler.UpdateCredential)
|
||||||
|
api.DELETE("/credentials/:id", handler.DeleteCredential)
|
||||||
|
|
||||||
|
api.GET("/build-nodes", handler.ListBuildNodes)
|
||||||
|
api.POST("/build-nodes", handler.CreateBuildNode)
|
||||||
|
api.PUT("/build-nodes/:id", handler.UpdateBuildNode)
|
||||||
|
api.DELETE("/build-nodes/:id", handler.DeleteBuildNode)
|
||||||
|
api.POST("/build-nodes/:id/test", handler.TestBuildNode)
|
||||||
|
api.POST("/build-nodes/:id/disk-test", handler.TestBuildNodeDisk)
|
||||||
|
|
||||||
|
api.GET("/tools", handler.ListTools)
|
||||||
|
api.PUT("/tools/:id", handler.UpdateTool)
|
||||||
|
api.POST("/tools/:id/check", handler.CheckTool)
|
||||||
|
api.POST("/tools/:id/install", handler.InstallTool)
|
||||||
|
|
||||||
|
api.GET("/webhooks", handler.ListWebhooks)
|
||||||
|
api.POST("/webhooks", handler.CreateWebhook)
|
||||||
|
api.PUT("/webhooks/:id", handler.UpdateWebhook)
|
||||||
|
api.DELETE("/webhooks/:id", handler.DeleteWebhook)
|
||||||
|
api.POST("/webhooks/:id/test", handler.TestWebhook)
|
||||||
|
|
||||||
|
api.GET("/jenkins-servers", handler.ListJenkinsServers)
|
||||||
|
api.POST("/jenkins-servers", handler.CreateJenkinsServer)
|
||||||
|
api.PUT("/jenkins-servers/:id", handler.UpdateJenkinsServer)
|
||||||
|
api.DELETE("/jenkins-servers/:id", handler.DeleteJenkinsServer)
|
||||||
|
api.POST("/jenkins-servers/:id/test", handler.TestJenkinsServer)
|
||||||
|
api.GET("/jenkins-servers/:id/jobs", handler.ListJenkinsJobs)
|
||||||
|
api.POST("/jenkins-servers/:id/import", handler.ImportJenkinsJobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
distFS, _ := fs.Sub(staticFS, "static")
|
distFS, _ := fs.Sub(staticFS, "static")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head><meta charset="UTF-8"><title>FlowStack</title></head>
|
<head><meta charset="UTF-8"><title>FlowStack</title></head>
|
||||||
<body style="font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f6f8fa;">
|
<body style="font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f6f8fa;">
|
||||||
<div style="text-align: center; color: #57606a;">
|
<div style="text-align: center; color: #57606a;">
|
||||||
<h1 style="font-size: 20px;">FlowStack 前端尚未构建</h1>
|
<h1 style="font-size: 20px;">FlowStack 发布系统</h1>
|
||||||
<p>请在项目根目录执行 <code style="background:#eaeef2;padding:2px 6px;border-radius:4px;">./run.sh build</code> 生成前端产物后重新编译。</p>
|
<p>请在项目根目录执行 <code style="background:#eaeef2;padding:2px 6px;border-radius:4px;">./run.sh build</code> 生成前端产物后重新编译。</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# FlowStack 构建节点镜像(Ubuntu 22.04)
|
||||||
|
# 用法: docker build -f deploy/Dockerfile.buildnode -t flowstack-buildnode .
|
||||||
|
# 自定义默认 Node 版本: docker build --build-arg NODE_VERSION=18 -f deploy/Dockerfile.buildnode -t flowstack-buildnode .
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG NODE_VERSION=20
|
||||||
|
ENV NVM_DIR=/root/.nvm \
|
||||||
|
NVM_NODE_DEFAULT=${NODE_VERSION}
|
||||||
|
|
||||||
|
# 基础工具 + JDK17 + Maven + Git + Docker CLI(Ubuntu apt 一步到位)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
openjdk-17-jdk-headless \
|
||||||
|
maven \
|
||||||
|
git \
|
||||||
|
docker.io \
|
||||||
|
curl ca-certificates unzip xz-utils \
|
||||||
|
tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Golang(官方 tar,apt 的 golang 版本较旧)
|
||||||
|
ARG GOLANG_VERSION=1.24.2
|
||||||
|
RUN curl -fsSL https://go.dev/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz | tar -C /usr/local -xzf - \
|
||||||
|
&& ln -s /usr/local/go/bin/go /usr/local/bin/go \
|
||||||
|
&& ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||||
|
|
||||||
|
# nvm + 默认 Node 版本(构建时可在流水线脚本里 nvm install <ver> 灵活切换)
|
||||||
|
ARG NVM_VERSION=v0.40.1
|
||||||
|
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh | bash \
|
||||||
|
&& . "$NVM_DIR/nvm.sh" \
|
||||||
|
&& nvm install ${NODE_VERSION} \
|
||||||
|
&& nvm alias default ${NODE_VERSION} \
|
||||||
|
&& echo '. "$NVM_DIR/nvm.sh"' >> /root/.bashrc
|
||||||
|
|
||||||
|
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \
|
||||||
|
PATH=/usr/local/go/bin:$PATH
|
||||||
|
|
||||||
|
# 构建工作空间
|
||||||
|
RUN mkdir -p /data/flowstack-workspace
|
||||||
|
WORKDIR /data/flowstack-workspace
|
||||||
|
|
||||||
|
# 验证工具链可用
|
||||||
|
RUN java -version && mvn -version && go version && git --version \
|
||||||
|
&& . "$NVM_DIR/nvm.sh" && node -v && npm -v
|
||||||
|
|
||||||
|
CMD ["bash", "-c", "echo 'FlowStack build node ready' && bash"]
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<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" />
|
||||||
<title>FlowStack · 发布系统</title>
|
<title>FlowStack · 发布系统</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0969da"/>
|
||||||
|
<stop offset="1" stop-color="#8250df"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#g)"/>
|
||||||
|
<g transform="translate(32 33) rotate(45) translate(-11 -13)">
|
||||||
|
<path d="M11 0 C17 4 20 10 20 17 L20 22 L14 22 C8 22 3 19 0 13 L0 0 Z" fill="#fff" opacity="0.95"/>
|
||||||
|
<circle cx="13" cy="9" r="3" fill="#0969da"/>
|
||||||
|
<path d="M0 13 L-6 20 L1 18 Z" fill="#ffd23e"/>
|
||||||
|
<path d="M20 22 L26 28 L19 27 Z" fill="#ffd23e" opacity="0.9"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 671 B |
+146
-35
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import Sidebar from '@/components/Sidebar';
|
import Sidebar from '@/components/Sidebar';
|
||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/components/TopBar';
|
||||||
import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor';
|
import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor';
|
||||||
@@ -19,45 +21,144 @@ import WebhookConfig from '@/pages/WebhookConfig';
|
|||||||
import GitOpsDeploy from '@/pages/GitOpsDeploy';
|
import GitOpsDeploy from '@/pages/GitOpsDeploy';
|
||||||
import PluginManager from '@/pages/PluginManager';
|
import PluginManager from '@/pages/PluginManager';
|
||||||
import SystemSettings from '@/pages/SystemSettings';
|
import SystemSettings from '@/pages/SystemSettings';
|
||||||
import { pipelineScenarios } from '@/data/pipelineData';
|
import AiAssistant from '@/components/ai/AiAssistant';
|
||||||
|
import Login from '@/pages/Login';
|
||||||
|
import UserManage from '@/pages/UserManage';
|
||||||
|
import { apiClient, auth } from '@/lib/api';
|
||||||
|
|
||||||
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
|
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'users' | 'settings';
|
||||||
export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
||||||
|
|
||||||
|
// 路由 ↔ 模块映射(用户可直接访问 /release、/pipelines/1 等)
|
||||||
|
const ROUTE_TO_MODULE: Record<string, ModuleType> = {
|
||||||
|
'/': 'pipelines',
|
||||||
|
'/pipelines': 'pipelines',
|
||||||
|
'/gitops': 'gitops',
|
||||||
|
'/release': 'batch-release',
|
||||||
|
'/records': 'release-records',
|
||||||
|
'/environments': 'environments',
|
||||||
|
'/credentials': 'credentials',
|
||||||
|
'/build-nodes': 'build-nodes',
|
||||||
|
'/tools': 'tools',
|
||||||
|
'/jenkins-import': 'jenkins-import',
|
||||||
|
'/webhooks': 'webhooks',
|
||||||
|
'/plugins': 'plugins',
|
||||||
|
'/users': 'users',
|
||||||
|
'/settings': 'settings',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODULE_TO_ROUTE: Record<ModuleType, string> = {
|
||||||
|
pipelines: '/pipelines',
|
||||||
|
gitops: '/gitops',
|
||||||
|
'batch-release': '/release',
|
||||||
|
'release-records': '/records',
|
||||||
|
environments: '/environments',
|
||||||
|
credentials: '/credentials',
|
||||||
|
'build-nodes': '/build-nodes',
|
||||||
|
tools: '/tools',
|
||||||
|
'jenkins-import': '/jenkins-import',
|
||||||
|
webhooks: '/webhooks',
|
||||||
|
plugins: '/plugins',
|
||||||
|
users: '/users',
|
||||||
|
settings: '/settings',
|
||||||
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loggedIn, setLoggedIn] = useState(!!auth.getToken());
|
||||||
|
|
||||||
|
// 未登录访问任何页面 → 跳登录页
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loggedIn && location.pathname !== '/login') {
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
}
|
||||||
|
}, [loggedIn, location.pathname, navigate]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
auth.clear();
|
||||||
|
setLoggedIn(false);
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
// Module navigation
|
// Module navigation
|
||||||
const [activeModule, setActiveModule] = useState<ModuleType>('pipelines');
|
const [activeModule, setActiveModule] = useState<ModuleType>('pipelines');
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
// Pipeline list/editor view toggle
|
// Pipeline list/editor view toggle
|
||||||
const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list');
|
const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list');
|
||||||
// Current editing pipeline ID for flow editor
|
|
||||||
const [currentPipelineId, setCurrentPipelineId] = useState<number>(1);
|
const [currentPipelineId, setCurrentPipelineId] = useState<number>(1);
|
||||||
// Pipeline editor state (only used when in pipeline editor mode)
|
|
||||||
const [activeScenario, setActiveScenario] = useState<'go-backend' | 'vue-frontend'>('go-backend');
|
|
||||||
const [activeTab, setActiveTab] = useState<EditorTab>('流程配置');
|
const [activeTab, setActiveTab] = useState<EditorTab>('流程配置');
|
||||||
const [isSimulating, setIsSimulating] = useState(false);
|
const [saveTick, setSaveTick] = useState(0);
|
||||||
const [executionStatus, setExecutionStatus] = useState<'idle' | 'running' | 'success' | 'failed'>('idle');
|
const [runTick, setRunTick] = useState(0);
|
||||||
|
|
||||||
const scenario = pipelineScenarios[activeScenario];
|
// URL → 页面状态(直接输入/刷新/分享链接时恢复)
|
||||||
|
useEffect(() => {
|
||||||
|
const path = location.pathname.replace(/\/+$/, '') || '/';
|
||||||
|
const editorMatch = path.match(/^\/pipelines\/(\d+)/);
|
||||||
|
if (editorMatch) {
|
||||||
|
setActiveModule('pipelines');
|
||||||
|
setCurrentPipelineId(Number(editorMatch[1]) || 1);
|
||||||
|
setPipelineView('editor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mod = ROUTE_TO_MODULE[path];
|
||||||
|
if (mod) {
|
||||||
|
setActiveModule(mod);
|
||||||
|
if (mod !== 'pipelines') {
|
||||||
|
setPipelineView('list');
|
||||||
|
} else if (path === '/pipelines' || path === '/') {
|
||||||
|
setPipelineView('list');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 未知路径回首页
|
||||||
|
navigate('/pipelines', { replace: true });
|
||||||
|
}, [location.pathname, navigate]);
|
||||||
|
|
||||||
const handleScenarioChange = (key: 'go-backend' | 'vue-frontend') => {
|
const goToModule = useCallback((m: ModuleType) => {
|
||||||
setActiveScenario(key);
|
setActiveModule(m);
|
||||||
};
|
navigate(MODULE_TO_ROUTE[m]);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const { data: pipelines = [] } = useQuery({
|
||||||
|
queryKey: ['pipelines'],
|
||||||
|
queryFn: apiClient.listPipelines,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当前流水线最近一次运行状态(TopBar 徽章)
|
||||||
|
const { data: currentRuns = [] } = useQuery({
|
||||||
|
queryKey: ['runs', { pipelineId: currentPipelineId }],
|
||||||
|
queryFn: () => apiClient.listRuns({ pipelineId: currentPipelineId }),
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
query.state.data?.[0]?.status === 'running' || query.state.data?.[0]?.status === 'pending' ? 2000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentPipeline = pipelines.find((p: any) => p.id === currentPipelineId);
|
||||||
|
const latestStatus = currentRuns[0]?.status as string | undefined;
|
||||||
|
const isPipelineRunning = latestStatus === 'running' || latestStatus === 'pending';
|
||||||
|
|
||||||
const handleSelectPipeline = useCallback((id: string) => {
|
const handleSelectPipeline = useCallback((id: string) => {
|
||||||
setCurrentPipelineId(Number(id) || 1);
|
setCurrentPipelineId(Number(id) || 1);
|
||||||
setPipelineView('editor');
|
setPipelineView('editor');
|
||||||
setActiveTab('流程配置');
|
setActiveTab('流程配置');
|
||||||
}, []);
|
navigate(`/pipelines/${Number(id) || 1}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
const handleSimulateRun = useCallback(() => {
|
const handlePipelineSwitch = useCallback((id: number) => {
|
||||||
setIsSimulating(true);
|
setCurrentPipelineId(id);
|
||||||
setExecutionStatus('running');
|
setActiveTab('流程配置');
|
||||||
setTimeout(() => {
|
navigate(`/pipelines/${id}`);
|
||||||
setIsSimulating(false);
|
}, [navigate]);
|
||||||
setExecutionStatus('success');
|
|
||||||
}, 3000);
|
const handleOnlySave = useCallback(() => {
|
||||||
|
if (activeTab !== '流程配置') setActiveTab('流程配置');
|
||||||
|
setSaveTick((t) => t + 1);
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const handleSaveAndRun = useCallback(() => {
|
||||||
|
setActiveTab('流程配置');
|
||||||
|
setRunTick((t) => t + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Render content based on active module
|
// Render content based on active module
|
||||||
@@ -71,40 +172,42 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<TopBar
|
<TopBar
|
||||||
pipelineName={scenario.name}
|
pipelineName={currentPipeline?.name || '...'}
|
||||||
|
pipelines={pipelines.map((p: any) => ({ id: p.id, name: p.name }))}
|
||||||
|
currentPipelineId={currentPipelineId}
|
||||||
|
onPipelineChange={handlePipelineSwitch}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
onSimulateRun={handleSimulateRun}
|
onOnlySave={handleOnlySave}
|
||||||
isSimulating={isSimulating}
|
onSaveAndRun={handleSaveAndRun}
|
||||||
scenarioKey={activeScenario}
|
isRunning={isPipelineRunning}
|
||||||
onScenarioChange={handleScenarioChange}
|
executionStatus={(latestStatus as 'running' | 'success' | 'failed' | 'cancelled') || 'idle'}
|
||||||
executionStatus={executionStatus}
|
onBack={() => { setPipelineView('list'); navigate('/pipelines'); }}
|
||||||
onBack={() => setPipelineView('list')}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{activeTab === '流程配置' && (
|
{activeTab === '流程配置' && (
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<PipelineFlowEditor pipelineId={currentPipelineId} />
|
<PipelineFlowEditor pipelineId={currentPipelineId} saveSignal={saveTick} runSignal={runTick} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeTab === '基本信息' && (
|
{activeTab === '基本信息' && (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<BasicInfoPanel />
|
<BasicInfoPanel pipelineId={currentPipelineId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeTab === '触发设置' && (
|
{activeTab === '触发设置' && (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<TriggerPanel />
|
<TriggerPanel pipelineId={currentPipelineId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeTab === '变量和缓存' && (
|
{activeTab === '变量和缓存' && (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<VariablesPanel />
|
<VariablesPanel pipelineId={currentPipelineId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeTab === '发布' && (
|
{activeTab === '发布' && (
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ReleasePanel />
|
<ReleasePanel pipelineId={currentPipelineId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +224,7 @@ export default function App() {
|
|||||||
case 'jenkins-import':
|
case 'jenkins-import':
|
||||||
return <JenkinsImport />;
|
return <JenkinsImport />;
|
||||||
case 'batch-release':
|
case 'batch-release':
|
||||||
return <BatchRelease />;
|
return <BatchRelease onViewRecords={() => goToModule('release-records')} />;
|
||||||
case 'release-records':
|
case 'release-records':
|
||||||
return <ReleaseRecords />;
|
return <ReleaseRecords />;
|
||||||
case 'webhooks':
|
case 'webhooks':
|
||||||
@@ -130,6 +233,8 @@ export default function App() {
|
|||||||
return <GitOpsDeploy />;
|
return <GitOpsDeploy />;
|
||||||
case 'plugins':
|
case 'plugins':
|
||||||
return <PluginManager />;
|
return <PluginManager />;
|
||||||
|
case 'users':
|
||||||
|
return <UserManage />;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <SystemSettings />;
|
return <SystemSettings />;
|
||||||
default:
|
default:
|
||||||
@@ -137,18 +242,24 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!loggedIn || location.pathname === '/login') {
|
||||||
|
return <Login onSuccess={() => { setLoggedIn(true); navigate('/pipelines', { replace: true }); }} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen flex overflow-hidden" style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}>
|
<div className="h-screen w-screen flex overflow-hidden" style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}>
|
||||||
<Toaster position="top-right" richColors />
|
<Toaster position="top-right" richColors />
|
||||||
<Sidebar
|
<Sidebar
|
||||||
activeModule={activeModule}
|
activeModule={activeModule}
|
||||||
onModuleChange={setActiveModule}
|
onModuleChange={goToModule}
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
<AiAssistant onNavigate={(m) => goToModule(m as ModuleType)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,270 +1,115 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Bell, Info, TestTube } from 'lucide-react';
|
import { Info, Save, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
export default function BasicInfoPanel() {
|
interface Props {
|
||||||
const [pipelineName, setPipelineName] = useState('gateway-pre-serverless');
|
pipelineId: number;
|
||||||
const [description, setDescription] = useState('');
|
}
|
||||||
|
|
||||||
// Feishu notification
|
export default function BasicInfoPanel({ pipelineId }: Props) {
|
||||||
const [feishuEnabled, setFeishuEnabled] = useState(false);
|
const queryClient = useQueryClient();
|
||||||
const [feishuWebhook, setFeishuWebhook] = useState('');
|
const { data: detail } = useQuery({
|
||||||
const [feishuTiming, setFeishuTiming] = useState('all');
|
queryKey: ['pipeline', pipelineId],
|
||||||
|
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||||
|
});
|
||||||
|
const p = detail?.pipeline;
|
||||||
|
|
||||||
// DingTalk notification
|
const [form, setForm] = useState({ name: '', description: '', sourceRepo: '', sourceBranch: '', deployType: 'host', env: 'staging', notifyWebhookURL: '' });
|
||||||
const [dingtalkEnabled, setDingtalkEnabled] = useState(false);
|
|
||||||
const [dingtalkWebhook, setDingtalkWebhook] = useState('');
|
|
||||||
const [dingtalkTiming, setDingtalkTiming] = useState('all');
|
|
||||||
|
|
||||||
const handleTestConnection = (type: 'feishu' | 'dingtalk') => {
|
useEffect(() => {
|
||||||
toast.success(`${type === 'feishu' ? '飞书' : '钉钉'} Webhook 连接测试成功`, {
|
if (p) {
|
||||||
description: '通知服务可正常访问',
|
setForm({
|
||||||
});
|
name: p.name || '', description: p.description || '',
|
||||||
};
|
sourceRepo: p.sourceRepo || '', sourceBranch: p.sourceBranch || 'main',
|
||||||
|
deployType: p.deployType || 'host', env: p.env || 'staging',
|
||||||
|
notifyWebhookURL: p.notifyWebhookURL || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [p?.id, p?.updatedAt]);
|
||||||
|
|
||||||
const timingOptions = [
|
const saveMutation = useMutation({
|
||||||
{ value: 'all', label: '全部事件' },
|
mutationFn: (body: any) => apiClient.updatePipeline(pipelineId, body),
|
||||||
{ value: 'success', label: '仅成功' },
|
onSuccess: () => {
|
||||||
{ value: 'failure', label: '仅失败' },
|
toast.success('基本信息已保存');
|
||||||
];
|
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-content-fade space-y-6 w-full px-6 py-6">
|
<div className="tab-content-fade w-full px-6 py-6 max-w-3xl">
|
||||||
{/* Basic Information */}
|
<div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div
|
<div className="flex items-center justify-between mb-5">
|
||||||
className="rounded-lg border p-6 shadow-sm"
|
<div className="flex items-center gap-2">
|
||||||
style={{
|
<Info className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||||
background: 'var(--panel-bg)',
|
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>基本信息</h3>
|
||||||
borderColor: 'var(--border-color)',
|
{p?.type === 'jenkins' && (
|
||||||
}}
|
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: '#fbefff', color: '#8250df' }}>Jenkins 导入</span>
|
||||||
>
|
)}
|
||||||
<div className="flex items-center gap-2 mb-5">
|
</div>
|
||||||
<Info className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
<div className="flex items-center gap-3">
|
||||||
<h3
|
{p && <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>更新于 {new Date(p.updatedAt).toLocaleString('zh-CN', { hour12: false })}</span>}
|
||||||
className="text-base font-semibold"
|
<button
|
||||||
style={{ color: 'var(--text-primary)' }}
|
onClick={() => saveMutation.mutate(form)}
|
||||||
>
|
disabled={saveMutation.isPending || !form.name}
|
||||||
基本信息
|
className="flex items-center gap-1 px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
|
||||||
</h3>
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<Label className="text-sm font-medium mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>流水线名称 *</label>
|
||||||
流水线名称
|
<input value={form.name} onChange={(e) => set('name', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={pipelineName}
|
|
||||||
onChange={(e) => setPipelineName(e.target.value)}
|
|
||||||
placeholder="输入流水线名称"
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
<div>
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>默认分支</label>
|
||||||
<Label className="text-sm font-medium mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
<input value={form.sourceBranch} onChange={(e) => set('sourceBranch', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
|
||||||
描述
|
|
||||||
</Label>
|
|
||||||
<textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="输入流水线描述(可选)"
|
|
||||||
rows={1}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-md border outline-none focus:ring-1 focus:ring-[var(--accent-blue)] focus:border-transparent transition-all resize-none h-9"
|
|
||||||
style={{
|
|
||||||
background: 'var(--canvas-bg)',
|
|
||||||
borderColor: 'var(--border-color)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
<div>
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>描述</label>
|
||||||
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
|
<input value={form.description} onChange={(e) => set('description', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle} />
|
||||||
创建人
|
|
||||||
</Label>
|
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
admin
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-1.5 col-span-2">
|
||||||
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>代码仓库</label>
|
||||||
创建时间
|
<input value={form.sourceRepo} onChange={(e) => set('sourceRepo', e.target.value)} placeholder="https://gitee.com/org/repo.git" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
|
||||||
</Label>
|
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
2024-06-20
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>部署方式</label>
|
||||||
最近执行
|
<select value={form.deployType} onChange={(e) => set('deployType', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle}>
|
||||||
</Label>
|
<option value="host">主机部署</option>
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
<option value="k8s">K8s 部署</option>
|
||||||
未执行
|
<option value="gitops">GitOps</option>
|
||||||
</span>
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>默认环境</label>
|
||||||
|
<select value={form.env} onChange={(e) => set('env', e.target.value)} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none" style={inputStyle}>
|
||||||
|
<option value="dev">dev</option>
|
||||||
|
<option value="test">test</option>
|
||||||
|
<option value="staging">staging</option>
|
||||||
|
<option value="production">production</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notification Settings */}
|
<div className="rounded-lg border p-6 shadow-sm mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div
|
<h3 className="text-[14px] font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>发布通知</h3>
|
||||||
className="rounded-lg border p-6 shadow-sm"
|
<p className="text-[11px] mb-4" style={{ color: 'var(--text-tertiary)' }}>流水线成功/失败后回调该 Webhook(也在「Webhook 配置」中统一管理)</p>
|
||||||
style={{
|
<div className="space-y-1.5">
|
||||||
background: 'var(--panel-bg)',
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>通知 Webhook URL</label>
|
||||||
borderColor: 'var(--border-color)',
|
<input value={form.notifyWebhookURL} onChange={(e) => set('notifyWebhookURL', e.target.value)} placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-5">
|
|
||||||
<Bell className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
|
||||||
<h3
|
|
||||||
className="text-base font-semibold"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
通知设置
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Feishu */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
飞书通知
|
|
||||||
</span>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>通过飞书群机器人发送流水线执行通知</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Switch checked={feishuEnabled} onCheckedChange={setFeishuEnabled} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{feishuEnabled && (
|
|
||||||
<div className="tab-content-fade pl-0 space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Webhook 地址
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={feishuWebhook}
|
|
||||||
onChange={(e) => setFeishuWebhook(e.target.value)}
|
|
||||||
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs mb-2 block" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
通知时机
|
|
||||||
</Label>
|
|
||||||
<RadioGroup
|
|
||||||
value={feishuTiming}
|
|
||||||
onValueChange={setFeishuTiming}
|
|
||||||
className="flex items-center gap-4"
|
|
||||||
>
|
|
||||||
{timingOptions.map((opt) => (
|
|
||||||
<div key={opt.value} className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value={opt.value} id={`feishu-${opt.value}`} />
|
|
||||||
<Label htmlFor={`feishu-${opt.value}`} className="text-sm cursor-pointer">
|
|
||||||
{opt.label}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
<div className="pt-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleTestConnection('feishu')}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
<TestTube className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
测试连接
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator style={{ backgroundColor: 'var(--border-color)' }} />
|
|
||||||
|
|
||||||
{/* DingTalk */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
钉钉通知
|
|
||||||
</span>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>通过钉钉群机器人发送流水线执行通知</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Switch checked={dingtalkEnabled} onCheckedChange={setDingtalkEnabled} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dingtalkEnabled && (
|
|
||||||
<div className="tab-content-fade pl-0 space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Webhook 地址
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={dingtalkWebhook}
|
|
||||||
onChange={(e) => setDingtalkWebhook(e.target.value)}
|
|
||||||
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs mb-2 block" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
通知时机
|
|
||||||
</Label>
|
|
||||||
<RadioGroup
|
|
||||||
value={dingtalkTiming}
|
|
||||||
onValueChange={setDingtalkTiming}
|
|
||||||
className="flex items-center gap-4"
|
|
||||||
>
|
|
||||||
{timingOptions.map((opt) => (
|
|
||||||
<div key={opt.value} className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value={opt.value} id={`dingtalk-${opt.value}`} />
|
|
||||||
<Label htmlFor={`dingtalk-${opt.value}`} className="text-sm cursor-pointer">
|
|
||||||
{opt.label}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
<div className="pt-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleTestConnection('dingtalk')}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
<TestTube className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
测试连接
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Plus, ChevronDown, ChevronRight, ChevronLeft, Box, Layers, GitBranch, Container, Terminal, Shield, Package, BookOpen, X } from 'lucide-react';
|
|
||||||
import type { PipelineStage } from '@/types/pipeline';
|
|
||||||
import StageCard from './StageCard';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
interface TektonTask {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
icon: typeof Box;
|
|
||||||
category: 'source' | 'build' | 'test' | 'security' | 'deploy' | 'gitops';
|
|
||||||
params: { name: string; type: string; default?: string; description: string }[];
|
|
||||||
workspaces: string[];
|
|
||||||
results?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskCatalog: TektonTask[] = [
|
|
||||||
{ id: 'tk-git-clone', name: 'git-clone', description: '从 Git 仓库克隆代码', icon: GitBranch, category: 'source', params: [{ name: 'url', type: 'string', description: 'Git 仓库 URL' }, { name: 'revision', type: 'string', default: 'main', description: '分支或标签' }], workspaces: ['source'] },
|
|
||||||
{ id: 'tk-maven', name: 'maven', description: 'Maven 构建 Java 项目', icon: Package, category: 'build', params: [{ name: 'goals', type: 'array', default: 'clean package', description: 'Maven 目标' }, { name: 'settings', type: 'string', description: 'settings.xml 路径' }], workspaces: ['source', 'm2-cache'] },
|
|
||||||
{ id: 'tk-npm', name: 'npm', description: 'npm install + build', icon: Package, category: 'build', params: [{ name: 'script', type: 'string', default: 'build', description: 'npm 脚本' }], workspaces: ['source'] },
|
|
||||||
{ id: 'tk-go-build', name: 'go-build', description: 'Go 编译', icon: Package, category: 'build', params: [{ name: 'package', type: 'string', description: '包路径' }, { name: 'flags', type: 'string', description: '编译参数' }], workspaces: ['source'] },
|
|
||||||
{ id: 'tk-kaniko', name: 'kaniko-build', description: '无需特权构建 Docker 镜像', icon: Container, category: 'build', params: [{ name: 'dockerfile', type: 'string', default: 'Dockerfile', description: 'Dockerfile 路径' }, { name: 'image', type: 'string', description: '目标镜像地址' }], workspaces: ['source'], results: ['IMAGE_DIGEST'] },
|
|
||||||
{ id: 'tk-trivy', name: 'trivy-scan', description: '容器镜像漏洞扫描', icon: Shield, category: 'security', params: [{ name: 'image', type: 'string', description: '待扫描镜像' }, { name: 'severity', type: 'string', default: 'HIGH,CRITICAL', description: '严重等级' }], workspaces: [] },
|
|
||||||
{ id: 'tk-unit-test', name: 'unit-test', description: '运行单元测试', icon: Terminal, category: 'test', params: [{ name: 'test-command', type: 'string', description: '测试命令' }], workspaces: ['source'] },
|
|
||||||
{ id: 'tk-kubectl', name: 'kubectl-deploy', description: 'kubectl apply 部署', icon: Layers, category: 'deploy', params: [{ name: 'manifest', type: 'string', description: 'manifest 路径' }, { name: 'namespace', type: 'string', description: '目标命名空间' }], workspaces: ['source'] },
|
|
||||||
{ id: 'tk-update-manifest', name: 'update-manifest', description: '更新 GitOps 仓库镜像标签', icon: BookOpen, category: 'gitops', params: [{ name: 'repo', type: 'string', description: 'GitOps 仓库' }, { name: 'image', type: 'string', description: '新镜像地址' }, { name: 'path', type: 'string', description: 'manifest 路径' }], workspaces: ['source'] },
|
|
||||||
{ id: 'tk-helm-upgrade', name: 'helm-upgrade', description: 'Helm 部署/升级', icon: Layers, category: 'deploy', params: [{ name: 'release', type: 'string', description: 'Release 名称' }, { name: 'chart', type: 'string', description: 'Chart 路径' }, { name: 'namespace', type: 'string', description: '命名空间' }], workspaces: ['source'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const categoryConfig: Record<string, { label: string; color: string }> = {
|
|
||||||
source: { label: '源码', color: '#0969da' },
|
|
||||||
build: { label: '构建', color: '#1a7f37' },
|
|
||||||
test: { label: '测试', color: '#9a6700' },
|
|
||||||
security: { label: '安全', color: '#cf222e' },
|
|
||||||
deploy: { label: '部署', color: '#8250df' },
|
|
||||||
gitops: { label: 'GitOps', color: '#0e7c7b' },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PipelineStageEditorProps {
|
|
||||||
stages: PipelineStage[];
|
|
||||||
selectedTaskId: string | null;
|
|
||||||
onSelectTask: (taskId: string) => void;
|
|
||||||
onUpdateStages: (stages: PipelineStage[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PipelineStageEditor({
|
|
||||||
stages,
|
|
||||||
selectedTaskId,
|
|
||||||
onSelectTask,
|
|
||||||
onUpdateStages,
|
|
||||||
}: PipelineStageEditorProps) {
|
|
||||||
const [catalogOpen, setCatalogOpen] = useState(true);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
|
||||||
const [showWorkspaceConfig, setShowWorkspaceConfig] = useState(false);
|
|
||||||
|
|
||||||
const handleMoveStage = (index: number, direction: 'up' | 'down') => {
|
|
||||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
|
||||||
if (newIndex < 0 || newIndex >= stages.length) return;
|
|
||||||
const newStages = [...stages];
|
|
||||||
[newStages[index], newStages[newIndex]] = [newStages[newIndex], newStages[index]];
|
|
||||||
onUpdateStages(newStages);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteStage = (index: number) => {
|
|
||||||
const newStages = stages.filter((_, i) => i !== index);
|
|
||||||
onUpdateStages(newStages);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateStageName = (index: number, name: string) => {
|
|
||||||
const newStages = stages.map((s, i) => (i === index ? { ...s, name } : s));
|
|
||||||
onUpdateStages(newStages);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddStage = () => {
|
|
||||||
const newStage: PipelineStage = {
|
|
||||||
id: `stage-${Date.now()}`,
|
|
||||||
name: '新阶段',
|
|
||||||
type: 'custom',
|
|
||||||
tasks: [],
|
|
||||||
};
|
|
||||||
onUpdateStages([...stages, newStage]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addTaskFromCatalog = (task: TektonTask) => {
|
|
||||||
// Add to last stage or create new stage
|
|
||||||
if (stages.length === 0) {
|
|
||||||
handleAddStage();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lastStageIndex = stages.length - 1;
|
|
||||||
const newTask: any = {
|
|
||||||
id: `${task.id}-${Date.now()}`,
|
|
||||||
name: task.name,
|
|
||||||
type: (task.category === 'deploy' || task.category === 'gitops' ? 'deploy' : task.category === 'build' ? 'build' : 'custom') as 'build' | 'deploy' | 'test' | 'custom',
|
|
||||||
icon: task.category,
|
|
||||||
config: {
|
|
||||||
params: task.params.reduce((acc, p) => ({ ...acc, [p.name]: p.default || '' }), {} as Record<string, string>),
|
|
||||||
workspaces: task.workspaces.reduce((acc, w) => ({ ...acc, [w]: w }), {} as Record<string, string>),
|
|
||||||
results: task.results || [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const newStages = stages.map((s, i) =>
|
|
||||||
i === lastStageIndex ? { ...s, tasks: [...s.tasks, newTask] } : s
|
|
||||||
);
|
|
||||||
onUpdateStages(newStages);
|
|
||||||
toast.success(`已添加 Task: ${task.name}`, { description: `${task.workspaces.length} 个 Workspace` });
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredTasks = selectedCategory === 'all' ? taskCatalog : taskCatalog.filter(t => t.category === selectedCategory);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-full flex" style={{ background: 'var(--canvas-bg)' }}>
|
|
||||||
{/* Task Catalog Sidebar */}
|
|
||||||
<div
|
|
||||||
className="border-r flex-shrink-0 transition-all duration-300 overflow-hidden"
|
|
||||||
style={{
|
|
||||||
width: catalogOpen ? '260px' : '0px',
|
|
||||||
borderColor: 'var(--border-color)',
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="w-[260px] h-full flex flex-col">
|
|
||||||
<div className="px-3 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
|
||||||
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Task 任务库</span>
|
|
||||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>Tekton</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-2 border-b" style={{ borderColor: 'var(--border-color)' }}>
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
|
||||||
<button onClick={() => setSelectedCategory('all')}
|
|
||||||
className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors"
|
|
||||||
style={{ background: selectedCategory === 'all' ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)', color: selectedCategory === 'all' ? '#fff' : 'var(--text-tertiary)' }}>
|
|
||||||
全部
|
|
||||||
</button>
|
|
||||||
{Object.entries(categoryConfig).map(([key, cfg]) => (
|
|
||||||
<button key={key} onClick={() => setSelectedCategory(key)}
|
|
||||||
className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors"
|
|
||||||
style={{ background: selectedCategory === key ? cfg.color : 'var(--panel-bg-secondary)', color: selectedCategory === key ? '#fff' : 'var(--text-tertiary)' }}>
|
|
||||||
{cfg.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
|
||||||
{filteredTasks.map(task => {
|
|
||||||
const Icon = task.icon;
|
|
||||||
const cat = categoryConfig[task.category];
|
|
||||||
return (
|
|
||||||
<div key={task.id}
|
|
||||||
onClick={() => addTaskFromCatalog(task)}
|
|
||||||
className="p-2.5 rounded-md border cursor-pointer hover:bg-black/[0.02] transition-colors group"
|
|
||||||
style={{ borderColor: 'var(--border-color-light)' }}>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Icon className="w-3.5 h-3.5" style={{ color: cat.color }} />
|
|
||||||
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{task.name}</span>
|
|
||||||
<span className="text-[9px] px-1 py-[1px] rounded font-medium ml-auto" style={{ background: cat.color + '15', color: cat.color }}>{cat.label}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] mb-1" style={{ color: 'var(--text-secondary)' }}>{task.description}</p>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{task.workspaces.length > 0 && (
|
|
||||||
<span className="text-[9px] flex items-center gap-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
<Box className="w-2.5 h-2.5" />{task.workspaces.length} ws
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{task.params.length > 0 && (
|
|
||||||
<span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.params.length} params</span>
|
|
||||||
)}
|
|
||||||
{task.results && (
|
|
||||||
<span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.results.length} results</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-2 border-t text-[10px]" style={{ borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}>
|
|
||||||
点击 Task 添加到流水线末尾阶段
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Editor */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
{/* Pipeline metadata bar */}
|
|
||||||
<div className="flex items-center justify-between mb-5 w-full" style={{ maxWidth: '720px', marginLeft: 'auto', marginRight: 'auto' }}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setCatalogOpen(!catalogOpen)}
|
|
||||||
className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border"
|
|
||||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
|
||||||
title={catalogOpen ? '收起任务库' : '展开任务库'}
|
|
||||||
>
|
|
||||||
{catalogOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
||||||
Task 库
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[13px] font-semibold tracking-tight" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
流水线阶段
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
|
||||||
{stages.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowWorkspaceConfig(!showWorkspaceConfig)}
|
|
||||||
className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border"
|
|
||||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
<Box className="w-3 h-3" />Workspace
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleAddStage}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[12px] font-medium rounded-md text-white transition-all hover:brightness-110 active:scale-[0.98]"
|
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />添加阶段
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Workspace config panel */}
|
|
||||||
{showWorkspaceConfig && (
|
|
||||||
<div className="mb-4 rounded-lg border p-3 mx-auto" style={{ maxWidth: '720px', borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Pipeline Workspace 配置</span>
|
|
||||||
<button onClick={() => setShowWorkspaceConfig(false)} className="p-0.5 rounded hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}><X className="w-3.5 h-3.5" /></button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{['source', 'm2-cache', 'docker-config'].map(ws => (
|
|
||||||
<div key={ws} className="flex items-center gap-2 p-2 rounded-md border text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
|
||||||
<Box className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
|
||||||
<span style={{ color: 'var(--text-primary)' }}>{ws}</span>
|
|
||||||
<select className="ml-auto text-[10px] rounded border outline-none px-1 py-[2px]" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
|
||||||
<option>EmptyDir</option><option>PVC</option><option>ConfigMap</option><option>Secret</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stage list */}
|
|
||||||
<div className="flex flex-col items-center" style={{ maxWidth: '720px', marginLeft: 'auto', marginRight: 'auto' }}>
|
|
||||||
{stages.map((stage, index) => (
|
|
||||||
<div key={stage.id} className="w-full flex flex-col items-center">
|
|
||||||
<StageCard
|
|
||||||
stage={stage}
|
|
||||||
index={index}
|
|
||||||
isFirst={index === 0}
|
|
||||||
isLast={index === stages.length - 1}
|
|
||||||
selectedTaskId={selectedTaskId}
|
|
||||||
onSelectTask={onSelectTask}
|
|
||||||
onMoveUp={() => handleMoveStage(index, 'up')}
|
|
||||||
onMoveDown={() => handleMoveStage(index, 'down')}
|
|
||||||
onDelete={() => handleDeleteStage(index)}
|
|
||||||
onUpdateName={(name) => handleUpdateStageName(index, name)}
|
|
||||||
/>
|
|
||||||
{index < stages.length - 1 && (
|
|
||||||
<div className="flex flex-col items-center py-2">
|
|
||||||
<div className="flex flex-col items-center gap-[2px]">
|
|
||||||
<div className="w-px h-3" style={{ background: 'var(--border-color)' }} />
|
|
||||||
<ChevronDown className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
|
||||||
<div className="w-px h-3" style={{ background: 'var(--border-color)' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stages.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 rounded-lg border-2 border-dashed mx-auto" style={{ maxWidth: '720px', borderColor: 'var(--border-color-light)', background: 'var(--panel-bg)' }}>
|
|
||||||
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
|
|
||||||
<Plus className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>暂无阶段</p>
|
|
||||||
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>从左侧 Task 库选择任务,或添加阶段</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,400 +1,179 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket, Sparkles } from 'lucide-react';
|
||||||
CheckCircle2,
|
import { toast } from 'sonner';
|
||||||
XCircle,
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
Loader2,
|
import { apiClient, type PipelineRunDTO } from '@/lib/api';
|
||||||
Clock,
|
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
|
||||||
RotateCcw,
|
|
||||||
Eye,
|
|
||||||
GitCommit,
|
|
||||||
Hand,
|
|
||||||
Timer,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { ExecutionRecord } from '@/types/pipeline';
|
|
||||||
|
|
||||||
const mockExecutions: ExecutionRecord[] = [
|
interface Props {
|
||||||
{
|
pipelineId: number;
|
||||||
id: 5,
|
|
||||||
status: 'running',
|
|
||||||
trigger: 'Manual',
|
|
||||||
triggerBy: 'admin',
|
|
||||||
duration: '1分12秒',
|
|
||||||
timestamp: '2024-06-20 15:45:00',
|
|
||||||
pipelineName: 'aiops-backend',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
status: 'success',
|
|
||||||
trigger: 'Git Push',
|
|
||||||
triggerBy: 'system',
|
|
||||||
duration: '3分22秒',
|
|
||||||
timestamp: '2024-06-20 14:30:15',
|
|
||||||
pipelineName: 'aiops-backend',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
status: 'failed',
|
|
||||||
trigger: 'Manual',
|
|
||||||
triggerBy: 'admin',
|
|
||||||
duration: '45秒',
|
|
||||||
timestamp: '2024-06-20 13:20:00',
|
|
||||||
pipelineName: 'aiops-backend',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
status: 'success',
|
|
||||||
trigger: 'Cron',
|
|
||||||
triggerBy: 'system',
|
|
||||||
duration: '5分10秒',
|
|
||||||
timestamp: '2024-06-20 02:00:00',
|
|
||||||
pipelineName: 'aiops-backend',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
status: 'success',
|
|
||||||
trigger: 'Git Push',
|
|
||||||
triggerBy: 'system',
|
|
||||||
duration: '4分55秒',
|
|
||||||
timestamp: '2024-06-19 18:45:30',
|
|
||||||
pipelineName: 'aiops-backend',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const fullLogLines: string[] = [
|
|
||||||
'[15:45:00] ===== 流水线开始执行: aiops-backend =====',
|
|
||||||
'[15:45:00] 触发方式: 手动 (admin)',
|
|
||||||
'',
|
|
||||||
'[15:45:01] ===== 阶段: 拉取代码 =====',
|
|
||||||
'[15:45:01] > Git 拉取',
|
|
||||||
'[15:45:03] Cloning into \'aiops\'...',
|
|
||||||
'[15:45:08] Already up to date. commit: 7a3f2e1',
|
|
||||||
'[15:45:08] \u2713 Git 拉取 完成 (7s)',
|
|
||||||
'',
|
|
||||||
'[15:45:08] ===== 阶段: 构建镜像 =====',
|
|
||||||
'[15:45:09] > Docker 构建 & 推送',
|
|
||||||
'[15:45:10] === 开始构建 Docker 镜像 ===',
|
|
||||||
'[15:45:10] 镜像地址: registry.example.com/aiops/aiops-backend:202506201545',
|
|
||||||
'[15:45:11] Docker登录...',
|
|
||||||
'[15:45:12] Login Succeeded',
|
|
||||||
'[15:45:15] Building image...',
|
|
||||||
'[15:45:45] Successfully built 9a8b7c6d5e4f',
|
|
||||||
'[15:45:46] 推送镜像...',
|
|
||||||
'[15:46:02] latest: digest: sha256:abc123... size: 2847',
|
|
||||||
'[15:46:02] \u2713 Docker 构建 & 推送 完成 (53s)',
|
|
||||||
'[15:46:02] > 单元测试',
|
|
||||||
'[15:46:03] go test -v -race -coverprofile=coverage.out ./...',
|
|
||||||
'[15:46:15] PASS: TestUserLogin (0.23s)',
|
|
||||||
'[15:46:15] PASS: TestDataQuery (1.12s)',
|
|
||||||
'[15:46:18] PASS: TestWebhookHandler (0.45s)',
|
|
||||||
'[15:46:20] coverage: 82.3% of statements',
|
|
||||||
'[15:46:20] \u2713 单元测试 完成 (18s) \u2014 覆盖率: 82.3%',
|
|
||||||
'',
|
|
||||||
'[15:46:20] ===== 阶段: K8s 部署 =====',
|
|
||||||
'[15:46:21] > K8s 滚动更新',
|
|
||||||
'[15:46:22] 更新镜像: registry.example.com/aiops/aiops-backend:202506201545',
|
|
||||||
'[15:46:25] deployment.apps/aiops-backend image updated',
|
|
||||||
'[15:46:25] 等待滚动更新...',
|
|
||||||
'[15:46:30] [=====================> ] 2/3 pods ready',
|
|
||||||
'[15:46:35] [========================>] 3/3 pods ready',
|
|
||||||
'[15:46:36] deployment "aiops-backend" successfully rolled out',
|
|
||||||
'[15:46:36] Pod状态:',
|
|
||||||
'[15:46:36] NAME READY STATUS',
|
|
||||||
'[15:46:36] aiops-backend-7a3f2e1-x1 1/1 Running',
|
|
||||||
'[15:46:36] aiops-backend-7a3f2e1-x2 1/1 Running',
|
|
||||||
'[15:46:36] aiops-backend-7a3f2e1-x3 1/1 Running',
|
|
||||||
'[15:46:36] \u2713 K8s 滚动更新 完成 (15s)',
|
|
||||||
'',
|
|
||||||
'[15:46:37] ===== 阶段: 后置操作 =====',
|
|
||||||
'[15:46:37] > 刷新CDN',
|
|
||||||
'[15:46:37] 刷新CDN缓存...',
|
|
||||||
'[15:46:38] {"code": 200, "message": "refresh submitted"}',
|
|
||||||
'[15:46:38] \u2713 刷新CDN 完成 (1s)',
|
|
||||||
'',
|
|
||||||
'[15:46:38] ===== 流水线执行完成 =====',
|
|
||||||
'[15:46:38] 总耗时: 1分38秒',
|
|
||||||
'[15:46:38] 结果: \u2713 成功',
|
|
||||||
];
|
|
||||||
|
|
||||||
function getStatusIcon(status: string, className = 'w-5 h-5') {
|
|
||||||
switch (status) {
|
|
||||||
case 'success':
|
|
||||||
return <CheckCircle2 className={`${className} text-green-500`} />;
|
|
||||||
case 'failed':
|
|
||||||
return <XCircle className={`${className} text-red-500`} />;
|
|
||||||
case 'running':
|
|
||||||
return <Loader2 className={`${className} text-blue-500 animate-spin`} />;
|
|
||||||
default:
|
|
||||||
return <Clock className={`${className} text-gray-400`} />;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusText(status: string) {
|
// 列表行:PipelineRunDTO + 失败时自动生成的 AI 分析字段
|
||||||
switch (status) {
|
type RunRow = PipelineRunDTO & { analysis?: string };
|
||||||
case 'success':
|
|
||||||
return '成功';
|
const statusMap: Record<string, { color: string; bg: string; label: string }> = {
|
||||||
case 'failed':
|
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
||||||
return '失败';
|
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
||||||
case 'running':
|
running: { color: '#0969da', bg: '#ddf4ff', label: '执行中' },
|
||||||
return '正在执行';
|
pending: { color: '#9a6700', bg: '#fff8c5', label: '排队中' },
|
||||||
default:
|
queued: { color: '#9a6700', bg: '#fff8c5', label: '排队中' },
|
||||||
return '等待中';
|
cancelled: { color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
|
||||||
}
|
};
|
||||||
|
|
||||||
|
function fmtDuration(ms: number): string {
|
||||||
|
if (!ms) return '-';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}分${s % 60}秒`;
|
||||||
|
return `${Math.floor(s / 3600)}小时${Math.floor((s % 3600) / 60)}分`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTriggerIcon(trigger: string, className = 'w-3.5 h-3.5') {
|
export default function ReleasePanel({ pipelineId }: Props) {
|
||||||
switch (trigger) {
|
const queryClient = useQueryClient();
|
||||||
case 'Git Push':
|
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
||||||
return <GitCommit className={className} />;
|
|
||||||
case 'Manual':
|
|
||||||
return <Hand className={className} />;
|
|
||||||
case 'Cron':
|
|
||||||
return <Timer className={className} />;
|
|
||||||
default:
|
|
||||||
return <Clock className={className} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function LogLine({ line }: { line: string }) {
|
const { data: runs = [], isLoading } = useQuery({
|
||||||
if (!line) {
|
queryKey: ['runs', { pipelineId }],
|
||||||
return <div className="h-4" />;
|
queryFn: () => apiClient.listRuns({ pipelineId }) as Promise<RunRow[]>,
|
||||||
}
|
});
|
||||||
|
|
||||||
const timestamp = line.match(/^\[(\d{2}:\d{2}:\d{2})\]/)?.[0] || '';
|
const { data: detail } = useQuery({
|
||||||
const content = line.slice(timestamp.length);
|
queryKey: ['run', expandedRunId],
|
||||||
|
queryFn: () => apiClient.getRun(expandedRunId!),
|
||||||
|
enabled: !!expandedRunId,
|
||||||
|
});
|
||||||
|
|
||||||
// Determine color based on content
|
const expandedRun = runs.find((r) => r.runId === expandedRunId);
|
||||||
let contentColor = '#e6e6e6';
|
const isRunning = expandedRun?.status === 'running' || expandedRun?.status === 'pending';
|
||||||
if (content.includes('\u2713') || content.includes('成功') || content.includes('PASS')) {
|
const { logs: liveLogs, connected } = usePipelineLogs(expandedRunId, !!expandedRunId && isRunning);
|
||||||
contentColor = '#7ee787';
|
|
||||||
} else if (content.includes('失败') || content.includes('error') || content.includes('Error')) {
|
const rerunMutation = useMutation({
|
||||||
contentColor = '#f85149';
|
mutationFn: (runId: string) => apiClient.retryRun(runId),
|
||||||
} else if (content.includes('警告') || content.includes('warn') || content.includes('Waiting')) {
|
onSuccess: (run) => {
|
||||||
contentColor = '#d29922';
|
toast.success(`已重新触发 ${run.runId}`);
|
||||||
} else if (content.startsWith(' =====')) {
|
queryClient.invalidateQueries({ queryKey: ['runs'] });
|
||||||
contentColor = '#79c0ff';
|
},
|
||||||
} else if (content.startsWith(' > ')) {
|
onError: (err: Error) => toast.error('触发失败', { description: err.message }),
|
||||||
contentColor = '#d2a8ff';
|
});
|
||||||
}
|
|
||||||
|
const logs = isRunning ? liveLogs : (detail?.logs || []);
|
||||||
|
const stages = detail?.stages || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="font-mono text-sm leading-relaxed whitespace-pre-wrap">
|
<div className="tab-content-fade w-full px-6 py-6 max-w-4xl">
|
||||||
{timestamp && (
|
<div className="rounded-lg border shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<span style={{ color: '#8b949e' }}>{timestamp}</span>
|
<div className="flex items-center justify-between px-5 py-3.5 border-b" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
)}
|
|
||||||
<span style={{ color: contentColor }}>{content}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReleasePanel() {
|
|
||||||
const [selectedExecutionId, setSelectedExecutionId] = useState(5);
|
|
||||||
const [displayedLines, setDisplayedLines] = useState<string[]>([]);
|
|
||||||
const [isAnimating, setIsAnimating] = useState(false);
|
|
||||||
const logEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const animatingRef = useRef(false);
|
|
||||||
|
|
||||||
const selectedExecution = mockExecutions.find((e) => e.id === selectedExecutionId);
|
|
||||||
const isRunning = selectedExecution?.status === 'running';
|
|
||||||
|
|
||||||
// Animate log lines for running execution
|
|
||||||
useEffect(() => {
|
|
||||||
if (isRunning) {
|
|
||||||
setDisplayedLines([]);
|
|
||||||
setIsAnimating(true);
|
|
||||||
animatingRef.current = true;
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (!animatingRef.current) {
|
|
||||||
clearInterval(interval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index < fullLogLines.length) {
|
|
||||||
setDisplayedLines((prev) => [...prev, fullLogLines[index]]);
|
|
||||||
index++;
|
|
||||||
} else {
|
|
||||||
setIsAnimating(false);
|
|
||||||
animatingRef.current = false;
|
|
||||||
clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, 80); // Each line appears every 80ms
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
animatingRef.current = false;
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Show full log immediately for completed executions
|
|
||||||
setDisplayedLines(fullLogLines);
|
|
||||||
setIsAnimating(false);
|
|
||||||
}
|
|
||||||
}, [selectedExecutionId, isRunning]);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
useEffect(() => {
|
|
||||||
if (logEndRef.current) {
|
|
||||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, [displayedLines]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full" style={{ background: 'var(--panel-bg)' }}>
|
|
||||||
{/* Left: Execution History (40%) */}
|
|
||||||
<div
|
|
||||||
className="w-[40%] border-r flex flex-col overflow-hidden"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border-color)',
|
|
||||||
minWidth: '340px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-4 py-3 border-b"
|
|
||||||
style={{ borderColor: 'var(--border-color)' }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-sm font-semibold"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
执行记录
|
|
||||||
</span>
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full" style={{ color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}>
|
|
||||||
{mockExecutions.length} 条记录
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
|
||||||
{mockExecutions.map((execution) => (
|
|
||||||
<div
|
|
||||||
key={execution.id}
|
|
||||||
onClick={() => setSelectedExecutionId(execution.id)}
|
|
||||||
className={`px-4 py-3 border-b cursor-pointer transition-colors ${
|
|
||||||
selectedExecutionId === execution.id ? 'bg-blue-50/50' : 'hover:bg-[var(--panel-bg-secondary)]'
|
|
||||||
}`}
|
|
||||||
style={{ borderColor: 'var(--border-color-light)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{getStatusIcon(execution.status)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span
|
|
||||||
className="text-sm font-semibold"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
#{execution.id}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-xs px-1.5 py-0.5 rounded"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
execution.status === 'success'
|
|
||||||
? '#dafbe1'
|
|
||||||
: execution.status === 'failed'
|
|
||||||
? '#ffebe9'
|
|
||||||
: execution.status === 'running'
|
|
||||||
? '#ddf4ff'
|
|
||||||
: '#f6f8fa',
|
|
||||||
color:
|
|
||||||
execution.status === 'success'
|
|
||||||
? '#1a7f37'
|
|
||||||
: execution.status === 'failed'
|
|
||||||
? '#cf222e'
|
|
||||||
: execution.status === 'running'
|
|
||||||
? '#0969da'
|
|
||||||
: '#6e7781',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getStatusText(execution.status)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
{getTriggerIcon(execution.trigger)}
|
|
||||||
{execution.trigger}
|
|
||||||
</span>
|
|
||||||
<span>{execution.triggerBy}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-1.5">
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
{execution.duration}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
{execution.timestamp}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<button
|
|
||||||
className="p-1 rounded hover:bg-[var(--panel-bg-secondary)] transition-colors"
|
|
||||||
title="重新执行"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="p-1 rounded hover:bg-[var(--panel-bg-secondary)] transition-colors"
|
|
||||||
title="查看日志"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedExecutionId(execution.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Eye className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Log Viewer (60%) */}
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
|
||||||
{/* Log header */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-4 py-3 border-b"
|
|
||||||
style={{ borderColor: 'var(--border-color)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
<Rocket className="w-4.5 h-4.5" style={{ color: 'var(--accent-blue)' }} />
|
||||||
执行日志
|
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布历史</h3>
|
||||||
</span>
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{runs.length} 次执行</span>
|
||||||
{isRunning && isAnimating && (
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 animate-pulse">
|
|
||||||
实时输出中...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
{selectedExecution?.pipelineName} #{selectedExecutionId}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log content */}
|
{isLoading && (
|
||||||
<div
|
<div className="py-10 text-center" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>
|
||||||
className="flex-1 overflow-y-auto custom-scrollbar px-4 py-3"
|
)}
|
||||||
style={{ background: '#1e1e1e' }}
|
{!isLoading && runs.length === 0 && (
|
||||||
>
|
<div className="py-10 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>暂无发布记录,点击顶部「保存并运行」执行一次</div>
|
||||||
{displayedLines.map((line, index) => (
|
)}
|
||||||
<LogLine key={`${selectedExecutionId}-${index}`} line={line} />
|
|
||||||
))}
|
{runs.map((r) => {
|
||||||
{isAnimating && (
|
const st = statusMap[r.status] || statusMap.pending;
|
||||||
<div className="flex items-center gap-2 mt-2">
|
const isExpanded = expandedRunId === r.runId;
|
||||||
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
return (
|
||||||
<span className="text-xs" style={{ color: '#8b949e' }}>
|
<div key={r.runId} className="border-b last:border-b-0" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
等待新输出...
|
<div
|
||||||
</span>
|
className="flex items-center gap-3 px-5 py-2.5 cursor-pointer hover:bg-black/[0.015]"
|
||||||
|
onClick={() => setExpandedRunId(isExpanded ? null : r.runId)}
|
||||||
|
>
|
||||||
|
{r.status === 'running' ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" style={{ color: '#0969da' }} />
|
||||||
|
) : r.status === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-4 h-4" style={{ color: r.status === 'failed' ? '#cf222e' : '#6e7781' }} />
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-[11px] w-36" style={{ color: 'var(--text-tertiary)' }}>{r.runId}</span>
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>{r.branch}</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{r.env}</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: st.bg, color: st.color }}>{st.label}</span>
|
||||||
|
<span className="text-[11px] flex items-center gap-1 ml-auto" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Clock className="w-3 h-3" />{fmtDuration(r.durationMs)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] w-36 text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{new Date(r.startedAt).toLocaleString('zh-CN', { hour12: false })}
|
||||||
|
</span>
|
||||||
|
{(r.status === 'failed' || r.status === 'cancelled') && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); rerunMutation.mutate(r.runId); }}
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
style={{ color: 'var(--accent-blue)' }}
|
||||||
|
title="重新执行"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-5 pb-4">
|
||||||
|
{r.externalUrl && (
|
||||||
|
<a href={r.externalUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[11px] mb-2 font-mono" style={{ color: 'var(--accent-blue)' }}>
|
||||||
|
<ExternalLink className="w-3 h-3" />Jenkins 构建 #{r.buildNumber}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{stages.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||||
|
{stages.map((s: any) => {
|
||||||
|
const sst = statusMap[s.status] || statusMap.pending;
|
||||||
|
return (
|
||||||
|
<span key={s.id} className="text-[10px] px-2 py-[2px] rounded-md font-medium" style={{ background: sst.bg, color: sst.color }}>
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* AI 失败分析(紧凑版,日志区上方) */}
|
||||||
|
{r.analysis ? (
|
||||||
|
<div className="mb-2 rounded-md border px-3 py-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
||||||
|
<div className="flex items-center gap-1 text-[11px] font-semibold mb-1" style={{ color: '#8250df' }}>
|
||||||
|
<Sparkles className="w-3 h-3" />AI 失败分析
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] leading-[18px] whitespace-pre-wrap" style={{ color: 'var(--text-primary)' }}>{r.analysis}</div>
|
||||||
|
</div>
|
||||||
|
) : ['running', 'pending', 'queued'].includes(r.status) ? (
|
||||||
|
<div className="mb-2 flex items-center gap-1 text-[10px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Sparkles className="w-3 h-3" style={{ color: '#8250df' }} />失败后将自动生成 AI 分析
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)' }}>
|
||||||
|
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<span className="text-[11px] font-semibold" style={{ color: 'var(--text-primary)' }}>执行日志</span>
|
||||||
|
{isRunning && (
|
||||||
|
<span className="text-[9px] px-1.5 py-[1px] rounded animate-pulse" style={{ background: '#fff8c5', color: '#9a6700' }}>
|
||||||
|
{connected ? 'LIVE' : '连接中…'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto custom-scrollbar px-3 py-2 font-mono text-[11px] leading-5">
|
||||||
|
{logs.length === 0 && <div style={{ color: 'var(--text-tertiary)' }}>暂无日志</div>}
|
||||||
|
{logs.map((l: any, i: number) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{l.timestamp ? new Date(l.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: l.level === 'error' ? '#cf222e' : l.level === 'warn' ? '#9a6700' : 'var(--text-secondary)' }}>{l.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
<div ref={logEndRef} />
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,237 +0,0 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface ScriptEditorProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
language: 'shell' | 'python';
|
|
||||||
theme?: 'light' | 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic syntax highlighting patterns
|
|
||||||
const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'return', 'exit', 'echo', 'printf', 'read', 'source', 'export', 'unset', 'local', 'shift', 'break', 'continue', 'in', 'trap'];
|
|
||||||
const pythonKeywords = ['and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'False', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'None', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'True', 'try', 'while', 'with', 'yield'];
|
|
||||||
const builtinFunctions = ['print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', 'type', 'isinstance', 'hasattr', 'getattr', 'open', 'input', 'map', 'filter', 'zip', 'enumerate', 'sorted', 'reversed', 'sum', 'min', 'max', 'abs', 'round', 'pow', 'divmod', 'chr', 'ord', 'hex', 'bin', 'oct', 'format'];
|
|
||||||
|
|
||||||
// Dark theme syntax colors
|
|
||||||
const darkSyntaxColors = {
|
|
||||||
comment: '#6a9955',
|
|
||||||
string: '#ce9178',
|
|
||||||
keyword: '#569cd6',
|
|
||||||
function: '#dcdcaa',
|
|
||||||
number: '#b5cea8',
|
|
||||||
default: '#d4d4d4',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Light theme syntax colors
|
|
||||||
const lightSyntaxColors = {
|
|
||||||
comment: '#1a7f37',
|
|
||||||
string: '#cf222e',
|
|
||||||
keyword: '#8250df',
|
|
||||||
function: '#8250df',
|
|
||||||
number: '#9a6700',
|
|
||||||
default: '#1f2328',
|
|
||||||
};
|
|
||||||
|
|
||||||
function highlightLine(line: string, language: 'shell' | 'python', isDark: boolean): string {
|
|
||||||
let html = '';
|
|
||||||
let remaining = line;
|
|
||||||
|
|
||||||
const colors = isDark ? darkSyntaxColors : lightSyntaxColors;
|
|
||||||
const keywords = language === 'shell' ? shellKeywords : pythonKeywords;
|
|
||||||
const kwPattern = new RegExp(`^(\\s*)(${keywords.join('|')})(?![a-zA-Z0-9_])`);
|
|
||||||
const funcPattern = /^(\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*\()/;
|
|
||||||
const stringPattern = /^(["'])(.*?)(\1)/;
|
|
||||||
const commentPattern = language === 'shell' ? /^(#.*)/ : /^(#.*)/;
|
|
||||||
const numberPattern = /^([0-9]+\.?[0-9]*)/;
|
|
||||||
const wordPattern = /^([a-zA-Z_][a-zA-Z0-9_]*)/;
|
|
||||||
const spacePattern = /^(\s+)/;
|
|
||||||
const otherPattern = /^(.)/;
|
|
||||||
|
|
||||||
while (remaining.length > 0) {
|
|
||||||
// Check for comment first
|
|
||||||
const commentMatch = remaining.match(commentPattern);
|
|
||||||
if (commentMatch) {
|
|
||||||
html += `<span style="color: ${colors.comment}; font-style: italic;">${escapeHtml(commentMatch[0])}</span>`;
|
|
||||||
remaining = remaining.slice(commentMatch[0].length);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// String literals
|
|
||||||
const stringMatch = remaining.match(stringPattern);
|
|
||||||
if (stringMatch) {
|
|
||||||
html += `<span style="color: ${colors.string};">${escapeHtml(stringMatch[0])}</span>`;
|
|
||||||
remaining = remaining.slice(stringMatch[0].length);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function calls
|
|
||||||
const funcMatch = remaining.match(funcPattern);
|
|
||||||
if (funcMatch && language === 'python' && builtinFunctions.includes(funcMatch[2])) {
|
|
||||||
html += `${escapeHtml(funcMatch[1])}<span style="color: ${colors.function};">${escapeHtml(funcMatch[2])}</span>${escapeHtml(funcMatch[3])}`;
|
|
||||||
remaining = remaining.slice(funcMatch[0].length);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keywords
|
|
||||||
const kwMatch = remaining.match(kwPattern);
|
|
||||||
if (kwMatch) {
|
|
||||||
html += `${escapeHtml(kwMatch[1])}<span style="color: ${colors.keyword}; font-weight: 600;">${escapeHtml(kwMatch[2])}</span>`;
|
|
||||||
remaining = remaining.slice(kwMatch[0].length);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Numbers
|
|
||||||
const numMatch = remaining.match(numberPattern);
|
|
||||||
if (numMatch) {
|
|
||||||
html += `<span style="color: ${colors.number};">${escapeHtml(numMatch[0])}</span>`;
|
|
||||||
remaining = remaining.slice(numMatch[0].length);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Words
|
|
||||||
const wordMatch = remaining.match(wordPattern);
|
|
||||||
if (wordMatch) {
|
|
||||||
html += escapeHtml(wordMatch[0]);
|
|
||||||
remaining = remaining.slice(wordMatch[0].length);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whitespace
|
|
||||||
const spaceMatch = remaining.match(spacePattern);
|
|
||||||
if (spaceMatch) {
|
|
||||||
html += escapeHtml(spaceMatch[0]);
|
|
||||||
remaining = remaining.slice(spaceMatch[0].length);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other characters
|
|
||||||
const otherMatch = remaining.match(otherPattern);
|
|
||||||
if (otherMatch) {
|
|
||||||
html += escapeHtml(otherMatch[0]);
|
|
||||||
remaining = remaining.slice(otherMatch[0].length);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html || ' ';
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScriptEditor({ value, onChange, language, theme = 'light' }: ScriptEditorProps) {
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const highlightRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [focused, setFocused] = useState(false);
|
|
||||||
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
|
|
||||||
const lines = value.split('\n');
|
|
||||||
const lineNumbers = lines.map((_, i) => i + 1).join('\n');
|
|
||||||
|
|
||||||
const highlightedHtml = lines
|
|
||||||
.map((line) => highlightLine(line, language, isDark))
|
|
||||||
.join('<br>');
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
const target = e.currentTarget;
|
|
||||||
const start = target.selectionStart;
|
|
||||||
const end = target.selectionEnd;
|
|
||||||
const newValue = value.substring(0, start) + ' ' + value.substring(end);
|
|
||||||
onChange(newValue);
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
target.selectionStart = target.selectionEnd = start + 2;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[value, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
if (textareaRef.current && highlightRef.current) {
|
|
||||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
|
||||||
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleScroll();
|
|
||||||
}, [value, handleScroll]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`relative rounded-md border overflow-hidden script-editor ${
|
|
||||||
focused ? 'ring-1' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
borderColor: isDark ? '#333333' : 'var(--border-color)',
|
|
||||||
minHeight: '200px',
|
|
||||||
'--tw-ring-color': 'rgba(9, 105, 218, 0.25)',
|
|
||||||
background: isDark ? '#1e1e1e' : 'var(--panel-bg)',
|
|
||||||
} as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<div className="flex" style={{ minHeight: '200px' }}>
|
|
||||||
{/* Line numbers */}
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 py-3 px-2 text-right select-none"
|
|
||||||
style={{
|
|
||||||
background: isDark ? '#1e1e1e' : 'var(--panel-bg-secondary)',
|
|
||||||
color: isDark ? '#858585' : 'var(--text-tertiary)',
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
minWidth: '40px',
|
|
||||||
borderRight: `1px solid ${isDark ? '#333333' : 'var(--border-color-light)'}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{lineNumbers.split('\n').map((num, i) => (
|
|
||||||
<div key={i}>{num}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Editor area */}
|
|
||||||
<div className="relative flex-1" style={{ minHeight: '200px' }}>
|
|
||||||
{/* Highlight layer */}
|
|
||||||
<div
|
|
||||||
ref={highlightRef}
|
|
||||||
className="absolute inset-0 py-3 px-3 pointer-events-none overflow-hidden whitespace-pre"
|
|
||||||
style={{
|
|
||||||
color: isDark ? '#d4d4d4' : 'var(--text-primary)',
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
}}
|
|
||||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Textarea layer */}
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onFocus={() => setFocused(true)}
|
|
||||||
onBlur={() => setFocused(false)}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
spellCheck={false}
|
|
||||||
className="absolute inset-0 w-full h-full py-3 px-3 resize-none bg-transparent script-editor"
|
|
||||||
style={{
|
|
||||||
color: 'transparent',
|
|
||||||
caretColor: isDark ? '#d4d4d4' : 'var(--text-primary)',
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Server,
|
Server,
|
||||||
Key,
|
Key,
|
||||||
Cpu,
|
Cpu,
|
||||||
Wrench,
|
Wrench,
|
||||||
Download,
|
Download,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -13,16 +13,24 @@ import {
|
|||||||
Webhook,
|
Webhook,
|
||||||
CloudSync,
|
CloudSync,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Settings
|
Settings,
|
||||||
|
Users,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
LogOut,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import { useTheme } from '@/providers/theme';
|
||||||
|
|
||||||
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
|
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'users' | 'settings';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeModule: ModuleType;
|
activeModule: ModuleType;
|
||||||
onModuleChange: (module: ModuleType) => void;
|
onModuleChange: (module: ModuleType) => void;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: typeof GitBranch; badge: string | null }[] }[] = [
|
const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: typeof GitBranch; badge: string | null }[] }[] = [
|
||||||
@@ -55,12 +63,27 @@ const menuGroups: { label: string; items: { id: ModuleType; label: string; icon:
|
|||||||
label: '系统',
|
label: '系统',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' },
|
{ id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' },
|
||||||
|
{ id: 'users', label: '用户管理', icon: Users, badge: null },
|
||||||
{ id: 'settings', label: '系统设置', icon: Settings, badge: null },
|
{ id: 'settings', label: '系统设置', icon: Settings, badge: null },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse }: SidebarProps) {
|
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse, onLogout }: SidebarProps) {
|
||||||
|
// 徽标数来自后端真实统计
|
||||||
|
const { data: summary } = useQuery({
|
||||||
|
queryKey: ['summary'],
|
||||||
|
queryFn: apiClient.getSummary,
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
const { toggle: themeToggle, isDark } = useTheme();
|
||||||
|
|
||||||
|
const badgeFor = (id: ModuleType): string | null => {
|
||||||
|
if (id === 'build-nodes') return summary?.buildNodes != null ? String(summary.buildNodes) : null;
|
||||||
|
if (id === 'plugins') return summary?.plugins != null ? String(summary.plugins) : null;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col border-r transition-all duration-300 ease-in-out h-full"
|
className="flex flex-col border-r transition-all duration-300 ease-in-out h-full"
|
||||||
@@ -121,17 +144,21 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
|
|||||||
<span className="text-[13px] font-medium truncate flex-1 text-left">
|
<span className="text-[13px] font-medium truncate flex-1 text-left">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
{item.badge && (
|
{(() => {
|
||||||
<span
|
const badge = item.badge === 'New' ? 'New' : badgeFor(item.id);
|
||||||
className="text-[9px] px-1 py-[1px] rounded font-medium"
|
if (!badge) return null;
|
||||||
style={{
|
return (
|
||||||
background: item.badge === 'New' ? '#ddf4ff' : 'var(--panel-bg-secondary)',
|
<span
|
||||||
color: item.badge === 'New' ? 'var(--accent-blue)' : 'var(--text-tertiary)',
|
className="text-[9px] px-1 py-[1px] rounded font-medium"
|
||||||
}}
|
style={{
|
||||||
>
|
background: badge === 'New' ? '#ddf4ff' : 'var(--panel-bg-secondary)',
|
||||||
{item.badge}
|
color: badge === 'New' ? 'var(--accent-blue)' : 'var(--text-tertiary)',
|
||||||
</span>
|
}}
|
||||||
)}
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isActive && !collapsed && (
|
{isActive && !collapsed && (
|
||||||
@@ -148,11 +175,27 @@ export default function Sidebar({ activeModule, onModuleChange, collapsed, onTog
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collapse toggle */}
|
{/* Bottom bar: theme toggle + collapse */}
|
||||||
<div
|
<div
|
||||||
className="px-2 py-2 border-t flex justify-center"
|
className="px-2 py-2 border-t flex items-center justify-center gap-1"
|
||||||
style={{ borderColor: 'var(--border-color)' }}
|
style={{ borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={themeToggle}
|
||||||
|
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
title="切换深色/浅色主题"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
X,
|
|
||||||
Plus,
|
|
||||||
GitBranch,
|
|
||||||
Wrench,
|
|
||||||
Server,
|
|
||||||
FlaskConical,
|
|
||||||
Settings,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { PipelineStage } from '@/types/pipeline';
|
|
||||||
import TaskCard from './TaskCard';
|
|
||||||
|
|
||||||
interface StageCardProps {
|
|
||||||
stage: PipelineStage;
|
|
||||||
index: number;
|
|
||||||
isFirst: boolean;
|
|
||||||
isLast: boolean;
|
|
||||||
selectedTaskId: string | null;
|
|
||||||
onSelectTask: (taskId: string) => void;
|
|
||||||
onMoveUp: () => void;
|
|
||||||
onMoveDown: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onUpdateName: (name: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only use color for the LEFT BORDER — keep everything else neutral
|
|
||||||
const stageTypeConfig: Record<string, { color: string; icon: typeof GitBranch }> = {
|
|
||||||
source: { color: '#0969da', icon: GitBranch },
|
|
||||||
build: { color: '#9a6700', icon: Wrench },
|
|
||||||
deploy: { color: '#1a7f37', icon: Server },
|
|
||||||
test: { color: '#8250df', icon: FlaskConical },
|
|
||||||
custom: { color: '#6e7781', icon: Settings },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StageCard({
|
|
||||||
stage,
|
|
||||||
index,
|
|
||||||
isFirst,
|
|
||||||
isLast,
|
|
||||||
selectedTaskId,
|
|
||||||
onSelectTask,
|
|
||||||
onMoveUp,
|
|
||||||
onMoveDown,
|
|
||||||
onDelete,
|
|
||||||
onUpdateName,
|
|
||||||
}: StageCardProps) {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [editName, setEditName] = useState(stage.name);
|
|
||||||
const config = stageTypeConfig[stage.type] || stageTypeConfig.custom;
|
|
||||||
const IconComponent = config.icon;
|
|
||||||
|
|
||||||
const handleNameSubmit = () => {
|
|
||||||
if (editName.trim()) {
|
|
||||||
onUpdateName(editName.trim());
|
|
||||||
} else {
|
|
||||||
setEditName(stage.name);
|
|
||||||
}
|
|
||||||
setIsEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') handleNameSubmit();
|
|
||||||
else if (e.key === 'Escape') {
|
|
||||||
setEditName(stage.name);
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div
|
|
||||||
className="rounded-lg overflow-hidden transition-shadow duration-200 hover:shadow-sm"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
border: '1px solid var(--border-color)',
|
|
||||||
borderLeft: `3px solid ${config.color}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header — unified neutral background, NO colorful bg */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-4 py-2.5 border-b"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border-color-light)',
|
|
||||||
background: '#f8f9fa',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
||||||
<IconComponent className="w-3.5 h-3.5 flex-shrink-0" style={{ color: config.color }} />
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
value={editName}
|
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
|
||||||
onBlur={handleNameSubmit}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
autoFocus
|
|
||||||
className="text-[13px] font-semibold bg-white border rounded px-2 py-0.5 outline-none"
|
|
||||||
style={{
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
borderColor: config.color,
|
|
||||||
minWidth: '140px',
|
|
||||||
fontSize: '13px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
className="text-[13px] font-semibold hover:opacity-70 transition-opacity truncate text-left"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
title="点击编辑"
|
|
||||||
>
|
|
||||||
阶段 {index + 1}: {stage.name}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Refined action buttons — smaller, tighter */}
|
|
||||||
<div className="flex items-center gap-[2px] flex-shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={onMoveUp}
|
|
||||||
disabled={isFirst}
|
|
||||||
className="p-1 rounded transition-all hover:bg-black/5 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
|
||||||
title="上移"
|
|
||||||
>
|
|
||||||
<ChevronUp className="w-3 h-3" style={{ color: 'var(--text-secondary)' }} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onMoveDown}
|
|
||||||
disabled={isLast}
|
|
||||||
className="p-1 rounded transition-all hover:bg-black/5 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
|
||||||
title="下移"
|
|
||||||
>
|
|
||||||
<ChevronDown className="w-3 h-3" style={{ color: 'var(--text-secondary)' }} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
className="p-1 rounded transition-all hover:bg-red-50"
|
|
||||||
title="删除阶段"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body: Task list */}
|
|
||||||
<div className="px-2 py-1.5">
|
|
||||||
{stage.tasks.map((task) => (
|
|
||||||
<TaskCard
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
isSelected={selectedTaskId === task.id}
|
|
||||||
onSelect={() => onSelectTask(task.id)}
|
|
||||||
onDelete={() => {/* TODO */}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{stage.tasks.length === 0 && (
|
|
||||||
<div
|
|
||||||
className="text-center py-4 rounded-md"
|
|
||||||
style={{ color: 'var(--text-tertiary)' }}
|
|
||||||
>
|
|
||||||
<p className="text-[12px]">暂无任务</p>
|
|
||||||
<p className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
点击下方按钮添加
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer: Add task — refined style */}
|
|
||||||
<div
|
|
||||||
className="px-2 py-1.5 border-t"
|
|
||||||
style={{ borderColor: 'var(--border-color-light)' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="flex items-center justify-center gap-1.5 w-full py-[6px] text-[12px] rounded-md border border-dashed transition-all hover:border-solid hover:bg-[var(--panel-bg-secondary)]"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--border-color-light)',
|
|
||||||
color: 'var(--text-tertiary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
添加任务
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { Pencil, Trash2 } from 'lucide-react';
|
|
||||||
import { iconMap } from '@/data/pipelineData';
|
|
||||||
import type { PipelineTask } from '@/types/pipeline';
|
|
||||||
|
|
||||||
interface TaskCardProps {
|
|
||||||
task: PipelineTask;
|
|
||||||
isSelected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtle type indicator colors — just for the small dot/icon, NOT background
|
|
||||||
const typeConfig: Record<string, { color: string; label: string }> = {
|
|
||||||
build: { color: '#9a6700', label: '构建' },
|
|
||||||
deploy: { color: '#1a7f37', label: '部署' },
|
|
||||||
test: { color: '#8250df', label: '测试' },
|
|
||||||
custom: { color: '#6e7781', label: '自定义' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TaskCard({ task, isSelected, onSelect, onDelete }: TaskCardProps) {
|
|
||||||
const config = typeConfig[task.type] || typeConfig.custom;
|
|
||||||
const IconComponent = iconMap[task.icon] || iconMap.Settings;
|
|
||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={onSelect}
|
|
||||||
className="flex items-center gap-2.5 px-3 py-[10px] rounded-md cursor-pointer transition-all duration-150 group mb-[2px] last:mb-0"
|
|
||||||
style={{
|
|
||||||
background: isSelected ? '#f0f5ff' : 'transparent',
|
|
||||||
border: isSelected ? '1px solid #b7d4f0' : '1px solid transparent',
|
|
||||||
...(isSelected
|
|
||||||
? { boxShadow: '0 0 0 1px rgba(9,105,218,0.08)' }
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Type icon — small colored dot with icon inside */}
|
|
||||||
<div
|
|
||||||
className="w-6 h-6 rounded-[6px] flex items-center justify-center flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
background: `${config.color}10`,
|
|
||||||
border: `1px solid ${config.color}25`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconComponent className="w-3 h-3" style={{ color: config.color }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div
|
|
||||||
className="text-[13px] font-medium truncate leading-tight"
|
|
||||||
style={{ color: isSelected ? 'var(--text-primary)' : 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
{task.name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-[11px] truncate leading-tight mt-[1px]"
|
|
||||||
style={{ color: 'var(--text-tertiary)' }}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
{task.scriptType && ` · ${task.scriptType}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions — hover reveal */}
|
|
||||||
<div className="flex items-center gap-[2px] opacity-0 group-hover:opacity-100 transition-opacity duration-150 flex-shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onSelect(); }}
|
|
||||||
className="p-1 rounded transition-colors hover:bg-black/5"
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="p-1 rounded transition-colors hover:bg-red-50"
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,52 @@
|
|||||||
import { ChevronLeft, Save, Play, MonitorPlay, ChevronDown } from 'lucide-react';
|
import { ChevronLeft, Save, Play, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
||||||
|
|
||||||
|
interface PipelineOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
pipelineName: string;
|
pipelineName: string;
|
||||||
|
pipelines: PipelineOption[];
|
||||||
|
currentPipelineId: number;
|
||||||
|
onPipelineChange: (id: number) => void;
|
||||||
activeTab: EditorTab;
|
activeTab: EditorTab;
|
||||||
onTabChange: (tab: EditorTab) => void;
|
onTabChange: (tab: EditorTab) => void;
|
||||||
onSimulateRun: () => void;
|
onOnlySave: () => void;
|
||||||
isSimulating: boolean;
|
onSaveAndRun: () => void;
|
||||||
scenarioKey: 'go-backend' | 'vue-frontend';
|
isSaving?: boolean;
|
||||||
onScenarioChange: (key: 'go-backend' | 'vue-frontend') => void;
|
isRunning?: boolean;
|
||||||
executionStatus: 'idle' | 'running' | 'success' | 'failed';
|
executionStatus: 'idle' | 'running' | 'success' | 'failed' | 'cancelled';
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布'];
|
const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布'];
|
||||||
|
|
||||||
const scenarios = [
|
|
||||||
{ key: 'go-backend' as const, label: 'aiops-backend (Go + K8s)' },
|
|
||||||
{ key: 'vue-frontend' as const, label: 'aiops-web (Vue + Host)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TopBar({
|
export default function TopBar({
|
||||||
pipelineName,
|
pipelineName,
|
||||||
|
pipelines,
|
||||||
|
currentPipelineId,
|
||||||
|
onPipelineChange,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
onSimulateRun,
|
onOnlySave,
|
||||||
isSimulating,
|
onSaveAndRun,
|
||||||
scenarioKey,
|
isSaving = false,
|
||||||
onScenarioChange,
|
isRunning = false,
|
||||||
executionStatus,
|
executionStatus,
|
||||||
onBack,
|
onBack,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false);
|
const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig: Record<string, { color: string; bg: string; label: string } | null> = {
|
||||||
idle: null,
|
idle: null,
|
||||||
running: { color: '#0969da', bg: '#ddf4ff', label: '执行中' },
|
running: { color: '#0969da', bg: '#ddf4ff', label: '执行中' },
|
||||||
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
||||||
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
||||||
|
cancelled: { color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const status = statusConfig[executionStatus];
|
const status = statusConfig[executionStatus];
|
||||||
@@ -82,24 +89,24 @@ export default function TopBar({
|
|||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setPipelineDropdownOpen(false)} />
|
<div className="fixed inset-0 z-40" onClick={() => setPipelineDropdownOpen(false)} />
|
||||||
<div
|
<div
|
||||||
className="absolute top-full left-0 mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[200px]"
|
className="absolute top-full left-0 mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[200px] max-h-[320px] overflow-y-auto custom-scrollbar"
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
{scenarios.map((s) => (
|
{pipelines.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={s.key}
|
key={p.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onScenarioChange(s.key);
|
onPipelineChange(p.id);
|
||||||
setPipelineDropdownOpen(false);
|
setPipelineDropdownOpen(false);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 text-[13px] transition-colors hover:bg-[var(--panel-bg-secondary)]"
|
className="w-full text-left px-3 py-2 text-[13px] transition-colors hover:bg-[var(--panel-bg-secondary)]"
|
||||||
style={{
|
style={{
|
||||||
color: scenarioKey === s.key ? 'var(--accent-blue)' : 'var(--text-primary)',
|
color: p.id === currentPipelineId ? 'var(--accent-blue)' : 'var(--text-primary)',
|
||||||
fontWeight: scenarioKey === s.key ? 600 : 400,
|
fontWeight: p.id === currentPipelineId ? 600 : 400,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{s.label}
|
{p.name}
|
||||||
{scenarioKey === s.key && (
|
{p.id === currentPipelineId && (
|
||||||
<span className="ml-2 text-[11px]" style={{ color: 'var(--accent-blue)' }}>✓</span>
|
<span className="ml-2 text-[11px]" style={{ color: 'var(--accent-blue)' }}>✓</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -112,7 +119,7 @@ export default function TopBar({
|
|||||||
{/* Execution status badge */}
|
{/* Execution status badge */}
|
||||||
{status && (
|
{status && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] px-1.5 py-[2px] rounded-full font-medium"
|
className={`text-[10px] px-1.5 py-[2px] rounded-full font-medium ${executionStatus === 'running' ? 'animate-pulse' : ''}`}
|
||||||
style={{ background: status.bg, color: status.color }}
|
style={{ background: status.bg, color: status.color }}
|
||||||
>
|
>
|
||||||
{status.label}
|
{status.label}
|
||||||
@@ -142,35 +149,26 @@ export default function TopBar({
|
|||||||
{/* Right: Action Buttons */}
|
{/* Right: Action Buttons */}
|
||||||
<div className="flex items-center gap-2 min-w-[280px] justify-end">
|
<div className="flex items-center gap-2 min-w-[280px] justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={onSimulateRun}
|
onClick={onOnlySave}
|
||||||
disabled={isSimulating}
|
disabled={isSaving}
|
||||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50"
|
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50"
|
||||||
style={{
|
|
||||||
color: 'var(--accent-blue)',
|
|
||||||
background: 'var(--accent-blue-light)',
|
|
||||||
border: '1px solid rgba(9, 105, 218, 0.2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MonitorPlay className="w-4 h-4" />
|
|
||||||
{isSimulating ? '运行中...' : '模拟运行'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors"
|
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--text-secondary)',
|
color: 'var(--text-secondary)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid var(--border-color)',
|
border: '1px solid var(--border-color)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
仅保存
|
仅保存
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors"
|
onClick={onSaveAndRun}
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
disabled={isRunning}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors disabled:opacity-60"
|
||||||
|
style={{ background: isRunning ? '#6e7781' : 'var(--accent-blue)' }}
|
||||||
>
|
>
|
||||||
<Play className="w-4 h-4" />
|
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||||
保存并运行
|
{isRunning ? '运行中…' : '保存并运行'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,257 +1,144 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { GitCommit, Clock, Webhook, Hand, Info, Copy, Check } from 'lucide-react';
|
import { Webhook, Hand, Clock, GitCommit, Save, Loader2, Copy, Check } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
export default function TriggerPanel() {
|
interface Props {
|
||||||
// Code commit trigger
|
pipelineId: number;
|
||||||
const [codeCommitEnabled, setCodeCommitEnabled] = useState(true);
|
}
|
||||||
const [watchBranch, setWatchBranch] = useState('');
|
|
||||||
const [excludeBranch, setExcludeBranch] = useState('');
|
|
||||||
|
|
||||||
// Cron trigger
|
const modes = [
|
||||||
const [cronEnabled, setCronEnabled] = useState(false);
|
{ key: 'manual', label: '手动触发', desc: '在流水线列表或编辑器中手动执行', icon: Hand },
|
||||||
const [cronExpression, setCronExpression] = useState('');
|
{ key: 'webhook', label: 'Webhook', desc: '通过 Webhook URL 推送事件触发', icon: Webhook },
|
||||||
const [nextExecution, setNextExecution] = useState('2024-06-21 02:00:00');
|
{ key: 'git_push', label: 'Git Push', desc: '代码仓库分支变更时触发', icon: GitCommit },
|
||||||
|
{ key: 'cron', label: '定时触发', desc: '按 Cron 表达式定时执行', icon: Clock },
|
||||||
|
];
|
||||||
|
|
||||||
// Webhook trigger
|
export default function TriggerPanel({ pipelineId }: Props) {
|
||||||
const [webhookEnabled, setWebhookEnabled] = useState(false);
|
const queryClient = useQueryClient();
|
||||||
const [webhookSecret, setWebhookSecret] = useState('');
|
const { data: detail } = useQuery({
|
||||||
|
queryKey: ['pipeline', pipelineId],
|
||||||
|
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||||
|
});
|
||||||
|
const p = detail?.pipeline;
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ triggerMode: 'manual', cronExpr: '', webhookToken: '', watchBranches: '' });
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const webhookUrl = 'https://api.example.com/webhook/pipeline/gateway-pre-serverless';
|
useEffect(() => {
|
||||||
|
if (p) {
|
||||||
const handleCopyWebhook = () => {
|
setForm({
|
||||||
navigator.clipboard.writeText(webhookUrl).then(() => {
|
triggerMode: p.triggerMode || 'manual',
|
||||||
setCopied(true);
|
cronExpr: p.cronExpr || '',
|
||||||
toast.success('Webhook URL 已复制');
|
webhookToken: p.webhookToken || '',
|
||||||
setTimeout(() => setCopied(false), 2000);
|
watchBranches: p.watchBranches || '',
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const cronPresets = [
|
|
||||||
{ label: '每天凌晨2点', value: '0 2 * * *' },
|
|
||||||
{ label: '每小时', value: '0 * * * *' },
|
|
||||||
{ label: '自定义', value: '' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleCronPreset = (value: string) => {
|
|
||||||
setCronExpression(value);
|
|
||||||
if (value === '0 2 * * *') {
|
|
||||||
setNextExecution('2024-06-21 02:00:00');
|
|
||||||
} else if (value === '0 * * * *') {
|
|
||||||
setNextExecution('2024-06-20 15:00:00');
|
|
||||||
}
|
}
|
||||||
|
}, [p?.id, p?.updatedAt]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (body: any) => apiClient.updatePipeline(pipelineId, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('触发设置已保存');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const regenToken = () => {
|
||||||
|
const array = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
const token = Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
setForm((f) => ({ ...f, webhookToken: token }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hookUrl = `${window.location.origin}/api/hooks/pipeline/${pipelineId}${form.webhookToken ? `?token=${form.webhookToken}` : ''}`;
|
||||||
|
|
||||||
|
const copyUrl = () => {
|
||||||
|
navigator.clipboard.writeText(hookUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-content-fade space-y-4 w-full px-6 py-6">
|
<div className="tab-content-fade w-full px-6 py-6 max-w-3xl overflow-y-auto">
|
||||||
{/* Code Commit Trigger */}
|
<div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
<div className="flex items-center justify-between mb-5">
|
||||||
<CardHeader className="pb-3">
|
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>触发方式</h3>
|
||||||
<div className="flex items-center justify-between">
|
<button
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => saveMutation.mutate(form)}
|
||||||
<GitCommit className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
disabled={saveMutation.isPending}
|
||||||
<CardTitle className="text-base">代码提交触发</CardTitle>
|
className="flex items-center gap-1 px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
|
||||||
</div>
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
<Switch checked={codeCommitEnabled} onCheckedChange={setCodeCommitEnabled} />
|
>
|
||||||
</div>
|
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存
|
||||||
<CardDescription>
|
</button>
|
||||||
当指定分支有新的代码提交时自动触发流水线
|
</div>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{codeCommitEnabled && (
|
|
||||||
<CardContent className="tab-content-fade space-y-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>监听分支</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>支持正则表达式,如 release/*</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={watchBranch}
|
|
||||||
onChange={(e) => setWatchBranch(e.target.value)}
|
|
||||||
placeholder="支持正则,如 release/*"
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
|
||||||
多个分支用逗号分隔,支持 * 通配符
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
排除分支
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={excludeBranch}
|
|
||||||
onChange={(e) => setExcludeBranch(e.target.value)}
|
|
||||||
placeholder="如 hotfix/*"
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
|
||||||
匹配的分支不会触发流水线
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Cron Trigger */}
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
{modes.map((m) => {
|
||||||
<CardHeader className="pb-3">
|
const Icon = m.icon;
|
||||||
<div className="flex items-center justify-between">
|
const active = form.triggerMode === m.key;
|
||||||
<div className="flex items-center gap-2">
|
return (
|
||||||
<Clock className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
<button
|
||||||
<CardTitle className="text-base">定时触发</CardTitle>
|
key={m.key}
|
||||||
</div>
|
onClick={() => setForm((f) => ({ ...f, triggerMode: m.key }))}
|
||||||
<Switch checked={cronEnabled} onCheckedChange={setCronEnabled} />
|
className="flex items-start gap-3 p-4 rounded-lg border text-left transition-colors"
|
||||||
</div>
|
style={{
|
||||||
<CardDescription>
|
borderColor: active ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||||
按照 Cron 表达式定时触发流水线
|
background: active ? 'rgba(9,105,218,0.04)' : 'transparent',
|
||||||
</CardDescription>
|
}}
|
||||||
</CardHeader>
|
|
||||||
{cronEnabled && (
|
|
||||||
<CardContent className="tab-content-fade space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{cronPresets.map((preset) => (
|
|
||||||
<Badge
|
|
||||||
key={preset.label}
|
|
||||||
variant={cronExpression === preset.value && preset.value !== '' ? 'default' : 'outline'}
|
|
||||||
className="cursor-pointer h-7 px-3"
|
|
||||||
onClick={() => handleCronPreset(preset.value)}
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Cron 表达式
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={cronExpression}
|
|
||||||
onChange={(e) => setCronExpression(e.target.value)}
|
|
||||||
placeholder="0 2 * * *"
|
|
||||||
className="h-9 font-mono"
|
|
||||||
/>
|
|
||||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
|
||||||
格式:分 时 日 月 周
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{cronExpression && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm"
|
|
||||||
style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
>
|
||||||
<Clock className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
|
<Icon className="w-4.5 h-4.5 mt-0.5" style={{ color: active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} />
|
||||||
下次执行: {nextExecution}
|
<div>
|
||||||
|
<div className="text-[13px] font-medium" style={{ color: active ? 'var(--accent-blue)' : 'var(--text-primary)' }}>{m.label}</div>
|
||||||
|
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{m.desc}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.triggerMode === 'cron' && (
|
||||||
|
<div className="mt-4 space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Cron 表达式</label>
|
||||||
|
<input value={form.cronExpr} onChange={(e) => setForm((f) => ({ ...f, cronExpr: e.target.value }))} placeholder="0 2 * * *(每天凌晨 2 点)" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(form.triggerMode === 'webhook' || form.triggerMode === 'git_push') && (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Webhook Token(触发校验)</label>
|
||||||
|
<button onClick={regenToken} className="text-[11px] px-2 py-[2px] rounded" style={{ color: 'var(--accent-blue)', background: 'rgba(9,105,218,0.08)' }}>随机生成</button>
|
||||||
|
</div>
|
||||||
|
<input value={form.webhookToken} onChange={(e) => setForm((f) => ({ ...f, webhookToken: e.target.value }))} className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Webhook URL(保存后生效)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input readOnly value={hookUrl} className="flex-1 px-3 py-2 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
|
||||||
|
<button onClick={copyUrl} className="p-2 rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }} title="复制">
|
||||||
|
{copied ? <Check className="w-3.5 h-3.5" style={{ color: '#1a7f37' }} /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-mono p-2.5 rounded-md" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||||
|
curl -X POST '{hookUrl}'
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.triggerMode === 'git_push' && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>监听分支(逗号分隔,支持通配符)</label>
|
||||||
|
<input value={form.watchBranches} onChange={(e) => setForm((f) => ({ ...f, watchBranches: e.target.value }))} placeholder="master,release/*,feature/*" className="w-full px-3 py-2 text-[13px] rounded-md border outline-none font-mono" style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
{/* Webhook Trigger */}
|
|
||||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Webhook className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
|
||||||
<CardTitle className="text-base">Webhook 触发</CardTitle>
|
|
||||||
</div>
|
|
||||||
<Switch checked={webhookEnabled} onCheckedChange={setWebhookEnabled} />
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
|
||||||
通过外部系统的 Webhook 调用触发流水线
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
{webhookEnabled && (
|
|
||||||
<CardContent className="tab-content-fade space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Webhook URL
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div
|
|
||||||
className="flex-1 px-3 py-2 text-sm rounded-md border font-mono"
|
|
||||||
style={{
|
|
||||||
background: 'var(--canvas-bg)',
|
|
||||||
borderColor: 'var(--border-color)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{webhookUrl}
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={handleCopyWebhook}>
|
|
||||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
|
||||||
将此 URL 配置到外部系统的 Webhook 中
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Secret Token</Label>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>用于验证 Webhook 请求的签名</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={webhookSecret}
|
|
||||||
onChange={(e) => setWebhookSecret(e.target.value)}
|
|
||||||
placeholder="输入 Secret Token"
|
|
||||||
type="password"
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Manual Trigger - always on */}
|
|
||||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Hand className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
|
||||||
<CardTitle className="text-base">手动触发</CardTitle>
|
|
||||||
<Badge variant="secondary" className="h-5 text-xs">默认启用</Badge>
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
|
||||||
支持通过控制台手动触发或 API 调用触发
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div
|
|
||||||
className="px-3 py-2.5 rounded-md text-sm"
|
|
||||||
style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
API 调用示例:
|
|
||||||
</span>
|
|
||||||
<code className="ml-2 text-xs font-mono" style={{ color: 'var(--accent-blue)' }}>
|
|
||||||
POST /api/v1/pipelines/gateway-pre-serverless/run
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,261 +1,114 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Variable, Database, Trash2, Plus, Info, Eye, EyeOff } from 'lucide-react';
|
import { Variable, Database, Trash2, Plus, Save, Loader2 } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { toast } from 'sonner';
|
||||||
import { Input } from '@/components/ui/input';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Label } from '@/components/ui/label';
|
import { apiClient } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
|
|
||||||
interface EnvVar {
|
interface Props {
|
||||||
id: string;
|
pipelineId: number;
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
description: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VariablesPanel() {
|
interface VarItem { key: string; value: string; remark?: string }
|
||||||
const [envVars, setEnvVars] = useState<EnvVar[]>([
|
interface CacheItem { path: string; key: string }
|
||||||
{ id: '1', name: 'REGISTRY_URL', value: 'registry.cn-hangzhou.aliyuncs.com', description: '镜像仓库地址' },
|
|
||||||
{ id: '2', name: 'NAMESPACE', value: 'gateway-ns', description: 'K8s 命名空间' },
|
|
||||||
{ id: '3', name: 'APP_NAME', value: 'gateway-serverless', description: '应用名称' },
|
|
||||||
{ id: '4', name: 'ENV', value: 'pre', description: '环境标识' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
function parseJSON<T>(s: string | undefined, fallback: T): T {
|
||||||
const [newVarName, setNewVarName] = useState('');
|
if (!s) return fallback;
|
||||||
const [newVarValue, setNewVarValue] = useState('');
|
try {
|
||||||
const [newVarDesc, setNewVarDesc] = useState('');
|
const v = JSON.parse(s);
|
||||||
const [visibleValues, setVisibleValues] = useState<Record<string, boolean>>({});
|
return Array.isArray(v) ? (v as T) : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cache config
|
export default function VariablesPanel({ pipelineId }: Props) {
|
||||||
const [cacheEnabled, setCacheEnabled] = useState(true);
|
const queryClient = useQueryClient();
|
||||||
const [cachePath, setCachePath] = useState('/root/.m2');
|
const { data: detail } = useQuery({
|
||||||
const [cacheKey, setCacheKey] = useState('${BRANCH_NAME}-${BUILD_ID}');
|
queryKey: ['pipeline', pipelineId],
|
||||||
|
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||||
|
});
|
||||||
|
const p = detail?.pipeline;
|
||||||
|
|
||||||
const handleAddVar = () => {
|
const [vars, setVars] = useState<VarItem[]>([]);
|
||||||
if (!newVarName.trim() || !newVarValue.trim()) return;
|
const [caches, setCaches] = useState<CacheItem[]>([]);
|
||||||
const newVar: EnvVar = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
name: newVarName.trim(),
|
|
||||||
value: newVarValue.trim(),
|
|
||||||
description: newVarDesc.trim(),
|
|
||||||
};
|
|
||||||
setEnvVars([...envVars, newVar]);
|
|
||||||
setNewVarName('');
|
|
||||||
setNewVarValue('');
|
|
||||||
setNewVarDesc('');
|
|
||||||
setShowAddForm(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteVar = (id: string) => {
|
useEffect(() => {
|
||||||
setEnvVars(envVars.filter((v) => v.id !== id));
|
if (p) {
|
||||||
};
|
setVars(parseJSON<VarItem[]>(p.variables, []));
|
||||||
|
setCaches(parseJSON<CacheItem[]>(p.cachePaths, []));
|
||||||
|
}
|
||||||
|
}, [p?.id, p?.updatedAt]);
|
||||||
|
|
||||||
const toggleVisible = (id: string) => {
|
const saveMutation = useMutation({
|
||||||
setVisibleValues((prev) => ({ ...prev, [id]: !prev[id] }));
|
mutationFn: () => apiClient.updatePipeline(pipelineId, {
|
||||||
};
|
variables: JSON.stringify(vars.filter((v) => v.key)),
|
||||||
|
cachePaths: JSON.stringify(caches.filter((c) => c.path)),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('变量与缓存配置已保存');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-content-fade space-y-4 w-full px-6 py-6">
|
<div className="tab-content-fade w-full px-6 py-6 max-w-4xl">
|
||||||
{/* Environment Variables */}
|
<div className="rounded-lg border p-6 shadow-sm" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Variable className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
<Variable className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||||
<CardTitle className="text-base">环境变量</CardTitle>
|
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>环境变量</h3>
|
||||||
</div>
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{vars.length} 项</span>
|
||||||
<CardDescription className="flex items-center gap-1">
|
|
||||||
在流水线各阶段可通过
|
|
||||||
<code className="px-1 py-0.5 rounded text-xs font-mono" style={{ background: 'var(--canvas-bg)' }}>
|
|
||||||
${'{VAR_NAME}'}
|
|
||||||
</code>
|
|
||||||
引用
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow style={{ background: 'var(--canvas-bg)' }}>
|
|
||||||
<TableHead className="text-xs font-semibold h-9">变量名</TableHead>
|
|
||||||
<TableHead className="text-xs font-semibold h-9">变量值</TableHead>
|
|
||||||
<TableHead className="text-xs font-semibold h-9">说明</TableHead>
|
|
||||||
<TableHead className="text-xs font-semibold h-9 w-16">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{envVars.map((envVar) => (
|
|
||||||
<TableRow key={envVar.id} className="group">
|
|
||||||
<TableCell className="font-mono text-sm py-2.5">{envVar.name}</TableCell>
|
|
||||||
<TableCell className="py-2.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-sm">
|
|
||||||
{visibleValues[envVar.id] ? envVar.value : '••••••••'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleVisible(envVar.id)}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
{visibleValues[envVar.id] ? (
|
|
||||||
<EyeOff className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm py-2.5" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{envVar.description}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="py-2.5">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={() => handleDeleteVar(envVar.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" style={{ color: '#EF4444' }} />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{envVars.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4} className="text-center py-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
暂无环境变量
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={() => setVars((v) => [...v, { key: '', value: '', remark: '' }])} className="flex items-center gap-1 px-2 py-1 text-[11px] rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
<Plus className="w-3 h-3" />添加变量
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showAddForm ? (
|
{vars.length === 0 && <div className="text-[12px] py-4 text-center" style={{ color: 'var(--text-tertiary)' }}>暂无环境变量</div>}
|
||||||
<div className="tab-content-fade space-y-3 p-4 rounded-lg border" style={{ borderColor: 'var(--border-color)', background: 'var(--canvas-bg)' }}>
|
{vars.map((v, i) => (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div key={i} className="flex items-center gap-2 mb-2">
|
||||||
<div>
|
<input value={v.key} onChange={(e) => setVars((arr) => arr.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)))} placeholder="KEY" className="w-40 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
|
||||||
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>变量名</Label>
|
<input value={v.value} onChange={(e) => setVars((arr) => arr.map((x, j) => (j === i ? { ...x, value: e.target.value } : x)))} placeholder="value" className="flex-1 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
|
||||||
<Input
|
<input value={v.remark || ''} onChange={(e) => setVars((arr) => arr.map((x, j) => (j === i ? { ...x, remark: e.target.value } : x)))} placeholder="备注" className="w-32 px-2.5 py-1.5 text-[12px] rounded-md border outline-none" style={inputStyle} />
|
||||||
value={newVarName}
|
<button onClick={() => setVars((arr) => arr.filter((_, j) => j !== i))} className="p-1.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: '#cf222e' }} title="删除">
|
||||||
onChange={(e) => setNewVarName(e.target.value)}
|
<Trash2 className="w-3 h-3" />
|
||||||
placeholder="如 BUILD_ARGS"
|
</button>
|
||||||
className="h-8 font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>变量值</Label>
|
|
||||||
<Input
|
|
||||||
value={newVarValue}
|
|
||||||
onChange={(e) => setNewVarValue(e.target.value)}
|
|
||||||
placeholder="变量值"
|
|
||||||
className="h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>说明</Label>
|
|
||||||
<Input
|
|
||||||
value={newVarDesc}
|
|
||||||
onChange={(e) => setNewVarDesc(e.target.value)}
|
|
||||||
placeholder="变量说明(可选)"
|
|
||||||
className="h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-1">
|
|
||||||
<Button size="sm" className="h-8" onClick={handleAddVar}>
|
|
||||||
确认添加
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowAddForm(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => setShowAddForm(true)}
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
添加变量
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Cache Config */}
|
|
||||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Database className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
|
||||||
<CardTitle className="text-base">缓存配置</CardTitle>
|
|
||||||
</div>
|
|
||||||
<Switch checked={cacheEnabled} onCheckedChange={setCacheEnabled} />
|
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
))}
|
||||||
配置构建缓存可显著加速后续构建速度
|
</div>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
<div className="rounded-lg border p-6 shadow-sm mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
{cacheEnabled && (
|
<div className="flex items-center justify-between mb-4">
|
||||||
<CardContent className="tab-content-fade space-y-4">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<Database className="w-5 h-5" style={{ color: '#1a7f37' }} />
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<h3 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>缓存配置</h3>
|
||||||
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>缓存路径</Label>
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{caches.length} 项</span>
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
<button onClick={() => setCaches((c) => [...c, { path: '', key: '' }])} className="flex items-center gap-1 px-2 py-1 text-[11px] rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
<Plus className="w-3 h-3" />添加缓存
|
||||||
</TooltipTrigger>
|
</button>
|
||||||
<TooltipContent>
|
</div>
|
||||||
<p>需要缓存的目录路径,多个路径用逗号分隔</p>
|
|
||||||
</TooltipContent>
|
{caches.length === 0 && <div className="text-[12px] py-4 text-center" style={{ color: 'var(--text-tertiary)' }}>暂无缓存配置</div>}
|
||||||
</Tooltip>
|
{caches.map((c, i) => (
|
||||||
</div>
|
<div key={i} className="flex items-center gap-2 mb-2">
|
||||||
<Input
|
<input value={c.path} onChange={(e) => setCaches((arr) => arr.map((x, j) => (j === i ? { ...x, path: e.target.value } : x)))} placeholder="缓存路径(如 /root/.m2)" className="flex-1 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
|
||||||
value={cachePath}
|
<input value={c.key} onChange={(e) => setCaches((arr) => arr.map((x, j) => (j === i ? { ...x, key: e.target.value } : x)))} placeholder="缓存 Key(如 ${BRANCH}-${BUILD_ID})" className="flex-1 px-2.5 py-1.5 text-[12px] rounded-md border outline-none font-mono" style={inputStyle} />
|
||||||
onChange={(e) => setCachePath(e.target.value)}
|
<button onClick={() => setCaches((arr) => arr.filter((_, j) => j !== i))} className="p-1.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: '#cf222e' }} title="删除">
|
||||||
placeholder="/root/.m2, node_modules"
|
<Trash2 className="w-3 h-3" />
|
||||||
className="h-9"
|
</button>
|
||||||
/>
|
</div>
|
||||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
))}
|
||||||
多个路径用逗号分隔
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
<div className="mt-4 flex justify-end">
|
||||||
<div>
|
<button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending} className="flex items-center gap-1 px-4 py-1.5 text-[12px] rounded-md text-white disabled:opacity-50" style={{ background: 'var(--accent-blue)' }}>
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}保存全部
|
||||||
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>缓存 Key</Label>
|
</button>
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>支持使用变量,用于区分不同场景的缓存</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={cacheKey}
|
|
||||||
onChange={(e) => setCacheKey(e.target.value)}
|
|
||||||
placeholder="${BRANCH_NAME}-${BUILD_ID}"
|
|
||||||
className="h-9 font-mono"
|
|
||||||
/>
|
|
||||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
|
||||||
支持变量引用,相同的 Key 会命中已有缓存
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Sparkles, Send, X, Loader2, CheckCircle2, XCircle, History, Bot, Square } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient, auth } from '@/lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onNavigate?: (module: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatItem {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
reasoning?: string;
|
||||||
|
streaming?: boolean;
|
||||||
|
stopped?: boolean;
|
||||||
|
action?: any; // {type:'release', jobs, branch, env}
|
||||||
|
actionState?: 'pending' | 'done' | 'cancelled';
|
||||||
|
result?: { triggered: any[]; failed: any[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = [
|
||||||
|
'发布 aiops-backend、aiops-web 到 test 分支',
|
||||||
|
'把后端两个服务发到 pre',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 解析一个 SSE 事件块(不含结尾空行):event: xxx / data: {...}
|
||||||
|
function parseSSEChunk(chunk: string): { event: string; data: string } | null {
|
||||||
|
let event = 'message';
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
for (const line of chunk.split('\n')) {
|
||||||
|
if (!line || line.startsWith(':')) continue; // 注释/空行
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
|
||||||
|
}
|
||||||
|
if (dataLines.length === 0) return null;
|
||||||
|
return { event, data: dataLines.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isJsonInstruction = (s: string) => s.trimStart().startsWith('{');
|
||||||
|
|
||||||
|
export default function AiAssistant({ onNavigate }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<ChatItem[]>([
|
||||||
|
{ role: 'assistant', content: '你好,我是 FlowStack 智能发布助手。告诉我你要发布哪些服务(支持自然语言),例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。' },
|
||||||
|
]);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: apiClient.getSettings,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const aiConnected = settings?.llmEnabled && settings?.llmApiKeySet;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages, open]);
|
||||||
|
|
||||||
|
const send = async (text?: string) => {
|
||||||
|
const content = (text ?? input).trim();
|
||||||
|
if (!content || sending) return;
|
||||||
|
setInput('');
|
||||||
|
const userMsg: ChatItem = { role: 'user', content };
|
||||||
|
const assistantIndex = messages.length + 1; // 占位 assistant 气泡下标
|
||||||
|
const history = [...messages, userMsg].slice(-10).map((m) => ({ role: m.role, content: m.content }));
|
||||||
|
setMessages((prev) => [...prev, userMsg, { role: 'assistant', content: '', streaming: true }]);
|
||||||
|
setSending(true);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
const patch = (updater: (m: ChatItem) => ChatItem) => {
|
||||||
|
setMessages((prev) => prev.map((m, i) => (i === assistantIndex ? updater(m) : m)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let accContent = '';
|
||||||
|
let accReasoning = '';
|
||||||
|
let gotDone = false;
|
||||||
|
|
||||||
|
const applyDone = (data: { reply?: string; action?: any }) => {
|
||||||
|
gotDone = true;
|
||||||
|
const reply = typeof data?.reply === 'string' && data.reply ? data.reply : accContent;
|
||||||
|
patch((m) => ({
|
||||||
|
...m,
|
||||||
|
content: reply,
|
||||||
|
action: data?.action,
|
||||||
|
actionState: data?.action ? 'pending' : undefined,
|
||||||
|
streaming: false,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
const token = auth.getToken();
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
const res = await fetch('/api/ai/chat/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ messages: history }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
auth.clear();
|
||||||
|
if (!window.location.pathname.startsWith('/login')) window.location.href = '/login';
|
||||||
|
throw new Error('登录已失效');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `请求失败 (HTTP ${res.status})`;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body?.error) message = body.error;
|
||||||
|
} catch {
|
||||||
|
// 非 JSON 响应体
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctype = res.headers.get('content-type') || '';
|
||||||
|
if (!ctype.includes('text/event-stream')) {
|
||||||
|
// 后端未配置 LLM:直接返回普通 JSON {reply, viaAI:false, action}
|
||||||
|
const data = await res.json();
|
||||||
|
applyDone(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手写 SSE 解析:事件以空行分隔
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
|
||||||
|
const handleChunk = (chunk: string) => {
|
||||||
|
const parsed = parseSSEChunk(chunk);
|
||||||
|
if (!parsed) return;
|
||||||
|
let payload: any = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(parsed.data);
|
||||||
|
} catch {
|
||||||
|
payload = { content: parsed.data };
|
||||||
|
}
|
||||||
|
if (parsed.event === 'delta') {
|
||||||
|
if (typeof payload?.content === 'string' && payload.content) accContent += payload.content;
|
||||||
|
if (typeof payload?.reasoning === 'string' && payload.reasoning) accReasoning += payload.reasoning;
|
||||||
|
patch((m) => ({ ...m, content: accContent, reasoning: accReasoning }));
|
||||||
|
} else if (parsed.event === 'done') {
|
||||||
|
applyDone(payload);
|
||||||
|
}
|
||||||
|
// start / 其他事件:无需处理
|
||||||
|
};
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
let idx: number;
|
||||||
|
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
||||||
|
const chunk = buf.slice(0, idx);
|
||||||
|
buf = buf.slice(idx + 2);
|
||||||
|
if (chunk.trim()) handleChunk(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buf.trim()) handleChunk(buf.trim()); // 冲刷结尾未跟空行的残包
|
||||||
|
if (!gotDone) patch((m) => ({ ...m, streaming: false }));
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as any)?.name === 'AbortError') {
|
||||||
|
// 用户主动停止:保留已生成内容
|
||||||
|
patch((m) => ({ ...m, content: accContent || m.content, streaming: false, stopped: true }));
|
||||||
|
} else if (accContent) {
|
||||||
|
// 中途断流:保留已有内容并标记
|
||||||
|
patch((m) => ({ ...m, content: accContent, streaming: false, stopped: true }));
|
||||||
|
} else {
|
||||||
|
patch(() => ({ role: 'assistant', content: '请求失败:' + (err as Error).message }));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
abortRef.current = null;
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopStream = () => abortRef.current?.abort();
|
||||||
|
|
||||||
|
const confirmRelease = async (index: number) => {
|
||||||
|
const msg = messages[index];
|
||||||
|
if (!msg?.action) return;
|
||||||
|
const jobs = msg.action.jobs.filter((j: any) => j.matched || j.source === 'jenkins' || true).map((j: any) => j.name);
|
||||||
|
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, actionState: 'done' } : m)));
|
||||||
|
try {
|
||||||
|
const res = await apiClient.aiRelease({
|
||||||
|
jobs,
|
||||||
|
branch: msg.action.branch,
|
||||||
|
env: msg.action.env,
|
||||||
|
autoCreate: true,
|
||||||
|
});
|
||||||
|
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, result: res } : m)));
|
||||||
|
const okN = res.triggered?.length || 0;
|
||||||
|
const failN = res.failed?.length || 0;
|
||||||
|
if (okN > 0) toast.success(`已触发 ${okN} 个服务${failN ? `,${failN} 个失败` : ''}`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['runs'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('发布失败:' + (err as Error).message);
|
||||||
|
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, actionState: 'pending' } : m)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAction = (index: number) => {
|
||||||
|
setMessages((prev) => prev.map((m, i) => (i === index ? { ...m, actionState: 'cancelled' } : m)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 悬浮按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="fixed bottom-6 right-6 z-50 w-12 h-12 rounded-full shadow-lg flex items-center justify-center transition-transform hover:scale-105"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #0969da, #8250df)' }}
|
||||||
|
title="FlowStack 智能助手"
|
||||||
|
>
|
||||||
|
{open ? <X className="w-5 h-5 text-white" /> : <Sparkles className="w-5 h-5 text-white" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 对话面板 */}
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="fixed right-6 z-50 w-[400px] rounded-xl border shadow-2xl flex flex-col overflow-hidden"
|
||||||
|
style={{ bottom: 84, height: 520, background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
|
>
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="px-4 py-3 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'linear-gradient(135deg, rgba(9,105,218,0.06), rgba(130,80,223,0.06))' }}>
|
||||||
|
<div className="w-7 h-7 rounded-full flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0969da, #8250df)' }}>
|
||||||
|
<Bot className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>FlowStack 智能助手</div>
|
||||||
|
<div className="text-[10px] flex items-center gap-1" style={{ color: aiConnected ? '#1a7f37' : 'var(--text-tertiary)' }}>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full" style={{ background: aiConnected ? '#1a7f37' : '#9a6700' }} />
|
||||||
|
{aiConnected ? 'AI 已连接' : '本地规则模式(系统设置可接入 AI)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-black/5">
|
||||||
|
<X className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息列表 */}
|
||||||
|
<div ref={listRef} className="flex-1 overflow-y-auto custom-scrollbar px-4 py-3 space-y-3">
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<div key={i} className={m.role === 'user' ? 'flex justify-end' : 'flex justify-start'}>
|
||||||
|
<div className="max-w-[85%]">
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 rounded-xl text-[12px] leading-5 whitespace-pre-wrap"
|
||||||
|
style={
|
||||||
|
m.role === 'user'
|
||||||
|
? { background: 'var(--accent-blue)', color: '#fff', borderBottomRightRadius: 4 }
|
||||||
|
: { background: 'var(--panel-bg-secondary)', color: 'var(--text-primary)', borderBottomLeftRadius: 4 }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* reasoning 增量:灰色小字(仅推理过程中展示,最近 120 字符) */}
|
||||||
|
{m.role === 'assistant' && m.streaming && !!m.reasoning && (
|
||||||
|
<div className="italic text-[10px] leading-4 mb-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{m.reasoning.length > 120 ? '…' : ''}{m.reasoning.slice(-120)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
if (m.role === 'assistant' && m.streaming && isJsonInstruction(m.content)) {
|
||||||
|
// 指令解析 JSON:不展示原始流
|
||||||
|
return <span className="inline-flex items-center gap-1 animate-pulse">📋 解析发布指令中…</span>;
|
||||||
|
}
|
||||||
|
if (m.role === 'assistant' && !m.streaming && isJsonInstruction(m.content) && m.action) {
|
||||||
|
return <span>已识别发布指令,请确认:</span>;
|
||||||
|
}
|
||||||
|
if (m.role === 'assistant' && m.streaming && !m.content && !m.reasoning) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />思考中…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return m.content;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.stopped && (
|
||||||
|
<div className="mt-1 text-[10px] italic" style={{ color: 'var(--text-tertiary)' }}>已停止</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 发布计划卡片 */}
|
||||||
|
{m.action && m.action.type === 'release' && (
|
||||||
|
<div className="mt-2 rounded-lg border p-3" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
|
||||||
|
<div className="text-[11px] font-semibold mb-2 flex items-center gap-1" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
<Sparkles className="w-3 h-3" style={{ color: '#8250df' }} />
|
||||||
|
发布计划 · {m.action.jobs.length} 个服务
|
||||||
|
{m.action.branch && <span className="font-normal" style={{ color: 'var(--text-tertiary)' }}>@{m.action.branch}</span>}
|
||||||
|
{m.action.env && <span className="font-normal" style={{ color: 'var(--text-tertiary)' }}>env:{m.action.env}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
{m.action.jobs.map((j: any, ji: number) => (
|
||||||
|
<span
|
||||||
|
key={ji}
|
||||||
|
className="text-[10px] px-2 py-[2px] rounded-md font-mono"
|
||||||
|
style={{
|
||||||
|
background: j.matched ? '#dafbe1' : j.source === 'jenkins' ? '#ddf4ff' : '#ffebe9',
|
||||||
|
color: j.matched ? '#1a7f37' : j.source === 'jenkins' ? '#0969da' : '#cf222e',
|
||||||
|
}}
|
||||||
|
title={j.matched ? '已匹配流水线' : j.source === 'jenkins' ? 'Jenkins Job(发布时自动创建流水线)' : '未匹配'}
|
||||||
|
>
|
||||||
|
{j.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.actionState === 'pending' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => confirmRelease(i)}
|
||||||
|
className="px-3 py-1 text-[11px] rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
确认发布
|
||||||
|
</button>
|
||||||
|
<button onClick={() => cancelAction(i)} className="px-3 py-1 text-[11px] rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{m.result && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{m.result.triggered?.map((t: any, ti: number) => (
|
||||||
|
<div key={ti} className="flex items-center gap-1.5 text-[10px]">
|
||||||
|
<CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} />
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-secondary)' }}>{t.job}</span>
|
||||||
|
{t.created && <span className="px-1 rounded" style={{ background: '#ddf4ff', color: '#0969da' }}>自动创建</span>}
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-tertiary)' }}>{t.runId}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{m.result.failed?.map((f: any, fi: number) => (
|
||||||
|
<div key={fi} className="flex items-center gap-1.5 text-[10px]">
|
||||||
|
<XCircle className="w-3 h-3" style={{ color: '#cf222e' }} />
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-secondary)' }}>{f.job}</span>
|
||||||
|
<span style={{ color: '#cf222e' }}>{f.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(m.result.triggered?.length > 0) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onNavigate?.('release-records'); setOpen(false); }}
|
||||||
|
className="mt-1 flex items-center gap-1 text-[10px] px-2 py-1 rounded border"
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
<History className="w-3 h-3" />查看发布记录
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷示例 */}
|
||||||
|
{messages.length <= 1 && (
|
||||||
|
<div className="px-4 pb-2 flex flex-wrap gap-1.5">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => send(s)}
|
||||||
|
className="text-[10px] px-2 py-1 rounded-full border"
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 输入区 */}
|
||||||
|
<div className="px-3 py-2.5 border-t flex items-center gap-2" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) send(); }}
|
||||||
|
placeholder="描述要发布的服务,如:发布 agent 三件套到 pre"
|
||||||
|
className="flex-1 px-3 py-2 text-[12px] rounded-lg border outline-none"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
|
/>
|
||||||
|
{sending ? (
|
||||||
|
<button
|
||||||
|
onClick={stopStream}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white flex-shrink-0"
|
||||||
|
style={{ background: '#cf222e' }}
|
||||||
|
title="停止生成"
|
||||||
|
>
|
||||||
|
<Square className="w-3 h-3" fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => send()}
|
||||||
|
disabled={!input.trim()}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white disabled:opacity-40"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { ClipboardPaste, Play, Loader2, CheckCircle2, XCircle, History, Wand2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onViewRecords?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchHints = ['pre', 'origin/pre', 'test', 'origin/test', 'main'];
|
||||||
|
|
||||||
|
const exampleText = 'cex-agent-server\ncex-agent-api\ncex-agent-admin\ncex-cex-web-nextjs\ncex-agent-admin-web';
|
||||||
|
|
||||||
|
export default function PasteRelease({ onViewRecords }: Props) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [refType, setRefType] = useState<'branch' | 'tag'>('branch');
|
||||||
|
const [fixedRef, setFixedRef] = useState('test');
|
||||||
|
const [parallel, setParallel] = useState(false);
|
||||||
|
const [autoCreate, setAutoCreate] = useState(true);
|
||||||
|
const [preview, setPreview] = useState<any>(null);
|
||||||
|
const [parseError, setParseError] = useState('');
|
||||||
|
const [result, setResult] = useState<any>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
|
||||||
|
|
||||||
|
// 700ms 防抖自动解析
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
if (!text.trim()) {
|
||||||
|
setPreview(null);
|
||||||
|
setParseError('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.batchParse({ text, refType, fixedRef });
|
||||||
|
setPreview(res);
|
||||||
|
setParseError('');
|
||||||
|
} catch (err) {
|
||||||
|
setPreview(null);
|
||||||
|
setParseError((err as Error).message);
|
||||||
|
}
|
||||||
|
}, 700);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [text, refType, fixedRef]);
|
||||||
|
|
||||||
|
const releaseMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient.aiRelease({
|
||||||
|
jobs: (preview?.jobs || []).map((j: any) => j.name),
|
||||||
|
branch: refType === 'branch' ? fixedRef : undefined,
|
||||||
|
parallel,
|
||||||
|
autoCreate,
|
||||||
|
}),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setResult(res);
|
||||||
|
const okN = res.triggered?.length || 0;
|
||||||
|
const failN = res.failed?.length || 0;
|
||||||
|
if (okN > 0) toast.success(`已触发 ${okN} 个服务${failN ? `,${failN} 个失败` : ''}`);
|
||||||
|
else toast.error('全部触发失败');
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('发布失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobs = preview?.jobs || [];
|
||||||
|
const matchedCount = jobs.filter((j: any) => j.matched || j.autoCreatable).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
|
{/* 粘贴区 */}
|
||||||
|
<div className="rounded-lg border p-5" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardPaste className="w-4.5 h-4.5" style={{ color: 'var(--accent-blue)' }} />
|
||||||
|
<h3 className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布清单</h3>
|
||||||
|
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>每行一个服务;# 开头为注释</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setText(exampleText)} className="text-[11px] px-2 py-1 rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
填入示例
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={refType === 'branch' ? 'cex-agent-server\ncex-agent-api\ncex-agent-admin' : 'cex-openapi-api\nV2.08\ncex-openapi-svc\nV2.09'}
|
||||||
|
rows={7}
|
||||||
|
className="w-full px-3 py-2.5 text-[12px] font-mono rounded-md border outline-none resize-y"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 配置行 */}
|
||||||
|
<div className="flex items-center gap-3 mt-3 flex-wrap">
|
||||||
|
<div className="flex items-center rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
{(['branch', 'tag'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setRefType(t)}
|
||||||
|
className="px-3 py-1 text-[11px] font-medium"
|
||||||
|
style={{ background: refType === t ? 'var(--accent-blue)' : 'transparent', color: refType === t ? '#fff' : 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t === 'branch' ? '分支' : 'TAG'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{refType === 'branch' && (
|
||||||
|
<>
|
||||||
|
<input value={fixedRef} onChange={(e) => setFixedRef(e.target.value)} placeholder="分支名" className="px-2.5 py-1 text-[11px] rounded-md border outline-none font-mono w-28" style={inputStyle} />
|
||||||
|
{branchHints.map((b) => (
|
||||||
|
<button key={b} onClick={() => setFixedRef(b)} className="text-[10px] px-2 py-[3px] rounded-full border font-mono"
|
||||||
|
style={{ borderColor: fixedRef === b ? 'var(--accent-blue)' : 'var(--border-color-light)', color: fixedRef === b ? 'var(--accent-blue)' : 'var(--text-tertiary)' }}>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] ml-auto cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<input type="checkbox" checked={autoCreate} onChange={(e) => setAutoCreate(e.target.checked)} />
|
||||||
|
未导入的 Jenkins Job 自动创建流水线
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<input type="checkbox" checked={parallel} onChange={(e) => setParallel(e.target.checked)} />
|
||||||
|
并发发布(默认串行,逐个触发)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 解析预览 */}
|
||||||
|
{(parseError || preview) && (
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: parseError ? '#cf222e' : 'var(--border-color)' }}>
|
||||||
|
<div className="px-4 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wand2 className="w-3.5 h-3.5" style={{ color: parseError ? '#cf222e' : 'var(--accent-blue)' }} />
|
||||||
|
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>{parseError ? '解析失败' : '解析结果'}</span>
|
||||||
|
{!parseError && <span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{preview.count} 个 · 可发布 {matchedCount} 个{preview.param ? ` · 参数 ${preview.param}` : ''}</span>}
|
||||||
|
</div>
|
||||||
|
{!parseError && (
|
||||||
|
<button
|
||||||
|
onClick={() => releaseMutation.mutate()}
|
||||||
|
disabled={releaseMutation.isPending || matchedCount === 0}
|
||||||
|
className="flex items-center gap-1.5 px-3.5 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
{releaseMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
|
||||||
|
开始{parallel ? '并发' : '串行'}发布
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{parseError ? (
|
||||||
|
<div className="px-4 py-3 text-[12px]" style={{ color: '#cf222e' }}>{parseError}</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{jobs.map((j: any, i: number) => (
|
||||||
|
<div key={i} className="grid grid-cols-[36px_1fr_130px_110px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)' }}>{i + 1}</span>
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{j.name}</span>
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-tertiary)' }}>{j.version}</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded w-fit" style={{
|
||||||
|
background: j.matched ? '#dafbe1' : j.autoCreatable ? '#ddf4ff' : '#ffebe9',
|
||||||
|
color: j.matched ? '#1a7f37' : j.autoCreatable ? '#0969da' : '#cf222e',
|
||||||
|
}}>
|
||||||
|
{j.matched ? (j.source === 'jenkins' ? 'Jenkins 已导入' : '流水线') : j.autoCreatable ? 'Jenkins · 将自动创建' : '未找到'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 执行结果 */}
|
||||||
|
{result && (
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="px-4 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
触发结果 · 成功 {result.triggered?.length || 0} / 失败 {result.failed?.length || 0}
|
||||||
|
</span>
|
||||||
|
<button onClick={onViewRecords} className="flex items-center gap-1 text-[11px] px-2 py-1 rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--accent-blue)' }}>
|
||||||
|
<History className="w-3 h-3" />查看发布记录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{result.triggered?.map((t: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 px-4 py-1.5 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: '#1a7f37' }} />
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{t.job}</span>
|
||||||
|
{t.created && <span className="text-[9px] px-1 rounded" style={{ background: '#ddf4ff', color: '#0969da' }}>自动创建</span>}
|
||||||
|
<span className="font-mono ml-auto" style={{ color: 'var(--text-tertiary)' }}>{t.runId}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{result.failed?.map((f: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 px-4 py-1.5 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<XCircle className="w-3.5 h-3.5" style={{ color: '#cf222e' }} />
|
||||||
|
<span className="font-mono" style={{ color: 'var(--text-primary)' }}>{f.job}</span>
|
||||||
|
<span className="ml-auto" style={{ color: '#cf222e' }}>{f.reason}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{text && !preview && !parseError && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-4 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />解析中…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,10 @@ import { apiClient } from '@/lib/api';
|
|||||||
|
|
||||||
interface FlowEditorProps {
|
interface FlowEditorProps {
|
||||||
pipelineId: number;
|
pipelineId: number;
|
||||||
|
/** 自增信号:TopBar「仅保存」触发 */
|
||||||
|
saveSignal?: number;
|
||||||
|
/** 自增信号:TopBar「保存并运行」触发 */
|
||||||
|
runSignal?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogTask {
|
interface CatalogTask {
|
||||||
@@ -58,11 +62,12 @@ const categoryColors: Record<string, string> = {
|
|||||||
|
|
||||||
const nodeTypes = { tektonNode: TektonNode };
|
const nodeTypes = { tektonNode: TektonNode };
|
||||||
|
|
||||||
function FlowEditorInner({ pipelineId }: FlowEditorProps) {
|
function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEditorProps) {
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TektonNodeData>>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TektonNodeData>>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
const [catalogOpen, setCatalogOpen] = useState(true);
|
const [catalogOpen, setCatalogOpen] = useState(true);
|
||||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||||
|
const [taskSearch, setTaskSearch] = useState('');
|
||||||
const [selectedNode, setSelectedNode] = useState<Node<TektonNodeData> | null>(null);
|
const [selectedNode, setSelectedNode] = useState<Node<TektonNodeData> | null>(null);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [logOpen, setLogOpen] = useState(false);
|
const [logOpen, setLogOpen] = useState(false);
|
||||||
@@ -212,6 +217,22 @@ function FlowEditorInner({ pipelineId }: FlowEditorProps) {
|
|||||||
}
|
}
|
||||||
}, [logs, logOpen]);
|
}, [logs, logOpen]);
|
||||||
|
|
||||||
|
// TopBar「仅保存」信号(用 ref 取最新 handler 避免闭包过期)
|
||||||
|
const saveRef = useRef(handleSave);
|
||||||
|
saveRef.current = handleSave;
|
||||||
|
const runRef = useRef(handleRun);
|
||||||
|
runRef.current = handleRun;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveSignal > 0) saveRef.current();
|
||||||
|
}, [saveSignal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (runSignal > 0) {
|
||||||
|
saveRef.current().then(() => runRef.current()).catch(() => runRef.current());
|
||||||
|
}
|
||||||
|
}, [runSignal]);
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(params: Connection) => setEdges((eds) => addEdge({ ...params, animated: true, style: { stroke: '#0969da' } }, eds)),
|
(params: Connection) => setEdges((eds) => addEdge({ ...params, animated: true, style: { stroke: '#0969da' } }, eds)),
|
||||||
[setEdges]
|
[setEdges]
|
||||||
@@ -235,9 +256,16 @@ function FlowEditorInner({ pipelineId }: FlowEditorProps) {
|
|||||||
setNodes((nds) => [...nds, newNode]);
|
setNodes((nds) => [...nds, newNode]);
|
||||||
}, [nodes.length, setNodes]);
|
}, [nodes.length, setNodes]);
|
||||||
|
|
||||||
const filteredTasks = selectedCategory === 'all'
|
const filteredTasks = catalogTasks.filter((t) => {
|
||||||
? catalogTasks
|
if (selectedCategory !== 'all' && t.category !== selectedCategory) return false;
|
||||||
: catalogTasks.filter((t) => t.category === selectedCategory);
|
if (!taskSearch.trim()) return true;
|
||||||
|
const q = taskSearch.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
t.description.toLowerCase().includes(q) ||
|
||||||
|
t.params.some((p) => p.name.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full" style={{ background: 'var(--canvas-bg)' }}>
|
<div className="flex h-full" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
@@ -249,6 +277,15 @@ function FlowEditorInner({ pipelineId }: FlowEditorProps) {
|
|||||||
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Task 任务库</span>
|
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Task 任务库</span>
|
||||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>Tekton</span>
|
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>Tekton</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-2 pt-2">
|
||||||
|
<input
|
||||||
|
value={taskSearch}
|
||||||
|
onChange={(e) => setTaskSearch(e.target.value)}
|
||||||
|
placeholder="搜索任务/参数…"
|
||||||
|
className="w-full px-2 py-[5px] text-[11px] rounded-md border outline-none"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="p-2 border-b" style={{ borderColor: 'var(--border-color)' }}>
|
<div className="p-2 border-b" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
<button onClick={() => setSelectedCategory('all')} className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors" style={{ background: selectedCategory === 'all' ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)', color: selectedCategory === 'all' ? '#fff' : 'var(--text-tertiary)' }}>全部</button>
|
<button onClick={() => setSelectedCategory('all')} className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors" style={{ background: selectedCategory === 'all' ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)', color: selectedCategory === 'all' ? '#fff' : 'var(--text-tertiary)' }}>全部</button>
|
||||||
|
|||||||
+96
-2
@@ -7,11 +7,30 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'flowstack-token'
|
||||||
|
|
||||||
|
export const auth = {
|
||||||
|
getToken: () => localStorage.getItem(TOKEN_KEY),
|
||||||
|
setToken: (t: string) => localStorage.setItem(TOKEN_KEY, t),
|
||||||
|
clear: () => localStorage.removeItem(TOKEN_KEY),
|
||||||
|
}
|
||||||
|
|
||||||
export async function api<T = any>(path: string, init?: RequestInit): Promise<T> {
|
export async function api<T = any>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
const token = auth.getToken()
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
headers: { ...headers, ...(init?.headers as Record<string, string> || {}) },
|
||||||
...init,
|
...init,
|
||||||
})
|
})
|
||||||
|
if (res.status === 401 && !path.includes('/auth/login')) {
|
||||||
|
// 登录失效:清理并跳登录页(避免循环)
|
||||||
|
auth.clear()
|
||||||
|
if (!window.location.pathname.startsWith('/login')) {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
throw new ApiError('登录已失效', 401)
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = `请求失败 (HTTP ${res.status})`
|
let message = `请求失败 (HTTP ${res.status})`
|
||||||
try {
|
try {
|
||||||
@@ -64,10 +83,15 @@ export interface PipelineRunDTO {
|
|||||||
pipelineName: string
|
pipelineName: string
|
||||||
branch: string
|
branch: string
|
||||||
env: string
|
env: string
|
||||||
status: 'pending' | 'running' | 'success' | 'failed' | 'cancelled'
|
status: string
|
||||||
durationMs: number
|
durationMs: number
|
||||||
|
triggerBy?: string
|
||||||
|
triggerType?: string
|
||||||
startedAt: string
|
startedAt: string
|
||||||
completedAt?: string
|
completedAt?: string
|
||||||
|
buildNumber?: number
|
||||||
|
externalRunId?: string
|
||||||
|
externalUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
@@ -92,4 +116,74 @@ export const apiClient = {
|
|||||||
return api<PipelineRunDTO[]>(`/api/runs${qs ? `?${qs}` : ''}`)
|
return api<PipelineRunDTO[]>(`/api/runs${qs ? `?${qs}` : ''}`)
|
||||||
},
|
},
|
||||||
getRun: (runId: string) => api<any>(`/api/runs/${runId}`),
|
getRun: (runId: string) => api<any>(`/api/runs/${runId}`),
|
||||||
|
retryRun: (runId: string) => api<PipelineRunDTO>(`/api/runs/${runId}/retry`, { method: 'POST' }),
|
||||||
|
// 摘要/设置
|
||||||
|
getSummary: () => api<any>('/api/summary'),
|
||||||
|
getSettings: () => api<any>('/api/settings'),
|
||||||
|
updateSettings: (body: any) => api('/api/settings', { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
// 主机
|
||||||
|
listHosts: () => api<any[]>('/api/hosts'),
|
||||||
|
createHost: (b: any) => api('/api/hosts', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
updateHost: (id: any, b: any) => api(`/api/hosts/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||||
|
deleteHost: (id: any) => api(`/api/hosts/${id}`, { method: 'DELETE' }),
|
||||||
|
testHost: (id: any) => api<any>(`/api/hosts/${id}/test`, { method: 'POST' }),
|
||||||
|
// 集群
|
||||||
|
listClusters: () => api<any[]>('/api/clusters'),
|
||||||
|
createCluster: (b: any) => api('/api/clusters', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
updateCluster: (id: any, b: any) => api(`/api/clusters/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||||
|
deleteCluster: (id: any) => api(`/api/clusters/${id}`, { method: 'DELETE' }),
|
||||||
|
testCluster: (id: any) => api<any>(`/api/clusters/${id}/test`, { method: 'POST' }),
|
||||||
|
// 凭证
|
||||||
|
listCredentials: (type?: string) => api<any[]>(`/api/credentials${type && type !== 'all' ? `?type=${type}` : ''}`),
|
||||||
|
createCredential: (b: any) => api('/api/credentials', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
updateCredential: (id: any, b: any) => api(`/api/credentials/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||||
|
deleteCredential: (id: any) => api(`/api/credentials/${id}`, { method: 'DELETE' }),
|
||||||
|
// 构建节点
|
||||||
|
listBuildNodes: () => api<any[]>('/api/build-nodes'),
|
||||||
|
createBuildNode: (b: any) => api('/api/build-nodes', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
updateBuildNode: (id: any, b: any) => api(`/api/build-nodes/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||||
|
deleteBuildNode: (id: any) => api(`/api/build-nodes/${id}`, { method: 'DELETE' }),
|
||||||
|
testBuildNode: (id: any) => api<any>(`/api/build-nodes/${id}/test`, { method: 'POST' }),
|
||||||
|
testBuildNodeDisk: (id: any) => api<any>(`/api/build-nodes/${id}/disk-test`, { method: 'POST' }),
|
||||||
|
// 工具
|
||||||
|
listTools: () => api<any[]>('/api/tools'),
|
||||||
|
updateTool: (id: any, b: any) => api(`/api/tools/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||||
|
checkTool: (id: any) => api<any>(`/api/tools/${id}/check`, { method: 'POST' }),
|
||||||
|
installTool: (id: any) => api<any>(`/api/tools/${id}/install`, { method: 'POST' }),
|
||||||
|
// Webhook
|
||||||
|
listWebhooks: () => api<any[]>('/api/webhooks'),
|
||||||
|
createWebhook: (b: any) => api('/api/webhooks', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
updateWebhook: (id: any, b: any) => api(`/api/webhooks/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||||
|
deleteWebhook: (id: any) => api(`/api/webhooks/${id}`, { method: 'DELETE' }),
|
||||||
|
testWebhook: (id: any) => api<any>(`/api/webhooks/${id}/test`, { method: 'POST' }),
|
||||||
|
// Jenkins
|
||||||
|
listJenkinsServers: () => api<any[]>('/api/jenkins-servers'),
|
||||||
|
createJenkinsServer: (b: any) => api('/api/jenkins-servers', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
updateJenkinsServer: (id: any, b: any) => api(`/api/jenkins-servers/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||||
|
deleteJenkinsServer: (id: any) => api(`/api/jenkins-servers/${id}`, { method: 'DELETE' }),
|
||||||
|
testJenkinsServer: (id: any) => api<any>(`/api/jenkins-servers/${id}/test`, { method: 'POST' }),
|
||||||
|
listJenkinsJobs: (id: any) => api<any[]>(`/api/jenkins-servers/${id}/jobs`),
|
||||||
|
importJenkinsJobs: (id: any, jobs: string[]) =>
|
||||||
|
api<any>(`/api/jenkins-servers/${id}/import`, { method: 'POST', body: JSON.stringify({ jobs }) }),
|
||||||
|
// AI 助手与批量发布
|
||||||
|
aiChat: (messages: { role: string; content: string }[]) =>
|
||||||
|
api<any>('/api/ai/chat', { method: 'POST', body: JSON.stringify({ messages }) }),
|
||||||
|
aiRelease: (b: { jobs: string[]; branch?: string; env?: string; parallel?: boolean; jenkinsServerId?: number; autoCreate?: boolean }) =>
|
||||||
|
api<any>('/api/ai/release', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
batchParse: (b: { text: string; refType?: string; fixedRef?: string }) =>
|
||||||
|
api<any>('/api/batch/parse', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
testLLM: () => api<any>('/api/settings/test-llm', { method: 'POST' }),
|
||||||
|
testLLMDraft: (body: any) => api<any>('/api/settings/test-llm', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
testLark: () => api<any>('/api/settings/test-lark', { method: 'POST' }),
|
||||||
|
// 用户与会话
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
api<any>('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||||
|
listUsers: () => api<any[]>('/api/users'),
|
||||||
|
createUser: (b: any) => api<any>('/api/users', { method: 'POST', body: JSON.stringify(b) }),
|
||||||
|
updateUser: (id: any, b: any) => api(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
|
||||||
|
deleteUser: (id: any) => api(`/api/users/${id}`, { method: 'DELETE' }),
|
||||||
|
resetUserPassword: (id: any) => api<any>(`/api/users/${id}/reset-password`, { method: 'POST' }),
|
||||||
|
ldapListUsers: () => api<any[]>('/api/ldap/users', { method: 'POST' }),
|
||||||
|
ldapImport: (users: any[]) => api<any>('/api/users/ldap-import', { method: 'POST', body: JSON.stringify({ users }) }),
|
||||||
|
testLDAP: (body: any) => api<any>('/api/settings/ldap/test', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,54 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ChevronRight, CheckCircle2, Circle, GitBranch, Server,
|
ChevronRight, CheckCircle2, Circle, GitBranch, Server,
|
||||||
Play, Loader2, Terminal, ArrowRight, CloudCog, Package, AlertCircle
|
Play, Loader2, Terminal, ArrowRight, CloudCog, Package, AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import PasteRelease from '@/components/batch/PasteRelease';
|
||||||
|
|
||||||
interface PipelineItem {
|
// ---- 类型(与后端 GET /api/pipelines、GET /api/runs 对齐)----
|
||||||
id: string; name: string; branch: string; type: 'native' | 'jenkins';
|
interface PipelineDTO {
|
||||||
deployType: 'host' | 'k8s'; env: string; lastStatus: string;
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
sourceBranch?: string;
|
||||||
|
deployType: string;
|
||||||
|
env?: string;
|
||||||
|
lastRun: { status: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockPipelines: PipelineItem[] = [
|
interface BatchRun {
|
||||||
{ id: '1', name: 'aiops-backend', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' },
|
runId: string;
|
||||||
{ id: '2', name: 'aiops-web', branch: 'master', type: 'native', deployType: 'host', env: 'production', lastStatus: 'success' },
|
pipelineName: string;
|
||||||
{ id: '3', name: 'gateway-api', branch: 'release/v2.1', type: 'jenkins', deployType: 'k8s', env: 'staging', lastStatus: 'pending' },
|
status: string;
|
||||||
{ id: '4', name: 'admin-dashboard', branch: 'master', type: 'native', deployType: 'host', env: 'staging', lastStatus: 'success' },
|
triggerType?: string;
|
||||||
{ id: '5', name: 'data-processor', branch: 'develop', type: 'jenkins', deployType: 'k8s', env: 'test', lastStatus: 'failed' },
|
startedAt: string;
|
||||||
{ id: '6', name: 'notification-service', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' },
|
}
|
||||||
];
|
|
||||||
|
const statusMeta: Record<string, { label: string; color: string }> = {
|
||||||
|
success: { label: '成功', color: '#1a7f37' },
|
||||||
|
failed: { label: '失败', color: '#cf222e' },
|
||||||
|
running: { label: '运行中', color: '#0969da' },
|
||||||
|
pending: { label: '排队中', color: '#9a6700' },
|
||||||
|
queued: { label: '排队中', color: '#9a6700' },
|
||||||
|
cancelled: { label: '已取消', color: '#6e7781' },
|
||||||
|
none: { label: '未执行', color: '#6e7781' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeMeta: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
native: { label: '原生', color: '#0969da', bg: '#ddf4ff' },
|
||||||
|
jenkins: { label: 'Jenkins', color: '#8250df', bg: '#fbefff' },
|
||||||
|
tekton: { label: 'Tekton', color: '#0e7c7b', bg: '#d5f5f6' },
|
||||||
|
};
|
||||||
|
const fallbackType = typeMeta.native;
|
||||||
|
|
||||||
|
const failPolicyLabels: Record<string, string> = {
|
||||||
|
stop: '立即中止后续发布',
|
||||||
|
continue: '继续发布其他流水线',
|
||||||
|
skip: '标记失败并跳过',
|
||||||
|
};
|
||||||
|
|
||||||
type StepType = 'select' | 'configure' | 'confirm' | 'executing' | 'done';
|
type StepType = 'select' | 'configure' | 'confirm' | 'executing' | 'done';
|
||||||
const steps: { id: StepType; label: string }[] = [
|
const steps: { id: StepType; label: string }[] = [
|
||||||
@@ -28,44 +59,162 @@ const steps: { id: StepType; label: string }[] = [
|
|||||||
{ id: 'done', label: '完成' },
|
{ id: 'done', label: '完成' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BatchRelease() {
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const stamp = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||||
|
|
||||||
|
export default function BatchRelease({ onViewRecords }: { onViewRecords?: () => void } = {}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [inputMode, setInputMode] = useState<'paste' | 'wizard'>('paste');
|
||||||
const [currentStep, setCurrentStep] = useState<StepType>('select');
|
const [currentStep, setCurrentStep] = useState<StepType>('select');
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
// 发布配置(本地状态)
|
||||||
|
const [strategy, setStrategy] = useState<'sequential' | 'parallel'>('sequential');
|
||||||
|
const [failPolicy, setFailPolicy] = useState<string>('stop');
|
||||||
|
const [checkDeployment, setCheckDeployment] = useState(true);
|
||||||
|
const [checkService, setCheckService] = useState(true);
|
||||||
|
const [checkPod, setCheckPod] = useState(false);
|
||||||
|
const [notifyFeishu, setNotifyFeishu] = useState(true);
|
||||||
|
const [notifyDingtalk, setNotifyDingtalk] = useState(false);
|
||||||
|
const [notifyWebhook, setNotifyWebhook] = useState(false);
|
||||||
|
// 执行状态
|
||||||
const [execLogs, setExecLogs] = useState<string[]>([]);
|
const [execLogs, setExecLogs] = useState<string[]>([]);
|
||||||
const [completedCount, setCompletedCount] = useState(0);
|
const [executingIds, setExecutingIds] = useState<Set<string>>(new Set());
|
||||||
|
const [failedTriggerIds, setFailedTriggerIds] = useState<Set<string>>(new Set());
|
||||||
|
const [triggeredRuns, setTriggeredRuns] = useState<Record<string, string>>({});
|
||||||
|
const [triggerDone, setTriggerDone] = useState(false);
|
||||||
|
const [execStartedAt, setExecStartedAt] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 流水线列表(真实数据)
|
||||||
|
const { data: pipelines = [], isLoading: pipelinesLoading } = useQuery({
|
||||||
|
queryKey: ['pipelines'],
|
||||||
|
queryFn: () => apiClient.listPipelines(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行期间轮询运行列表,展示每项进度
|
||||||
|
const { data: progressRuns = [] } = useQuery({
|
||||||
|
queryKey: ['runs'],
|
||||||
|
queryFn: () => apiClient.listRuns(),
|
||||||
|
refetchInterval: currentStep === 'executing' ? 2000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = (pipelines as PipelineDTO[]).map((p) => ({
|
||||||
|
id: String(p.id),
|
||||||
|
raw: p,
|
||||||
|
name: p.name,
|
||||||
|
branch: p.sourceBranch || '-',
|
||||||
|
type: p.type,
|
||||||
|
deployType: p.deployType,
|
||||||
|
env: p.env || '-',
|
||||||
|
lastStatus: p.lastRun?.status ?? 'none',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectedPipelines = items.filter((p) => selectedIds.includes(p.id));
|
||||||
|
const stepIndex = steps.findIndex((s) => s.id === currentStep);
|
||||||
|
|
||||||
const toggleSelect = (id: string) => {
|
const toggleSelect = (id: string) => {
|
||||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedPipelines = mockPipelines.filter(p => selectedIds.includes(p.id));
|
// 按 runId 精确匹配,回退到 pipelineName 匹配本次批量发布时间窗内最新的 run
|
||||||
const stepIndex = steps.findIndex(s => s.id === currentStep);
|
const getItemRun = (p: (typeof items)[number]): BatchRun | null => {
|
||||||
|
const rid = triggeredRuns[p.id];
|
||||||
|
if (rid) {
|
||||||
|
const hit = (progressRuns as BatchRun[]).find((r) => r.runId === rid);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
const since = execStartedAt ? execStartedAt - 60_000 : 0;
|
||||||
|
const candidates = (progressRuns as BatchRun[]).filter(
|
||||||
|
(r) => r.pipelineName === p.name && r.triggerType === 'batch' && new Date(r.startedAt).getTime() >= since
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
return candidates.reduce((latest, r) =>
|
||||||
|
new Date(r.startedAt).getTime() > new Date(latest.startedAt).getTime() ? r : latest
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const startExecution = () => {
|
const itemStatus = (p: (typeof items)[number]): { label: string; color: string; pulse: boolean; terminal: boolean } => {
|
||||||
|
if (executingIds.has(p.id)) return { label: '发布中…', color: '#0969da', pulse: true, terminal: false };
|
||||||
|
if (failedTriggerIds.has(p.id)) return { label: '触发失败', color: '#cf222e', pulse: false, terminal: true };
|
||||||
|
const run = getItemRun(p);
|
||||||
|
if (!run) return { label: triggerDone ? '等待中' : '排队中', color: '#9a6700', pulse: false, terminal: false };
|
||||||
|
const meta = statusMeta[run.status] || statusMeta.pending;
|
||||||
|
const terminal = run.status === 'success' || run.status === 'failed' || run.status === 'cancelled';
|
||||||
|
return { label: meta.label, color: meta.color, pulse: run.status === 'running', terminal };
|
||||||
|
};
|
||||||
|
|
||||||
|
const completedCount = selectedPipelines.filter((p) => itemStatus(p).terminal).length;
|
||||||
|
const successCount = selectedPipelines.filter((p) => getItemRun(p)?.status === 'success').length;
|
||||||
|
const failedCount = selectedPipelines.length - successCount;
|
||||||
|
|
||||||
|
// 全部触发完成且每个 run 都到终态后,进入完成步骤
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep !== 'executing' || !triggerDone) return;
|
||||||
|
const allDone = selectedPipelines.length > 0 && selectedPipelines.every((p) => itemStatus(p).terminal);
|
||||||
|
if (!allDone) return;
|
||||||
|
const ok = selectedPipelines.filter((p) => getItemRun(p)?.status === 'success').length;
|
||||||
|
setExecLogs((prev) => [...prev, `[${stamp()}] 批量发布结束:成功 ${ok} 个,失败 ${selectedPipelines.length - ok} 个`]);
|
||||||
|
setCurrentStep('done');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [progressRuns, triggerDone, currentStep, executingIds, failedTriggerIds]);
|
||||||
|
|
||||||
|
const startExecution = async () => {
|
||||||
|
if (currentStep === 'executing' || selectedPipelines.length === 0) return;
|
||||||
setCurrentStep('executing');
|
setCurrentStep('executing');
|
||||||
setExecLogs([`[${new Date().toLocaleTimeString()}] 批量发布开始,共 ${selectedIds.length} 个流水线`]);
|
setTriggeredRuns({});
|
||||||
|
setFailedTriggerIds(new Set());
|
||||||
let count = 0;
|
setTriggerDone(false);
|
||||||
selectedPipelines.forEach((p, idx) => {
|
setExecStartedAt(Date.now());
|
||||||
setTimeout(() => {
|
setExecLogs([`[${stamp()}] 批量发布开始,共 ${selectedPipelines.length} 个流水线(${strategy === 'sequential' ? '顺序' : '并行'}执行)`]);
|
||||||
setExecLogs(prev => [
|
|
||||||
...prev,
|
const triggerOne = async (p: (typeof items)[number]) => {
|
||||||
`[${new Date().toLocaleTimeString()}] [${idx + 1}/${selectedPipelines.length}] 开始发布: ${p.name} (${p.branch})`,
|
setExecutingIds((prev) => new Set(prev).add(p.id));
|
||||||
`[${new Date().toLocaleTimeString()}] -> 拉取代码...`,
|
try {
|
||||||
`[${new Date().toLocaleTimeString()}] -> 构建中...`,
|
const run = await apiClient.createRun(p.raw.id, { triggerType: 'batch' });
|
||||||
`[${new Date().toLocaleTimeString()}] -> ${p.deployType === 'k8s' ? '更新 K8s Deployment...' : 'SCP 上传 + SSH 部署...'}`,
|
setTriggeredRuns((prev) => ({ ...prev, [p.id]: run.runId }));
|
||||||
`[${new Date().toLocaleTimeString()}] ✓ ${p.name} 发布成功`,
|
setExecLogs((prev) => [...prev, `[${stamp()}] ✓ 已触发 ${p.name} (${p.branch}) → ${run.runId}`]);
|
||||||
]);
|
} catch (e) {
|
||||||
count++;
|
setFailedTriggerIds((prev) => new Set(prev).add(p.id));
|
||||||
setCompletedCount(count);
|
setExecLogs((prev) => [...prev, `[${stamp()}] ✗ 触发失败 ${p.name}: ${(e as Error).message}`]);
|
||||||
if (count === selectedPipelines.length) {
|
toast.error(`触发失败: ${p.name}`, { description: (e as Error).message });
|
||||||
setTimeout(() => {
|
} finally {
|
||||||
setExecLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] 批量发布全部完成`]);
|
setExecutingIds((prev) => {
|
||||||
setCurrentStep('done');
|
const next = new Set(prev);
|
||||||
}, 500);
|
next.delete(p.id);
|
||||||
}
|
return next;
|
||||||
}, idx * 2500);
|
});
|
||||||
});
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (strategy === 'sequential') {
|
||||||
|
for (let i = 0; i < selectedPipelines.length; i++) {
|
||||||
|
await triggerOne(selectedPipelines[i]);
|
||||||
|
if (i < selectedPipelines.length - 1) await sleep(300);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await Promise.all(selectedPipelines.map(triggerOne));
|
||||||
|
}
|
||||||
|
|
||||||
|
setTriggerDone(true);
|
||||||
|
setExecLogs((prev) => [...prev, `[${stamp()}] 全部触发完成,等待运行结束...`]);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['runs'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetWizard = () => {
|
||||||
|
setCurrentStep('select');
|
||||||
|
setSelectedIds([]);
|
||||||
|
setExecLogs([]);
|
||||||
|
setExecutingIds(new Set());
|
||||||
|
setFailedTriggerIds(new Set());
|
||||||
|
setTriggeredRuns({});
|
||||||
|
setTriggerDone(false);
|
||||||
|
setExecStartedAt(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewRecords = () => {
|
||||||
|
if (onViewRecords) onViewRecords();
|
||||||
|
else toast.info('请点击左侧【发布记录】查看详细记录');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,10 +222,25 @@ export default function BatchRelease() {
|
|||||||
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>批量发布</h1>
|
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>批量发布</h1>
|
||||||
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>向导</span>
|
{/* 输入方式切换:粘贴清单 / 手动向导 */}
|
||||||
|
<div className="flex items-center rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
{[{ id: 'paste', label: '粘贴清单' }, { id: 'wizard', label: '手动勾选' }].map((m) => (
|
||||||
|
<button key={m.id} onClick={() => setInputMode(m.id as 'paste' | 'wizard')} className="px-3 py-[3px] text-[11px] font-medium"
|
||||||
|
style={{ background: inputMode === m.id ? 'var(--accent-blue)' : 'transparent', color: inputMode === m.id ? '#fff' : 'var(--text-secondary)' }}>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{inputMode === 'wizard' && <span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>向导</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{inputMode === 'paste' ? (
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
|
<PasteRelease onViewRecords={handleViewRecords} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="px-5 py-3 border-b flex items-center justify-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="px-5 py-3 border-b flex items-center justify-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
{steps.map((step, idx) => {
|
{steps.map((step, idx) => {
|
||||||
const isActive = idx === stepIndex;
|
const isActive = idx === stepIndex;
|
||||||
@@ -100,26 +264,47 @@ export default function BatchRelease() {
|
|||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<p className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>选择要批量发布的流水线(支持原生流水线和 Jenkins Job 混合发布)</p>
|
<p className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>选择要批量发布的流水线(支持原生流水线和 Jenkins Job 混合发布)</p>
|
||||||
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个</span>
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setSelectedIds(items.map((p) => p.id))}
|
||||||
|
className="text-[12px] px-2 py-[3px] rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>全选</button>
|
||||||
|
<button onClick={() => setSelectedIds(items.filter((p) => !selectedIds.includes(p.id)).map((p) => p.id))}
|
||||||
|
className="text-[12px] px-2 py-[3px] rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>反选</button>
|
||||||
|
</div>
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2 text-[11px] font-semibold uppercase" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
<div className="grid grid-cols-[40px_1fr_120px_100px_100px_80px_100px] gap-2 px-4 py-2 text-[11px] font-semibold uppercase" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||||
<div></div><div>流水线</div><div>分支</div><div>类型</div><div>部署方式</div><div>环境</div>
|
<div></div><div>流水线</div><div>分支</div><div>类型</div><div>部署方式</div><div>环境</div><div>最近状态</div>
|
||||||
</div>
|
</div>
|
||||||
{mockPipelines.map((p) => {
|
{pipelinesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-10 border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="py-10 text-center border-t text-[12px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>暂无可发布的流水线</div>
|
||||||
|
) : items.map((p) => {
|
||||||
const isSelected = selectedIds.includes(p.id);
|
const isSelected = selectedIds.includes(p.id);
|
||||||
|
const tp = typeMeta[p.type] || fallbackType;
|
||||||
|
const last = statusMeta[p.lastStatus] || statusMeta.none;
|
||||||
return (
|
return (
|
||||||
<div key={p.id} onClick={() => toggleSelect(p.id)}
|
<div key={p.id} onClick={() => toggleSelect(p.id)}
|
||||||
className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2.5 border-t items-center cursor-pointer hover:bg-black/[0.02] transition-colors"
|
className="grid grid-cols-[40px_1fr_120px_100px_100px_80px_100px] gap-2 px-4 py-2.5 border-t items-center cursor-pointer hover:bg-black/[0.02] transition-colors"
|
||||||
style={{ borderColor: 'var(--border-color-light)' }}>
|
style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
<div><div className="w-4 h-4 rounded border flex items-center justify-center" style={{ borderColor: isSelected ? 'var(--accent-blue)' : 'var(--border-color)', background: isSelected ? 'var(--accent-blue)' : 'transparent' }}>
|
<div><div className="w-4 h-4 rounded border flex items-center justify-center" style={{ borderColor: isSelected ? 'var(--accent-blue)' : 'var(--border-color)', background: isSelected ? 'var(--accent-blue)' : 'transparent' }}>
|
||||||
{isSelected && <CheckCircle2 className="w-3 h-3 text-white" />}
|
{isSelected && <CheckCircle2 className="w-3 h-3 text-white" />}
|
||||||
</div></div>
|
</div></div>
|
||||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{p.name}</div>
|
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{p.name}</div>
|
||||||
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-secondary)' }}><GitBranch className="w-3 h-3" /> {p.branch}</div>
|
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-secondary)' }}><GitBranch className="w-3 h-3" /> {p.branch}</div>
|
||||||
<div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span></div>
|
<div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
|
||||||
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> K8s</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div>
|
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> K8s</span> : p.deployType === 'gitops' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> GitOps</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div>
|
||||||
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{p.env}</div>
|
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{p.env}</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${p.lastStatus === 'running' ? 'animate-pulse' : ''}`} style={{ background: last.color }} />
|
||||||
|
<span className="text-[11px]" style={{ color: last.color }}>{last.label}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -139,34 +324,42 @@ export default function BatchRelease() {
|
|||||||
<div className="rounded-lg border p-4 space-y-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border p-4 space-y-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布策略</label>
|
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布策略</label>
|
||||||
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
<select value={strategy} onChange={(e) => setStrategy(e.target.value as 'sequential' | 'parallel')}
|
||||||
<option>串行发布(逐个执行,失败即停)</option><option>并行发布(同时执行,互不影响)</option><option>分组发布(按环境分组串行)</option>
|
className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||||
|
<option value="sequential">顺序发布(逐个执行,间隔 300ms)</option>
|
||||||
|
<option value="parallel">并行发布(同时执行,互不影响)</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>失败处理</label>
|
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>失败处理</label>
|
||||||
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
<select value={failPolicy} onChange={(e) => setFailPolicy(e.target.value)}
|
||||||
<option>立即中止后续发布</option><option>继续发布其他流水线</option><option>标记失败并跳过</option>
|
className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||||
|
<option value="stop">立即中止后续发布</option>
|
||||||
|
<option value="continue">继续发布其他流水线</option>
|
||||||
|
<option value="skip">标记失败并跳过</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
</div>
|
</div>
|
||||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>K8s 就绪检查</label>
|
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>K8s 就绪检查</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label>
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkDeployment} onChange={(e) => setCheckDeployment(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label>
|
||||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label>
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkService} onChange={(e) => setCheckService(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label>
|
||||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>等待 Pod 健康检查通过</span></label>
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkPod} onChange={(e) => setCheckPod(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>等待 Pod 健康检查通过</span></label>
|
||||||
</div></div>
|
</div></div>
|
||||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布后通知</label>
|
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布后通知</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>飞书通知</span></label>
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={notifyFeishu} onChange={(e) => setNotifyFeishu(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>飞书通知</span></label>
|
||||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>钉钉通知</span></label>
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={notifyDingtalk} onChange={(e) => setNotifyDingtalk(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>钉钉通知</span></label>
|
||||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>Webhook</span></label>
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={notifyWebhook} onChange={(e) => setNotifyWebhook(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>Webhook</span></label>
|
||||||
</div></div>
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div className="text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>已选择的流水线</div>
|
<div className="text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>已选择的流水线</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{selectedPipelines.map((p) => (
|
{selectedPipelines.map((p) => {
|
||||||
<span key={p.id} className="text-[11px] px-2 py-[3px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
const tp = typeMeta[p.type] || fallbackType;
|
||||||
<Package className="w-3 h-3" />{p.name}</span>
|
return (
|
||||||
))}
|
<span key={p.id} className="text-[11px] px-2 py-[3px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||||
|
<Package className="w-3 h-3" style={{ color: tp.color }} />{p.name}</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div></div>
|
</div></div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<button onClick={() => setCurrentStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
|
<button onClick={() => setCurrentStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
|
||||||
@@ -181,17 +374,20 @@ export default function BatchRelease() {
|
|||||||
<AlertCircle className="w-8 h-8" style={{ color: 'var(--accent-blue)' }} />
|
<AlertCircle className="w-8 h-8" style={{ color: 'var(--accent-blue)' }} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>确认批量发布</h2>
|
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>确认批量发布</h2>
|
||||||
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>即将发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线,串行执行,失败即停</p>
|
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>即将发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线,{strategy === 'sequential' ? '顺序' : '并行'}执行,{failPolicyLabels[failPolicy] || failPolicy}</p>
|
||||||
<div className="rounded-lg border p-4 mb-6 text-left" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border p-4 mb-6 text-left" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
{selectedPipelines.map((p, idx) => (
|
{selectedPipelines.map((p, idx) => {
|
||||||
<div key={p.id} className="flex items-center gap-3 py-2" style={{ borderTop: idx > 0 ? '1px solid var(--border-color-light)' : 'none' }}>
|
const tp = typeMeta[p.type] || fallbackType;
|
||||||
<span className="text-[11px] w-5 text-center font-medium" style={{ color: 'var(--text-tertiary)' }}>{idx + 1}</span>
|
return (
|
||||||
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<div key={p.id} className="flex items-center gap-3 py-2" style={{ borderTop: idx > 0 ? '1px solid var(--border-color-light)' : 'none' }}>
|
||||||
<span className="text-[13px] font-medium flex-1" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
<span className="text-[11px] w-5 text-center font-medium" style={{ color: 'var(--text-tertiary)' }}>{idx + 1}</span>
|
||||||
<span className="text-[11px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{p.branch}</span>
|
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span>
|
<span className="text-[13px] font-medium flex-1" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
||||||
</div>
|
<span className="text-[11px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{p.branch}</span>
|
||||||
))}
|
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center gap-3">
|
<div className="flex justify-center gap-3">
|
||||||
<button onClick={() => setCurrentStep('configure')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>返回修改</button>
|
<button onClick={() => setCurrentStep('configure')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>返回修改</button>
|
||||||
@@ -212,7 +408,24 @@ export default function BatchRelease() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{completedCount} / {selectedIds.length} 完成</span>
|
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{completedCount} / {selectedIds.length} 完成</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-0">
|
{/* 每个流水线的实时状态 */}
|
||||||
|
<div className="divide-y" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
{selectedPipelines.map((p) => {
|
||||||
|
const st = itemStatus(p);
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="px-4 py-2 flex items-center gap-3">
|
||||||
|
<GitBranch className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<span className="text-[13px] font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{p.branch}</span>
|
||||||
|
<span className={`flex items-center gap-1.5 text-[12px] font-medium w-[90px] justify-end ${st.pulse ? 'animate-pulse' : ''}`} style={{ color: st.color }}>
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ background: st.color }} />
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="p-0 border-t" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
<div className="px-4 py-2 border-b" style={{ background: '#0d1117', borderColor: '#21262d' }}>
|
<div className="px-4 py-2 border-b" style={{ background: '#0d1117', borderColor: '#21262d' }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
|
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
|
||||||
@@ -221,7 +434,7 @@ export default function BatchRelease() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-3 font-mono text-[12px] leading-relaxed max-h-[400px] overflow-y-auto" style={{ background: '#0d1117', color: '#e6edf3' }}>
|
<div className="p-3 font-mono text-[12px] leading-relaxed max-h-[400px] overflow-y-auto" style={{ background: '#0d1117', color: '#e6edf3' }}>
|
||||||
{execLogs.map((log, idx) => (
|
{execLogs.map((log, idx) => (
|
||||||
<div key={idx} className="py-[1px]">{log.includes('✓') ? <span style={{ color: '#3fb950' }}>{log}</span> : log.includes('->') ? <span style={{ color: '#58a6ff' }}>{log}</span> : log}</div>
|
<div key={idx} className="py-[1px]">{log.includes('✓') ? <span style={{ color: '#3fb950' }}>{log}</span> : log.includes('✗') ? <span style={{ color: '#f85149' }}>{log}</span> : log.includes('→') ? <span style={{ color: '#58a6ff' }}>{log}</span> : log}</div>
|
||||||
))}
|
))}
|
||||||
<div className="py-[1px] animate-pulse"><span style={{ color: '#7d8590' }}>_</span></div>
|
<div className="py-[1px] animate-pulse"><span style={{ color: '#7d8590' }}>_</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,19 +445,26 @@ export default function BatchRelease() {
|
|||||||
|
|
||||||
{currentStep === 'done' && (
|
{currentStep === 'done' && (
|
||||||
<div className="max-w-2xl mx-auto text-center py-8">
|
<div className="max-w-2xl mx-auto text-center py-8">
|
||||||
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: '#dafbe1' }}>
|
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: failedCount > 0 ? '#fff8c5' : '#dafbe1' }}>
|
||||||
<CheckCircle2 className="w-8 h-8" style={{ color: '#1a7f37' }} />
|
{failedCount > 0
|
||||||
|
? <AlertCircle className="w-8 h-8" style={{ color: '#9a6700' }} />
|
||||||
|
: <CheckCircle2 className="w-8 h-8" style={{ color: '#1a7f37' }} />}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>批量发布完成</h2>
|
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>批量发布完成</h2>
|
||||||
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>共成功发布 <span className="font-semibold" style={{ color: 'var(--accent-green)' }}>{selectedIds.length}</span> 个流水线</p>
|
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
共发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线:
|
||||||
|
成功 <span className="font-semibold" style={{ color: '#1a7f37' }}>{successCount}</span> 个,失败 <span className="font-semibold" style={{ color: '#cf222e' }}>{failedCount}</span> 个
|
||||||
|
</p>
|
||||||
<div className="flex justify-center gap-3">
|
<div className="flex justify-center gap-3">
|
||||||
<button onClick={() => { setCurrentStep('select'); setSelectedIds([]); setExecLogs([]); setCompletedCount(0); }}
|
<button onClick={resetWizard}
|
||||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>继续发布</button>
|
className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>继续发布</button>
|
||||||
<button onClick={() => toast.success('已跳转至发布记录')} className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>查看记录</button>
|
<button onClick={handleViewRecords} className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>查看发布记录</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,52 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Cpu,
|
Cpu,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Edit2,
|
Edit2,
|
||||||
Trash2,
|
Trash2,
|
||||||
Tag,
|
Tag,
|
||||||
Activity
|
Zap,
|
||||||
|
HardDrive,
|
||||||
|
Gauge,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
interface BuildNode {
|
// 后端 labels 为 JSON 字符串,安全解析为数组
|
||||||
id: string;
|
function parseJsonArray(v: unknown): string[] {
|
||||||
name: string;
|
try {
|
||||||
ip: string;
|
const arr = JSON.parse(String(v ?? '[]'));
|
||||||
labels: string[];
|
return Array.isArray(arr) ? arr.map(String) : [];
|
||||||
maxConcurrency: number;
|
} catch {
|
||||||
currentJobs: number;
|
return [];
|
||||||
status: 'online' | 'offline' | 'busy';
|
}
|
||||||
cpu: string;
|
|
||||||
memory: string;
|
|
||||||
disk: string;
|
|
||||||
lastHeartbeat: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockNodes: BuildNode[] = [
|
function splitComma(v: string): string[] {
|
||||||
{ id: '1', name: 'build-host-01', ip: '192.168.10.101', labels: ['docker', 'go', 'java'], maxConcurrency: 4, currentJobs: 2, status: 'busy', cpu: '45%', memory: '6.2GB/16GB', disk: '120GB/500GB', lastHeartbeat: '10秒前' },
|
return v.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||||
{ id: '2', name: 'build-host-02', ip: '192.168.10.102', labels: ['docker', 'node', 'python'], maxConcurrency: 3, currentJobs: 0, status: 'online', cpu: '12%', memory: '2.1GB/8GB', disk: '80GB/300GB', lastHeartbeat: '5秒前' },
|
}
|
||||||
{ id: '3', name: 'build-host-03', ip: '192.168.10.103', labels: ['k8s', 'helm'], maxConcurrency: 2, currentJobs: 0, status: 'offline', cpu: '-', memory: '-', disk: '-', lastHeartbeat: '2小时前' },
|
|
||||||
];
|
// 相对时间:如 "10秒前"
|
||||||
|
function timeAgo(v?: string): string {
|
||||||
|
if (!v) return '—';
|
||||||
|
const t = new Date(v).getTime();
|
||||||
|
if (Number.isNaN(t)) return v;
|
||||||
|
const s = Math.floor((Date.now() - t) / 1000);
|
||||||
|
if (s < 10) return '刚刚';
|
||||||
|
if (s < 60) return `${s}秒前`;
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}分钟前`;
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)}小时前`;
|
||||||
|
return `${Math.floor(s / 86400)}天前`;
|
||||||
|
}
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
online: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '在线' },
|
online: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '在线' },
|
||||||
@@ -39,15 +54,140 @@ const statusConfig = {
|
|||||||
busy: { icon: Loader2, color: '#9a6700', bg: '#fff8c5', label: '忙碌' },
|
busy: { icon: Loader2, color: '#9a6700', bg: '#fff8c5', label: '忙碌' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unknownStatus = { icon: XCircle, color: '#6e7781', bg: '#f6f8fa', label: '未知' };
|
||||||
|
|
||||||
|
interface NodeForm {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
labels: string;
|
||||||
|
maxConcurrency: string;
|
||||||
|
workspacePath: string;
|
||||||
|
diskInfo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: NodeForm = { name: '', ip: '', labels: '', maxConcurrency: '2', workspacePath: '/var/lib/flowstack/workspace', diskInfo: '' };
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
background: 'var(--panel-bg-secondary)',
|
||||||
|
borderColor: 'var(--border-color-light)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
};
|
||||||
|
|
||||||
export default function BuildNodeManager() {
|
export default function BuildNodeManager() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const filtered = mockNodes.filter(n =>
|
|
||||||
n.name.includes(searchQuery) || n.ip.includes(searchQuery)
|
const nodesQuery = useQuery({ queryKey: ['build-nodes'], queryFn: () => apiClient.listBuildNodes() });
|
||||||
);
|
// SSH 凭证(磁盘性能测试等远程操作用)
|
||||||
|
const credentialsQuery = useQuery({ queryKey: ['credentials', 'ssh'], queryFn: () => apiClient.listCredentials('ssh') });
|
||||||
|
const sshCredentials: any[] = credentialsQuery.data ?? [];
|
||||||
|
const nodes: any[] = nodesQuery.data ?? [];
|
||||||
|
|
||||||
|
// ---- 弹窗 ----
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<any | null>(null);
|
||||||
|
const [form, setForm] = useState<NodeForm>(emptyForm);
|
||||||
|
|
||||||
|
const openDialog = (node?: any) => {
|
||||||
|
setEditing(node ?? null);
|
||||||
|
setForm(
|
||||||
|
node
|
||||||
|
? {
|
||||||
|
name: node.name ?? '',
|
||||||
|
ip: node.ip ?? '',
|
||||||
|
labels: parseJsonArray(node.labels).join(','),
|
||||||
|
maxConcurrency: String(node.maxConcurrency ?? 2),
|
||||||
|
workspacePath: node.workspacePath ?? '',
|
||||||
|
diskInfo: node.diskInfo ?? '',
|
||||||
|
}
|
||||||
|
: emptyForm,
|
||||||
|
);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['build-nodes'] });
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (vars: { id?: any; body: any }) =>
|
||||||
|
vars.id ? apiClient.updateBuildNode(vars.id, vars.body) : apiClient.createBuildNode(vars.body),
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
toast.success(vars.id ? '节点已更新' : '节点已创建');
|
||||||
|
invalidate();
|
||||||
|
setDialogOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.deleteBuildNode(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('节点已删除');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.testBuildNode(id),
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
if (result?.success) toast.success(result?.message || '连接成功');
|
||||||
|
else toast.error(result?.message || '连接失败');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 磁盘性能测试(dd 实测)
|
||||||
|
const diskMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.testBuildNodeDisk(id),
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
if (result?.success) toast.success('磁盘性能:' + result.message, { duration: 6000 });
|
||||||
|
else toast.error(result?.message || '磁盘测试失败');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('磁盘测试失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: (vars: { id: any; enabled: boolean }) => apiClient.updateBuildNode(vars.id, { enabled: vars.enabled }),
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
toast.success(vars.enabled ? '节点已启用' : '节点已停用');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!form.name.trim() || !form.ip.trim()) {
|
||||||
|
toast.error('请填写节点名称和 IP 地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveMutation.mutate({
|
||||||
|
id: editing?.id,
|
||||||
|
body: {
|
||||||
|
name: form.name.trim(),
|
||||||
|
ip: form.ip.trim(),
|
||||||
|
credentialId: editing?.credentialId ?? null,
|
||||||
|
labels: JSON.stringify(splitComma(form.labels)),
|
||||||
|
maxConcurrency: Number(form.maxConcurrency) || 1,
|
||||||
|
workspacePath: form.workspacePath.trim(),
|
||||||
|
diskInfo: form.diskInfo.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统计(真实数据计算)
|
||||||
|
const stats = [
|
||||||
|
{ label: '在线节点', count: nodes.filter((n) => n.status === 'online').length },
|
||||||
|
{ label: '忙碌节点', count: nodes.filter((n) => n.status === 'busy').length },
|
||||||
|
{ label: '离线节点', count: nodes.filter((n) => n.status !== 'online' && n.status !== 'busy').length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = nodes.filter((n) => n.name?.includes(searchQuery) || n.ip?.includes(searchQuery));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
<div
|
<div
|
||||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
@@ -62,7 +202,7 @@ export default function BuildNodeManager() {
|
|||||||
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索节点..." className="bg-transparent outline-none w-32 text-[13px]" style={{ color: 'var(--text-primary)' }} />
|
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索节点..." className="bg-transparent outline-none w-32 text-[13px]" style={{ color: 'var(--text-primary)' }} />
|
||||||
</div>
|
</div>
|
||||||
<button className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }} onClick={() => toast.info('添加节点', { description: '功能开发中...' })}>
|
<button className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }} onClick={() => openDialog()}>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
添加节点
|
添加节点
|
||||||
</button>
|
</button>
|
||||||
@@ -70,56 +210,113 @@ export default function BuildNodeManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
{nodesQuery.isLoading ? (
|
||||||
{[
|
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{ label: '在线节点', count: 2, color: '#1a7f37' },
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
{ label: '忙碌节点', count: 1, color: '#9a6700' },
|
加载中...
|
||||||
{ label: '离线节点', count: 1, color: '#cf222e' },
|
</div>
|
||||||
].map((stat) => (
|
) : nodesQuery.isError ? (
|
||||||
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="flex flex-col items-center gap-3 py-16">
|
||||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
<XCircle className="w-6 h-6" style={{ color: 'var(--accent-red)' }} />
|
||||||
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
|
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(nodesQuery.error as Error).message}</div>
|
||||||
</div>
|
<button
|
||||||
))}
|
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||||
</div>
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
|
onClick={() => nodesQuery.refetch()}
|
||||||
<div className="space-y-3">
|
>
|
||||||
{filtered.map((node) => {
|
重试
|
||||||
const status = statusConfig[node.status];
|
</button>
|
||||||
const StatusIcon = status.icon;
|
</div>
|
||||||
const utilization = node.status === 'offline' ? 0 : (node.currentJobs / node.maxConcurrency) * 100;
|
) : (
|
||||||
return (
|
<>
|
||||||
<div key={node.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
{stats.map((stat) => (
|
||||||
<div className="flex items-center gap-3">
|
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<Cpu className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
||||||
<div>
|
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
|
||||||
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{node.name}</div>
|
|
||||||
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{node.ip}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[11px] px-2 py-[3px] rounded-full font-medium flex items-center gap-1" style={{ background: status.bg, color: status.color }}>
|
|
||||||
<StatusIcon className={`w-3 h-3 ${node.status === 'busy' ? 'animate-spin' : ''}`} />
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
|
||||||
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map((node) => {
|
||||||
|
const status = statusConfig[node.status as keyof typeof statusConfig] ?? { ...unknownStatus, label: String(node.status) };
|
||||||
|
const StatusIcon = status.icon;
|
||||||
|
const maxConcurrency = Number(node.maxConcurrency) || 1;
|
||||||
|
const currentJobs = Number(node.currentJobs) || 0;
|
||||||
|
const utilization = node.status === 'offline' ? 0 : (currentJobs / maxConcurrency) * 100;
|
||||||
|
const labels = parseJsonArray(node.labels);
|
||||||
|
return (
|
||||||
|
<div key={node.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)', opacity: node.enabled === false ? 0.7 : 1 }}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Cpu className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||||
|
<div>
|
||||||
|
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{node.name}</div>
|
||||||
|
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{node.ip}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] px-2 py-[3px] rounded-full font-medium flex items-center gap-1" style={{ background: status.bg, color: status.color }}>
|
||||||
|
<StatusIcon className={`w-3 h-3 ${node.status === 'busy' ? 'animate-spin' : ''}`} />
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1" title={node.enabled === false ? '已停用' : '已启用'}>
|
||||||
|
<Switch
|
||||||
|
checked={node.enabled !== false}
|
||||||
|
disabled={toggleMutation.isPending && toggleMutation.variables?.id === node.id}
|
||||||
|
onCheckedChange={(checked) => toggleMutation.mutate({ id: node.id, enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="磁盘性能测试(dd 读写 256MB)"
|
||||||
|
disabled={diskMutation.isPending && diskMutation.variables === node.id}
|
||||||
|
onClick={() => diskMutation.mutate(node.id)}
|
||||||
|
>
|
||||||
|
{diskMutation.isPending && diskMutation.variables === node.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--accent-blue)' }} />
|
||||||
|
) : (
|
||||||
|
<Gauge className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="测试连接"
|
||||||
|
disabled={testMutation.isPending && testMutation.variables === node.id}
|
||||||
|
onClick={() => testMutation.mutate(node.id)}
|
||||||
|
>
|
||||||
|
{testMutation.isPending && testMutation.variables === node.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
) : (
|
||||||
|
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openDialog(node)}>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="删除"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`确定删除节点 ${node.name} 吗?`)) deleteMutation.mutate(node.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{node.status !== 'offline' && (
|
|
||||||
<>
|
|
||||||
{/* Concurrency bar */}
|
{/* Concurrency bar */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="flex items-center justify-between text-[11px] mb-1">
|
<div className="flex items-center justify-between text-[11px] mb-1">
|
||||||
<span style={{ color: 'var(--text-tertiary)' }}>并发任务</span>
|
<span style={{ color: 'var(--text-tertiary)' }}>并发任务</span>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{node.currentJobs} / {node.maxConcurrency}</span>
|
<span style={{ color: 'var(--text-secondary)' }}>{currentJobs} / {maxConcurrency}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--panel-bg-secondary)' }}>
|
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all duration-500"
|
className="h-full rounded-full transition-all duration-500"
|
||||||
style={{
|
style={{
|
||||||
width: `${utilization}%`,
|
width: `${utilization}%`,
|
||||||
background: utilization > 80 ? 'var(--accent-red)' : utilization > 50 ? 'var(--accent-amber)' : 'var(--accent-green)',
|
background: utilization > 80 ? 'var(--accent-red)' : utilization > 50 ? 'var(--accent-amber)' : 'var(--accent-green)',
|
||||||
}}
|
}}
|
||||||
@@ -127,42 +324,134 @@ export default function BuildNodeManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resource metrics */}
|
{/* Info: 磁盘(含性能) / 工作空间 / 心跳 */}
|
||||||
<div className="grid grid-cols-4 gap-3 text-[11px]">
|
<div className="grid grid-cols-3 gap-3 text-[11px]">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Activity className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
<HardDrive className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>CPU {node.cpu}</span>
|
<span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }} title={node.diskInfo ? `磁盘 ${node.diskInfo}` : '磁盘未上报'}>
|
||||||
|
{node.diskInfo || '—'}
|
||||||
|
</span>
|
||||||
|
{node.diskWriteMBps > 0 && (
|
||||||
|
<span
|
||||||
|
className="font-mono"
|
||||||
|
style={{ color: node.diskWriteMBps > 200 ? '#1a7f37' : node.diskWriteMBps > 50 ? '#9a6700' : '#cf222e' }}
|
||||||
|
title={`磁盘性能(${node.diskTestMode || ''} ${node.diskTestedAt ? new Date(node.diskTestedAt).toLocaleString('zh-CN', { hour12: false }) : ''})`}
|
||||||
|
>
|
||||||
|
↑{Math.round(node.diskWriteMBps)}M ↓{Math.round(node.diskReadMBps)}M
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span style={{ color: 'var(--text-tertiary)' }}>内存</span>
|
<span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>空间</span>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{node.memory}</span>
|
<span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }} title={node.workspacePath}>
|
||||||
</div>
|
{node.workspacePath || '—'}
|
||||||
<div className="flex items-center gap-1.5">
|
</span>
|
||||||
<span style={{ color: 'var(--text-tertiary)' }}>磁盘</span>
|
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{node.disk}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span style={{ color: 'var(--text-tertiary)' }}>心跳</span>
|
<span style={{ color: 'var(--text-tertiary)' }}>心跳</span>
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>{node.lastHeartbeat}</span>
|
<span style={{ color: 'var(--text-secondary)' }}>{timeAgo(node.lastHeartbeat)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
<div className="flex items-center gap-1 mt-3 flex-wrap">
|
{labels.length > 0 && (
|
||||||
{node.labels.map((label) => (
|
<div className="flex items-center gap-1 mt-3 flex-wrap">
|
||||||
<span key={label} className="text-[10px] px-1.5 py-[2px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
{labels.map((label) => (
|
||||||
<Tag className="w-2.5 h-2.5" />
|
<span key={label} className="text-[10px] px-1.5 py-[2px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||||
{label}
|
<Tag className="w-2.5 h-2.5" />
|
||||||
</span>
|
{label}
|
||||||
))}
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-10 text-center text-[12px]"
|
||||||
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
暂无构建节点,点击右上角「添加节点」创建
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
})}
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 添加/编辑节点弹窗 */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{editing ? '编辑节点' : '添加节点'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="build-host-01" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>IP 地址 *</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={form.ip} onChange={(e) => setForm({ ...form, ip: e.target.value })} placeholder="192.168.10.101" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>标签(逗号分隔)</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={form.labels} onChange={(e) => setForm({ ...form, labels: e.target.value })} placeholder="docker,go,java" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>最大并发</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={form.maxConcurrency} onChange={(e) => setForm({ ...form, maxConcurrency: e.target.value })} placeholder="2" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>工作空间路径</Label>
|
||||||
|
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.workspacePath} onChange={(e) => setForm({ ...form, workspacePath: e.target.value })} placeholder="/var/lib/flowstack/workspace" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>磁盘用量</Label>
|
||||||
|
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.diskInfo} onChange={(e) => setForm({ ...form, diskInfo: e.target.value })} placeholder="如 45G/200G(已用/总量)" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>SSH 凭证(磁盘性能测试等远程操作)</Label>
|
||||||
|
<select
|
||||||
|
className="w-full h-8 px-2 text-[12px] rounded-md border outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
value={editing?.credentialId ? String(editing.credentialId) : ''}
|
||||||
|
onChange={(e) => setEditing((prev: any) => ({ ...(prev || {}), credentialId: e.target.value ? Number(e.target.value) : null }))}
|
||||||
|
>
|
||||||
|
<option value="">不关联(本机节点无需凭证)</option>
|
||||||
|
{sshCredentials.map((cred: any) => (
|
||||||
|
<option key={cred.id} value={cred.id}>{cred.name}{cred.summary ? ` · ${cred.summary}` : ''}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{editing ? '保存' : '创建'}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,36 +1,25 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Key,
|
Key,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Eye,
|
Copy,
|
||||||
EyeOff,
|
Trash2,
|
||||||
Copy,
|
|
||||||
Trash2,
|
|
||||||
Edit2,
|
Edit2,
|
||||||
Shield,
|
Shield,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Container
|
Container,
|
||||||
|
Cloud,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
interface Credential {
|
import { apiClient } from '@/lib/api';
|
||||||
id: string;
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
name: string;
|
import { Label } from '@/components/ui/label';
|
||||||
type: 'ssh' | 'password' | 'docker' | 'git' | 'kubeconfig';
|
import { Input } from '@/components/ui/input';
|
||||||
scope: string;
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
createdAt: string;
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
usageCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockCredentials: Credential[] = [
|
|
||||||
{ id: '1', name: 'production-ssh-key', type: 'ssh', scope: 'production-group', createdAt: '2024-06-01', usageCount: 12 },
|
|
||||||
{ id: '2', name: 'docker-registry-auth', type: 'docker', scope: '全局', createdAt: '2024-06-05', usageCount: 8 },
|
|
||||||
{ id: '3', name: 'gitee-token', type: 'git', scope: '全局', createdAt: '2024-06-10', usageCount: 5 },
|
|
||||||
{ id: '4', name: 'staging-ssh-key', type: 'ssh', scope: 'staging-group', createdAt: '2024-06-12', usageCount: 3 },
|
|
||||||
{ id: '5', name: 'ack-kubeconfig', type: 'kubeconfig', scope: 'ack-production', createdAt: '2024-06-15', usageCount: 6 },
|
|
||||||
{ id: '6', name: 'db-password', type: 'password', scope: 'production-group', createdAt: '2024-06-18', usageCount: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = {
|
const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = {
|
||||||
ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' },
|
ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' },
|
||||||
@@ -38,41 +27,137 @@ const typeConfig: Record<string, { icon: typeof Key; label: string; color: strin
|
|||||||
docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' },
|
docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' },
|
||||||
git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' },
|
git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' },
|
||||||
kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' },
|
kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' },
|
||||||
|
aksk: { icon: Cloud, label: 'AK/SK', color: '#0f766e', bg: '#ccfbf1' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackConfig = { icon: Key, label: '其他', color: '#6e7781', bg: '#f6f8fa' };
|
||||||
|
|
||||||
|
function getConfig(type: string) {
|
||||||
|
return typeConfig[type] ?? { ...fallbackConfig, label: type };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(v?: string): string {
|
||||||
|
if (!v || v.startsWith('0001-')) return '—';
|
||||||
|
return v.replace('T', ' ').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialForm {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
scope: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: CredentialForm = { name: '', type: 'ssh', scope: '全局', content: '' };
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
background: 'var(--panel-bg-secondary)',
|
||||||
|
borderColor: 'var(--border-color-light)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CredentialManager() {
|
export default function CredentialManager() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||||
|
|
||||||
const filtered = mockCredentials.filter(c =>
|
// 列表(跟随类型筛选)
|
||||||
c.name.includes(searchQuery) || c.type.includes(searchQuery)
|
const credentialsQuery = useQuery({
|
||||||
|
queryKey: ['credentials', typeFilter],
|
||||||
|
queryFn: () => apiClient.listCredentials(typeFilter),
|
||||||
|
});
|
||||||
|
// 全量(用于类型统计卡数量)
|
||||||
|
const allQuery = useQuery({ queryKey: ['credentials', 'all'], queryFn: () => apiClient.listCredentials() });
|
||||||
|
|
||||||
|
const credentials: any[] = credentialsQuery.data ?? [];
|
||||||
|
const allCredentials: any[] = allQuery.data ?? [];
|
||||||
|
|
||||||
|
// ---- 弹窗 ----
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<any | null>(null);
|
||||||
|
const [form, setForm] = useState<CredentialForm>(emptyForm);
|
||||||
|
|
||||||
|
const openDialog = (cred?: any) => {
|
||||||
|
setEditing(cred ?? null);
|
||||||
|
setForm(
|
||||||
|
cred
|
||||||
|
? { name: cred.name ?? '', type: cred.type ?? 'ssh', scope: cred.scope ?? '', content: '' }
|
||||||
|
: emptyForm,
|
||||||
|
);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['credentials'] });
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (vars: { id?: any; body: any }) =>
|
||||||
|
vars.id ? apiClient.updateCredential(vars.id, vars.body) : apiClient.createCredential(vars.body),
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
toast.success(vars.id ? '凭证已更新' : '凭证已创建');
|
||||||
|
invalidate();
|
||||||
|
setDialogOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.deleteCredential(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('凭证已删除');
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error('请填写凭证名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 内容加密存储(write-only):编辑时留空表示不修改
|
||||||
|
const body: any = { name: form.name.trim(), type: form.type, scope: form.scope.trim() };
|
||||||
|
if (form.content.trim()) body.content = form.content.trim();
|
||||||
|
saveMutation.mutate({ id: editing?.id, body });
|
||||||
|
};
|
||||||
|
|
||||||
|
const copySummary = (cred: any) => {
|
||||||
|
const text = cred.summary || getConfig(cred.type).label;
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(text)
|
||||||
|
.then(() => toast.success('已复制', { description: text }))
|
||||||
|
.catch(() => toast.error('复制失败'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = credentials.filter(
|
||||||
|
(c) => c.name?.includes(searchQuery) || c.scope?.includes(searchQuery) || c.type?.includes(searchQuery),
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleSecret = (id: string) => {
|
|
||||||
setShowSecrets(prev => ({ ...prev, [id]: !prev[id] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyValue = (name: string) => {
|
|
||||||
toast.success('已复制', { description: `${name} 的值已复制到剪贴板` });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
<div
|
<div
|
||||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>凭证管理</h1>
|
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>凭证管理</h1>
|
||||||
<span
|
<span
|
||||||
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||||
>
|
>
|
||||||
{filtered.length}
|
{filtered.length}
|
||||||
</span>
|
</span>
|
||||||
|
{typeFilter !== 'all' && (
|
||||||
|
<button
|
||||||
|
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
||||||
|
style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}
|
||||||
|
onClick={() => setTypeFilter('all')}
|
||||||
|
>
|
||||||
|
{getConfig(typeFilter).label} ×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
|
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
||||||
>
|
>
|
||||||
@@ -88,7 +173,7 @@ export default function CredentialManager() {
|
|||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
onClick={() => toast.info('新建凭证', { description: '功能开发中...' })}
|
onClick={() => openDialog()}
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
新建凭证
|
新建凭证
|
||||||
@@ -97,86 +182,220 @@ export default function CredentialManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
{/* Type summary */}
|
{credentialsQuery.isLoading ? (
|
||||||
<div className="grid grid-cols-5 gap-3 mb-4">
|
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{Object.entries(typeConfig).map(([type, config]) => {
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
const count = mockCredentials.filter(c => c.type === type).length;
|
加载中...
|
||||||
const Icon = config.icon;
|
</div>
|
||||||
return (
|
) : credentialsQuery.isError ? (
|
||||||
<div key={type} className="rounded-lg border p-3 flex items-center gap-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="flex flex-col items-center gap-3 py-16">
|
||||||
<div className="w-8 h-8 rounded-md flex items-center justify-center" style={{ background: config.bg }}>
|
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(credentialsQuery.error as Error).message}</div>
|
||||||
<Icon className="w-4 h-4" style={{ color: config.color }} />
|
<button
|
||||||
</div>
|
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||||
<div>
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{config.label}</div>
|
onClick={() => credentialsQuery.refetch()}
|
||||||
<div className="text-[16px] font-bold" style={{ color: 'var(--text-primary)' }}>{count}</div>
|
>
|
||||||
</div>
|
重试
|
||||||
</div>
|
</button>
|
||||||
);
|
</div>
|
||||||
})}
|
) : (
|
||||||
</div>
|
<>
|
||||||
|
{/* Type summary(点击可按类型筛选) */}
|
||||||
{/* Credential list */}
|
<div className="grid grid-cols-6 gap-3 mb-4">
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
{Object.entries(typeConfig).map(([type, config]) => {
|
||||||
<table className="w-full">
|
const count = allCredentials.filter((c) => c.type === type).length;
|
||||||
<thead>
|
|
||||||
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
|
||||||
{['凭证名称', '类型', '使用范围', '使用次数', '创建时间', '值', '操作'].map((h) => (
|
|
||||||
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
{h}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map((cred) => {
|
|
||||||
const config = typeConfig[cred.type];
|
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
const isRevealed = showSecrets[cred.id];
|
const active = typeFilter === type;
|
||||||
return (
|
return (
|
||||||
<tr key={cred.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
<button
|
||||||
<td className="px-4 py-3">
|
key={type}
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => setTypeFilter(active ? 'all' : type)}
|
||||||
<div className="w-6 h-6 rounded flex items-center justify-center" style={{ background: config.bg }}>
|
className="rounded-lg border p-3 flex items-center gap-3 text-left transition-colors hover:bg-black/[0.02]"
|
||||||
<Icon className="w-3 h-3" style={{ color: config.color }} />
|
style={{
|
||||||
</div>
|
background: 'var(--panel-bg)',
|
||||||
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{cred.name}</span>
|
borderColor: active ? 'var(--accent-blue)' : 'var(--border-color)',
|
||||||
</div>
|
}}
|
||||||
</td>
|
>
|
||||||
<td className="px-4 py-3">
|
<div className="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0" style={{ background: config.bg }}>
|
||||||
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: config.bg, color: config.color }}>
|
<Icon className="w-4 h-4" style={{ color: config.color }} />
|
||||||
{config.label}
|
</div>
|
||||||
</span>
|
<div>
|
||||||
</td>
|
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{config.label}</div>
|
||||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.scope}</td>
|
<div className="text-[16px] font-bold" style={{ color: 'var(--text-primary)' }}>{count}</div>
|
||||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.usageCount}</td>
|
</div>
|
||||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{cred.createdAt}</td>
|
</button>
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<code className="text-[11px] px-2 py-[3px] rounded font-mono" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
|
||||||
{isRevealed ? 'AKIAIOSFODNN7EXAMPLE' : '••••••••••••••••'}
|
|
||||||
</code>
|
|
||||||
<button onClick={() => toggleSecret(cred.id)} className="p-1 rounded hover:bg-black/5">
|
|
||||||
{isRevealed ? <EyeOff className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} /> : <Eye className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => copyValue(cred.name)} className="p-1 rounded hover:bg-black/5">
|
|
||||||
<Copy className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-0">
|
|
||||||
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
|
||||||
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
{/* Credential list */}
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||||
|
{['凭证名称', '类型', '使用范围', '使用次数', '最近使用', '创建时间', '摘要', '操作'].map((h) => (
|
||||||
|
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((cred) => {
|
||||||
|
const config = getConfig(cred.type);
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<tr key={cred.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 rounded flex items-center justify-center" style={{ background: config.bg }}>
|
||||||
|
<Icon className="w-3 h-3" style={{ color: config.color }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{cred.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: config.bg, color: config.color }}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.scope || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.usageCount ?? 0}</td>
|
||||||
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{fmtTime(cred.lastUsedAt)}</td>
|
||||||
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{fmtTime(cred.createdAt)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{cred.contentSet === false ? (
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未设置内容</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<code
|
||||||
|
className="text-[11px] px-2 py-[3px] rounded font-mono max-w-[220px] truncate inline-block"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}
|
||||||
|
title={cred.summary || config.label}
|
||||||
|
>
|
||||||
|
{cred.summary || config.label}
|
||||||
|
</code>
|
||||||
|
<button onClick={() => copySummary(cred)} className="p-1 rounded hover:bg-black/5" title="复制摘要">
|
||||||
|
<Copy className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openDialog(cred)}>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="删除"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`确定删除凭证 ${cred.name} 吗?`)) deleteMutation.mutate(cred.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-12 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
暂无凭证,点击右上角「新建凭证」创建
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 新建/编辑凭证弹窗 */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{editing ? '编辑凭证' : '新建凭证'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
|
||||||
|
<Input
|
||||||
|
className="h-8 text-[12px]"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
placeholder="production-ssh-key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>类型</Label>
|
||||||
|
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||||
|
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
|
||||||
|
<SelectValue placeholder="选择类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(typeConfig).map(([type, config]) => (
|
||||||
|
<SelectItem key={type} value={type} className="text-[12px]">
|
||||||
|
{config.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>使用范围</Label>
|
||||||
|
<Input
|
||||||
|
className="h-8 text-[12px]"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.scope}
|
||||||
|
onChange={(e) => setForm({ ...form, scope: e.target.value })}
|
||||||
|
placeholder="全局 / production-group"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>内容</Label>
|
||||||
|
<Textarea
|
||||||
|
className="text-[12px] min-h-[88px] font-mono"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.content}
|
||||||
|
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||||
|
placeholder={editing ? '留空表示不修改' : '私钥 / 密码 / Token / kubeconfig / AK·SK 等'}
|
||||||
|
/>
|
||||||
|
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
内容加密存储,保存后不可查看。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{editing ? '保存' : '创建'}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,69 +1,253 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Server,
|
Server,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Edit2,
|
Edit2,
|
||||||
Trash2,
|
Trash2,
|
||||||
Terminal,
|
|
||||||
CloudCog,
|
CloudCog,
|
||||||
|
Zap,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
interface Host {
|
// 后端 tags/namespaces 为 JSON 字符串,安全解析为数组
|
||||||
id: string;
|
function parseJsonArray(v: unknown): string[] {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(String(v ?? '[]'));
|
||||||
|
return Array.isArray(arr) ? arr.map(String) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitComma(v: string): string[] {
|
||||||
|
return v.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(v?: string): string {
|
||||||
|
if (!v || v.startsWith('0001-')) return '—';
|
||||||
|
return v.replace('T', ' ').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostForm {
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: string;
|
||||||
username: string;
|
username: string;
|
||||||
tags: string[];
|
tags: string;
|
||||||
status: 'online' | 'offline';
|
|
||||||
os: string;
|
os: string;
|
||||||
|
remark: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface K8sCluster {
|
const emptyHostForm: HostForm = { name: '', ip: '', port: '22', username: 'root', tags: '', os: '', remark: '' };
|
||||||
id: string;
|
|
||||||
|
interface ClusterForm {
|
||||||
name: string;
|
name: string;
|
||||||
context: string;
|
context: string;
|
||||||
|
apiServer: string;
|
||||||
version: string;
|
version: string;
|
||||||
nodes: number;
|
kubeconfig: string;
|
||||||
namespaces: string[];
|
|
||||||
status: 'connected' | 'disconnected';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockHosts: Host[] = [
|
const emptyClusterForm: ClusterForm = { name: '', context: '', apiServer: '', version: '', kubeconfig: '' };
|
||||||
{ id: '1', name: 'prod-web-01', ip: '192.168.1.101', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
|
|
||||||
{ id: '2', name: 'prod-web-02', ip: '192.168.1.102', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
|
|
||||||
{ id: '3', name: 'prod-api-01', ip: '192.168.1.111', port: 22, username: 'root', tags: ['production', 'api'], status: 'online', os: 'Ubuntu 22.04' },
|
|
||||||
{ id: '4', name: 'staging-web-01', ip: '192.168.2.101', port: 22, username: 'admin', tags: ['staging', 'web'], status: 'online', os: 'Ubuntu 22.04' },
|
|
||||||
{ id: '5', name: 'staging-api-01', ip: '192.168.2.111', port: 22, username: 'admin', tags: ['staging', 'api'], status: 'offline', os: 'CentOS 7' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockClusters: K8sCluster[] = [
|
const inputStyle = {
|
||||||
{ id: '1', name: 'ack-production', context: 'ack-prod', version: 'v1.28.3', nodes: 6, namespaces: ['aiops', 'gateway', 'monitoring'], status: 'connected' },
|
background: 'var(--panel-bg-secondary)',
|
||||||
{ id: '2', name: 'ack-staging', context: 'ack-staging', version: 'v1.27.8', nodes: 3, namespaces: ['aiops', 'default'], status: 'connected' },
|
borderColor: 'var(--border-color-light)',
|
||||||
{ id: '3', name: 'tke-test', context: 'tke-test', version: 'v1.26.5', nodes: 2, namespaces: ['default'], status: 'disconnected' },
|
color: 'var(--text-primary)',
|
||||||
];
|
};
|
||||||
|
|
||||||
export default function EnvironmentManager() {
|
export default function EnvironmentManager() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState<'hosts' | 'k8s'>('hosts');
|
const [activeTab, setActiveTab] = useState<'hosts' | 'k8s'>('hosts');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
const filteredHosts = mockHosts.filter(h =>
|
// ---- 数据获取 ----
|
||||||
h.name.includes(searchQuery) || h.ip.includes(searchQuery)
|
const hostsQuery = useQuery({ queryKey: ['hosts'], queryFn: () => apiClient.listHosts() });
|
||||||
);
|
const clustersQuery = useQuery({ queryKey: ['clusters'], queryFn: () => apiClient.listClusters() });
|
||||||
|
|
||||||
const filteredClusters = mockClusters.filter(c =>
|
const hosts: any[] = hostsQuery.data ?? [];
|
||||||
c.name.includes(searchQuery) || c.context.includes(searchQuery)
|
const clusters: any[] = clustersQuery.data ?? [];
|
||||||
);
|
|
||||||
|
// ---- 主机弹窗 ----
|
||||||
|
const [hostDialogOpen, setHostDialogOpen] = useState(false);
|
||||||
|
const [editingHost, setEditingHost] = useState<any | null>(null);
|
||||||
|
const [hostForm, setHostForm] = useState<HostForm>(emptyHostForm);
|
||||||
|
|
||||||
|
// ---- 集群弹窗 ----
|
||||||
|
const [clusterDialogOpen, setClusterDialogOpen] = useState(false);
|
||||||
|
const [editingCluster, setEditingCluster] = useState<any | null>(null);
|
||||||
|
const [clusterForm, setClusterForm] = useState<ClusterForm>(emptyClusterForm);
|
||||||
|
|
||||||
|
const openHostDialog = (host?: any) => {
|
||||||
|
setEditingHost(host ?? null);
|
||||||
|
setHostForm(
|
||||||
|
host
|
||||||
|
? {
|
||||||
|
name: host.name ?? '',
|
||||||
|
ip: host.ip ?? '',
|
||||||
|
port: String(host.port ?? 22),
|
||||||
|
username: host.username ?? '',
|
||||||
|
tags: parseJsonArray(host.tags).join(','),
|
||||||
|
os: host.os ?? '',
|
||||||
|
remark: host.remark ?? '',
|
||||||
|
}
|
||||||
|
: emptyHostForm,
|
||||||
|
);
|
||||||
|
setHostDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openClusterDialog = (cluster?: any) => {
|
||||||
|
setEditingCluster(cluster ?? null);
|
||||||
|
setClusterForm(
|
||||||
|
cluster
|
||||||
|
? {
|
||||||
|
name: cluster.name ?? '',
|
||||||
|
context: cluster.context ?? '',
|
||||||
|
apiServer: cluster.apiServer ?? '',
|
||||||
|
version: cluster.version ?? '',
|
||||||
|
kubeconfig: '',
|
||||||
|
}
|
||||||
|
: emptyClusterForm,
|
||||||
|
);
|
||||||
|
setClusterDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 变更 ----
|
||||||
|
const invalidateHosts = () => queryClient.invalidateQueries({ queryKey: ['hosts'] });
|
||||||
|
const invalidateClusters = () => queryClient.invalidateQueries({ queryKey: ['clusters'] });
|
||||||
|
|
||||||
|
const hostSaveMutation = useMutation({
|
||||||
|
mutationFn: (vars: { id?: any; body: any }) =>
|
||||||
|
vars.id ? apiClient.updateHost(vars.id, vars.body) : apiClient.createHost(vars.body),
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
toast.success(vars.id ? '主机已更新' : '主机已创建');
|
||||||
|
invalidateHosts();
|
||||||
|
setHostDialogOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostDeleteMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.deleteHost(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('主机已删除');
|
||||||
|
invalidateHosts();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostTestMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.testHost(id),
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
const latency = result?.latencyMs != null && result.latencyMs !== '' ? `${result.latencyMs}ms` : null;
|
||||||
|
if (result?.success) toast.success(result?.message || '连接成功', { description: latency ? `延迟 ${latency}` : undefined });
|
||||||
|
else toast.error(result?.message || '连接失败');
|
||||||
|
invalidateHosts();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clusterSaveMutation = useMutation({
|
||||||
|
mutationFn: (vars: { id?: any; body: any }) =>
|
||||||
|
vars.id ? apiClient.updateCluster(vars.id, vars.body) : apiClient.createCluster(vars.body),
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
toast.success(vars.id ? '集群已更新' : '集群已创建');
|
||||||
|
invalidateClusters();
|
||||||
|
setClusterDialogOpen(false);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clusterDeleteMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.deleteCluster(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('集群已删除');
|
||||||
|
invalidateClusters();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clusterTestMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.testCluster(id),
|
||||||
|
onSuccess: (result: any) => {
|
||||||
|
if (result?.success) toast.success(result?.message || '连接成功');
|
||||||
|
else toast.error(result?.message || '连接失败');
|
||||||
|
invalidateClusters();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitHost = () => {
|
||||||
|
if (!hostForm.name.trim() || !hostForm.ip.trim()) {
|
||||||
|
toast.error('请填写主机名称和 IP 地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hostSaveMutation.mutate({
|
||||||
|
id: editingHost?.id,
|
||||||
|
body: {
|
||||||
|
name: hostForm.name.trim(),
|
||||||
|
ip: hostForm.ip.trim(),
|
||||||
|
port: Number(hostForm.port) || 22,
|
||||||
|
username: hostForm.username.trim(),
|
||||||
|
tags: JSON.stringify(splitComma(hostForm.tags)),
|
||||||
|
os: hostForm.os.trim(),
|
||||||
|
remark: hostForm.remark.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCluster = () => {
|
||||||
|
if (!clusterForm.name.trim()) {
|
||||||
|
toast.error('请填写集群名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body: any = {
|
||||||
|
name: clusterForm.name.trim(),
|
||||||
|
context: clusterForm.context.trim(),
|
||||||
|
apiServer: clusterForm.apiServer.trim(),
|
||||||
|
version: clusterForm.version.trim(),
|
||||||
|
};
|
||||||
|
// kubeconfig 只写不读:编辑时留空表示不修改
|
||||||
|
if (clusterForm.kubeconfig.trim()) body.kubeconfig = clusterForm.kubeconfig.trim();
|
||||||
|
clusterSaveMutation.mutate({ id: editingCluster?.id, body });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 统计(真实数据计算) ----
|
||||||
|
const tagsOf = (h: any) => parseJsonArray(h.tags);
|
||||||
|
const hostStats = [
|
||||||
|
{ label: '生产环境', list: hosts.filter((h) => tagsOf(h).some((t) => /prod/i.test(t))) },
|
||||||
|
{ label: '预发环境', list: hosts.filter((h) => tagsOf(h).some((t) => /stag/i.test(t))) },
|
||||||
|
{ label: '测试环境', list: hosts.filter((h) => tagsOf(h).some((t) => /test|qa|dev/i.test(t))) },
|
||||||
|
{ label: '总计', list: hosts },
|
||||||
|
].map((s) => ({ ...s, count: s.list.length, online: s.list.filter((h) => h.status === 'online').length }));
|
||||||
|
|
||||||
|
const clusterStats = [
|
||||||
|
{ label: '已连接', count: clusters.filter((c) => c.status === 'connected').length },
|
||||||
|
{ label: '未连接', count: clusters.filter((c) => c.status !== 'connected').length },
|
||||||
|
{ label: '总节点数', count: clusters.reduce((sum, c) => sum + (Number(c.nodes) || 0), 0) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredHosts = hosts.filter((h) => h.name?.includes(searchQuery) || h.ip?.includes(searchQuery));
|
||||||
|
const filteredClusters = clusters.filter((c) => c.name?.includes(searchQuery) || c.context?.includes(searchQuery));
|
||||||
|
|
||||||
|
const isLoading = activeTab === 'hosts' ? hostsQuery.isLoading : clustersQuery.isLoading;
|
||||||
|
const queryError = activeTab === 'hosts' ? hostsQuery.error : clustersQuery.error;
|
||||||
|
const refetch = activeTab === 'hosts' ? hostsQuery.refetch : clustersQuery.refetch;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
@@ -73,7 +257,7 @@ export default function EnvironmentManager() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('hosts')}
|
onClick={() => setActiveTab('hosts')}
|
||||||
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
|
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
|
||||||
style={{
|
style={{
|
||||||
background: activeTab === 'hosts' ? 'var(--panel-bg-secondary)' : 'transparent',
|
background: activeTab === 'hosts' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||||
color: activeTab === 'hosts' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
color: activeTab === 'hosts' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
}}
|
}}
|
||||||
@@ -83,7 +267,7 @@ export default function EnvironmentManager() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('k8s')}
|
onClick={() => setActiveTab('k8s')}
|
||||||
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
|
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
|
||||||
style={{
|
style={{
|
||||||
background: activeTab === 'k8s' ? 'var(--panel-bg-secondary)' : 'transparent',
|
background: activeTab === 'k8s' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||||
color: activeTab === 'k8s' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
color: activeTab === 'k8s' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
}}
|
}}
|
||||||
@@ -93,7 +277,7 @@ export default function EnvironmentManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
|
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
||||||
>
|
>
|
||||||
@@ -109,7 +293,7 @@ export default function EnvironmentManager() {
|
|||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
onClick={() => toast.info(`添加${activeTab === 'hosts' ? '主机' : '集群'}`, { description: '功能开发中...' })}
|
onClick={() => (activeTab === 'hosts' ? openHostDialog() : openClusterDialog())}
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
添加{activeTab === 'hosts' ? '主机' : '集群'}
|
添加{activeTab === 'hosts' ? '主机' : '集群'}
|
||||||
@@ -119,17 +303,29 @@ export default function EnvironmentManager() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
{activeTab === 'hosts' ? (
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
) : queryError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-16">
|
||||||
|
<XCircle className="w-6 h-6" style={{ color: 'var(--accent-red)' }} />
|
||||||
|
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(queryError as Error).message}</div>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : activeTab === 'hosts' ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Host groups summary */}
|
{/* Host groups summary */}
|
||||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||||
{[
|
{hostStats.map((stat) => (
|
||||||
{ label: '生产环境', count: 3, online: 3, color: '#1a7f37' },
|
<div
|
||||||
{ label: '预发环境', count: 2, online: 1, color: '#9a6700' },
|
|
||||||
{ label: '测试环境', count: 0, online: 0, color: '#8250df' },
|
|
||||||
{ label: '总计', count: 5, online: 4, color: '#0969da' },
|
|
||||||
].map((stat) => (
|
|
||||||
<div
|
|
||||||
key={stat.label}
|
key={stat.label}
|
||||||
className="rounded-lg border p-4"
|
className="rounded-lg border p-4"
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
@@ -171,8 +367,8 @@ export default function EnvironmentManager() {
|
|||||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.username}</td>
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.username}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{host.tags.map((tag) => (
|
{tagsOf(host).map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||||
@@ -182,7 +378,7 @@ export default function EnvironmentManager() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.os}</td>
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.os || '—'}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{host.status === 'online' ? (
|
{host.status === 'online' ? (
|
||||||
@@ -197,19 +393,41 @@ export default function EnvironmentManager() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-0">
|
<div className="flex items-center gap-0">
|
||||||
<button className="p-1 rounded hover:bg-black/5" title="SSH连接" onClick={() => toast.info('SSH连接', { description: `${host.username}@${host.ip}:${host.port}` })}>
|
<button
|
||||||
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="测试连接"
|
||||||
|
disabled={hostTestMutation.isPending && hostTestMutation.variables === host.id}
|
||||||
|
onClick={() => hostTestMutation.mutate(host.id)}
|
||||||
|
>
|
||||||
|
{hostTestMutation.isPending && hostTestMutation.variables === host.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
) : (
|
||||||
|
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button className="p-1 rounded hover:bg-black/5" title="编辑">
|
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openHostDialog(host)}>
|
||||||
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
<button className="p-1 rounded hover:bg-black/5" title="删除">
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="删除"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`确定删除主机 ${host.name} 吗?`)) hostDeleteMutation.mutate(host.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{filteredHosts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-12 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
暂无主机,点击右上角「添加主机」创建
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,11 +436,7 @@ export default function EnvironmentManager() {
|
|||||||
/* K8s Clusters */
|
/* K8s Clusters */
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||||
{[
|
{clusterStats.map((stat) => (
|
||||||
{ label: '已连接', count: 2, color: '#1a7f37' },
|
|
||||||
{ label: '未连接', count: 1, color: '#cf222e' },
|
|
||||||
{ label: '总节点数', count: 11, color: '#0969da' },
|
|
||||||
].map((stat) => (
|
|
||||||
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
||||||
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
|
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
|
||||||
@@ -232,7 +446,7 @@ export default function EnvironmentManager() {
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{filteredClusters.map((cluster) => (
|
{filteredClusters.map((cluster) => (
|
||||||
<div
|
<div
|
||||||
key={cluster.id}
|
key={cluster.id}
|
||||||
className="rounded-lg border p-4"
|
className="rounded-lg border p-4"
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
@@ -242,38 +456,65 @@ export default function EnvironmentManager() {
|
|||||||
<CloudCog className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
<CloudCog className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{cluster.name}</div>
|
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{cluster.name}</div>
|
||||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Context: {cluster.context}</div>
|
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Context: {cluster.context || '—'} · <span className="font-mono">{cluster.apiServer || '—'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="text-[11px] px-2 py-[3px] rounded-full font-medium"
|
className="text-[11px] px-2 py-[3px] rounded-full font-medium"
|
||||||
style={{
|
style={{
|
||||||
background: cluster.status === 'connected' ? '#dafbe1' : '#ffebe9',
|
background: cluster.status === 'connected' ? '#dafbe1' : '#ffebe9',
|
||||||
color: cluster.status === 'connected' ? '#1a7f37' : '#cf222e',
|
color: cluster.status === 'connected' ? '#1a7f37' : '#cf222e',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cluster.status === 'connected' ? '已连接' : '未连接'}
|
{cluster.status === 'connected' ? '已连接' : '未连接'}
|
||||||
</span>
|
</span>
|
||||||
<button className="p-1 rounded hover:bg-black/5">
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="测试连接"
|
||||||
|
disabled={clusterTestMutation.isPending && clusterTestMutation.variables === cluster.id}
|
||||||
|
onClick={() => clusterTestMutation.mutate(cluster.id)}
|
||||||
|
>
|
||||||
|
{clusterTestMutation.isPending && clusterTestMutation.variables === cluster.id ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
) : (
|
||||||
|
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openClusterDialog(cluster)}>
|
||||||
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="删除"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`确定删除集群 ${cluster.name} 吗?`)) clusterDeleteMutation.mutate(cluster.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-4 text-[12px]">
|
<div className="grid grid-cols-4 gap-4 text-[12px]">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>版本</div>
|
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>版本</div>
|
||||||
<div style={{ color: 'var(--text-primary)' }}>{cluster.version}</div>
|
<div style={{ color: 'var(--text-primary)' }}>{cluster.version || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>节点数</div>
|
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>节点数</div>
|
||||||
<div style={{ color: 'var(--text-primary)' }}>{cluster.nodes}</div>
|
<div style={{ color: 'var(--text-primary)' }}>{cluster.nodes ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div>
|
||||||
|
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>最近心跳</div>
|
||||||
|
<div style={{ color: 'var(--text-primary)' }}>{fmtTime(cluster.lastHeartbeat)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div>
|
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div>
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{cluster.namespaces.map((ns) => (
|
{parseJsonArray(cluster.namespaces).map((ns) => (
|
||||||
<span
|
<span
|
||||||
key={ns}
|
key={ns}
|
||||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||||
@@ -286,10 +527,151 @@ export default function EnvironmentManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{filteredClusters.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-10 text-center text-[12px]"
|
||||||
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
暂无集群,点击右上角「添加集群」创建
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 添加/编辑主机弹窗 */}
|
||||||
|
<Dialog open={hostDialogOpen} onOpenChange={setHostDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{editingHost ? '编辑主机' : '添加主机'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.name} onChange={(e) => setHostForm({ ...hostForm, name: e.target.value })} placeholder="prod-web-01" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>IP 地址 *</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.ip} onChange={(e) => setHostForm({ ...hostForm, ip: e.target.value })} placeholder="192.168.1.101" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>SSH 端口</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.port} onChange={(e) => setHostForm({ ...hostForm, port: e.target.value })} placeholder="22" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.username} onChange={(e) => setHostForm({ ...hostForm, username: e.target.value })} placeholder="root" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>标签(逗号分隔)</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.tags} onChange={(e) => setHostForm({ ...hostForm, tags: e.target.value })} placeholder="production,web" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>操作系统</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.os} onChange={(e) => setHostForm({ ...hostForm, os: e.target.value })} placeholder="Ubuntu 22.04" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>备注</Label>
|
||||||
|
<Textarea
|
||||||
|
className="text-[12px] min-h-[56px]"
|
||||||
|
style={inputStyle}
|
||||||
|
value={hostForm.remark}
|
||||||
|
onChange={(e) => setHostForm({ ...hostForm, remark: e.target.value })}
|
||||||
|
placeholder="可选"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
onClick={() => setHostDialogOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
disabled={hostSaveMutation.isPending}
|
||||||
|
onClick={submitHost}
|
||||||
|
>
|
||||||
|
{hostSaveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{editingHost ? '保存' : '创建'}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 添加/编辑集群弹窗 */}
|
||||||
|
<Dialog open={clusterDialogOpen} onOpenChange={setClusterDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{editingCluster ? '编辑集群' : '添加集群'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.name} onChange={(e) => setClusterForm({ ...clusterForm, name: e.target.value })} placeholder="ack-production" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Context</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.context} onChange={(e) => setClusterForm({ ...clusterForm, context: e.target.value })} placeholder="ack-prod" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Server</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.apiServer} onChange={(e) => setClusterForm({ ...clusterForm, apiServer: e.target.value })} placeholder="https://xxx.xxx.aliyuncs.com:6443" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>版本</Label>
|
||||||
|
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.version} onChange={(e) => setClusterForm({ ...clusterForm, version: e.target.value })} placeholder="v1.28.3" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Kubeconfig</Label>
|
||||||
|
<Textarea
|
||||||
|
className="text-[12px] min-h-[88px] font-mono"
|
||||||
|
style={inputStyle}
|
||||||
|
value={clusterForm.kubeconfig}
|
||||||
|
onChange={(e) => setClusterForm({ ...clusterForm, kubeconfig: e.target.value })}
|
||||||
|
placeholder={editingCluster ? '留空表示不修改' : '粘贴 kubeconfig YAML(只写不读,保存后不可查看)'}
|
||||||
|
/>
|
||||||
|
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
kubeconfig 加密存储、只写不读,保存后无法再次查看;编辑时留空表示不修改。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
onClick={() => setClusterDialogOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
disabled={clusterSaveMutation.isPending}
|
||||||
|
onClick={submitCluster}
|
||||||
|
>
|
||||||
|
{clusterSaveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{editingCluster ? '保存' : '创建'}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Server, AlertTriangle, FolderGit, Package
|
Server, AlertTriangle, FolderGit, Package
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
interface Application {
|
interface Application {
|
||||||
@@ -34,57 +34,6 @@ interface ResourceNode {
|
|||||||
children?: ResourceNode[];
|
children?: ResourceNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockApps: Application[] = [
|
|
||||||
{
|
|
||||||
id: 'app-1', name: 'aiops-backend', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
|
||||||
branch: 'main', path: 'apps/aiops-backend/overlays/production',
|
|
||||||
cluster: 'production-k8s', namespace: 'aiops', status: 'synced', syncStatus: 'synced',
|
|
||||||
health: 'healthy', lastSync: '2分钟前', resources: 12, manifestType: 'kustomize', autoSync: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'app-2', name: 'aiops-web', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
|
||||||
branch: 'main', path: 'apps/aiops-web/overlays/production',
|
|
||||||
cluster: 'production-k8s', namespace: 'aiops', status: 'syncing', syncStatus: 'out-of-sync',
|
|
||||||
health: 'progressing', lastSync: '正在同步', resources: 8, manifestType: 'helm', autoSync: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'app-3', name: 'gateway-api', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
|
||||||
branch: 'release/v2.1', path: 'apps/gateway/overlays/staging',
|
|
||||||
cluster: 'staging-k8s', namespace: 'gateway', status: 'out-of-sync', syncStatus: 'out-of-sync',
|
|
||||||
health: 'degraded', lastSync: '1小时前', resources: 6, manifestType: 'kustomize', autoSync: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'app-4', name: 'data-processor', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
|
||||||
branch: 'main', path: 'apps/data/overlays/production',
|
|
||||||
cluster: 'production-k8s', namespace: 'data', status: 'synced', syncStatus: 'synced',
|
|
||||||
health: 'healthy', lastSync: '30分钟前', resources: 5, manifestType: 'plain', autoSync: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockResourceTree: ResourceNode[] = [
|
|
||||||
{
|
|
||||||
id: 'r1', kind: 'Deployment', name: 'aiops-backend', namespace: 'aiops', status: 'healthy',
|
|
||||||
children: [
|
|
||||||
{ id: 'r1-1', kind: 'ReplicaSet', name: 'aiops-backend-7d9f4', namespace: 'aiops', status: 'healthy',
|
|
||||||
children: [
|
|
||||||
{ id: 'r1-1-1', kind: 'Pod', name: 'aiops-backend-7d9f4-abc1', namespace: 'aiops', status: 'healthy' },
|
|
||||||
{ id: 'r1-1-2', kind: 'Pod', name: 'aiops-backend-7d9f4-abc2', namespace: 'aiops', status: 'healthy' },
|
|
||||||
{ id: 'r1-1-3', kind: 'Pod', name: 'aiops-backend-7d9f4-abc3', namespace: 'aiops', status: 'healthy' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'r2', kind: 'Service', name: 'aiops-backend-svc', namespace: 'aiops', status: 'healthy',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'r3', kind: 'Ingress', name: 'aiops-backend-ingress', namespace: 'aiops', status: 'healthy',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'r4', kind: 'ConfigMap', name: 'aiops-backend-config', namespace: 'aiops', status: 'healthy',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusConfig: Record<string, { color: string; bg: string; label: string; icon: typeof CheckCircle2 }> = {
|
const statusConfig: Record<string, { color: string; bg: string; label: string; icon: typeof CheckCircle2 }> = {
|
||||||
synced: { color: '#1a7f37', bg: '#dafbe1', label: '已同步', icon: CheckCircle2 },
|
synced: { color: '#1a7f37', bg: '#dafbe1', label: '已同步', icon: CheckCircle2 },
|
||||||
'out-of-sync': { color: '#9a6700', bg: '#fff8c5', label: '待同步', icon: Clock },
|
'out-of-sync': { color: '#9a6700', bg: '#fff8c5', label: '待同步', icon: Clock },
|
||||||
@@ -109,38 +58,73 @@ const manifestConfig: Record<string, { label: string; icon: typeof Package }> =
|
|||||||
export default function GitOpsDeploy() {
|
export default function GitOpsDeploy() {
|
||||||
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
|
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
|
||||||
const [activeView, setActiveView] = useState<'list' | 'tree' | 'diff'>('list');
|
const [activeView, setActiveView] = useState<'list' | 'tree' | 'diff'>('list');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: dbApps = [] } = useQuery({ queryKey: ['gitops', 'apps'], queryFn: () => api<any[]>('/api/gitops/apps') });
|
const { data: dbApps = [] } = useQuery({ queryKey: ['gitops', 'apps'], queryFn: () => api<any[]>('/api/gitops/apps') });
|
||||||
const { data: argoApps = [] } = useQuery({ queryKey: ['gitops', 'argo'], queryFn: () => api<any[]>('/api/gitops/argo/apps') });
|
|
||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
mutationFn: (appName: string) => api<any>('/api/gitops/argo/sync', { method: 'POST', body: JSON.stringify({ appName }) }),
|
mutationFn: (appName: string) => api<any>('/api/gitops/argo/sync', { method: 'POST', body: JSON.stringify({ appName }) }),
|
||||||
onSuccess: (data) => toast.success(data.message),
|
onSuccess: (data) => {
|
||||||
|
toast.success(data.message);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gitops'] });
|
||||||
|
},
|
||||||
onError: (err: Error) => toast.error(err.message),
|
onError: (err: Error) => toast.error(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const apps: Application[] = dbApps.length > 0
|
// dbApps 直接携带 syncStatus/healthStatus(sync 动作会真实推进状态)
|
||||||
? dbApps.map((db: any) => {
|
const apps: Application[] = dbApps.map((db: any) => {
|
||||||
const argo = (argoApps as any[]).find((a: any) => a.metadata?.name === db.name);
|
const sync = (db.syncStatus || '').toLowerCase();
|
||||||
return {
|
return {
|
||||||
id: String(db.id),
|
id: String(db.id),
|
||||||
name: db.name,
|
name: db.name,
|
||||||
repo: db.repo,
|
repo: db.repo,
|
||||||
branch: db.branch,
|
branch: db.branch,
|
||||||
path: db.path,
|
path: db.path,
|
||||||
cluster: db.cluster,
|
cluster: db.cluster,
|
||||||
namespace: db.namespace,
|
namespace: db.namespace,
|
||||||
status: argo?.status?.sync?.status === 'Synced' ? 'synced' : argo?.status?.sync?.status === 'OutOfSync' ? 'out-of-sync' : 'unknown',
|
status: sync === 'synced' ? 'synced' : sync === 'syncing' ? 'syncing' : sync === 'outofsync' ? 'out-of-sync' : 'unknown',
|
||||||
syncStatus: argo?.status?.sync?.status === 'Synced' ? 'synced' : 'out-of-sync',
|
syncStatus: sync === 'synced' ? 'synced' : 'out-of-sync',
|
||||||
health: (argo?.status?.health?.status?.toLowerCase() || 'unknown') as any,
|
health: (db.healthStatus || 'unknown').toLowerCase(),
|
||||||
lastSync: '刚刚',
|
lastSync: db.lastSyncAt ? new Date(db.lastSyncAt).toLocaleString('zh-CN', { hour12: false }) : '-',
|
||||||
resources: 0,
|
resources: 0,
|
||||||
manifestType: db.manifestType,
|
manifestType: db.manifestType,
|
||||||
autoSync: db.syncPolicy === 'automated',
|
autoSync: db.syncPolicy === 'automated',
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
: mockApps;
|
|
||||||
|
|
||||||
const selectedApp = apps.find(a => a.id === selectedAppId);
|
const selectedApp = apps.find(a => a.id === selectedAppId) || apps[0];
|
||||||
|
const selectedName = selectedApp?.name;
|
||||||
|
|
||||||
|
// 资源树 / 差异:真实调用后端生成端点
|
||||||
|
const { data: treeData, isLoading: treeLoading } = useQuery({
|
||||||
|
queryKey: ['gitops', 'tree', selectedName],
|
||||||
|
queryFn: () => api<any>(`/api/gitops/argo/${selectedName}/tree`),
|
||||||
|
enabled: activeView === 'tree' && !!selectedName,
|
||||||
|
});
|
||||||
|
const { data: diffData, isLoading: diffLoading } = useQuery({
|
||||||
|
queryKey: ['gitops', 'diff', selectedName],
|
||||||
|
queryFn: () => api<any>(`/api/gitops/argo/${selectedName}/diff`),
|
||||||
|
enabled: activeView === 'diff' && !!selectedName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// nodes+edges 扁平结构 → 树
|
||||||
|
const resourceTree: ResourceNode[] = (() => {
|
||||||
|
if (!treeData?.nodes) return [];
|
||||||
|
const byUid = new Map<string, ResourceNode>();
|
||||||
|
for (const n of treeData.nodes) {
|
||||||
|
byUid.set(n.uid, { id: n.uid, kind: n.kind, name: n.name, namespace: n.namespace, status: n.status });
|
||||||
|
}
|
||||||
|
const childSet = new Set<string>();
|
||||||
|
for (const e of treeData.edges || []) {
|
||||||
|
const parent = byUid.get(e.source);
|
||||||
|
const child = byUid.get(e.target);
|
||||||
|
if (parent && child) {
|
||||||
|
parent.children = parent.children || [];
|
||||||
|
parent.children.push(child);
|
||||||
|
childSet.add(e.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return treeData.nodes.filter((n: any) => !childSet.has(n.uid)).map((n: any) => byUid.get(n.uid)!);
|
||||||
|
})();
|
||||||
|
|
||||||
const renderResourceNode = (node: ResourceNode, depth: number = 0) => {
|
const renderResourceNode = (node: ResourceNode, depth: number = 0) => {
|
||||||
const health = healthConfig[node.status];
|
const health = healthConfig[node.status];
|
||||||
@@ -184,7 +168,7 @@ export default function GitOpsDeploy() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => toast.success('刷新应用状态')} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
<button onClick={() => { queryClient.invalidateQueries({ queryKey: ['gitops'] }); toast.success('已刷新'); }} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />刷新
|
<RefreshCw className="w-3.5 h-3.5" />刷新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,29 +309,36 @@ export default function GitOpsDeploy() {
|
|||||||
) : activeView === 'tree' ? (
|
) : activeView === 'tree' ? (
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div className="px-4 py-2 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
<div className="px-4 py-2 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>资源树</span>
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>资源树{selectedName ? ` · ${selectedName}` : ''}</span>
|
||||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{mockResourceTree.length} 个顶级资源</span>
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{resourceTree.length} 个顶级资源</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
{mockResourceTree.map(node => renderResourceNode(node))}
|
{treeLoading && <div className="py-6 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>}
|
||||||
|
{!treeLoading && resourceTree.map(node => renderResourceNode(node))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>Git vs 集群差异</h3>
|
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>Git vs 集群差异{selectedName ? ` · ${selectedName}` : ''}</h3>
|
||||||
|
{diffLoading && <div className="py-6 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#dafbe1' }}>
|
{(diffData?.diffs || []).map((d: any, i: number) => (
|
||||||
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
|
<div key={i} className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
<span className="text-[12px]" style={{ color: '#1a7f37' }}>Deployment/aiops-backend: Git 和集群一致</span>
|
<div className="flex items-center gap-3 px-3 py-2" style={{ background: d.status === 'modified' ? '#fff8c5' : '#f6f8fa' }}>
|
||||||
</div>
|
<AlertTriangle className="w-4 h-4" style={{ color: d.status === 'modified' ? '#9a6700' : '#6e7781' }} />
|
||||||
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#fff8c5' }}>
|
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{d.kind}/{d.name}</span>
|
||||||
<AlertTriangle className="w-4 h-4" style={{ color: '#9a6700' }} />
|
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{d.namespace}</span>
|
||||||
<span className="text-[12px]" style={{ color: '#9a6700' }}>Service/aiops-backend-svc: 集群中端口被手动修改 (80 {'->'} 8080)</span>
|
<span className="text-[11px] ml-auto" style={{ color: d.status === 'modified' ? '#9a6700' : 'var(--text-tertiary)' }}>{d.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#ffebe9' }}>
|
<pre className="px-4 py-2 text-[11px] font-mono whitespace-pre-wrap" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>{d.diff}</pre>
|
||||||
<XCircle className="w-4 h-4" style={{ color: '#cf222e' }} />
|
</div>
|
||||||
<span className="text-[12px]" style={{ color: '#cf222e' }}>ConfigMap/aiops-backend-config: Git 中新增,集群中不存在</span>
|
))}
|
||||||
</div>
|
{!diffLoading && (diffData?.diffs || []).length === 0 && (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-md" style={{ background: '#dafbe1' }}>
|
||||||
|
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
|
||||||
|
<span className="text-[12px]" style={{ color: '#1a7f37' }}>Git 与集群一致,无差异</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,80 +1,192 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Link,
|
Link,
|
||||||
Search,
|
Search,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
FileCode,
|
FileCode,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
RefreshCw
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Server,
|
||||||
|
Folder,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
|
// 与后端 jenkins.JobInfo 对齐:name 即压平全路径(folder/job)
|
||||||
interface JenkinsJob {
|
interface JenkinsJob {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
url: string;
|
||||||
type: 'freestyle' | 'pipeline' | 'multibranch';
|
type: 'folder' | 'freestyle' | 'pipeline' | 'multibranch';
|
||||||
lastBuild: string;
|
status: 'success' | 'failed' | 'running' | 'unknown';
|
||||||
status: 'success' | 'failed' | 'unstable';
|
|
||||||
selected: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockJobs: JenkinsJob[] = [
|
// 与后端 model.JenkinsServer 对齐(token 只写不读)
|
||||||
{ id: '1', name: 'backend-build', path: 'aiops/backend-build', type: 'pipeline', lastBuild: '2024-06-20 14:30', status: 'success', selected: false },
|
interface JenkinsServerItem {
|
||||||
{ id: '2', name: 'frontend-deploy', path: 'aiops/frontend-deploy', type: 'freestyle', lastBuild: '2024-06-20 13:15', status: 'success', selected: false },
|
id: number;
|
||||||
{ id: '3', name: 'gateway-service', path: 'platform/gateway-service', type: 'pipeline', lastBuild: '2024-06-19 18:45', status: 'failed', selected: false },
|
name: string;
|
||||||
{ id: '4', name: 'data-sync-job', path: 'data/data-sync-job', type: 'freestyle', lastBuild: '2024-06-18 02:00', status: 'unstable', selected: false },
|
baseUrl: string;
|
||||||
{ id: '5', name: 'mobile-app-build', path: 'mobile/mobile-app-build', type: 'multibranch', lastBuild: '2024-06-17 16:20', status: 'success', selected: false },
|
username: string;
|
||||||
];
|
tokenSet: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const typeConfig = {
|
const emptyServerForm = { name: '', baseUrl: '', username: '', token: '' };
|
||||||
|
|
||||||
|
const typeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
folder: { label: '文件夹', color: '#6e7781', bg: '#f6f8fa' },
|
||||||
freestyle: { label: '自由风格', color: '#6e7781', bg: '#f6f8fa' },
|
freestyle: { label: '自由风格', color: '#6e7781', bg: '#f6f8fa' },
|
||||||
pipeline: { label: '流水线', color: '#0969da', bg: '#ddf4ff' },
|
pipeline: { label: '流水线', color: '#0969da', bg: '#ddf4ff' },
|
||||||
multibranch: { label: '多分支', color: '#8250df', bg: '#fbefff' },
|
multibranch: { label: '多分支', color: '#8250df', bg: '#fbefff' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig: Record<string, { color: string; label: string }> = {
|
||||||
success: { icon: CheckCircle2, color: '#1a7f37' },
|
success: { color: '#1a7f37', label: '成功' },
|
||||||
failed: { icon: AlertCircle, color: '#cf222e' },
|
failed: { color: '#cf222e', label: '失败' },
|
||||||
unstable: { icon: AlertCircle, color: '#9a6700' },
|
running: { color: '#9a6700', label: '运行中' },
|
||||||
|
unknown: { color: '#6e7781', label: '未知' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function JenkinsImport() {
|
export default function JenkinsImport() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [step, setStep] = useState<'connect' | 'select' | 'preview' | 'done'>('connect');
|
const [step, setStep] = useState<'connect' | 'select' | 'preview' | 'done'>('connect');
|
||||||
const [jenkinsUrl, setJenkinsUrl] = useState('http://jenkins.company.com:8080');
|
const [selectedServerId, setSelectedServerId] = useState<number | null>(null);
|
||||||
const [jenkinsUser, setJenkinsUser] = useState('');
|
const [showManage, setShowManage] = useState(false);
|
||||||
const [jenkinsToken, setJenkinsToken] = useState('');
|
const [serverFormOpen, setServerFormOpen] = useState(false);
|
||||||
const [jobs, setJobs] = useState<JenkinsJob[]>(mockJobs);
|
const [editingServerId, setEditingServerId] = useState<number | null>(null);
|
||||||
|
const [serverForm, setServerForm] = useState(emptyServerForm);
|
||||||
|
const [selectedJobs, setSelectedJobs] = useState<string[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [importResult, setImportResult] = useState<{ imported: number; skipped: number } | null>(null);
|
||||||
|
|
||||||
|
// Jenkins 实例列表
|
||||||
|
const { data: servers = [], isLoading: serversLoading } = useQuery({
|
||||||
|
queryKey: ['jenkins-servers'],
|
||||||
|
queryFn: apiClient.listJenkinsServers,
|
||||||
|
});
|
||||||
|
const selectedServer: JenkinsServerItem | null = servers.find((s: JenkinsServerItem) => s.id === selectedServerId) || null;
|
||||||
|
|
||||||
|
// 真实拉取 Job 列表(进入选择/预览步骤后按实例加载)
|
||||||
|
const jobsQuery = useQuery({
|
||||||
|
queryKey: ['jenkins-jobs', selectedServerId],
|
||||||
|
queryFn: () => apiClient.listJenkinsJobs(selectedServerId!),
|
||||||
|
enabled: !!selectedServerId && (step === 'select' || step === 'preview'),
|
||||||
|
});
|
||||||
|
const jobs: JenkinsJob[] = jobsQuery.data || [];
|
||||||
|
|
||||||
|
const invalidateServers = () => queryClient.invalidateQueries({ queryKey: ['jenkins-servers'] });
|
||||||
|
|
||||||
|
// 新增 / 编辑实例(编辑时 Token 留空不改)
|
||||||
|
const saveServerMutation = useMutation({
|
||||||
|
mutationFn: (input: { id: number | null; form: typeof emptyServerForm }) => {
|
||||||
|
const body: Record<string, any> = { name: input.form.name, baseUrl: input.form.baseUrl, username: input.form.username };
|
||||||
|
if (input.form.token) body.token = input.form.token;
|
||||||
|
return input.id != null ? apiClient.updateJenkinsServer(input.id, body) : apiClient.createJenkinsServer(body);
|
||||||
|
},
|
||||||
|
onSuccess: (data: any, input) => {
|
||||||
|
toast.success(input.id != null ? '实例已更新' : '实例已创建');
|
||||||
|
if (input.id == null && data?.id) setSelectedServerId(data.id);
|
||||||
|
invalidateServers();
|
||||||
|
setServerFormOpen(false);
|
||||||
|
setEditingServerId(null);
|
||||||
|
setServerForm(emptyServerForm);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteServerMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => apiClient.deleteJenkinsServer(id),
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
toast.success('实例已删除');
|
||||||
|
if (selectedServerId === id) setSelectedServerId(null);
|
||||||
|
invalidateServers();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 真实连通测试
|
||||||
|
const testServerMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => apiClient.testJenkinsServer(id),
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success(data.message || '连接成功', { description: `延迟 ${data.latencyMs}ms` });
|
||||||
|
} else {
|
||||||
|
toast.error(data?.message || '连接失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('连接失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 真实导入:创建 jenkins 类型流水线,重名跳过
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: () => apiClient.importJenkinsJobs(selectedServerId!, selectedJobs),
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
const imported = data?.imported ?? selectedJobs.length;
|
||||||
|
const skipped = data?.skipped ?? 0;
|
||||||
|
setImportResult({ imported, skipped });
|
||||||
|
toast.success(`成功导入 ${imported} 个(跳过 ${skipped} 个已存在)`);
|
||||||
|
setStep('done');
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('导入失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
const handleConnect = () => {
|
const handleConnect = () => {
|
||||||
setIsConnecting(true);
|
if (!selectedServerId) {
|
||||||
setTimeout(() => {
|
toast.error('请先选择 Jenkins 实例');
|
||||||
setIsConnecting(false);
|
return;
|
||||||
setStep('select');
|
}
|
||||||
toast.success('连接成功', { description: `已连接到 ${jenkinsUrl}` });
|
setSelectedJobs([]);
|
||||||
}, 1500);
|
setSearchQuery('');
|
||||||
|
setImportResult(null);
|
||||||
|
setStep('select');
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleJob = (id: string) => {
|
const startEditServer = (s: JenkinsServerItem) => {
|
||||||
setJobs(prev => prev.map(j => j.id === id ? { ...j, selected: !j.selected } : j));
|
setEditingServerId(s.id);
|
||||||
|
setServerForm({ name: s.name, baseUrl: s.baseUrl, username: s.username, token: '' });
|
||||||
|
setServerFormOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedCount = jobs.filter(j => j.selected).length;
|
const handleSaveServer = () => {
|
||||||
|
if (!serverForm.name.trim() || !serverForm.baseUrl.trim()) {
|
||||||
const filteredJobs = jobs.filter(j =>
|
toast.error('名称和地址不能为空');
|
||||||
j.name.includes(searchQuery) || j.path.includes(searchQuery)
|
return;
|
||||||
);
|
}
|
||||||
|
saveServerMutation.mutate({ id: editingServerId, form: serverForm });
|
||||||
const handleImport = () => {
|
|
||||||
toast.success('导入完成', { description: `成功导入 ${selectedCount} 个Job` });
|
|
||||||
setStep('done');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteServer = (s: JenkinsServerItem) => {
|
||||||
|
if (!window.confirm(`确定删除实例「${s.name}」?`)) return;
|
||||||
|
deleteServerMutation.mutate(s.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Job 勾选(folder 不可选)
|
||||||
|
const isSelectable = (job: JenkinsJob) => job.type !== 'folder';
|
||||||
|
const filteredJobs = jobs.filter(j => j.name.toLowerCase().includes(searchQuery.trim().toLowerCase()));
|
||||||
|
const selectableFiltered = filteredJobs.filter(isSelectable);
|
||||||
|
const allSelected = selectableFiltered.length > 0 && selectableFiltered.every(j => selectedJobs.includes(j.name));
|
||||||
|
|
||||||
|
const toggleJob = (name: string) => {
|
||||||
|
setSelectedJobs(prev => prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
setSelectedJobs(prev => allSelected
|
||||||
|
? prev.filter(n => !selectableFiltered.some(j => j.name === n))
|
||||||
|
: Array.from(new Set([...prev, ...selectableFiltered.map(j => j.name)])));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedJobEntries = selectedJobs
|
||||||
|
.map(name => jobs.find(j => j.name === name))
|
||||||
|
.filter((j): j is JenkinsJob => !!j);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
@@ -102,54 +214,163 @@ export default function JenkinsImport() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>连接 Jenkins 服务器</h2>
|
<h2 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>连接 Jenkins 服务器</h2>
|
||||||
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>输入 Jenkins 地址和认证信息以获取 Job 列表</p>
|
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>选择已配置的 Jenkins 实例,或先在「管理」中维护实例与认证信息</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>Jenkins 地址</label>
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>Jenkins 实例</label>
|
||||||
<input
|
<div className="flex items-center gap-2">
|
||||||
value={jenkinsUrl}
|
<select
|
||||||
onChange={(e) => setJenkinsUrl(e.target.value)}
|
value={selectedServerId ?? ''}
|
||||||
placeholder="http://jenkins.company.com:8080"
|
onChange={(e) => setSelectedServerId(e.target.value ? Number(e.target.value) : null)}
|
||||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
|
className="flex-1 px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>用户名</label>
|
|
||||||
<input
|
|
||||||
value={jenkinsUser}
|
|
||||||
onChange={(e) => setJenkinsUser(e.target.value)}
|
|
||||||
placeholder="admin"
|
|
||||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
|
|
||||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
/>
|
>
|
||||||
</div>
|
<option value="" disabled>{serversLoading ? '加载实例中...' : '请选择 Jenkins 实例'}</option>
|
||||||
<div>
|
{servers.map((s: JenkinsServerItem) => (
|
||||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>API Token</label>
|
<option key={s.id} value={s.id}>{s.name}({s.baseUrl})</option>
|
||||||
<input
|
))}
|
||||||
type="password"
|
</select>
|
||||||
value={jenkinsToken}
|
<button
|
||||||
onChange={(e) => setJenkinsToken(e.target.value)}
|
onClick={() => selectedServerId && testServerMutation.mutate(selectedServerId)}
|
||||||
placeholder="11xxxxxxxxxxxxxxxxxxxxxx"
|
disabled={!selectedServerId || testServerMutation.isPending}
|
||||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
|
className="flex items-center gap-1 px-3 py-[7px] text-[12px] font-medium rounded-md border disabled:opacity-50"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
/>
|
>
|
||||||
|
{testServerMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
测试连接
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowManage(v => !v)}
|
||||||
|
className="flex items-center gap-1 px-3 py-[7px] text-[12px] font-medium rounded-md border"
|
||||||
|
style={{
|
||||||
|
borderColor: showManage ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||||
|
color: showManage ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Server className="w-3.5 h-3.5" />
|
||||||
|
管理
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedServer && (
|
||||||
|
<p className="text-[11px] mt-1.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
用户: {selectedServer.username || '匿名'} · Token: {selectedServer.tokenSet ? '已配置' : '未配置'} · {selectedServer.enabled ? '已启用' : '已禁用'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 pt-2">
|
|
||||||
|
{/* 实例管理:列表 + 内联新增/编辑表单 */}
|
||||||
|
{showManage && (
|
||||||
|
<div className="rounded-md border p-3 space-y-2" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<div className="text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>实例管理</div>
|
||||||
|
{serversLoading && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[12px] py-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />加载中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!serversLoading && servers.length === 0 && (
|
||||||
|
<div className="text-[12px] py-1" style={{ color: 'var(--text-tertiary)' }}>暂无实例,请在下方新增</div>
|
||||||
|
)}
|
||||||
|
{servers.map((s: JenkinsServerItem) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg)' }}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{s.name}</span>
|
||||||
|
{selectedServerId === s.id && (
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: '#0969da' }}>当前</span>
|
||||||
|
)}
|
||||||
|
{s.tokenSet ? (
|
||||||
|
<span className="text-[10px]" style={{ color: '#1a7f37' }}>Token 已配置</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px]" style={{ color: '#9a6700' }}>Token 未配置</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] font-mono truncate" style={{ color: 'var(--text-tertiary)' }}>{s.baseUrl} · {s.username || '匿名'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button onClick={() => setSelectedServerId(s.id)} disabled={selectedServerId === s.id}
|
||||||
|
className="px-2 py-[4px] text-[11px] font-medium rounded-md border disabled:opacity-40" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
使用
|
||||||
|
</button>
|
||||||
|
<button onClick={() => startEditServer(s)} title="编辑"
|
||||||
|
className="p-1.5 rounded-md hover:bg-black/[0.04] transition-colors" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteServer(s)} disabled={deleteServerMutation.isPending} title="删除"
|
||||||
|
className="p-1.5 rounded-md hover:bg-black/[0.04] transition-colors disabled:opacity-50" style={{ color: '#cf222e' }}>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{serverFormOpen ? (
|
||||||
|
<div className="rounded-md border p-3 space-y-2" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<div className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{editingServerId != null ? '编辑实例' : '新增实例'}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label>
|
||||||
|
<input value={serverForm.name} onChange={e => setServerForm(p => ({ ...p, name: e.target.value }))}
|
||||||
|
placeholder="如:生产 Jenkins" className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>地址</label>
|
||||||
|
<input value={serverForm.baseUrl} onChange={e => setServerForm(p => ({ ...p, baseUrl: e.target.value }))}
|
||||||
|
placeholder="http://jenkins.company.com:8080" className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>用户名</label>
|
||||||
|
<input value={serverForm.username} onChange={e => setServerForm(p => ({ ...p, username: e.target.value }))}
|
||||||
|
placeholder="admin" className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>API Token(只写不读)</label>
|
||||||
|
<input type="password" value={serverForm.token} onChange={e => setServerForm(p => ({ ...p, token: e.target.value }))}
|
||||||
|
placeholder={editingServerId != null ? '留空则不修改已保存的 Token' : '11xxxxxxxxxxxxxxxxxxxxxx'}
|
||||||
|
className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button onClick={() => { setServerFormOpen(false); setEditingServerId(null); setServerForm(emptyServerForm); }}
|
||||||
|
className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
|
||||||
|
<button onClick={handleSaveServer} disabled={saveServerMutation.isPending}
|
||||||
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||||
|
{saveServerMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => { setEditingServerId(null); setServerForm(emptyServerForm); setServerFormOpen(true); }}
|
||||||
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
<Plus className="w-3.5 h-3.5" />新增实例
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleConnect}
|
onClick={handleConnect}
|
||||||
disabled={isConnecting}
|
disabled={!selectedServerId}
|
||||||
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-60"
|
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-60"
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
>
|
>
|
||||||
{isConnecting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Link className="w-3.5 h-3.5" />}
|
<Link className="w-3.5 h-3.5" />
|
||||||
{isConnecting ? '连接中...' : '连接并获取Job'}
|
连接并获取Job
|
||||||
</button>
|
</button>
|
||||||
|
{servers.length === 0 && !serversLoading && (
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>尚未配置实例,点击「管理」新增</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,65 +384,105 @@ export default function JenkinsImport() {
|
|||||||
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索Job..." className="bg-transparent outline-none w-40 text-[13px]" style={{ color: 'var(--text-primary)' }} />
|
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索Job..." className="bg-transparent outline-none w-40 text-[13px]" style={{ color: 'var(--text-primary)' }} />
|
||||||
</div>
|
</div>
|
||||||
|
{selectedServer && (
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{selectedServer.name}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
<div className="flex items-center gap-3">
|
||||||
已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedCount}</span> 个 Job
|
<span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedJobs.length}</span> 个 Job
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setStep('connect')} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<table className="w-full">
|
{jobsQuery.isLoading ? (
|
||||||
<thead>
|
<div className="py-12 flex items-center justify-center gap-2 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
<th className="w-10 px-3 py-2"></th>
|
正在从 Jenkins 拉取 Job 列表...
|
||||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Job名称</th>
|
</div>
|
||||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>类型</th>
|
) : jobsQuery.isError ? (
|
||||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>状态</th>
|
<div className="py-10 px-4 flex flex-col items-center gap-3 text-center">
|
||||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>最后构建</th>
|
<AlertCircle className="w-6 h-6" style={{ color: '#cf222e' }} />
|
||||||
</tr>
|
<div className="text-[13px]" style={{ color: 'var(--text-primary)' }}>拉取 Job 失败: {(jobsQuery.error as Error)?.message}</div>
|
||||||
</thead>
|
<button onClick={() => jobsQuery.refetch()} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
||||||
<tbody>
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
{filteredJobs.map((job) => {
|
<RefreshCw className="w-3.5 h-3.5" />重试
|
||||||
const type = typeConfig[job.type];
|
</button>
|
||||||
const status = statusConfig[job.status];
|
</div>
|
||||||
const StatusIcon = status.icon;
|
) : filteredJobs.length === 0 ? (
|
||||||
return (
|
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<tr key={job.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
{jobs.length === 0 ? '该实例没有可用的 Job' : '没有匹配搜索的 Job'}
|
||||||
<td className="px-3 py-3">
|
</div>
|
||||||
<input
|
) : (
|
||||||
type="checkbox"
|
<table className="w-full">
|
||||||
checked={job.selected}
|
<thead>
|
||||||
onChange={() => toggleJob(job.id)}
|
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||||
className="w-4 h-4 rounded cursor-pointer"
|
<th className="w-10 px-3 py-2">
|
||||||
style={{ accentColor: 'var(--accent-blue)' }}
|
<input
|
||||||
/>
|
type="checkbox"
|
||||||
</td>
|
checked={allSelected}
|
||||||
<td className="px-3 py-3">
|
onChange={toggleAll}
|
||||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
|
className="w-4 h-4 rounded cursor-pointer"
|
||||||
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{job.path}</div>
|
style={{ accentColor: 'var(--accent-blue)' }}
|
||||||
</td>
|
/>
|
||||||
<td className="px-3 py-3">
|
</th>
|
||||||
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: type.bg, color: type.color }}>{type.label}</span>
|
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Job名称</th>
|
||||||
</td>
|
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>类型</th>
|
||||||
<td className="px-3 py-3">
|
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>状态</th>
|
||||||
<div className="flex items-center gap-1">
|
</tr>
|
||||||
<StatusIcon className="w-3.5 h-3.5" style={{ color: status.color }} />
|
</thead>
|
||||||
<span className="text-[12px]" style={{ color: status.color }}>
|
<tbody>
|
||||||
{job.status === 'success' ? '成功' : job.status === 'failed' ? '失败' : '不稳定'}
|
{filteredJobs.map((job) => {
|
||||||
</span>
|
const type = typeConfig[job.type] || typeConfig.pipeline;
|
||||||
</div>
|
const status = statusConfig[job.status] || statusConfig.unknown;
|
||||||
</td>
|
const selectable = isSelectable(job);
|
||||||
<td className="px-3 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{job.lastBuild}</td>
|
const checked = selectedJobs.includes(job.name);
|
||||||
</tr>
|
return (
|
||||||
);
|
<tr key={job.name} className="border-t hover:bg-black/[0.02] transition-colors"
|
||||||
})}
|
style={{ borderColor: 'var(--border-color-light)', opacity: selectable ? 1 : 0.6 }}>
|
||||||
</tbody>
|
<td className="px-3 py-3">
|
||||||
</table>
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={!selectable}
|
||||||
|
onChange={() => toggleJob(job.name)}
|
||||||
|
className="w-4 h-4 rounded cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
style={{ accentColor: 'var(--accent-blue)' }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{job.type === 'folder' && <Folder className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />}
|
||||||
|
<span className="text-[13px] font-medium" style={{ color: selectable ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>{job.name}</span>
|
||||||
|
</div>
|
||||||
|
{!selectable && (
|
||||||
|
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>文件夹不可导入,其子 Job 已压平列出</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: type.bg, color: type.color }}>{type.label}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: status.color }} />
|
||||||
|
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => selectedCount > 0 ? setStep('preview') : toast.error('请至少选择一个Job')}
|
onClick={() => selectedJobs.length > 0 ? setStep('preview') : toast.error('请至少选择一个Job')}
|
||||||
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
|
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
>
|
>
|
||||||
@@ -237,17 +498,17 @@ export default function JenkinsImport() {
|
|||||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>转换预览</h3>
|
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>转换预览</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{jobs.filter(j => j.selected).map((job) => (
|
{selectedJobEntries.map((job) => (
|
||||||
<div key={job.id} className="flex items-center gap-3 p-3 rounded-md" style={{ background: 'var(--panel-bg-secondary)' }}>
|
<div key={job.name} className="flex items-center gap-3 p-3 rounded-md" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||||
<FileCode className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
|
<FileCode className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--accent-blue)' }} />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
|
<div className="text-[12px] font-medium truncate" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
|
||||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Jenkins {typeConfig[job.type].label} → AIOps 流水线</div>
|
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Jenkins {typeConfig[job.type]?.label || job.type} → FlowStack 流水线</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
<ArrowRight className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
<GitBranch className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--accent-green)' }} />
|
||||||
<span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>aiops-{job.name}</span>
|
<span className="text-[12px] truncate" style={{ color: 'var(--text-secondary)' }}>{job.name.length > 40 ? job.name.slice(0, 40) + '…' : job.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -255,9 +516,10 @@ export default function JenkinsImport() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button onClick={() => setStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
|
<button onClick={() => setStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
|
||||||
<button onClick={handleImport} className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
<button onClick={() => importMutation.mutate()} disabled={importMutation.isPending}
|
||||||
<Download className="w-3.5 h-3.5" />
|
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||||
确认导入
|
{importMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
|
||||||
|
{importMutation.isPending ? '导入中...' : '确认导入'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,18 +532,19 @@ export default function JenkinsImport() {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>导入完成</h2>
|
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>导入完成</h2>
|
||||||
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
成功导入 {selectedCount} 个 Jenkins Job 为 AIOps 流水线
|
成功导入 {importResult?.imported ?? 0} 个 Jenkins Job 为 FlowStack 流水线
|
||||||
|
{importResult && importResult.skipped > 0 && `(跳过 ${importResult.skipped} 个已存在)`}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center gap-2">
|
<div className="flex justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setStep('connect'); setJobs(mockJobs.map(j => ({ ...j, selected: false }))); }}
|
onClick={() => { setStep('connect'); setSelectedJobs([]); setSearchQuery(''); setImportResult(null); }}
|
||||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
|
className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
|
||||||
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
|
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
继续导入
|
继续导入
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => toast.info('跳转到流水线列表')}
|
onClick={() => toast.info('请点击左侧【流水线】查看')}
|
||||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
|
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Rocket, Loader2, KeyRound, User } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { apiClient, auth } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
toast.error('请输入用户名和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.login(username.trim(), password);
|
||||||
|
auth.setToken(res.token);
|
||||||
|
toast.success(`欢迎,${res.nickname || res.username}`);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('登录失败', { description: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle = { background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex items-center justify-center" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
|
<div className="w-[380px] rounded-xl border shadow-lg p-8" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex flex-col items-center mb-6">
|
||||||
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-3" style={{ background: 'linear-gradient(135deg, #0969da, #8250df)' }}>
|
||||||
|
<Rocket className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[18px] font-bold" style={{ color: 'var(--text-primary)' }}>FlowStack</h1>
|
||||||
|
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>发布系统 · 请登录</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-[13px] rounded-md border outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码</label>
|
||||||
|
<div className="relative">
|
||||||
|
<KeyRound className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-[13px] rounded-md border outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="初始密码见 data/initial-admin-password.txt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2.5 text-[13px] font-medium rounded-md text-white disabled:opacity-60 flex items-center justify-center gap-2"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
登 录
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-[11px] text-center mt-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
支持本地账号与 LDAP 账号(启用后自动识别)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+396
-147
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type CSSProperties } from 'react';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Plus,
|
Plus,
|
||||||
Play,
|
Play,
|
||||||
Settings,
|
Settings,
|
||||||
Trash2,
|
Trash2,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -13,97 +13,172 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
MoreHorizontal
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
interface Pipeline {
|
// ---- 工具函数 ----
|
||||||
id: string;
|
function fmtDuration(ms?: number | null): string {
|
||||||
name: string;
|
if (ms == null || ms < 0) return '-';
|
||||||
description: string;
|
const sec = ms / 1000;
|
||||||
lastStatus: 'success' | 'failed' | 'running' | 'pending';
|
if (ms < 1000) return `${Math.max(0, Math.round(sec))}s`;
|
||||||
lastRunAt: string;
|
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||||
triggerType: string;
|
if (sec < 3600) return `${Math.floor(sec / 60)}分${Math.round(sec % 60)}秒`;
|
||||||
duration: string;
|
return `${Math.floor(sec / 3600)}小时${Math.round((sec % 3600) / 60)}分`;
|
||||||
branch: string;
|
|
||||||
deployType: 'host' | 'k8s';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockPipelines: Pipeline[] = [
|
function fmtTime(iso?: string | null): string {
|
||||||
{
|
return iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||||
id: '1',
|
}
|
||||||
name: 'aiops-backend',
|
|
||||||
description: 'Go后端服务 - Docker构建 + K8s部署',
|
|
||||||
lastStatus: 'success',
|
|
||||||
lastRunAt: '2024-06-20 14:30:15',
|
|
||||||
triggerType: 'Git Push',
|
|
||||||
duration: '3分22秒',
|
|
||||||
branch: 'master',
|
|
||||||
deployType: 'k8s',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: 'aiops-web',
|
|
||||||
description: 'Vue前端应用 - npm构建 + 主机部署',
|
|
||||||
lastStatus: 'running',
|
|
||||||
lastRunAt: '2024-06-20 15:45:00',
|
|
||||||
triggerType: '手动',
|
|
||||||
duration: '1分12秒',
|
|
||||||
branch: 'master',
|
|
||||||
deployType: 'host',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
name: 'gateway-api',
|
|
||||||
description: 'API网关服务 - Java构建 + K8s部署',
|
|
||||||
lastStatus: 'failed',
|
|
||||||
lastRunAt: '2024-06-20 13:20:00',
|
|
||||||
triggerType: '手动',
|
|
||||||
duration: '45秒',
|
|
||||||
branch: 'release/v2.1',
|
|
||||||
deployType: 'k8s',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
name: 'admin-dashboard',
|
|
||||||
description: '管理后台 - React构建 + 主机部署',
|
|
||||||
lastStatus: 'success',
|
|
||||||
lastRunAt: '2024-06-20 12:10:00',
|
|
||||||
triggerType: 'Cron',
|
|
||||||
duration: '2分05秒',
|
|
||||||
branch: 'master',
|
|
||||||
deployType: 'host',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
name: 'data-processor',
|
|
||||||
description: '数据处理服务 - Python构建 + K8s部署',
|
|
||||||
lastStatus: 'pending',
|
|
||||||
lastRunAt: '-',
|
|
||||||
triggerType: 'Webhook',
|
|
||||||
duration: '-',
|
|
||||||
branch: 'develop',
|
|
||||||
deployType: 'k8s',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusConfig = {
|
function timeAgo(iso?: string | null): string {
|
||||||
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
if (!iso) return '-';
|
||||||
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
running: { icon: Loader2, color: '#0969da', bg: '#ddf4ff', label: '执行中' },
|
if (diff < 0) return '刚刚';
|
||||||
pending: { icon: Clock, color: '#6e7781', bg: '#f6f8fa', label: '未执行' },
|
const s = Math.floor(diff / 1000);
|
||||||
|
if (s < 60) return `${s}秒前`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}分钟前`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}小时前`;
|
||||||
|
return `${Math.floor(h / 24)}天前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 类型(与后端 GET /api/pipelines 对齐)----
|
||||||
|
interface LastRun {
|
||||||
|
status: string;
|
||||||
|
startedAt: string;
|
||||||
|
durationMs: number;
|
||||||
|
triggerType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineDTO {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type: string; // native | jenkins | tekton
|
||||||
|
sourceBranch?: string;
|
||||||
|
deployType: string; // host | k8s | gitops
|
||||||
|
env?: string;
|
||||||
|
lastRun: LastRun | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; label: string }> = {
|
||||||
|
success: { icon: CheckCircle2, color: '#1a7f37', label: '成功' },
|
||||||
|
failed: { icon: XCircle, color: '#cf222e', label: '失败' },
|
||||||
|
running: { icon: Loader2, color: '#0969da', label: '执行中' },
|
||||||
|
pending: { icon: Clock, color: '#9a6700', label: '排队中' },
|
||||||
|
queued: { icon: Clock, color: '#9a6700', label: '排队中' },
|
||||||
|
cancelled: { icon: XCircle, color: '#6e7781', label: '已取消' },
|
||||||
|
none: { icon: Clock, color: '#6e7781', label: '未执行' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const deployTypeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
k8s: { label: 'K8s', color: '#0969da', bg: '#ddf4ff' },
|
||||||
|
host: { label: '主机', color: '#1a7f37', bg: '#dafbe1' },
|
||||||
|
gitops: { label: 'GitOps', color: '#0e7c7b', bg: '#d5f5f6' },
|
||||||
|
};
|
||||||
|
const fallbackDeploy = { label: '未知', color: '#6e7781', bg: '#f6f8fa' };
|
||||||
|
|
||||||
|
const triggerTypeLabels: Record<string, string> = {
|
||||||
|
manual: '手动',
|
||||||
|
webhook: 'Webhook',
|
||||||
|
cron: '定时',
|
||||||
|
batch: '批量',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CreatePipelineForm {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
sourceRepo: string;
|
||||||
|
sourceBranch: string;
|
||||||
|
deployType: string;
|
||||||
|
env: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: CreatePipelineForm = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
sourceRepo: '',
|
||||||
|
sourceBranch: 'main',
|
||||||
|
deployType: 'host',
|
||||||
|
env: 'staging',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: CSSProperties = {
|
||||||
|
background: 'var(--panel-bg-secondary)',
|
||||||
|
borderColor: 'var(--border-color-light)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (id: string) => void }) {
|
export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (id: string) => void }) {
|
||||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [pipelines] = useState<Pipeline[]>(mockPipelines);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
|
||||||
|
|
||||||
const filtered = pipelines.filter((p) => {
|
const queryClient = useQueryClient();
|
||||||
const matchSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
|
const { data: pipelines = [], isLoading } = useQuery({
|
||||||
const matchStatus = statusFilter === 'all' || p.lastStatus === statusFilter;
|
queryKey: ['pipelines'],
|
||||||
|
queryFn: () => apiClient.listPipelines(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: CreatePipelineForm) => apiClient.createPipeline(body),
|
||||||
|
onSuccess: (_data, body) => {
|
||||||
|
toast.success('流水线已创建', { description: body.name });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm(emptyForm);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error('创建失败', { description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runMutation = useMutation({
|
||||||
|
mutationFn: (id: number | string) => apiClient.createRun(id),
|
||||||
|
onSuccess: (run) => toast.success(`已触发运行 ${run?.runId ?? ''}`),
|
||||||
|
onError: (e: Error) => toast.error('触发失败', { description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number | string) => apiClient.deletePipeline(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('流水线已删除');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error('删除失败', { description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = (pipelines as PipelineDTO[]).map((p) => {
|
||||||
|
const displayStatus = p.lastRun ? p.lastRun.status : 'none';
|
||||||
|
return {
|
||||||
|
raw: p,
|
||||||
|
id: String(p.id),
|
||||||
|
name: p.name,
|
||||||
|
description: p.description || '',
|
||||||
|
branch: p.sourceBranch || '-',
|
||||||
|
type: p.type,
|
||||||
|
deploy: deployTypeConfig[p.deployType] || fallbackDeploy,
|
||||||
|
// 筛选用映射状态:lastRun 为空记为 pending(未执行)
|
||||||
|
mappedStatus: p.lastRun?.status ?? 'pending',
|
||||||
|
status: statusConfig[displayStatus] || statusConfig.none,
|
||||||
|
isRunning: displayStatus === 'running',
|
||||||
|
durationText: p.lastRun ? fmtDuration(p.lastRun.durationMs) : '-',
|
||||||
|
timeText: p.lastRun ? fmtTime(p.lastRun.startedAt) : '-',
|
||||||
|
timeAgoText: p.lastRun ? timeAgo(p.lastRun.startedAt) : '',
|
||||||
|
triggerText: p.lastRun ? (triggerTypeLabels[p.lastRun.triggerType] || p.lastRun.triggerType) : '-',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = items.filter((p) => {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
const matchSearch = p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q);
|
||||||
|
const matchStatus = statusFilter === 'all' || p.mappedStatus === statusFilter;
|
||||||
return matchSearch && matchStatus;
|
return matchSearch && matchStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,10 +190,24 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
{ value: 'pending', label: '未执行' },
|
{ value: 'pending', label: '未执行' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleDelete = (p: (typeof items)[number]) => {
|
||||||
|
if (window.confirm(`确定删除流水线「${p.name}」?此操作不可恢复。`)) {
|
||||||
|
deleteMutation.mutate(p.raw.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCreate = () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error('请输入流水线名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createMutation.mutate({ ...form, name: form.name.trim() });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
{/* Header toolbar */}
|
{/* Header toolbar */}
|
||||||
<div
|
<div
|
||||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
@@ -126,18 +215,18 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
流水线列表
|
流水线列表
|
||||||
</h1>
|
</h1>
|
||||||
<span
|
<span
|
||||||
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||||
>
|
>
|
||||||
{filtered.length}
|
{items.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-3 py-[6px] rounded-md border text-[13px]"
|
className="flex items-center gap-2 px-3 py-[6px] rounded-md border text-[13px]"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--panel-bg-secondary)',
|
background: 'var(--panel-bg-secondary)',
|
||||||
borderColor: 'var(--border-color-light)',
|
borderColor: 'var(--border-color-light)',
|
||||||
color: 'var(--text-secondary)',
|
color: 'var(--text-secondary)',
|
||||||
@@ -154,9 +243,9 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
</div>
|
</div>
|
||||||
{/* Status filter */}
|
{/* Status filter */}
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 px-3 py-[6px] rounded-md border text-[13px] font-medium"
|
className="flex items-center gap-1.5 px-3 py-[6px] rounded-md border text-[13px] font-medium"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--panel-bg)',
|
background: 'var(--panel-bg)',
|
||||||
borderColor: 'var(--border-color-light)',
|
borderColor: 'var(--border-color-light)',
|
||||||
color: 'var(--text-secondary)',
|
color: 'var(--text-secondary)',
|
||||||
@@ -166,7 +255,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
{statusOptions.find(s => s.value === statusFilter)?.label}
|
{statusOptions.find(s => s.value === statusFilter)?.label}
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50">
|
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50">
|
||||||
<div
|
<div
|
||||||
className="rounded-md border shadow-lg py-1 min-w-[120px]"
|
className="rounded-md border shadow-lg py-1 min-w-[120px]"
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
@@ -204,7 +293,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
<button
|
<button
|
||||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
onClick={() => toast.info('新建流水线', { description: '功能开发中...' })}
|
onClick={() => setCreateOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
新建流水线
|
新建流水线
|
||||||
@@ -214,24 +303,29 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
{filtered.length === 0 ? (
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin mb-2" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</p>
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
|
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||||
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
|
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
|
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
|
||||||
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件</p>
|
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件或新建流水线</p>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'card' ? (
|
) : viewMode === 'card' ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{filtered.map((pipeline) => {
|
{filtered.map((pipeline) => {
|
||||||
const status = statusConfig[pipeline.lastStatus];
|
const status = pipeline.status;
|
||||||
const StatusIcon = status.icon;
|
const StatusIcon = status.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pipeline.id}
|
key={pipeline.id}
|
||||||
className="rounded-lg border transition-all duration-200 hover:shadow-md cursor-pointer group"
|
className="rounded-lg border transition-all duration-200 hover:shadow-md cursor-pointer group"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--panel-bg)',
|
background: 'var(--panel-bg)',
|
||||||
borderColor: 'var(--border-color)',
|
borderColor: 'var(--border-color)',
|
||||||
}}
|
}}
|
||||||
@@ -240,28 +334,35 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
{/* Card header */}
|
{/* Card header */}
|
||||||
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color-light)' }}>
|
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full"
|
className={`w-2 h-2 rounded-full ${pipeline.isRunning ? 'animate-pulse' : ''}`}
|
||||||
style={{ background: status.color }}
|
style={{ background: status.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
{pipeline.name}
|
{pipeline.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<div className="flex items-center gap-1">
|
||||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
|
<span
|
||||||
style={{
|
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
|
||||||
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
|
style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
|
||||||
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
|
>
|
||||||
}}
|
{pipeline.deploy.label}
|
||||||
>
|
</span>
|
||||||
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
|
{pipeline.type === 'jenkins' && (
|
||||||
</span>
|
<span
|
||||||
|
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||||
|
style={{ background: '#fbefff', color: '#8250df' }}
|
||||||
|
>
|
||||||
|
Jenkins
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Card body */}
|
{/* Card body */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<p className="text-[12px] mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-[12px] mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{pipeline.description}
|
{pipeline.description || '暂无描述'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
<div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -270,46 +371,59 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{pipeline.duration}
|
{pipeline.durationText}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Card footer */}
|
{/* Card footer */}
|
||||||
<div
|
<div
|
||||||
className="px-4 py-2 border-t flex items-center justify-between"
|
className="px-4 py-2 border-t flex items-center justify-between"
|
||||||
style={{ borderColor: 'var(--border-color-light)' }}
|
style={{ borderColor: 'var(--border-color-light)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`}
|
className={`w-3.5 h-3.5 flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`}
|
||||||
style={{ color: status.color }}
|
style={{ color: status.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] font-medium" style={{ color: status.color }}>
|
<span className="text-[11px] font-medium" style={{ color: status.color }}>
|
||||||
{status.label}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
<span
|
||||||
{pipeline.lastRunAt}
|
className="text-[11px] truncate"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
title={pipeline.timeAgoText || undefined}
|
||||||
|
>
|
||||||
|
{pipeline.timeText}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); toast.success('开始执行', { description: pipeline.name }); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
runMutation.mutate(pipeline.raw.id);
|
||||||
|
}}
|
||||||
className="p-1 rounded hover:bg-black/5"
|
className="p-1 rounded hover:bg-black/5"
|
||||||
title="执行"
|
title="执行"
|
||||||
>
|
>
|
||||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-black/5"
|
className="p-1 rounded hover:bg-black/5"
|
||||||
title="配置"
|
title="配置"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectPipeline(pipeline.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded hover:bg-black/5"
|
className="p-1 rounded hover:bg-black/5"
|
||||||
title="删除"
|
title="删除"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(pipeline);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
@@ -321,7 +435,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* List view */
|
/* List view */
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border overflow-hidden"
|
className="rounded-lg border overflow-hidden"
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
@@ -337,21 +451,21 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map((pipeline) => {
|
{filtered.map((pipeline) => {
|
||||||
const status = statusConfig[pipeline.lastStatus];
|
const status = pipeline.status;
|
||||||
const StatusIcon = status.icon;
|
const StatusIcon = status.icon;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={pipeline.id}
|
key={pipeline.id}
|
||||||
className="border-t cursor-pointer hover:bg-black/[0.02] transition-colors"
|
className="border-t cursor-pointer hover:bg-black/[0.02] transition-colors"
|
||||||
style={{ borderColor: 'var(--border-color-light)' }}
|
style={{ borderColor: 'var(--border-color-light)' }}
|
||||||
onClick={() => onSelectPipeline(pipeline.id)}
|
onClick={() => onSelectPipeline(pipeline.id)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: status.color }} />
|
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ background: status.color }} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{pipeline.name}</div>
|
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{pipeline.name}</div>
|
||||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description}</div>
|
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description || '暂无描述'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -362,31 +476,65 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<div className="flex items-center gap-1">
|
||||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
|
<span
|
||||||
style={{
|
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
|
||||||
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
|
style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
|
||||||
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
|
>
|
||||||
}}
|
{pipeline.deploy.label}
|
||||||
>
|
</span>
|
||||||
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
|
{pipeline.type === 'jenkins' && (
|
||||||
</span>
|
<span
|
||||||
|
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||||
|
style={{ background: '#fbefff', color: '#8250df' }}
|
||||||
|
>
|
||||||
|
Jenkins
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`} style={{ color: status.color }} />
|
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
|
||||||
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
|
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={pipeline.timeAgoText || undefined}>
|
||||||
|
{pipeline.timeText}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.duration}</td>
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.durationText}</td>
|
||||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerType}</td>
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerText}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-0">
|
<div className="flex items-center gap-0">
|
||||||
<button onClick={(e) => { e.stopPropagation(); toast.success('开始执行'); }} className="p-1 rounded hover:bg-black/5">
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
runMutation.mutate(pipeline.raw.id);
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="执行"
|
||||||
|
>
|
||||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||||
</button>
|
</button>
|
||||||
<button className="p-1 rounded hover:bg-black/5">
|
<button
|
||||||
<MoreHorizontal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="配置"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectPipeline(pipeline.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="删除"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(pipeline);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -398,6 +546,107 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 新建流水线弹窗 */}
|
||||||
|
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setForm(emptyForm); }}>
|
||||||
|
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle style={{ color: 'var(--text-primary)' }}>新建流水线</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
名称 <span style={{ color: '#cf222e' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="例如 aiops-backend"
|
||||||
|
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>描述</label>
|
||||||
|
<input
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
placeholder="例如 Go后端服务 - Docker构建 + K8s部署"
|
||||||
|
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>代码仓库</label>
|
||||||
|
<input
|
||||||
|
value={form.sourceRepo}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, sourceRepo: e.target.value }))}
|
||||||
|
placeholder="https://gitee.com/xxx.git"
|
||||||
|
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>默认分支</label>
|
||||||
|
<input
|
||||||
|
value={form.sourceBranch}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, sourceBranch: e.target.value }))}
|
||||||
|
placeholder="main"
|
||||||
|
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>部署类型</label>
|
||||||
|
<Select value={form.deployType} onValueChange={(v) => setForm((f) => ({ ...f, deployType: v }))}>
|
||||||
|
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
|
||||||
|
<SelectValue placeholder="选择部署类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<SelectItem value="host">主机</SelectItem>
|
||||||
|
<SelectItem value="k8s">K8s</SelectItem>
|
||||||
|
<SelectItem value="gitops">GitOps</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>环境</label>
|
||||||
|
<Select value={form.env} onValueChange={(v) => setForm((f) => ({ ...f, env: v }))}>
|
||||||
|
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
|
||||||
|
<SelectValue placeholder="选择环境" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<SelectItem value="dev">开发 (dev)</SelectItem>
|
||||||
|
<SelectItem value="test">测试 (test)</SelectItem>
|
||||||
|
<SelectItem value="staging">预发 (staging)</SelectItem>
|
||||||
|
<SelectItem value="production">生产 (production)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreateOpen(false)}
|
||||||
|
className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
|
||||||
|
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submitCreate}
|
||||||
|
disabled={!form.name.trim() || createMutation.isPending}
|
||||||
|
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-50"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? '创建中...' : '创建'}
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,8 +54,8 @@ export default function PluginManager() {
|
|||||||
version: p.version,
|
version: p.version,
|
||||||
latestVersion: p.latestVersion || p.version,
|
latestVersion: p.latestVersion || p.version,
|
||||||
author: p.author || 'Unknown',
|
author: p.author || 'Unknown',
|
||||||
installs: 0,
|
installs: p.installs || 0,
|
||||||
rating: 4.5,
|
rating: p.rating || null,
|
||||||
category: p.category || 'general',
|
category: p.category || 'general',
|
||||||
installed: true,
|
installed: true,
|
||||||
hasUpdate: p.latestVersion && p.latestVersion !== p.version,
|
hasUpdate: p.latestVersion && p.latestVersion !== p.version,
|
||||||
@@ -71,7 +71,7 @@ export default function PluginManager() {
|
|||||||
latestVersion: p.version,
|
latestVersion: p.version,
|
||||||
author: p.author || 'Unknown',
|
author: p.author || 'Unknown',
|
||||||
installs: p.installs || 0,
|
installs: p.installs || 0,
|
||||||
rating: 4.5,
|
rating: p.rating || null,
|
||||||
category: p.category || 'general',
|
category: p.category || 'general',
|
||||||
installed: false,
|
installed: false,
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
@@ -228,8 +228,8 @@ export default function PluginManager() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] mb-2" style={{ color: 'var(--text-secondary)' }}>{plugin.description}</p>
|
<p className="text-[12px] mb-2" style={{ color: 'var(--text-secondary)' }}>{plugin.description}</p>
|
||||||
<div className="flex items-center gap-3 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
<div className="flex items-center gap-3 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<span className="flex items-center gap-1"><Star className="w-3 h-3" style={{ color: '#9a6700' }} />{plugin.rating}</span>
|
{plugin.rating != null && <span className="flex items-center gap-1"><Star className="w-3 h-3" style={{ color: '#9a6700' }} />{plugin.rating}</span>}
|
||||||
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{plugin.installs.toLocaleString()} 次安装</span>
|
{plugin.installs > 0 && <span className="flex items-center gap-1"><Users className="w-3 h-3" />{plugin.installs.toLocaleString()} 次安装</span>}
|
||||||
<span>作者: {plugin.author}</span>
|
<span>作者: {plugin.author}</span>
|
||||||
<span className="font-mono">v{plugin.version}</span>
|
<span className="font-mono">v{plugin.version}</span>
|
||||||
{plugin.hasUpdate && <span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>v{plugin.latestVersion} 可用</span>}
|
{plugin.hasUpdate && <span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>v{plugin.latestVersion} 可用</span>}
|
||||||
|
|||||||
@@ -1,137 +1,151 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Filter, Search, GitBranch,
|
Search, GitBranch,
|
||||||
CheckCircle2, XCircle, Clock, ChevronRight, Terminal,
|
CheckCircle2, XCircle, Clock, ChevronRight, Terminal,
|
||||||
RotateCcw, ExternalLink, Calendar, User, Container, Layers,
|
RotateCcw, ExternalLink, Calendar, User, Container, Layers,
|
||||||
Wifi, WifiOff
|
Wifi, WifiOff, Loader2, Sparkles
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
|
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
|
||||||
|
|
||||||
interface PipelineRunStage {
|
// ---- 工具函数 ----
|
||||||
name: string;
|
function fmtDuration(ms?: number | null): string {
|
||||||
status: string;
|
if (ms == null || ms < 0) return '-';
|
||||||
duration: string;
|
const sec = ms / 1000;
|
||||||
podName?: string;
|
if (ms < 1000) return `${Math.max(0, Math.round(sec))}s`;
|
||||||
logs?: string[];
|
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||||
|
if (sec < 3600) return `${Math.floor(sec / 60)}分${Math.round(sec % 60)}秒`;
|
||||||
|
return `${Math.floor(sec / 3600)}小时${Math.round((sec % 3600) / 60)}分`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReleaseRecord {
|
function fmtTime(iso?: string | null): string {
|
||||||
id: string;
|
return iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso?: string | null): string {
|
||||||
|
if (!iso) return '-';
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
if (diff < 0) return '刚刚';
|
||||||
|
const s = Math.floor(diff / 1000);
|
||||||
|
if (s < 60) return `${s}秒前`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}分钟前`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}小时前`;
|
||||||
|
return `${Math.floor(h / 24)}天前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 类型(与后端 GET /api/runs 对齐)----
|
||||||
|
interface RunRecord {
|
||||||
|
id: number;
|
||||||
|
runId: string;
|
||||||
pipelineName: string;
|
pipelineName: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
env: string;
|
env: string;
|
||||||
deployType: 'host' | 'k8s' | 'gitops';
|
deployType?: string;
|
||||||
type: 'native' | 'jenkins' | 'tekton';
|
type?: string;
|
||||||
status: 'success' | 'failed' | 'running' | 'cancelled';
|
status: string;
|
||||||
triggerBy: string;
|
triggerBy?: string;
|
||||||
startTime: string;
|
triggerType?: string;
|
||||||
duration: string;
|
startedAt: string;
|
||||||
stages: PipelineRunStage[];
|
completedAt?: string | null;
|
||||||
podStatus?: { name: string; status: string; restarts: number; age: string }[];
|
durationMs?: number;
|
||||||
|
buildNumber?: number;
|
||||||
|
externalUrl?: string;
|
||||||
|
analysis?: string; // 失败时后端自动生成的 AI 失败分析
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockRecords: ReleaseRecord[] = [
|
interface RunStage {
|
||||||
{
|
id: number;
|
||||||
id: 'pr-202410261430',
|
name: string;
|
||||||
pipelineName: 'aiops-backend',
|
nodeId: string;
|
||||||
branch: 'master',
|
podName?: string;
|
||||||
env: 'production',
|
status: string;
|
||||||
deployType: 'gitops',
|
startedAt?: string | null;
|
||||||
type: 'tekton',
|
completedAt?: string | null;
|
||||||
status: 'success',
|
exitCode?: number | null;
|
||||||
triggerBy: '张三',
|
}
|
||||||
startTime: '2024-10-26 14:30:00',
|
|
||||||
duration: '3m 42s',
|
interface RunLog {
|
||||||
stages: [
|
id?: number;
|
||||||
{ name: 'git-clone', status: 'success', duration: '15s', podName: 'pr-abc-git-clone-xyz', logs: ['Cloning into \'/workspace/source\'...', 'HEAD is now at 7d9f4e2 feat: update api'] },
|
level?: string;
|
||||||
{ name: 'go-build', status: 'success', duration: '1m 30s', podName: 'pr-abc-go-build-xyz', logs: ['go build -o bin/app ./cmd/app', 'Build successful'] },
|
message?: string;
|
||||||
{ name: 'kaniko-build', status: 'success', duration: '1m 45s', podName: 'pr-abc-kaniko-xyz', logs: ['INFO[0000] Resolved base name...', 'Pushing image to registry...', 'Image pushed: registry/aiops:v202410261430'] },
|
source?: string;
|
||||||
{ name: 'update-manifest', status: 'success', duration: '8s', podName: 'pr-abc-update-xyz', logs: ['git clone gitops-repo', 'Updating image tag to v202410261430', 'git push origin main'] },
|
timestamp?: string;
|
||||||
],
|
}
|
||||||
podStatus: [
|
|
||||||
{ name: 'aiops-backend-7d9f4-abc1', status: 'Running', restarts: 0, age: '5m' },
|
interface RunDetail {
|
||||||
{ name: 'aiops-backend-7d9f4-abc2', status: 'Running', restarts: 0, age: '5m' },
|
run: RunRecord;
|
||||||
{ name: 'aiops-backend-7d9f4-abc3', status: 'Running', restarts: 0, age: '5m' },
|
stages: RunStage[];
|
||||||
],
|
logs: RunLog[];
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: 'pr-202410261415',
|
|
||||||
pipelineName: 'aiops-web',
|
|
||||||
branch: 'master',
|
|
||||||
env: 'production',
|
|
||||||
deployType: 'host',
|
|
||||||
type: 'native',
|
|
||||||
status: 'success',
|
|
||||||
triggerBy: '李四',
|
|
||||||
startTime: '2024-10-26 14:15:00',
|
|
||||||
duration: '2m 18s',
|
|
||||||
stages: [
|
|
||||||
{ name: '拉取代码', status: 'success', duration: '12s' },
|
|
||||||
{ name: 'npm-build', status: 'success', duration: '1m 30s' },
|
|
||||||
{ name: 'scp-deploy', status: 'success', duration: '30s' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pr-202410261400',
|
|
||||||
pipelineName: 'gateway-api',
|
|
||||||
branch: 'release/v2.1',
|
|
||||||
env: 'staging',
|
|
||||||
deployType: 'k8s',
|
|
||||||
type: 'jenkins',
|
|
||||||
status: 'failed',
|
|
||||||
triggerBy: '王五',
|
|
||||||
startTime: '2024-10-26 14:00:00',
|
|
||||||
duration: '5m 10s',
|
|
||||||
stages: [
|
|
||||||
{ name: 'git-clone', status: 'success', duration: '18s' },
|
|
||||||
{ name: 'maven-build', status: 'success', duration: '2m 45s' },
|
|
||||||
{ name: 'kubectl-deploy', status: 'failed', duration: '2m 7s', logs: ['kubectl apply -f k8s/', 'Error: deployment gateway-api timeout', 'Pod gateway-api-xxx CrashLoopBackOff'] },
|
|
||||||
],
|
|
||||||
podStatus: [
|
|
||||||
{ name: 'gateway-api-xxx', status: 'CrashLoopBackOff', restarts: 5, age: '10m' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pr-202410261330',
|
|
||||||
pipelineName: 'admin-dashboard',
|
|
||||||
branch: 'master',
|
|
||||||
env: 'staging',
|
|
||||||
deployType: 'host',
|
|
||||||
type: 'native',
|
|
||||||
status: 'running',
|
|
||||||
triggerBy: '赵六',
|
|
||||||
startTime: '2024-10-26 13:30:00',
|
|
||||||
duration: '进行中',
|
|
||||||
stages: [
|
|
||||||
{ name: '拉取代码', status: 'success', duration: '10s' },
|
|
||||||
{ name: '构建', status: 'success', duration: '1m 20s' },
|
|
||||||
{ name: '部署到主机', status: 'running', duration: '30s' },
|
|
||||||
{ name: '后置操作', status: 'pending', duration: '-' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; bg: string; label: string }> = {
|
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; bg: string; label: string }> = {
|
||||||
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
||||||
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
||||||
running: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '执行中' },
|
running: { icon: Clock, color: '#0969da', bg: '#ddf4ff', label: '执行中' },
|
||||||
|
pending: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '排队中' },
|
||||||
|
queued: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '排队中' },
|
||||||
cancelled: { icon: XCircle, color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
|
cancelled: { icon: XCircle, color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
|
||||||
};
|
};
|
||||||
|
const fallbackStatus = statusConfig.pending;
|
||||||
|
|
||||||
const typeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
const typeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
native: { label: '原生', color: '#0969da', bg: '#ddf4ff' },
|
native: { label: '原生', color: '#0969da', bg: '#ddf4ff' },
|
||||||
jenkins: { label: 'Jenkins', color: '#8250df', bg: '#fbefff' },
|
jenkins: { label: 'Jenkins', color: '#8250df', bg: '#fbefff' },
|
||||||
tekton: { label: 'Tekton', color: '#0e7c7b', bg: '#d5f5f6' },
|
tekton: { label: 'Tekton', color: '#0e7c7b', bg: '#d5f5f6' },
|
||||||
};
|
};
|
||||||
|
const fallbackType = { label: '原生', color: '#0969da', bg: '#ddf4ff' };
|
||||||
|
|
||||||
|
type DetailTab = 'stages' | 'logs' | 'pods';
|
||||||
|
|
||||||
|
function stageDuration(stage: RunStage): string {
|
||||||
|
if (!stage.startedAt) return '-';
|
||||||
|
const start = new Date(stage.startedAt).getTime();
|
||||||
|
if (stage.completedAt) return fmtDuration(new Date(stage.completedAt).getTime() - start);
|
||||||
|
if (stage.status === 'running') return fmtDuration(Date.now() - start);
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
||||||
record: ReleaseRecord;
|
record: RunRecord;
|
||||||
activeTab: 'stages' | 'logs' | 'pods';
|
activeTab: DetailTab;
|
||||||
setActiveTab: (t: 'stages' | 'logs' | 'pods') => void;
|
setActiveTab: (t: DetailTab) => void;
|
||||||
}) {
|
}) {
|
||||||
const isRunning = record.status === 'running';
|
const queryClient = useQueryClient();
|
||||||
const { logs, connected } = usePipelineLogs(record.id, isRunning && activeTab === 'logs');
|
|
||||||
|
const { data: detail, isLoading: detailLoading } = useQuery({
|
||||||
|
queryKey: ['run', record.runId],
|
||||||
|
queryFn: () => apiClient.getRun(record.runId),
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const st = (query.state.data as RunDetail | undefined)?.run?.status ?? record.status;
|
||||||
|
return st === 'running' || st === 'pending' || st === 'queued' ? 3000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runStatus = detail?.run?.status ?? record.status;
|
||||||
|
const isRunning = runStatus === 'running';
|
||||||
|
const { logs: liveLogs, connected } = usePipelineLogs(record.runId, isRunning && activeTab === 'logs');
|
||||||
|
|
||||||
|
const stages: RunStage[] = detail?.stages ?? [];
|
||||||
|
const podStages = stages.filter((s) => s.podName);
|
||||||
|
const externalUrl = detail?.run?.externalUrl || record.externalUrl;
|
||||||
|
|
||||||
|
const retryMutation = useMutation({
|
||||||
|
mutationFn: () => apiClient.retryRun(record.runId),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast.success('已重新触发', { description: `新的运行 ${res?.runId ?? record.runId}` });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['runs'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['run', record.runId] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error('重试失败', { description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const logsToRender: RunLog[] = isRunning ? liveLogs : (detail?.logs ?? []);
|
||||||
|
|
||||||
|
const analysis: string | undefined = detail?.run?.analysis ?? record.analysis;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
<div className="border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
@@ -139,7 +153,7 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
|||||||
{[
|
{[
|
||||||
{ id: 'stages' as const, label: '阶段', icon: Layers },
|
{ id: 'stages' as const, label: '阶段', icon: Layers },
|
||||||
{ id: 'logs' as const, label: '日志', icon: Terminal },
|
{ id: 'logs' as const, label: '日志', icon: Terminal },
|
||||||
...(record.podStatus ? [{ id: 'pods' as const, label: 'Pod 状态', icon: Container }] : []),
|
...(podStages.length > 0 ? [{ id: 'pods' as const, label: 'Pod 状态', icon: Container }] : []),
|
||||||
].map(tab => (
|
].map(tab => (
|
||||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||||
className="flex items-center gap-1 px-3 py-[8px] text-[12px] font-medium border-b-2 transition-colors"
|
className="flex items-center gap-1 px-3 py-[8px] text-[12px] font-medium border-b-2 transition-colors"
|
||||||
@@ -154,112 +168,180 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{activeTab === 'stages' && (
|
{/* AI 失败分析(日志区上方) */}
|
||||||
<div className="flex items-center gap-0">
|
{analysis ? (
|
||||||
{record.stages.map((stage, idx) => {
|
<div className="mb-4 rounded-lg border p-3" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
|
||||||
const st = statusConfig[stage.status] || statusConfig.success;
|
<div className="flex items-center gap-1.5 text-[12px] font-semibold mb-1.5" style={{ color: '#8250df' }}>
|
||||||
const isLast = idx === record.stages.length - 1;
|
<Sparkles className="w-3.5 h-3.5" />AI 失败分析
|
||||||
return (
|
</div>
|
||||||
<div key={idx} className="flex items-center flex-1">
|
<div className="text-[12px] leading-5 whitespace-pre-wrap" style={{ color: 'var(--text-primary)' }}>{analysis}</div>
|
||||||
<div className="flex flex-col items-center flex-1">
|
|
||||||
<div className="w-6 h-6 rounded-full flex items-center justify-center mb-1" style={{ background: st.bg }}>
|
|
||||||
{stage.status === 'success' ? <CheckCircle2 className="w-3.5 h-3.5" style={{ color: st.color }} /> :
|
|
||||||
stage.status === 'failed' ? <XCircle className="w-3.5 h-3.5" style={{ color: st.color }} /> :
|
|
||||||
stage.status === 'running' ? <Clock className="w-3.5 h-3.5 animate-pulse" style={{ color: st.color }} /> :
|
|
||||||
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--border-color)' }} />}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] font-medium text-center" style={{ color: 'var(--text-secondary)' }}>{stage.name}</div>
|
|
||||||
<div className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{stage.duration}</div>
|
|
||||||
{stage.podName && <div className="text-[9px] font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{stage.podName}</div>}
|
|
||||||
</div>
|
|
||||||
{!isLast && <div className="flex-1 h-[2px] mx-1" style={{ background: idx < record.stages.findIndex(s => s.status === 'failed' || s.status === 'running') ? '#dafbe1' : 'var(--border-color-light)' }} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : isRunning ? (
|
||||||
|
<div className="mb-4 flex items-center gap-1.5 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Sparkles className="w-3 h-3" style={{ color: '#8250df' }} />失败后将自动生成 AI 分析
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeTab === 'stages' && (
|
||||||
|
detailLoading && stages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</div>
|
||||||
|
) : stages.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-center py-6" style={{ color: 'var(--text-tertiary)' }}>暂无阶段数据</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
{stages.map((stage, idx) => {
|
||||||
|
const st = statusConfig[stage.status] || fallbackStatus;
|
||||||
|
const isLast = idx === stages.length - 1;
|
||||||
|
return (
|
||||||
|
<div key={stage.id ?? idx} className="flex items-center flex-1">
|
||||||
|
<div className="flex flex-col items-center flex-1">
|
||||||
|
<div className="w-6 h-6 rounded-full flex items-center justify-center mb-1" style={{ background: st.bg }}>
|
||||||
|
{stage.status === 'success' ? <CheckCircle2 className="w-3.5 h-3.5" style={{ color: st.color }} /> :
|
||||||
|
stage.status === 'failed' ? <XCircle className="w-3.5 h-3.5" style={{ color: st.color }} /> :
|
||||||
|
stage.status === 'running' ? <Clock className="w-3.5 h-3.5 animate-pulse" style={{ color: st.color }} /> :
|
||||||
|
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--border-color)' }} />}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-medium text-center" style={{ color: 'var(--text-secondary)' }}>{stage.name}</div>
|
||||||
|
<div className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{stageDuration(stage)}</div>
|
||||||
|
{stage.podName && <div className="text-[9px] font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{stage.podName}</div>}
|
||||||
|
</div>
|
||||||
|
{!isLast && <div className="flex-1 h-[2px] mx-1" style={{ background: idx < stages.findIndex(s => s.status === 'failed' || s.status === 'running') ? '#dafbe1' : 'var(--border-color-light)' }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{activeTab === 'logs' && (
|
{activeTab === 'logs' && (
|
||||||
<div className="rounded-md overflow-hidden" style={{ background: '#0d1117' }}>
|
<div className="rounded-md overflow-hidden" style={{ background: '#0d1117' }}>
|
||||||
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: '#21262d' }}>
|
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: '#21262d' }}>
|
||||||
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
|
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
|
||||||
<span className="text-[11px] font-medium" style={{ color: '#7d8590' }}>PipelineRun 日志</span>
|
<span className="text-[11px] font-medium" style={{ color: '#7d8590' }}>PipelineRun 日志</span>
|
||||||
|
{externalUrl && (
|
||||||
|
<a
|
||||||
|
href={externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1"
|
||||||
|
style={{ background: '#8250df26', color: '#a371f7' }}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-2.5 h-2.5" />外部构建
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="flex-1" />
|
||||||
{isRunning && (
|
{isRunning && (
|
||||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium ml-auto" style={{ background: connected ? '#1a7f3720' : '#cf222e20', color: connected ? '#1a7f37' : '#cf222e' }}>
|
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: connected ? '#1a7f3720' : '#cf222e20', color: connected ? '#1a7f37' : '#cf222e' }}>
|
||||||
{connected ? '实时推送中' : '连接中...'}
|
{connected ? '实时推送中' : '连接中...'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 font-mono text-[11px] leading-relaxed max-h-[300px] overflow-y-auto" style={{ color: '#e6edf3' }}>
|
<div className="p-3 font-mono text-[11px] leading-relaxed max-h-[300px] overflow-y-auto" style={{ color: '#e6edf3' }}>
|
||||||
{record.stages.map((stage, idx) => (
|
{logsToRender.length === 0 ? (
|
||||||
<div key={idx}>
|
<div className="py-1" style={{ color: '#7d8590' }}>
|
||||||
<div className="py-1" style={{ color: '#58a6ff' }}>=== {stage.name} ===</div>
|
{isRunning ? '等待日志输出...' : detailLoading ? '日志加载中...' : '暂无日志'}
|
||||||
{stage.logs ? stage.logs.map((log, li) => (
|
</div>
|
||||||
<div key={li} className="py-[1px]" style={{ color: log.includes('Error') ? '#f85149' : '#e6edf3' }}>{log}</div>
|
) : logsToRender.map((log, idx) => (
|
||||||
)) : <div className="py-[1px]" style={{ color: '#7d8590' }}>No logs available</div>}
|
<div key={log.id ?? idx} className="py-[1px] flex items-start gap-1.5">
|
||||||
|
<span className="flex-shrink-0" style={{ color: '#7d8590' }}>
|
||||||
|
[{log.timestamp ? new Date(log.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : ''}]
|
||||||
|
</span>
|
||||||
|
{log.source === 'jenkins' && (
|
||||||
|
<span className="text-[9px] px-1 py-[1px] rounded font-medium flex-shrink-0 self-center" style={{ background: '#8250df33', color: '#a371f7' }}>Jenkins</span>
|
||||||
|
)}
|
||||||
|
<span style={{ color: log.level === 'error' ? '#f85149' : log.level === 'warn' ? '#d29922' : '#e6edf3' }}>
|
||||||
|
{log.message}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{logs.length > 0 && (
|
|
||||||
<div className="mt-2 pt-2 border-t" style={{ borderColor: '#21262d' }}>
|
|
||||||
<div className="py-1" style={{ color: '#58a6ff' }}>=== 实时日志 ===</div>
|
|
||||||
{logs.map((log, idx) => (
|
|
||||||
<div key={idx} className="py-[1px]" style={{ color: log.level === 'error' ? '#f85149' : log.level === 'warn' ? '#d29922' : '#e6edf3' }}>
|
|
||||||
<span style={{ color: '#7d8590' }}>[{new Date(log.timestamp || '').toLocaleTimeString()}]</span> {log.message}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'pods' && record.podStatus && (
|
{activeTab === 'pods' && podStages.length > 0 && (
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
<div className="grid grid-cols-[1fr_100px_80px_60px] gap-2 px-4 py-2 text-[11px] font-semibold" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
<div className="grid grid-cols-[1fr_120px_100px] gap-2 px-4 py-2 text-[11px] font-semibold" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||||
<div>Pod 名称</div><div>状态</div><div>重启次数</div><div>运行时间</div>
|
<div>Pod 名称</div><div>状态</div><div>运行时间</div>
|
||||||
</div>
|
</div>
|
||||||
{record.podStatus.map((pod, idx) => (
|
{podStages.map((stage, idx) => {
|
||||||
<div key={idx} className="grid grid-cols-[1fr_100px_80px_60px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
const st = statusConfig[stage.status] || fallbackStatus;
|
||||||
<div className="font-mono" style={{ color: 'var(--text-primary)' }}>{pod.name}</div>
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div key={stage.id ?? idx} className="grid grid-cols-[1fr_120px_100px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
<Container className="w-3 h-3" style={{ color: pod.status === 'Running' ? '#1a7f37' : '#cf222e' }} />
|
<div className="font-mono truncate" style={{ color: 'var(--text-primary)' }}>{stage.podName}</div>
|
||||||
<span style={{ color: pod.status === 'Running' ? '#1a7f37' : '#cf222e' }}>{pod.status}</span>
|
<div className="flex items-center gap-1">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${stage.status === 'running' ? 'animate-pulse' : ''}`} style={{ background: st.color }} />
|
||||||
|
<span style={{ color: st.color }}>{st.label}</span>
|
||||||
|
</div>
|
||||||
|
<div title={stage.startedAt ? fmtTime(stage.startedAt) : undefined} style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{timeAgo(stage.startedAt)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: pod.restarts > 0 ? '#cf222e' : 'var(--text-secondary)' }}>{pod.restarts}</div>
|
);
|
||||||
<div style={{ color: 'var(--text-tertiary)' }}>{pod.age}</div>
|
})}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 pt-3 mt-3 border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
<div className="flex items-center gap-2 pt-3 mt-3 border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
<button onClick={() => toast.info('查看日志', { description: `${record.pipelineName} 完整日志` })}
|
<button onClick={() => setActiveTab('logs')}
|
||||||
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
<Terminal className="w-3 h-3" />查看日志
|
<Terminal className="w-3 h-3" />查看日志
|
||||||
</button>
|
</button>
|
||||||
{record.status === 'failed' && (
|
{(record.status === 'failed' || record.status === 'cancelled') && (
|
||||||
<button onClick={() => toast.success('重试发布', { description: `${record.pipelineName} 重新开始发布` })}
|
<button
|
||||||
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
onClick={() => retryMutation.mutate()}
|
||||||
<RotateCcw className="w-3 h-3" />重试
|
disabled={retryMutation.isPending}
|
||||||
|
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
<RotateCcw className={`w-3 h-3 ${retryMutation.isPending ? 'animate-spin' : ''}`} />{retryMutation.isPending ? '重试中...' : '重试'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => toast.info('查看流水线', { description: `跳转至 ${record.pipelineName}` })}
|
<button onClick={() => toast.info('请点击左侧【流水线】进入对应编辑器')}
|
||||||
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
<ExternalLink className="w-3 h-3" />查看流水线
|
<ExternalLink className="w-3 h-3" />查看流水线
|
||||||
</button>
|
</button>
|
||||||
|
{record.buildNumber != null && record.buildNumber > 0 && (
|
||||||
|
<span className="text-[11px] ml-auto" style={{ color: 'var(--text-tertiary)' }}>构建 #{record.buildNumber}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabOptions = [
|
||||||
|
{ id: 'all', label: '全部' },
|
||||||
|
{ id: 'success', label: '成功' },
|
||||||
|
{ id: 'failed', label: '失败' },
|
||||||
|
{ id: 'running', label: '执行中' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ReleaseRecords() {
|
export default function ReleaseRecords() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusTab, setStatusTab] = useState<string>('all');
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'stages' | 'logs' | 'pods'>('stages');
|
const [activeTab, setActiveTab] = useState<DetailTab>('stages');
|
||||||
|
|
||||||
const filtered = mockRecords.filter(r => {
|
// 统计始终基于全量列表
|
||||||
const matchSearch = r.pipelineName.includes(search) || r.id.includes(search);
|
const { data: allRuns = [] } = useQuery({
|
||||||
const matchStatus = statusFilter === 'all' || r.status === statusFilter;
|
queryKey: ['runs', 'all'],
|
||||||
return matchSearch && matchStatus;
|
queryFn: () => apiClient.listRuns(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 列表按 Tab 使用服务端过滤(all 时与统计共用同一查询)
|
||||||
|
const { data: runs = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['runs', statusTab],
|
||||||
|
queryFn: () => apiClient.listRuns({ status: statusTab }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const runList = allRuns as RunRecord[];
|
||||||
|
const total = runList.length;
|
||||||
|
const successCount = runList.filter(r => r.status === 'success').length;
|
||||||
|
const failedCount = runList.filter(r => r.status === 'failed').length;
|
||||||
|
const runningCount = runList.filter(r => r.status === 'running' || r.status === 'pending' || r.status === 'queued').length;
|
||||||
|
const finished = total - runningCount;
|
||||||
|
const successRate = finished > 0 ? `${Math.round((successCount / finished) * 100)}%` : '-';
|
||||||
|
|
||||||
|
const filtered = (runs as RunRecord[]).filter(r => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
const matchSearch = r.pipelineName.toLowerCase().includes(q) || r.runId.toLowerCase().includes(q);
|
||||||
|
return matchSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -270,65 +352,103 @@ export default function ReleaseRecords() {
|
|||||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布记录</h1>
|
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布记录</h1>
|
||||||
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>PipelineRun</span>
|
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>PipelineRun</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
<Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} />
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索流水线或记录ID"
|
||||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索流水线或记录ID"
|
className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} />
|
||||||
className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
|
||||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
|
{/* Stats + Tabs */}
|
||||||
className="px-3 py-[5px] text-[12px] border rounded-md outline-none appearance-none pr-7"
|
<div className="px-5 py-2.5 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
<div className="flex items-center gap-5">
|
||||||
<option value="all">全部状态</option><option value="success">成功</option><option value="failed">失败</option><option value="running">执行中</option><option value="cancelled">已取消</option>
|
{[
|
||||||
</select>
|
{ label: '总次数', value: total, color: 'var(--text-primary)' },
|
||||||
<Filter className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--text-tertiary)' }} />
|
{ label: '成功率', value: successRate, color: '#1a7f37' },
|
||||||
</div>
|
{ label: '成功', value: successCount, color: '#1a7f37' },
|
||||||
|
{ label: '失败', value: failedCount, color: '#cf222e' },
|
||||||
|
{ label: '执行中', value: runningCount, color: '#0969da' },
|
||||||
|
].map(stat => (
|
||||||
|
<div key={stat.label} className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</span>
|
||||||
|
<span className="text-[13px] font-semibold" style={{ color: stat.color }}>{stat.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{tabOptions.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setStatusTab(tab.id)}
|
||||||
|
className="px-3 py-[5px] text-[12px] font-medium rounded-md transition-colors"
|
||||||
|
style={{
|
||||||
|
background: statusTab === tab.id ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)',
|
||||||
|
color: statusTab === tab.id ? '#fff' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
<div className="space-y-2">
|
{isLoading ? (
|
||||||
{filtered.map((record) => {
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
const status = statusConfig[record.status];
|
<Loader2 className="w-5 h-5 animate-spin mb-2" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
const StatusIcon = status.icon;
|
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</p>
|
||||||
const tp = typeConfig[record.type];
|
</div>
|
||||||
const isExpanded = expandedId === record.id;
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>暂无发布记录</p>
|
||||||
|
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试切换状态或执行一次流水线</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.map((record) => {
|
||||||
|
const status = statusConfig[record.status] || fallbackStatus;
|
||||||
|
const StatusIcon = status.icon;
|
||||||
|
const tp = typeConfig[record.type || 'native'] || fallbackType;
|
||||||
|
const isExpanded = expandedId === record.runId;
|
||||||
|
const durationText = record.status === 'running'
|
||||||
|
? (record.durationMs && record.durationMs > 0 ? fmtDuration(record.durationMs) : '进行中')
|
||||||
|
: fmtDuration(record.durationMs);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={record.id} className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div key={record.runId} className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
{/* Summary row */}
|
{/* Summary row */}
|
||||||
<div onClick={() => setExpandedId(isExpanded ? null : record.id)}
|
<div onClick={() => setExpandedId(isExpanded ? null : record.runId)}
|
||||||
className="px-4 py-3 flex items-center gap-4 cursor-pointer hover:bg-black/[0.01] transition-colors">
|
className="px-4 py-3 flex items-center gap-4 cursor-pointer hover:bg-black/[0.01] transition-colors">
|
||||||
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${record.status === 'running' ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
|
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${record.status === 'running' ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
|
||||||
<div className="w-[130px]">
|
<div className="w-[130px]">
|
||||||
<div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{record.pipelineName}</div>
|
<div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{record.pipelineName}</div>
|
||||||
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{record.id}</div>
|
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{record.runId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[12px] w-[100px]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<GitBranch className="w-3 h-3" /> {record.branch}
|
||||||
|
</div>
|
||||||
|
<div className="w-[80px]"><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
|
||||||
|
<div className="text-[12px] w-[70px]" style={{ color: 'var(--text-tertiary)' }}>{record.env}</div>
|
||||||
|
<div className="flex items-center gap-1 text-[11px] w-[80px]" style={{ color: 'var(--text-tertiary)' }}><User className="w-3 h-3" />{record.triggerBy || '-'}</div>
|
||||||
|
<div className="flex items-center gap-1 text-[11px] w-[150px]" style={{ color: 'var(--text-tertiary)' }} title={timeAgo(record.startedAt)}><Calendar className="w-3 h-3" />{fmtTime(record.startedAt)}</div>
|
||||||
|
<div className="text-[12px] font-medium w-[70px] text-right" style={{ color: record.status === 'running' ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>{durationText}</div>
|
||||||
|
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} style={{ color: 'var(--text-tertiary)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-[12px] w-[100px]" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
<GitBranch className="w-3 h-3" /> {record.branch}
|
{/* Expanded detail */}
|
||||||
</div>
|
{isExpanded && (
|
||||||
<div className="w-[80px]"><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
|
<ExpandedRecord
|
||||||
<div className="text-[12px] w-[70px]" style={{ color: 'var(--text-tertiary)' }}>{record.env}</div>
|
record={record}
|
||||||
<div className="flex items-center gap-1 text-[11px] w-[80px]" style={{ color: 'var(--text-tertiary)' }}><User className="w-3 h-3" />{record.triggerBy}</div>
|
activeTab={activeTab}
|
||||||
<div className="flex items-center gap-1 text-[11px] w-[130px]" style={{ color: 'var(--text-tertiary)' }}><Calendar className="w-3 h-3" />{record.startTime}</div>
|
setActiveTab={setActiveTab}
|
||||||
<div className="text-[12px] font-medium w-[70px] text-right" style={{ color: record.status === 'running' ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>{record.duration}</div>
|
/>
|
||||||
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} style={{ color: 'var(--text-tertiary)' }} />
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{/* Expanded detail */}
|
})}
|
||||||
{isExpanded && (
|
</div>
|
||||||
<ExpandedRecord
|
)}
|
||||||
record={record}
|
|
||||||
activeTab={activeTab}
|
|
||||||
setActiveTab={setActiveTab}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Palette, Monitor, Moon, Sun,
|
Palette, Monitor, Moon, Sun,
|
||||||
Save, RotateCcw, CheckCircle2, Globe, Bell
|
Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2, ExternalLink, ChevronDown, ChevronRight
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import { useTheme, type ThemeMode } from '@/providers/theme';
|
||||||
|
|
||||||
interface ThemeConfig {
|
interface ThemeConfig {
|
||||||
mode: 'light' | 'dark' | 'system';
|
mode: 'light' | 'dark' | 'system';
|
||||||
@@ -14,6 +17,43 @@ interface ThemeConfig {
|
|||||||
borderRadius: 'sharp' | 'soft' | 'round';
|
borderRadius: 'sharp' | 'soft' | 'round';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NotificationConfig {
|
||||||
|
email: boolean;
|
||||||
|
browser: boolean;
|
||||||
|
pipelineSuccess: boolean;
|
||||||
|
pipelineFailed: boolean;
|
||||||
|
securityAlert: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTheme: ThemeConfig = {
|
||||||
|
mode: 'light',
|
||||||
|
primaryColor: '#0969da',
|
||||||
|
fontSize: 'medium',
|
||||||
|
density: 'comfortable',
|
||||||
|
sidebarStyle: 'full',
|
||||||
|
borderRadius: 'soft',
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultNotifications: NotificationConfig = {
|
||||||
|
email: true,
|
||||||
|
browser: true,
|
||||||
|
pipelineSuccess: false,
|
||||||
|
pipelineFailed: true,
|
||||||
|
securityAlert: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 后端 theme / notification 均以 JSON 字符串存储,安全解析并合并默认值
|
||||||
|
function safeParse<T extends object>(raw: unknown, fallback: T): T {
|
||||||
|
if (typeof raw !== 'string' || !raw) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === 'object') return { ...fallback, ...parsed };
|
||||||
|
return fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const colorOptions = [
|
const colorOptions = [
|
||||||
{ name: '蓝色', value: '#0969da', class: 'bg-[#0969da]' },
|
{ name: '蓝色', value: '#0969da', class: 'bg-[#0969da]' },
|
||||||
{ name: '绿色', value: '#1a7f37', class: 'bg-[#1a7f37]' },
|
{ name: '绿色', value: '#1a7f37', class: 'bg-[#1a7f37]' },
|
||||||
@@ -23,51 +63,224 @@ const colorOptions = [
|
|||||||
{ name: '青色', value: '#0e7c7b', class: 'bg-[#0e7c7b]' },
|
{ name: '青色', value: '#0e7c7b', class: 'bg-[#0e7c7b]' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// LLM 服务商预设(OpenAI 兼容接口)
|
||||||
|
const LLM_PROVIDERS = [
|
||||||
|
{ id: 'deepseek', label: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', model: 'deepseek-chat', keyUrl: 'https://platform.deepseek.com/api_keys' },
|
||||||
|
{ id: 'glm', label: '智谱 GLM', endpoint: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4.7', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys' },
|
||||||
|
{ id: 'glm-coding', label: '智谱 GLM 编码套餐', endpoint: 'https://open.bigmodel.cn/api/coding/paas/v4', model: 'glm-4.7', keyUrl: 'https://open.bigmodel.cn/usercenter/apikeys' },
|
||||||
|
{ id: 'qwen', label: '阿里 Qwen', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', keyUrl: 'https://bailian.console.aliyun.com/' },
|
||||||
|
{ id: 'kimi', label: 'Kimi', endpoint: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k', keyUrl: 'https://platform.moonshot.cn/console/api-keys' },
|
||||||
|
{ id: 'ollama', label: 'Ollama 本地', endpoint: 'http://localhost:11434/v1', model: 'qwen2.5:7b', keyUrl: '' },
|
||||||
|
{ id: 'custom', label: '自定义…', endpoint: '', model: '', keyUrl: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 按 endpoint 反匹配预设(匹配不到返回 custom)
|
||||||
|
function matchProviderByEndpoint(endpoint?: string | null): string {
|
||||||
|
if (!endpoint) return 'custom';
|
||||||
|
const hit = LLM_PROVIDERS.find((p) => p.id !== 'custom' && p.endpoint === endpoint);
|
||||||
|
return hit ? hit.id : 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
export default function SystemSettings() {
|
export default function SystemSettings() {
|
||||||
const [theme, setTheme] = useState<ThemeConfig>({
|
const queryClient = useQueryClient();
|
||||||
mode: 'light',
|
const { change: themeChange } = useTheme();
|
||||||
primaryColor: '#0969da',
|
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
|
||||||
fontSize: 'medium',
|
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
|
||||||
density: 'comfortable',
|
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
|
||||||
sidebarStyle: 'full',
|
const [llmProvider, setLlmProvider] = useState('custom');
|
||||||
borderRadius: 'soft',
|
const [lark, setLark] = useState({ enabled: false, webhook: '', secret: '' });
|
||||||
|
const [ldap, setLdap] = useState({
|
||||||
|
enabled: false,
|
||||||
|
host: '',
|
||||||
|
port: '389',
|
||||||
|
useSSL: false,
|
||||||
|
bindDN: '',
|
||||||
|
bindPassword: '',
|
||||||
|
baseDN: '',
|
||||||
|
userFilter: '(objectClass=inetOrgPerson)',
|
||||||
|
usernameAttr: 'uid',
|
||||||
|
nameAttr: 'displayName',
|
||||||
|
emailAttr: 'mail',
|
||||||
|
});
|
||||||
|
// 测试连接的可选「测试账号」展开态
|
||||||
|
const [ldapTestOpen, setLdapTestOpen] = useState(false);
|
||||||
|
const [ldapTest, setLdapTest] = useState({ username: '', password: '' });
|
||||||
|
|
||||||
|
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||||
|
|
||||||
|
// 用后端数据初始化表单(仅在数据变化时覆盖本地编辑态)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings) return;
|
||||||
|
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
|
||||||
|
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications));
|
||||||
|
setLlm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: !!settings.llmEnabled,
|
||||||
|
endpoint: settings.llmEndpoint || '',
|
||||||
|
model: settings.llmModel || '',
|
||||||
|
apiKey: '', // 只写:回显永远留空
|
||||||
|
}));
|
||||||
|
setLlmProvider(matchProviderByEndpoint(settings.llmEndpoint));
|
||||||
|
setLark((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: !!settings.larkEnabled,
|
||||||
|
webhook: settings.larkWebhook || '',
|
||||||
|
secret: '',
|
||||||
|
}));
|
||||||
|
setLdap((prev) => ({
|
||||||
|
...prev,
|
||||||
|
enabled: !!settings.ldapEnabled,
|
||||||
|
host: settings.ldapHost || '',
|
||||||
|
port: settings.ldapPort ? String(settings.ldapPort) : '389',
|
||||||
|
useSSL: !!settings.ldapUseSSL,
|
||||||
|
bindDN: settings.ldapBindDN || '',
|
||||||
|
bindPassword: '', // 只写:回显永远留空
|
||||||
|
baseDN: settings.ldapBaseDN || '',
|
||||||
|
userFilter: settings.ldapUserFilter || '(objectClass=inetOrgPerson)',
|
||||||
|
usernameAttr: settings.ldapUsernameAttr || 'uid',
|
||||||
|
nameAttr: settings.ldapNameAttr || 'displayName',
|
||||||
|
emailAttr: settings.ldapEmailAttr || 'mail',
|
||||||
|
}));
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const saveLarkMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const body: any = { larkEnabled: lark.enabled, larkWebhook: lark.webhook };
|
||||||
|
if (lark.secret) body.larkSecret = lark.secret;
|
||||||
|
return apiClient.updateSettings(body);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('飞书通知配置已保存');
|
||||||
|
setLark((p) => ({ ...p, secret: '' }));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [notifications, setNotifications] = useState({
|
const testLarkMutation = useMutation({
|
||||||
email: true,
|
mutationFn: () => apiClient.testLark(),
|
||||||
browser: true,
|
onSuccess: (res: any) => {
|
||||||
pipelineSuccess: false,
|
if (res.success) toast.success('测试卡片已发送', { description: '请到飞书群查看' });
|
||||||
pipelineFailed: true,
|
else toast.error('发送失败', { description: res.message });
|
||||||
securityAlert: true,
|
},
|
||||||
|
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = () => {
|
const saveLdapMutation = useMutation({
|
||||||
toast.success('设置已保存', { description: '主题和偏好设置已更新' });
|
mutationFn: () => {
|
||||||
};
|
const body: any = {
|
||||||
|
ldapEnabled: true,
|
||||||
|
ldapHost: ldap.host,
|
||||||
|
ldapPort: Number(ldap.port) || 389,
|
||||||
|
ldapUseSSL: ldap.useSSL,
|
||||||
|
ldapBindDN: ldap.bindDN,
|
||||||
|
ldapBaseDN: ldap.baseDN,
|
||||||
|
ldapUserFilter: ldap.userFilter,
|
||||||
|
ldapUsernameAttr: ldap.usernameAttr,
|
||||||
|
ldapNameAttr: ldap.nameAttr,
|
||||||
|
ldapEmailAttr: ldap.emailAttr,
|
||||||
|
};
|
||||||
|
if (ldap.bindPassword) body.ldapBindPassword = ldap.bindPassword;
|
||||||
|
return apiClient.updateSettings(body);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('LDAP 配置已保存', { description: '可在「用户管理」页从 LDAP 导入用户' });
|
||||||
|
setLdap((p) => ({ ...p, bindPassword: '' }));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testLdapMutation = useMutation({
|
||||||
|
// 草稿直测:用当前表单值(无需先保存),Bind 密码留空时后端回退用已存密码
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient.testLDAP({
|
||||||
|
enabled: true,
|
||||||
|
host: ldap.host,
|
||||||
|
port: Number(ldap.port) || 389,
|
||||||
|
useSSL: ldap.useSSL,
|
||||||
|
bindDN: ldap.bindDN,
|
||||||
|
bindPassword: ldap.bindPassword || undefined,
|
||||||
|
baseDN: ldap.baseDN,
|
||||||
|
userFilter: ldap.userFilter,
|
||||||
|
usernameAttr: ldap.usernameAttr,
|
||||||
|
testUsername: ldapTest.username || undefined,
|
||||||
|
testPassword: ldapTest.password || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
if (res.success) toast.success('LDAP 连接成功', { description: res.message });
|
||||||
|
else toast.error('LDAP 连接失败', { description: res.message });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveLlmMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const body: any = { llmEnabled: llm.enabled, llmEndpoint: llm.endpoint, llmModel: llm.model };
|
||||||
|
if (llm.apiKey) body.llmApiKey = llm.apiKey;
|
||||||
|
return apiClient.updateSettings(body);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('AI 配置已保存');
|
||||||
|
setLlm((p) => ({ ...p, apiKey: '' }));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('保存失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const testLlmMutation = useMutation({
|
||||||
|
// 草稿直测:用当前表单值(无需先保存)
|
||||||
|
mutationFn: () => apiClient.testLLMDraft({
|
||||||
|
llmEnabled: true,
|
||||||
|
llmEndpoint: llm.endpoint,
|
||||||
|
llmModel: llm.model,
|
||||||
|
llmApiKey: llm.apiKey || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res.success) toast.success('AI 连接成功', { description: res.message });
|
||||||
|
else toast.error('AI 连接失败', { description: res.message });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () => apiClient.updateSettings({
|
||||||
|
theme: JSON.stringify(theme),
|
||||||
|
notification: JSON.stringify(notifications),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('设置已保存', { description: '主题和通知设置已更新' });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setTheme({
|
setTheme(defaultTheme);
|
||||||
mode: 'light',
|
setNotifications(defaultNotifications);
|
||||||
primaryColor: '#0969da',
|
toast.info('已恢复默认设置', { description: '仅本地重置,点击「保存设置」后生效' });
|
||||||
fontSize: 'medium',
|
|
||||||
density: 'comfortable',
|
|
||||||
sidebarStyle: 'full',
|
|
||||||
borderRadius: 'soft',
|
|
||||||
});
|
|
||||||
toast.info('已恢复默认设置');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>系统设置</h1>
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>系统设置</h1>
|
||||||
|
{!settings && (
|
||||||
|
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />加载中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={handleReset} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
<button onClick={handleReset} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
<RotateCcw className="w-3.5 h-3.5" />恢复默认
|
<RotateCcw className="w-3.5 h-3.5" />恢复默认
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleSave} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
<button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}
|
||||||
<Save className="w-3.5 h-3.5" />保存设置
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||||
|
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
{saveMutation.isPending ? '保存中...' : '保存设置'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,16 +297,16 @@ export default function SystemSettings() {
|
|||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
{/* Mode */}
|
{/* Mode */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题模式</label>
|
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题模式(选择即生效)</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{[
|
{[
|
||||||
{ id: 'light', label: '浅色', icon: Sun },
|
{ id: 'light', label: '浅色', icon: Sun },
|
||||||
{ id: 'dark', label: '深色', icon: Moon },
|
{ id: 'dark', label: '深色', icon: Moon },
|
||||||
{ id: 'system', label: '跟随系统', icon: Monitor },
|
{ id: 'system', label: '跟随系统', icon: Monitor },
|
||||||
].map(m => (
|
].map(m => (
|
||||||
<button key={m.id} onClick={() => setTheme(p => ({ ...p, mode: m.id as any }))}
|
<button key={m.id} onClick={() => { setTheme(p => ({ ...p, mode: m.id as ThemeConfig['mode'] })); themeChange(m.id as ThemeMode); }}
|
||||||
className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
|
className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
|
||||||
style={{
|
style={{
|
||||||
borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||||
background: theme.mode === m.id ? 'var(--accent-blue-light)' : 'transparent',
|
background: theme.mode === m.id ? 'var(--accent-blue-light)' : 'transparent',
|
||||||
color: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
color: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
@@ -127,9 +340,9 @@ export default function SystemSettings() {
|
|||||||
{ id: 'medium', label: '中', size: '14px' },
|
{ id: 'medium', label: '中', size: '14px' },
|
||||||
{ id: 'large', label: '大', size: '16px' },
|
{ id: 'large', label: '大', size: '16px' },
|
||||||
].map(f => (
|
].map(f => (
|
||||||
<button key={f.id} onClick={() => setTheme(p => ({ ...p, fontSize: f.id as any }))}
|
<button key={f.id} onClick={() => setTheme(p => ({ ...p, fontSize: f.id as ThemeConfig['fontSize'] }))}
|
||||||
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors text-center"
|
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors text-center"
|
||||||
style={{
|
style={{
|
||||||
borderColor: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
borderColor: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||||
background: theme.fontSize === f.id ? 'var(--accent-blue-light)' : 'transparent',
|
background: theme.fontSize === f.id ? 'var(--accent-blue-light)' : 'transparent',
|
||||||
color: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
color: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
@@ -150,9 +363,9 @@ export default function SystemSettings() {
|
|||||||
{ id: 'comfortable', label: '舒适' },
|
{ id: 'comfortable', label: '舒适' },
|
||||||
{ id: 'spacious', label: '宽松' },
|
{ id: 'spacious', label: '宽松' },
|
||||||
].map(d => (
|
].map(d => (
|
||||||
<button key={d.id} onClick={() => setTheme(p => ({ ...p, density: d.id as any }))}
|
<button key={d.id} onClick={() => setTheme(p => ({ ...p, density: d.id as ThemeConfig['density'] }))}
|
||||||
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
|
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
|
||||||
style={{
|
style={{
|
||||||
borderColor: theme.density === d.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
borderColor: theme.density === d.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||||
background: theme.density === d.id ? 'var(--accent-blue-light)' : 'transparent',
|
background: theme.density === d.id ? 'var(--accent-blue-light)' : 'transparent',
|
||||||
color: theme.density === d.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
color: theme.density === d.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
@@ -172,9 +385,9 @@ export default function SystemSettings() {
|
|||||||
{ id: 'soft', label: '圆角', radius: '6px' },
|
{ id: 'soft', label: '圆角', radius: '6px' },
|
||||||
{ id: 'round', label: '大圆角', radius: '12px' },
|
{ id: 'round', label: '大圆角', radius: '12px' },
|
||||||
].map(r => (
|
].map(r => (
|
||||||
<button key={r.id} onClick={() => setTheme(p => ({ ...p, borderRadius: r.id as any }))}
|
<button key={r.id} onClick={() => setTheme(p => ({ ...p, borderRadius: r.id as ThemeConfig['borderRadius'] }))}
|
||||||
className="flex-1 py-[7px] text-[12px] font-medium border transition-colors"
|
className="flex-1 py-[7px] text-[12px] font-medium border transition-colors"
|
||||||
style={{
|
style={{
|
||||||
borderColor: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
borderColor: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||||
background: theme.borderRadius === r.id ? 'var(--accent-blue-light)' : 'transparent',
|
background: theme.borderRadius === r.id ? 'var(--accent-blue-light)' : 'transparent',
|
||||||
color: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
color: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
@@ -203,7 +416,7 @@ export default function SystemSettings() {
|
|||||||
{ key: 'securityAlert', label: '安全警报', desc: '凭证泄露、异常登录等安全事件' },
|
{ key: 'securityAlert', label: '安全警报', desc: '凭证泄露、异常登录等安全事件' },
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<label key={item.key} className="flex items-start gap-3 cursor-pointer">
|
<label key={item.key} className="flex items-start gap-3 cursor-pointer">
|
||||||
<input type="checkbox" checked={(notifications as any)[item.key]}
|
<input type="checkbox" checked={notifications[item.key as keyof NotificationConfig]}
|
||||||
onChange={e => setNotifications(p => ({ ...p, [item.key]: e.target.checked }))}
|
onChange={e => setNotifications(p => ({ ...p, [item.key]: e.target.checked }))}
|
||||||
className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
|
className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
|
||||||
<div>
|
<div>
|
||||||
@@ -225,16 +438,261 @@ export default function SystemSettings() {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>界面语言</label>
|
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>界面语言</label>
|
||||||
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||||
<option>简体中文</option><option>English</option><option>日本語</option>
|
<option>简体中文</option><option>English</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>时区</label>
|
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>时区</label>
|
||||||
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||||
<option>Asia/Shanghai (UTC+8)</option><option>UTC</option><option>America/New_York</option>
|
<optgroup label="亚洲">
|
||||||
|
<option>Asia/Shanghai (UTC+8) 中国标准时间</option>
|
||||||
|
<option>Asia/Hong_Kong (UTC+8) 香港时间</option>
|
||||||
|
<option>Asia/Taipei (UTC+8) 台北时间</option>
|
||||||
|
<option>Asia/Singapore (UTC+8) 新加坡时间</option>
|
||||||
|
<option>Asia/Tokyo (UTC+9) 日本时间</option>
|
||||||
|
<option>Asia/Seoul (UTC+9) 韩国时间</option>
|
||||||
|
<option>Asia/Bangkok (UTC+7) 泰国时间</option>
|
||||||
|
<option>Asia/Dubai (UTC+4) 迪拜时间</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="欧洲">
|
||||||
|
<option>Europe/London (UTC+0/+1) 英国时间</option>
|
||||||
|
<option>Europe/Berlin (UTC+1/+2) 中欧时间</option>
|
||||||
|
<option>Europe/Paris (UTC+1/+2) 法国时间</option>
|
||||||
|
<option>Europe/Moscow (UTC+3) 莫斯科时间</option>
|
||||||
|
<option>Europe/Istanbul (UTC+3) 土耳其时间</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="美洲">
|
||||||
|
<option>America/New_York (UTC-5/-4) 美东时间</option>
|
||||||
|
<option>America/Chicago (UTC-6/-5) 美中时间</option>
|
||||||
|
<option>America/Denver (UTC-7/-6) 美山时间</option>
|
||||||
|
<option>America/Los_Angeles (UTC-8/-7) 美西时间</option>
|
||||||
|
<option>America/Sao_Paulo (UTC-3) 巴西时间</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="大洋洲 / 非洲 / 标准">
|
||||||
|
<option>Australia/Sydney (UTC+10/+11) 澳洲东部时间</option>
|
||||||
|
<option>Australia/Perth (UTC+8) 澳洲西部时间</option>
|
||||||
|
<option>Africa/Cairo (UTC+2) 埃及时间</option>
|
||||||
|
<option>Africa/Johannesburg (UTC+2) 南非时间</option>
|
||||||
|
<option>UTC 协调世界时</option>
|
||||||
|
</optgroup>
|
||||||
</select></div>
|
</select></div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>语言与时区暂未生效,仅作展示</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI 助手配置 */}
|
||||||
|
<div className="rounded-lg border overflow-hidden mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>AI 助手(智能发布)</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: settings?.llmEnabled && settings?.llmApiKeySet ? '#dafbe1' : '#fff8c5', color: settings?.llmEnabled && settings?.llmApiKeySet ? '#1a7f37' : '#9a6700' }}>
|
||||||
|
{settings?.llmEnabled && settings?.llmApiKeySet ? '已启用' : '未启用(规则模式)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={llm.enabled} onChange={(e) => setLlm({ ...llm, enabled: e.target.checked })} />
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 AI(OpenAI 兼容接口:DeepSeek / GLM / Qwen 等)</span>
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>服务商预设</label>
|
||||||
|
<select
|
||||||
|
value={llmProvider}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value;
|
||||||
|
setLlmProvider(id);
|
||||||
|
const preset = LLM_PROVIDERS.find((p) => p.id === id);
|
||||||
|
if (preset && id !== 'custom') {
|
||||||
|
// 非 custom:自动填入 endpoint/model(用户仍可修改)
|
||||||
|
setLlm((prev) => ({ ...prev, endpoint: preset.endpoint, model: preset.model }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
{LLM_PROVIDERS.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>接口地址</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input value={llm.endpoint} onChange={(e) => setLlm({ ...llm, endpoint: e.target.value })} placeholder="https://api.deepseek.com/v1" className="flex-1 min-w-0 px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
{!!LLM_PROVIDERS.find((p) => p.id === llmProvider)?.keyUrl && (
|
||||||
|
<a
|
||||||
|
href={LLM_PROVIDERS.find((p) => p.id === llmProvider)!.keyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="打开 API Key 申请页面"
|
||||||
|
className="flex-shrink-0 flex items-center gap-1 px-2 py-[6px] text-[11px] rounded-md border whitespace-nowrap"
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--accent-blue)' }}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />获取 API Key
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>模型</label>
|
||||||
|
<input value={llm.model} onChange={(e) => setLlm({ ...llm, model: e.target.value })} placeholder="deepseek-chat" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Key</label>
|
||||||
|
<input type="password" value={llm.apiKey} onChange={(e) => setLlm({ ...llm, apiKey: e.target.value })} placeholder={settings?.llmApiKeySet ? '已配置,留空不修改' : 'sk-...'} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未启用 AI 时,智能助手自动降级为本地规则模式(按服务名匹配,仍可正常发布)</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => saveLlmMutation.mutate()} disabled={saveLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||||
|
{saveLlmMutation.isPending ? '保存中…' : '保存 AI 配置'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => testLlmMutation.mutate()} disabled={testLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
{testLlmMutation.isPending ? '测试中…' : '测试连接(按当前表单直测)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 飞书 / Lark 通知 */}
|
||||||
|
<div className="rounded-lg border overflow-hidden mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>飞书 / Lark 群通知</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: settings?.larkEnabled && settings?.larkWebhook ? '#dafbe1' : '#f6f8fa', color: settings?.larkEnabled && settings?.larkWebhook ? '#1a7f37' : '#6e7781' }}>
|
||||||
|
{settings?.larkEnabled && settings?.larkWebhook ? '已启用' : '未启用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
发布成功/失败后向群推送结果卡片(应用 / 分支 / 构建 / 状态 / 时间 + 「查看构建详情」按钮跳转 Jenkins 构建页)。
|
||||||
|
流水线单独配置的「通知 Webhook」优先生效。
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={lark.enabled} onChange={(e) => setLark({ ...lark, enabled: e.target.checked })} />
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用全局群通知</span>
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>群机器人 Webhook URL</label>
|
||||||
|
<input value={lark.webhook} onChange={(e) => setLark({ ...lark, webhook: e.target.value })} placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxxx" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选,机器人开启签名校验时填写)</label>
|
||||||
|
<input type="password" value={lark.secret} onChange={(e) => setLark({ ...lark, secret: e.target.value })} placeholder={settings?.larkSecretSet ? '已配置,留空不修改' : '无需签名可留空'} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => saveLarkMutation.mutate()} disabled={saveLarkMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||||
|
{saveLarkMutation.isPending ? '保存中…' : '保存通知配置'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => testLarkMutation.mutate()} disabled={testLarkMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
{testLarkMutation.isPending ? '发送中…' : '发送测试卡片(需先保存)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LDAP 目录服务 */}
|
||||||
|
<div className="rounded-lg border overflow-hidden mt-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="px-5 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>LDAP 目录服务</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: settings?.ldapEnabled ? '#dafbe1' : '#f6f8fa', color: settings?.ldapEnabled ? '#1a7f37' : '#6e7781' }}>
|
||||||
|
{settings?.ldapEnabled ? '已启用' : '未启用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!ldap.enabled ? (
|
||||||
|
<div className="p-5 space-y-3">
|
||||||
|
<p className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
LDAP 未启用——打开开关填写目录服务信息,启用后可在用户管理页导入用户
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={ldap.enabled} onChange={(e) => setLdap({ ...ldap, enabled: e.target.checked })} />
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 LDAP 目录服务</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>服务器地址</label>
|
||||||
|
<input value={ldap.host} onChange={(e) => setLdap({ ...ldap, host: e.target.value })} placeholder="ldap.example.com" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>端口</label>
|
||||||
|
<input value={ldap.port} onChange={(e) => setLdap({ ...ldap, port: e.target.value })} placeholder="389" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={ldap.useSSL} onChange={(e) => setLdap({ ...ldap, useSSL: e.target.checked })} />
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>使用 LDAPS(SSL/TLS 加密连接,端口通常为 636)</span>
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Bind DN(绑定账号)</label>
|
||||||
|
<input value={ldap.bindDN} onChange={(e) => setLdap({ ...ldap, bindDN: e.target.value })} placeholder="cn=admin,dc=example,dc=com" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Bind 密码</label>
|
||||||
|
<input type="password" value={ldap.bindPassword} onChange={(e) => setLdap({ ...ldap, bindPassword: e.target.value })} placeholder={settings?.ldapBindPasswordSet ? '已保存,输入新密码以修改' : ''} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Base DN(用户搜索基准)</label>
|
||||||
|
<input value={ldap.baseDN} onChange={(e) => setLdap({ ...ldap, baseDN: e.target.value })} placeholder="ou=users,dc=example,dc=com" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户过滤器</label>
|
||||||
|
<input value={ldap.userFilter} onChange={(e) => setLdap({ ...ldap, userFilter: e.target.value })} placeholder="(objectClass=inetOrgPerson)" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名属性</label>
|
||||||
|
<input value={ldap.usernameAttr} onChange={(e) => setLdap({ ...ldap, usernameAttr: e.target.value })} placeholder="uid" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>姓名属性</label>
|
||||||
|
<input value={ldap.nameAttr} onChange={(e) => setLdap({ ...ldap, nameAttr: e.target.value })} placeholder="displayName" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>邮箱属性</label>
|
||||||
|
<input value={ldap.emailAttr} onChange={(e) => setLdap({ ...ldap, emailAttr: e.target.value })} placeholder="mail" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 可选:测试账号级验证 */}
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setLdapTestOpen(!ldapTestOpen)} className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--accent-blue)' }}>
|
||||||
|
{ldapTestOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||||
|
测试账号(可选,验证指定用户能否通过目录登录)
|
||||||
|
</button>
|
||||||
|
{ldapTestOpen && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-2.5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>测试用户名</label>
|
||||||
|
<input value={ldapTest.username} onChange={(e) => setLdapTest({ ...ldapTest, username: e.target.value })} placeholder="zhangsan" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>测试密码</label>
|
||||||
|
<input type="password" value={ldapTest.password} onChange={(e) => setLdapTest({ ...ldapTest, password: e.target.value })} placeholder="目录服务中的密码" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
「测试连接」按当前表单草稿直测(Bind 密码留空时使用已保存密码),无需先保存。保存后可在「用户管理」页从 LDAP 批量导入用户。
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => saveLdapMutation.mutate()} disabled={saveLdapMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||||
|
{saveLdapMutation.isPending ? '保存中…' : '保存 LDAP 配置'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => testLdapMutation.mutate()} disabled={testLdapMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||||
|
{testLdapMutation.isPending ? '测试中…' : '测试连接'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+193
-243
@@ -1,142 +1,48 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Save,
|
|
||||||
Download,
|
Download,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Settings,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Monitor,
|
Monitor,
|
||||||
Baby,
|
Baby,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Terminal
|
Terminal,
|
||||||
|
AlertCircle,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
interface DevOpsTool {
|
// 与后端 model.DeployTool 对齐
|
||||||
id: string;
|
interface DeployTool {
|
||||||
name: string;
|
id: number;
|
||||||
key: string;
|
key: string;
|
||||||
description: string;
|
name: string;
|
||||||
category: 'build' | 'deploy';
|
category: 'build' | 'deploy';
|
||||||
status: 'not-installed' | 'installed' | 'installing';
|
status: 'not-installed' | 'installed' | 'installing' | 'failed';
|
||||||
version: string;
|
version: string;
|
||||||
|
installedVersion: string;
|
||||||
installMode: 'preinstalled' | 'lazy' | 'disabled';
|
installMode: 'preinstalled' | 'lazy' | 'disabled';
|
||||||
path: string;
|
path: string;
|
||||||
|
installHint: string;
|
||||||
|
enabled: boolean;
|
||||||
|
lastCheck: string | null;
|
||||||
|
errorMsg: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialTools: DevOpsTool[] = [
|
|
||||||
{
|
|
||||||
id: 'jdk',
|
|
||||||
name: 'JDK (Java)',
|
|
||||||
key: 'jdk',
|
|
||||||
description: 'Java 开发工具包,流水线 Java/Maven 构建阶段使用',
|
|
||||||
category: 'build',
|
|
||||||
status: 'not-installed',
|
|
||||||
version: '',
|
|
||||||
installMode: 'lazy',
|
|
||||||
path: '/opt/tools/jdk',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'maven',
|
|
||||||
name: 'Maven',
|
|
||||||
key: 'maven',
|
|
||||||
description: 'Java 项目构建工具,流水线 Java 构建阶段使用(依赖 JDK)',
|
|
||||||
category: 'build',
|
|
||||||
status: 'not-installed',
|
|
||||||
version: '',
|
|
||||||
installMode: 'lazy',
|
|
||||||
path: '/opt/tools/maven',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'golang',
|
|
||||||
name: 'Go (Golang)',
|
|
||||||
key: 'golang',
|
|
||||||
description: 'Go 语言工具链,流水线 go build/test 阶段使用',
|
|
||||||
category: 'build',
|
|
||||||
status: 'not-installed',
|
|
||||||
version: '',
|
|
||||||
installMode: 'lazy',
|
|
||||||
path: '/opt/tools/go',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'nvm',
|
|
||||||
name: 'NVM (Node 版本管理)',
|
|
||||||
key: 'nvm',
|
|
||||||
description: 'Node 版本管理器,流水线 Node.js 构建阶段使用(node 依赖此工具)',
|
|
||||||
category: 'build',
|
|
||||||
status: 'not-installed',
|
|
||||||
version: '',
|
|
||||||
installMode: 'lazy',
|
|
||||||
path: '/opt/tools/nvm',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'nodejs',
|
|
||||||
name: 'Node.js + npm',
|
|
||||||
key: 'nodejs',
|
|
||||||
description: '前端构建工具,流水线 npm install/build 阶段使用(通过 NVM 安装)',
|
|
||||||
category: 'build',
|
|
||||||
status: 'not-installed',
|
|
||||||
version: '',
|
|
||||||
installMode: 'lazy',
|
|
||||||
path: '/opt/tools/node',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'git',
|
|
||||||
name: 'Git',
|
|
||||||
key: 'git',
|
|
||||||
description: '版本控制工具,流水线代码拉取使用(首次使用自动安装)',
|
|
||||||
category: 'build',
|
|
||||||
status: 'installed',
|
|
||||||
version: '2.43.0',
|
|
||||||
installMode: 'preinstalled',
|
|
||||||
path: '/usr/bin/git',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'docker',
|
|
||||||
name: 'Docker CLI',
|
|
||||||
key: 'docker-cli',
|
|
||||||
description: 'Docker 命令行工具,流水线镜像构建/推送使用(首次使用自动安装)',
|
|
||||||
category: 'deploy',
|
|
||||||
status: 'installed',
|
|
||||||
version: '24.0.7',
|
|
||||||
installMode: 'preinstalled',
|
|
||||||
path: '/usr/bin/docker',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'kubectl',
|
|
||||||
name: 'kubectl',
|
|
||||||
key: 'kubectl',
|
|
||||||
description: 'Kubernetes 命令行工具,流水线 K8s 部署阶段使用',
|
|
||||||
category: 'deploy',
|
|
||||||
status: 'not-installed',
|
|
||||||
version: '',
|
|
||||||
installMode: 'lazy',
|
|
||||||
path: '/opt/tools/kubectl',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'helm',
|
|
||||||
name: 'Helm',
|
|
||||||
key: 'helm',
|
|
||||||
description: 'Kubernetes 包管理器,流水线 Helm 部署阶段使用',
|
|
||||||
category: 'deploy',
|
|
||||||
status: 'not-installed',
|
|
||||||
version: '',
|
|
||||||
installMode: 'lazy',
|
|
||||||
path: '/opt/tools/helm',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const categoryConfig = {
|
const categoryConfig = {
|
||||||
build: { label: '构建', color: '#9a6700', bg: '#fff8c5' },
|
build: { label: '构建', color: '#9a6700', bg: '#fff8c5' },
|
||||||
deploy: { label: '部署', color: '#0969da', bg: '#ddf4ff' },
|
deploy: { label: '部署', color: '#0969da', bg: '#ddf4ff' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig: Record<string, { icon: typeof XCircle; label: string; color: string }> = {
|
||||||
'not-installed': { icon: XCircle, label: '未安装', color: '#6e7781', bg: '#f6f8fa' },
|
'not-installed': { icon: XCircle, label: '未安装', color: '#6e7781' },
|
||||||
'installed': { icon: CheckCircle2, label: '已安装', color: '#1a7f37', bg: '#dafbe1' },
|
installed: { icon: CheckCircle2, label: '已安装', color: '#1a7f37' },
|
||||||
'installing': { icon: Loader2, label: '安装中', color: '#9a6700', bg: '#fff8c5' },
|
installing: { icon: Loader2, label: '安装中', color: '#9a6700' },
|
||||||
|
failed: { icon: AlertCircle, label: '失败', color: '#cf222e' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const installModeConfig = {
|
const installModeConfig = {
|
||||||
@@ -145,54 +51,80 @@ const installModeConfig = {
|
|||||||
disabled: '禁用不安装',
|
disabled: '禁用不安装',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTime = (v: string | null | undefined) => {
|
||||||
|
if (!v) return '';
|
||||||
|
const d = new Date(v);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstLine = (s?: string, max = 120) => {
|
||||||
|
const line = (s || '').trim().split('\n')[0] || '';
|
||||||
|
return line.length > max ? line.slice(0, max) + '…' : line;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ToolConfig() {
|
export default function ToolConfig() {
|
||||||
const [tools, setTools] = useState<DevOpsTool[]>(initialTools);
|
const queryClient = useQueryClient();
|
||||||
const [mode, setMode] = useState<'local' | 'jenkins'>('local');
|
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
|
||||||
const [installingId, setInstallingId] = useState<string | null>(null);
|
|
||||||
const [dropdownOpen, setDropdownOpen] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleInstall = (toolId: string) => {
|
const { data: tools = [], isLoading } = useQuery({ queryKey: ['tools'], queryFn: apiClient.listTools });
|
||||||
setInstallingId(toolId);
|
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||||
setTools(prev => prev.map(t => t.id === toolId ? { ...t, status: 'installing' } : t));
|
const toolMode: 'local' | 'jenkins' = settings?.toolMode === 'jenkins' ? 'jenkins' : 'local';
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setTools(prev => prev.map(t =>
|
|
||||||
t.id === toolId
|
|
||||||
? { ...t, status: 'installed', version: getDefaultVersion(toolId) }
|
|
||||||
: t
|
|
||||||
));
|
|
||||||
setInstallingId(null);
|
|
||||||
toast.success('安装完成', { description: `${getToolName(toolId)} 安装成功` });
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTest = (toolId: string) => {
|
const invalidateTools = () => queryClient.invalidateQueries({ queryKey: ['tools'] });
|
||||||
const tool = tools.find(t => t.id === toolId);
|
|
||||||
if (tool?.status === 'installed') {
|
|
||||||
toast.success('测试通过', { description: `${tool.name} v${tool.version} 运行正常` });
|
|
||||||
} else {
|
|
||||||
toast.error('测试失败', { description: `${tool?.name || toolId} 未安装` });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModeChange = (toolId: string, newMode: 'preinstalled' | 'lazy' | 'disabled') => {
|
// 本地 / Jenkins 模式切换(写入 settings.toolMode)
|
||||||
setTools(prev => prev.map(t => t.id === toolId ? { ...t, installMode: newMode } : t));
|
const modeMutation = useMutation({
|
||||||
setDropdownOpen(null);
|
mutationFn: (mode: 'local' | 'jenkins') => apiClient.updateSettings({ toolMode: mode }),
|
||||||
toast.info('策略已更新', { description: `${getToolName(toolId)} 安装策略改为: ${installModeConfig[newMode]}` });
|
onSuccess: (_data, mode) => {
|
||||||
};
|
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||||
|
toast.success(mode === 'local' ? '已切换为本地模式' : '已切换为 Jenkins 模式');
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
const getDefaultVersion = (id: string) => {
|
// 安装策略切换
|
||||||
const versions: Record<string, string> = {
|
const modeChangeMutation = useMutation({
|
||||||
jdk: '17.0.9', maven: '3.9.6', golang: '1.22.0', nvm: '0.39.7',
|
mutationFn: (input: { tool: DeployTool; installMode: DeployTool['installMode'] }) =>
|
||||||
nodejs: '20.11.0', git: '2.43.0', docker: '24.0.7', kubectl: '1.28.3', helm: '3.14.0',
|
apiClient.updateTool(input.tool.id, { installMode: input.installMode }),
|
||||||
};
|
onSuccess: (_data, input) => {
|
||||||
return versions[id] || '1.0.0';
|
invalidateTools();
|
||||||
};
|
toast.success('策略已更新', { description: `${input.tool.name}: ${installModeConfig[input.installMode]}` });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
const getToolName = (id: string) => tools.find(t => t.id === id)?.name || id;
|
// 检测(真实执行探测命令)
|
||||||
|
const checkMutation = useMutation({
|
||||||
|
mutationFn: (tool: DeployTool) => apiClient.checkTool(tool.id),
|
||||||
|
onSuccess: (data: any, tool) => {
|
||||||
|
invalidateTools();
|
||||||
|
if (data?.detected) {
|
||||||
|
toast.success(`检测到版本 ${data.detected}`, { description: firstLine(data.output) || tool.name });
|
||||||
|
} else {
|
||||||
|
toast.error('未检测到', { description: firstLine(data?.output) || '检测命令未返回可用版本信息' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('检测失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
const installedCount = tools.filter(t => t.status === 'installed').length;
|
// 安装(保守策略:后端只真实检测,未装会提示手动安装)
|
||||||
const notInstalledCount = tools.filter(t => t.status === 'not-installed').length;
|
const installMutation = useMutation({
|
||||||
|
mutationFn: (tool: DeployTool) => apiClient.installTool(tool.id),
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
invalidateTools();
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success(data.message || '安装完成');
|
||||||
|
} else {
|
||||||
|
toast.warning(data.message || '未能完成安装,请参考输出信息手动安装');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('安装失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const installedCount = tools.filter((t: DeployTool) => t.status === 'installed').length;
|
||||||
|
const notInstalledCount = tools.filter((t: DeployTool) => t.status === 'not-installed').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
@@ -208,51 +140,45 @@ export default function ToolConfig() {
|
|||||||
{/* Mode toggle */}
|
{/* Mode toggle */}
|
||||||
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode('local')}
|
onClick={() => modeMutation.mutate('local')}
|
||||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
|
disabled={modeMutation.isPending || toolMode === 'local'}
|
||||||
style={{
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors disabled:opacity-60"
|
||||||
background: mode === 'local' ? 'var(--panel-bg-secondary)' : 'transparent',
|
style={{
|
||||||
color: mode === 'local' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
background: toolMode === 'local' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||||
|
color: toolMode === 'local' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Monitor className="w-3.5 h-3.5" />
|
<Monitor className="w-3.5 h-3.5" />
|
||||||
本地模式
|
本地模式
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode('jenkins')}
|
onClick={() => modeMutation.mutate('jenkins')}
|
||||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
|
disabled={modeMutation.isPending || toolMode === 'jenkins'}
|
||||||
style={{
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors disabled:opacity-60"
|
||||||
background: mode === 'jenkins' ? 'var(--panel-bg-secondary)' : 'transparent',
|
style={{
|
||||||
color: mode === 'jenkins' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
background: toolMode === 'jenkins' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||||
|
color: toolMode === 'jenkins' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Baby className="w-3.5 h-3.5" />
|
<Baby className="w-3.5 h-3.5" />
|
||||||
Jenkins 模式
|
Jenkins 模式
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => toast.success('刷新完成', { description: '工具状态已更新' })}
|
onClick={() => { invalidateTools(); }}
|
||||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
||||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => toast.success('配置已保存')}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
|
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
|
||||||
>
|
|
||||||
<Save className="w-3.5 h-3.5" />
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description bar */}
|
{/* Description bar */}
|
||||||
<div className="px-5 py-2 border-b text-[12px] flex items-center gap-2" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>
|
<div className="px-5 py-2 border-b text-[12px] flex items-center gap-2" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>
|
||||||
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
管理 DevOps 工具的安装策略:预装在镜像中、首次使用自动下载(懒加载)、或禁用不安装
|
构建环境建议 Ubuntu 22.04(apt 安装下方工具);Node 由 nvm 管理——默认版本可在此调整,流水线构建时用 <code className="px-1 rounded font-mono text-[11px]" style={{ background: 'var(--panel-bg)', color: 'var(--accent-blue)' }}>nvm install $NODE_VERSION</code> 灵活指定版本。可用 deploy/Dockerfile.buildnode 一键构建节点镜像
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -262,8 +188,8 @@ export default function ToolConfig() {
|
|||||||
{[
|
{[
|
||||||
{ label: '已安装', count: installedCount, color: '#1a7f37' },
|
{ label: '已安装', count: installedCount, color: '#1a7f37' },
|
||||||
{ label: '未安装', count: notInstalledCount, color: '#6e7781' },
|
{ label: '未安装', count: notInstalledCount, color: '#6e7781' },
|
||||||
{ label: '构建工具', count: tools.filter(t => t.category === 'build').length, color: '#9a6700' },
|
{ label: '构建工具', count: tools.filter((t: DeployTool) => t.category === 'build').length, color: '#9a6700' },
|
||||||
{ label: '部署工具', count: tools.filter(t => t.category === 'deploy').length, color: '#0969da' },
|
{ label: '部署工具', count: tools.filter((t: DeployTool) => t.category === 'deploy').length, color: '#0969da' },
|
||||||
].map((stat) => (
|
].map((stat) => (
|
||||||
<div key={stat.label} className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div key={stat.label} className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
||||||
@@ -272,11 +198,11 @@ export default function ToolConfig() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool list - screenshot style */}
|
{/* Tool list */}
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[1fr_100px_120px_280px] gap-4 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider"
|
className="grid grid-cols-[1fr_100px_130px_300px] gap-4 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||||
>
|
>
|
||||||
<div>工具配置</div>
|
<div>工具配置</div>
|
||||||
@@ -285,26 +211,50 @@ export default function ToolConfig() {
|
|||||||
<div className="text-right">操作</div>
|
<div className="text-right">操作</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tools.map((tool) => {
|
{isLoading ? (
|
||||||
const cat = categoryConfig[tool.category];
|
<div className="px-4 py-10 flex items-center justify-center gap-2 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
const status = statusConfig[tool.status];
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
正在加载工具列表...
|
||||||
|
</div>
|
||||||
|
) : tools.length === 0 ? (
|
||||||
|
<div className="px-4 py-10 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
暂无工具数据
|
||||||
|
</div>
|
||||||
|
) : tools.map((tool: DeployTool) => {
|
||||||
|
const cat = categoryConfig[tool.category] || categoryConfig.build;
|
||||||
|
const status = statusConfig[tool.status] || statusConfig['not-installed'];
|
||||||
const StatusIcon = status.icon;
|
const StatusIcon = status.icon;
|
||||||
const isInstalling = installingId === tool.id;
|
const isInstalling = tool.status === 'installing';
|
||||||
|
const isChecking = checkMutation.isPending && checkMutation.variables?.id === tool.id;
|
||||||
|
const isInstallingNow = installMutation.isPending && installMutation.variables?.id === tool.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
className="grid grid-cols-[1fr_100px_120px_280px] gap-4 px-4 py-3 border-t items-center"
|
className="grid grid-cols-[1fr_100px_130px_300px] gap-4 px-4 py-3 border-t items-center"
|
||||||
style={{ borderColor: 'var(--border-color-light)' }}
|
style={{ borderColor: 'var(--border-color-light)' }}
|
||||||
>
|
>
|
||||||
{/* Tool info */}
|
{/* Tool info */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[14px] font-semibold mb-0.5" style={{ color: 'var(--text-primary)' }}>{tool.name}</div>
|
<div className="text-[14px] font-semibold mb-0.5" style={{ color: 'var(--text-primary)' }}>{tool.name}</div>
|
||||||
<div className="text-[12px] font-mono mb-1" style={{ color: 'var(--text-tertiary)' }}>{tool.key}</div>
|
<div className="text-[12px] font-mono mb-1" style={{ color: 'var(--text-tertiary)' }}>{tool.key}</div>
|
||||||
<div className="text-[11px] leading-relaxed" style={{ color: 'var(--text-secondary)' }}>{tool.description}</div>
|
{tool.installHint && tool.status !== 'installed' && (
|
||||||
{tool.status === 'installed' && (
|
<button
|
||||||
|
onClick={() => { navigator.clipboard.writeText(tool.installHint); toast.success('安装命令已复制'); }}
|
||||||
|
className="mt-1 flex items-center gap-1.5 px-2 py-[3px] rounded font-mono text-[10px] max-w-full hover:opacity-80"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}
|
||||||
|
title={tool.installHint}
|
||||||
|
>
|
||||||
|
<Copy className="w-2.5 h-2.5 flex-shrink-0" />
|
||||||
|
<span className="truncate">{tool.installHint}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{tool.status === 'installed' && tool.path && (
|
||||||
<div className="text-[10px] mt-1 font-mono" style={{ color: 'var(--text-tertiary)' }}>路径: {tool.path}</div>
|
<div className="text-[10px] mt-1 font-mono" style={{ color: 'var(--text-tertiary)' }}>路径: {tool.path}</div>
|
||||||
)}
|
)}
|
||||||
|
{tool.status === 'failed' && tool.errorMsg && (
|
||||||
|
<div className="text-[10px] mt-1" style={{ color: '#cf222e' }}>{tool.errorMsg}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
@@ -320,25 +270,33 @@ export default function ToolConfig() {
|
|||||||
<StatusIcon className={`w-3.5 h-3.5 ${isInstalling ? 'animate-spin' : ''}`} style={{ color: status.color }} />
|
<StatusIcon className={`w-3.5 h-3.5 ${isInstalling ? 'animate-spin' : ''}`} style={{ color: status.color }} />
|
||||||
<span className="text-[12px] font-medium" style={{ color: status.color }}>{status.label}</span>
|
<span className="text-[12px] font-medium" style={{ color: status.color }}>{status.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{tool.status === 'installed' && (
|
{tool.status === 'installed' ? (
|
||||||
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>v{tool.version}</div>
|
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>v{tool.installedVersion || tool.version || '?'}</div>
|
||||||
|
) : (
|
||||||
|
tool.version && (
|
||||||
|
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={tool.errorMsg || undefined}>
|
||||||
|
{tool.key === 'nodejs' ? `默认 v${tool.version}(构建时可覆盖)` : `期望 v${tool.version}`}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{tool.lastCheck && (
|
||||||
|
<div className="text-[10px] mt-0.5" style={{ color: 'var(--text-tertiary)', opacity: 0.7 }}>检测: {formatTime(tool.lastCheck)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{/* Install / Installing button */}
|
{/* Install / Installing button */}
|
||||||
{tool.status === 'not-installed' ? (
|
{tool.status === 'installed' ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleInstall(tool.id)}
|
disabled
|
||||||
disabled={isInstalling}
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
||||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50"
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}
|
||||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
|
||||||
>
|
>
|
||||||
<Download className={`w-3.5 h-3.5 ${isInstalling ? 'animate-bounce' : ''}`} />
|
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||||
{isInstalling ? '安装中...' : '安装'}
|
已安装
|
||||||
</button>
|
</button>
|
||||||
) : tool.status === 'installing' ? (
|
) : isInstalling || isInstallingNow ? (
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border opacity-50"
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border opacity-50"
|
||||||
@@ -349,72 +307,64 @@ export default function ToolConfig() {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
onClick={() => installMutation.mutate(tool)}
|
||||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}
|
disabled={installMutation.isPending}
|
||||||
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50"
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
|
title="后端采用保守策略:仅真实检测,未安装时会提示手动安装"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
<Download className="w-3.5 h-3.5" />
|
||||||
已安装
|
安装
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Version / Install strategy dropdown */}
|
{/* Install strategy dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDropdownOpen(dropdownOpen === tool.id ? null : tool.id)}
|
onClick={() => setDropdownOpen(dropdownOpen === tool.id ? null : tool.id)}
|
||||||
className="flex items-center gap-1 px-2 py-[5px] text-[11px] rounded-md border"
|
className="flex items-center gap-1 px-2 py-[5px] text-[11px] rounded-md border"
|
||||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
{tool.status === 'installed' ? tool.version : installModeConfig[tool.installMode]}
|
{installModeConfig[tool.installMode]}
|
||||||
<ChevronDown className="w-3 h-3" />
|
<ChevronDown className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{dropdownOpen === tool.id && (
|
{dropdownOpen === tool.id && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setDropdownOpen(null)} />
|
<div className="fixed inset-0 z-40" onClick={() => setDropdownOpen(null)} />
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-full mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[160px]"
|
className="absolute right-0 top-full mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[170px]"
|
||||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
>
|
>
|
||||||
{tool.status !== 'installed' && (
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>安装策略</div>
|
||||||
<>
|
{(Object.keys(installModeConfig) as Array<keyof typeof installModeConfig>).map((modeKey) => (
|
||||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>安装策略</div>
|
<button
|
||||||
{(Object.keys(installModeConfig) as Array<keyof typeof installModeConfig>).map((modeKey) => (
|
key={modeKey}
|
||||||
<button
|
onClick={() => { setDropdownOpen(null); modeChangeMutation.mutate({ tool, installMode: modeKey }); }}
|
||||||
key={modeKey}
|
className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors flex items-center justify-between gap-2"
|
||||||
onClick={() => handleModeChange(tool.id, modeKey)}
|
style={{ color: tool.installMode === modeKey ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
|
||||||
className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors"
|
>
|
||||||
style={{ color: tool.installMode === modeKey ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
|
{installModeConfig[modeKey]}
|
||||||
>
|
{tool.installMode === modeKey && <CheckCircle2 className="w-3 h-3" />}
|
||||||
{installModeConfig[modeKey]}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
<div className="border-t my-1" style={{ borderColor: 'var(--border-color-light)' }} />
|
||||||
<div className="border-t my-1" style={{ borderColor: 'var(--border-color-light)' }} />
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>版本信息</div>
|
||||||
</>
|
<div className="px-3 py-[4px] text-[11px]" style={{ color: 'var(--text-secondary)' }}>期望版本: {tool.version || '—'}</div>
|
||||||
)}
|
<div className="px-3 py-[4px] text-[11px]" style={{ color: 'var(--text-secondary)' }}>已安装: {tool.installedVersion || '—'}</div>
|
||||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>版本</div>
|
|
||||||
<button className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
{getDefaultVersion(tool.id)} (默认)
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Test button */}
|
{/* Check button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTest(tool.id)}
|
onClick={() => checkMutation.mutate(tool)}
|
||||||
className="px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]"
|
disabled={checkMutation.isPending}
|
||||||
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50"
|
||||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
>
|
>
|
||||||
测试
|
{isChecking ? <Loader2 className="w-3 h-3 animate-spin" /> : null}
|
||||||
</button>
|
检测
|
||||||
|
|
||||||
{/* Settings button */}
|
|
||||||
<button
|
|
||||||
onClick={() => toast.info('工具配置', { description: `${tool.name} 高级配置功能开发中...` })}
|
|
||||||
className="p-[5px] rounded-md border transition-colors hover:bg-black/[0.02]"
|
|
||||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}
|
|
||||||
>
|
|
||||||
<Settings className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
KeyRound,
|
||||||
|
Trash2,
|
||||||
|
ShieldCheck,
|
||||||
|
Copy,
|
||||||
|
CloudDownload,
|
||||||
|
AlertCircle,
|
||||||
|
UserCog,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
function fmtTime(v?: string): string {
|
||||||
|
if (!v || v.startsWith('0001-')) return '—';
|
||||||
|
return v.replace('T', ' ').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleBadge = {
|
||||||
|
admin: { label: '管理员', bg: '#dafbe1', color: '#1a7f37' },
|
||||||
|
user: { label: '普通用户', bg: '#f6f8fa', color: '#6e7781' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const sourceBadge = {
|
||||||
|
ldap: { label: 'LDAP 导入', bg: '#fbefff', color: '#8250df' },
|
||||||
|
local: { label: '本地账号', bg: '#ddf4ff', color: '#0969da' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface UserForm {
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: UserForm = { username: '', nickname: '', email: '', role: 'user', password: '' };
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
background: 'var(--panel-bg-secondary)',
|
||||||
|
borderColor: 'var(--border-color-light)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LdapEntry {
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportResult {
|
||||||
|
created: number;
|
||||||
|
converted: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
failedUsers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||||
|
const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null);
|
||||||
|
const [ldapOpen, setLdapOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
|
|
||||||
|
const usersQuery = useQuery({ queryKey: ['users'], queryFn: apiClient.listUsers });
|
||||||
|
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||||
|
|
||||||
|
// LDAP 就绪(已启用且关键配置齐全)时才显示「从 LDAP 导入」入口
|
||||||
|
const ldapReady = !!(settings?.ldapEnabled && settings?.ldapHost && settings?.ldapBindDN && settings?.ldapBaseDN);
|
||||||
|
|
||||||
|
const users: any[] = usersQuery.data ?? [];
|
||||||
|
|
||||||
|
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
|
||||||
|
// LDAP 预览列表(仅弹窗打开且未进入结果态时请求)
|
||||||
|
const ldapQuery = useQuery({
|
||||||
|
queryKey: ['ldap-users'],
|
||||||
|
queryFn: apiClient.ldapListUsers,
|
||||||
|
enabled: ldapOpen && !importResult,
|
||||||
|
});
|
||||||
|
const ldapEntries: LdapEntry[] = (ldapQuery.data as LdapEntry[]) ?? [];
|
||||||
|
|
||||||
|
// 本地用户索引(按用户名小写匹配,目录服务通常大小写不敏感)
|
||||||
|
const localMap = useMemo(() => {
|
||||||
|
const m = new Map<string, any>();
|
||||||
|
users.forEach((u) => m.set(String(u.username || '').toLowerCase(), u));
|
||||||
|
return m;
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
|
// ---- 新建用户 ----
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: any) => apiClient.createUser(body),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
invalidateUsers();
|
||||||
|
setDialogOpen(false);
|
||||||
|
if (res?.initialPassword) {
|
||||||
|
setCreatedResult({ username: form.username.trim(), password: String(res.initialPassword) });
|
||||||
|
} else {
|
||||||
|
toast.success('用户已创建');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitCreate = () => {
|
||||||
|
if (!form.username.trim()) {
|
||||||
|
toast.error('请填写用户名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body: any = {
|
||||||
|
username: form.username.trim(),
|
||||||
|
nickname: form.nickname.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
role: form.role,
|
||||||
|
};
|
||||||
|
if (form.password) body.password = form.password;
|
||||||
|
createMutation.mutate(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 重置密码 ----
|
||||||
|
const resetMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.resetUserPassword(id),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
if (res?.initialPassword) {
|
||||||
|
toast.success('密码已重置', { description: `一次性密码:${res.initialPassword}(仅显示一次,请立即保存)` });
|
||||||
|
} else {
|
||||||
|
toast.success('密码已重置');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 角色切换 ----
|
||||||
|
const roleMutation = useMutation({
|
||||||
|
mutationFn: (vars: { id: any; role: string }) => apiClient.updateUser(vars.id, { role: vars.role }),
|
||||||
|
onSuccess: (_d, vars) => {
|
||||||
|
toast.success(vars.role === 'admin' ? '已设为管理员' : '已降为普通用户');
|
||||||
|
invalidateUsers();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 删除 ----
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: any) => apiClient.deleteUser(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('用户已删除');
|
||||||
|
invalidateUsers();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- LDAP 导入 ----
|
||||||
|
const importMutation = useMutation({
|
||||||
|
mutationFn: (list: LdapEntry[]) => apiClient.ldapImport(list),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
setImportResult({
|
||||||
|
created: res?.created ?? 0,
|
||||||
|
converted: res?.converted ?? 0,
|
||||||
|
skipped: res?.skipped ?? 0,
|
||||||
|
failed: res?.failed ?? 0,
|
||||||
|
failedUsers: res?.failedUsers ?? [],
|
||||||
|
});
|
||||||
|
invalidateUsers();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('导入失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openLdapDialog = () => {
|
||||||
|
setSelected(new Set());
|
||||||
|
setImportResult(null);
|
||||||
|
setLdapOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeLdapDialog = (open: boolean) => {
|
||||||
|
setLdapOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setSelected(new Set());
|
||||||
|
setImportResult(null);
|
||||||
|
invalidateUsers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelected = (username: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(username)) next.delete(username);
|
||||||
|
else next.add(username);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedEntries = ldapEntries.filter((e) => selected.has(e.username));
|
||||||
|
|
||||||
|
const copyText = (text: string) => {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(text)
|
||||||
|
.then(() => toast.success('已复制', { description: text }))
|
||||||
|
.catch(() => toast.error('复制失败'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const importStats = importResult
|
||||||
|
? [
|
||||||
|
{ key: 'created', label: '新增', value: importResult.created, bg: '#dafbe1', color: '#1a7f37' },
|
||||||
|
{ key: 'converted', label: '转换', value: importResult.converted, bg: '#ddf4ff', color: '#0969da' },
|
||||||
|
{ key: 'skipped', label: '跳过', value: importResult.skipped, bg: '#f6f8fa', color: '#6e7781' },
|
||||||
|
{ key: 'failed', label: '失败', value: importResult.failed, bg: '#ffebe9', color: '#cf222e' },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||||
|
<div
|
||||||
|
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||||
|
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>用户管理</h1>
|
||||||
|
<span
|
||||||
|
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
{users.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ldapReady && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
onClick={openLdapDialog}
|
||||||
|
>
|
||||||
|
<CloudDownload className="w-3.5 h-3.5" />
|
||||||
|
从 LDAP 导入
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
onClick={() => {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
新建用户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
|
{usersQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
) : usersQuery.isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-16">
|
||||||
|
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(usersQuery.error as Error).message}</div>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
|
onClick={() => usersQuery.refetch()}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||||
|
{['用户名', '昵称', '邮箱', '角色', '来源', '创建时间', '操作'].map((h) => (
|
||||||
|
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => {
|
||||||
|
const role = (roleBadge as any)[u.role] ?? roleBadge.user;
|
||||||
|
const source = (sourceBadge as any)[u.authSource] ?? sourceBadge.local;
|
||||||
|
const isAdmin = u.role === 'admin';
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-[13px] font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{u.nickname || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{u.email || '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: role.bg, color: role.color }}>
|
||||||
|
{role.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: source.bg, color: source.color }}>
|
||||||
|
{source.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{fmtTime(u.createdAt)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
{u.authSource !== 'ldap' && (
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="重置密码"
|
||||||
|
disabled={resetMutation.isPending}
|
||||||
|
onClick={() => resetMutation.mutate(u.id)}
|
||||||
|
>
|
||||||
|
<KeyRound className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title={isAdmin ? '降为普通用户' : '设为管理员'}
|
||||||
|
disabled={roleMutation.isPending}
|
||||||
|
onClick={() => roleMutation.mutate({ id: u.id, role: isAdmin ? 'user' : 'admin' })}
|
||||||
|
>
|
||||||
|
{isAdmin ? (
|
||||||
|
<UserCog className="w-3.5 h-3.5" style={{ color: '#1a7f37' }} />
|
||||||
|
) : (
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded hover:bg-black/5"
|
||||||
|
title="删除"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`确定删除用户 ${u.username} 吗?`)) deleteMutation.mutate(u.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{users.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-12 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
暂无用户,点击右上角「新建用户」创建
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 新建用户弹窗 */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>新建用户</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名 *</Label>
|
||||||
|
<Input
|
||||||
|
className="h-8 text-[12px] font-mono"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||||
|
placeholder="zhangsan"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>昵称</Label>
|
||||||
|
<Input
|
||||||
|
className="h-8 text-[12px]"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.nickname}
|
||||||
|
onChange={(e) => setForm({ ...form, nickname: e.target.value })}
|
||||||
|
placeholder="张三"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>角色</Label>
|
||||||
|
<Select value={form.role} onValueChange={(v) => setForm({ ...form, role: v })}>
|
||||||
|
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
|
||||||
|
<SelectValue placeholder="选择角色" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user" className="text-[12px]">普通用户</SelectItem>
|
||||||
|
<SelectItem value="admin" className="text-[12px]">管理员</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>邮箱</Label>
|
||||||
|
<Input
|
||||||
|
className="h-8 text-[12px]"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
placeholder="zhangsan@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>密码</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
className="h-8 text-[12px] font-mono"
|
||||||
|
style={inputStyle}
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
placeholder="留空由系统随机生成"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
onClick={submitCreate}
|
||||||
|
>
|
||||||
|
{createMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 创建成功:一次性密码 */}
|
||||||
|
<Dialog open={!!createdResult} onOpenChange={(open) => !open && setCreatedResult(null)}>
|
||||||
|
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>用户已创建</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
用户 <span className="font-mono font-medium" style={{ color: 'var(--text-primary)' }}>{createdResult?.username}</span> 创建成功。
|
||||||
|
系统已生成一次性初始密码,仅显示此次,请立即复制并妥善交付给用户:
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2.5 rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
||||||
|
>
|
||||||
|
<code className="text-[14px] font-mono font-semibold tracking-wide break-all" style={{ color: 'var(--accent-blue)' }}>
|
||||||
|
{createdResult?.password}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-[11px] rounded border flex-shrink-0"
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||||
|
onClick={() => createdResult && copyText(createdResult.password)}
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
onClick={() => setCreatedResult(null)}
|
||||||
|
>
|
||||||
|
我已保存
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* LDAP 导入弹窗 */}
|
||||||
|
<Dialog open={ldapOpen} onOpenChange={closeLdapDialog}>
|
||||||
|
<DialogContent className="sm:max-w-lg" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>从 LDAP 导入用户</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{importResult ? (
|
||||||
|
/* 结果态:四色统计 + 失败名单 */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{importStats.map((s) => (
|
||||||
|
<div key={s.key} className="rounded-md px-3 py-2.5 text-center" style={{ background: s.bg }}>
|
||||||
|
<div className="text-[18px] font-bold" style={{ color: s.color }}>{s.value}</div>
|
||||||
|
<div className="text-[11px] font-medium" style={{ color: s.color }}>{s.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{importResult.failedUsers.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-md border px-3 py-2.5 space-y-1.5"
|
||||||
|
style={{ background: '#ffebe9', borderColor: '#ff818266' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 text-[12px] font-medium" style={{ color: '#cf222e' }}>
|
||||||
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
|
以下用户导入失败:
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{importResult.failedUsers.map((name) => (
|
||||||
|
<code key={name} className="text-[11px] font-mono px-1.5 py-[2px] rounded" style={{ background: '#ffffff99', color: '#cf222e' }}>
|
||||||
|
{name}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
「转换」表示本地同名账号已切换为 LDAP 来源;「跳过」表示该用户已是 LDAP 账号。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : ldapQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-12 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
正在从 LDAP 目录读取用户...
|
||||||
|
</div>
|
||||||
|
) : ldapQuery.isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-10">
|
||||||
|
<div className="text-[13px] text-center" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{(ldapQuery.error as Error).message}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>请到系统设置检查 LDAP 配置</div>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||||
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||||
|
onClick={() => ldapQuery.refetch()}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 选择态 */
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="max-h-[340px] overflow-y-auto custom-scrollbar rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||||
|
{ldapEntries.map((entry) => {
|
||||||
|
const local = localMap.get(String(entry.username || '').toLowerCase());
|
||||||
|
const isImported = local?.authSource === 'ldap';
|
||||||
|
const willConvert = !!local && local.authSource !== 'ldap';
|
||||||
|
const checked = selected.has(entry.username);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={entry.username}
|
||||||
|
className={`flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 ${isImported ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
style={{ borderColor: 'var(--border-color-light)', background: checked ? 'var(--accent-blue-light)' : 'transparent' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-3.5 h-3.5 flex-shrink-0"
|
||||||
|
style={{ accentColor: 'var(--accent-blue)' }}
|
||||||
|
checked={checked}
|
||||||
|
disabled={isImported}
|
||||||
|
onChange={() => toggleSelected(entry.username)}
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] font-mono font-medium flex-shrink-0" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{entry.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] truncate flex-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{[entry.displayName, entry.email].filter(Boolean).join(' · ') || '—'}
|
||||||
|
</span>
|
||||||
|
{isImported && (
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded flex-shrink-0" style={{ background: '#f6f8fa', color: '#6e7781' }}>已导入</span>
|
||||||
|
)}
|
||||||
|
{willConvert && (
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded flex-shrink-0" style={{ background: '#ddf4ff', color: '#0969da' }}>将转为 LDAP</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{ldapEntries.length === 0 && (
|
||||||
|
<div className="px-3 py-10 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
目录中未读取到用户
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
共 {ldapEntries.length} 个用户,已选 {selected.size} 个
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white disabled:opacity-60"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
disabled={selected.size === 0 || importMutation.isPending}
|
||||||
|
onClick={() => importMutation.mutate(selectedEntries)}
|
||||||
|
>
|
||||||
|
{importMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
导入 {selected.size} 个用户
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importResult && (
|
||||||
|
<DialogFooter>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||||
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
|
onClick={() => closeLdapDialog(false)}
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,55 +1,24 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Webhook, Plus, Trash2, Edit2, ToggleLeft, ToggleRight,
|
Webhook, Plus, Trash2, Edit2, ToggleLeft, ToggleRight,
|
||||||
CheckCircle2, AlertTriangle,
|
CheckCircle2, AlertTriangle, Send, Loader2, Lock
|
||||||
Send
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
|
// 与后端 model.WebhookEndpoint 对齐(events 为 JSON 字符串,secret 只写不读)
|
||||||
interface WebhookItem {
|
interface WebhookItem {
|
||||||
id: string;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
events: string[];
|
secretSet: boolean;
|
||||||
secret: string;
|
events: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
lastTriggered: string;
|
lastTriggered: string | null;
|
||||||
lastStatus: 'success' | 'failed' | 'none';
|
lastStatus: 'success' | 'failed' | 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialWebhooks: WebhookItem[] = [
|
|
||||||
{
|
|
||||||
id: 'wh-1',
|
|
||||||
name: '飞书群通知',
|
|
||||||
url: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx',
|
|
||||||
events: ['pipeline.success', 'pipeline.failed'],
|
|
||||||
secret: 'whsec_xxxxxxxxxxxx',
|
|
||||||
active: true,
|
|
||||||
lastTriggered: '2024-10-26 14:30:15',
|
|
||||||
lastStatus: 'success',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'wh-2',
|
|
||||||
name: '钉钉群通知',
|
|
||||||
url: 'https://oapi.dingtalk.com/robot/send?access_token=xxxxx',
|
|
||||||
events: ['pipeline.success', 'pipeline.failed', 'pipeline.started'],
|
|
||||||
secret: 'SEC_xxxxxxxxxxxx',
|
|
||||||
active: true,
|
|
||||||
lastTriggered: '2024-10-26 14:15:10',
|
|
||||||
lastStatus: 'success',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'wh-3',
|
|
||||||
name: '自定义回调',
|
|
||||||
url: 'https://api.example.com/webhook/cicd',
|
|
||||||
events: ['pipeline.failed'],
|
|
||||||
secret: '',
|
|
||||||
active: false,
|
|
||||||
lastTriggered: '从未触发',
|
|
||||||
lastStatus: 'none',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const eventOptions = [
|
const eventOptions = [
|
||||||
{ value: 'pipeline.started', label: '流水线开始', desc: '当流水线开始执行时触发' },
|
{ value: 'pipeline.started', label: '流水线开始', desc: '当流水线开始执行时触发' },
|
||||||
{ value: 'pipeline.success', label: '流水线成功', desc: '当流水线执行成功时触发' },
|
{ value: 'pipeline.success', label: '流水线成功', desc: '当流水线执行成功时触发' },
|
||||||
@@ -61,44 +30,105 @@ const eventOptions = [
|
|||||||
{ value: 'deploy.failed', label: '部署失败', desc: '当部署阶段失败时触发' },
|
{ value: 'deploy.failed', label: '部署失败', desc: '当部署阶段失败时触发' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const parseEvents = (raw: string): string[] => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw);
|
||||||
|
return Array.isArray(v) ? v.filter(e => typeof e === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (v: string | null | undefined) => {
|
||||||
|
if (!v) return '从未触发';
|
||||||
|
const d = new Date(v);
|
||||||
|
if (Number.isNaN(d.getTime())) return '从未触发';
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = { name: '', url: '', secret: '', events: [] as string[] };
|
||||||
|
|
||||||
export default function WebhookConfig() {
|
export default function WebhookConfig() {
|
||||||
const [webhooks, setWebhooks] = useState<WebhookItem[]>(initialWebhooks);
|
const queryClient = useQueryClient();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [form, setForm] = useState({ name: '', url: '', secret: '', events: [] as string[] });
|
const [editingSecretSet, setEditingSecretSet] = useState(false);
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
|
||||||
const handleToggle = (id: string) => {
|
const { data: webhooks = [], isLoading } = useQuery({ queryKey: ['webhooks'], queryFn: apiClient.listWebhooks });
|
||||||
setWebhooks(prev => prev.map(w => w.id === id ? { ...w, active: !w.active } : w));
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['webhooks'] });
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
// 新建 / 编辑(secret 留空表示不修改)
|
||||||
setWebhooks(prev => prev.filter(w => w.id !== id));
|
const saveMutation = useMutation({
|
||||||
toast.success('Webhook 已删除');
|
mutationFn: (input: { editingId: number | null; form: typeof emptyForm }) => {
|
||||||
|
const body: Record<string, any> = { name: input.form.name, url: input.form.url, events: input.form.events };
|
||||||
|
if (input.form.secret) body.secret = input.form.secret;
|
||||||
|
return input.editingId != null
|
||||||
|
? apiClient.updateWebhook(input.editingId, body)
|
||||||
|
: apiClient.createWebhook(body);
|
||||||
|
},
|
||||||
|
onSuccess: (_data, input) => {
|
||||||
|
toast.success(input.editingId != null ? 'Webhook 已更新' : 'Webhook 已创建');
|
||||||
|
invalidate();
|
||||||
|
closeForm();
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => apiClient.deleteWebhook(id),
|
||||||
|
onSuccess: () => { toast.success('Webhook 已删除'); invalidate(); },
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: (wh: WebhookItem) => apiClient.updateWebhook(wh.id, { active: !wh.active }),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 测试发送:真实 HTTP POST + HMAC-SHA256 签名
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => apiClient.testWebhook(id),
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
invalidate();
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success('测试发送成功', { description: data.message || `响应 ${data.statusCode}` });
|
||||||
|
} else {
|
||||||
|
toast.error('测试发送失败', { description: data?.message || '目标地址返回异常' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error('测试发送失败', { description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeForm = () => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingSecretSet(false);
|
||||||
|
setForm(emptyForm);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!form.name || !form.url) {
|
if (!form.name.trim() || !form.url.trim()) {
|
||||||
toast.error('请填写名称和 URL');
|
toast.error('请填写名称和 URL');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (editingId) {
|
saveMutation.mutate({ editingId, form });
|
||||||
setWebhooks(prev => prev.map(w => w.id === editingId ? { ...w, ...form } : w));
|
|
||||||
toast.success('Webhook 已更新');
|
|
||||||
} else {
|
|
||||||
setWebhooks(prev => [...prev, { ...form, id: `wh-${Date.now()}`, active: true, lastTriggered: '从未触发', lastStatus: 'none' }]);
|
|
||||||
toast.success('Webhook 已创建');
|
|
||||||
}
|
|
||||||
setShowForm(false);
|
|
||||||
setEditingId(null);
|
|
||||||
setForm({ name: '', url: '', secret: '', events: [] });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEdit = (w: WebhookItem) => {
|
const startEdit = (wh: WebhookItem) => {
|
||||||
setForm({ name: w.name, url: w.url, secret: w.secret, events: [...w.events] });
|
setForm({ name: wh.name, url: wh.url, secret: '', events: parseEvents(wh.events) });
|
||||||
setEditingId(w.id);
|
setEditingId(wh.id);
|
||||||
|
setEditingSecretSet(wh.secretSet);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = (wh: WebhookItem) => {
|
||||||
|
if (!window.confirm(`确定删除 Webhook「${wh.name}」?`)) return;
|
||||||
|
deleteMutation.mutate(wh.id);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleEvent = (event: string) => {
|
const toggleEvent = (event: string) => {
|
||||||
setForm(prev => ({
|
setForm(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -116,8 +146,8 @@ export default function WebhookConfig() {
|
|||||||
{webhooks.length}
|
{webhooks.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowForm(true); setEditingId(null); setForm({ name: '', url: '', secret: '', events: [] }); }}
|
onClick={() => { setShowForm(true); setEditingId(null); setEditingSecretSet(false); setForm(emptyForm); }}
|
||||||
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
|
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
|
||||||
style={{ background: 'var(--accent-blue)' }}
|
style={{ background: 'var(--accent-blue)' }}
|
||||||
>
|
>
|
||||||
@@ -129,7 +159,7 @@ export default function WebhookConfig() {
|
|||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="rounded-lg border p-4 mb-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<div className="rounded-lg border p-4 mb-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId ? '编辑 Webhook' : '新建 Webhook'}</h3>
|
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId != null ? '编辑 Webhook' : '新建 Webhook'}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label>
|
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label>
|
||||||
@@ -141,84 +171,129 @@ export default function WebhookConfig() {
|
|||||||
placeholder="https://..." className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
|
placeholder="https://..." className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
|
||||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选)</label>
|
<div>
|
||||||
|
<label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选,只写不读)</label>
|
||||||
<input value={form.secret} onChange={e => setForm(p => ({ ...p, secret: e.target.value }))}
|
<input value={form.secret} onChange={e => setForm(p => ({ ...p, secret: e.target.value }))}
|
||||||
placeholder="用于验证请求来源" className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
|
placeholder={editingId != null && editingSecretSet ? '已设置密钥,留空保持不变' : '用于 HMAC-SHA256 签名验证,留空则不签名'}
|
||||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
|
className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
|
||||||
<div><label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>订阅事件</label>
|
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{editingId != null && editingSecretSet && (
|
||||||
{eventOptions.map(evt => (
|
<p className="text-[11px] mt-1 flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<label key={evt.value} className="flex items-start gap-2 p-2 rounded-md border cursor-pointer hover:bg-black/[0.02]"
|
<Lock className="w-3 h-3" />已保存密钥不可读取;填写新值将覆盖,留空则不修改
|
||||||
style={{ borderColor: 'var(--border-color-light)', background: form.events.includes(evt.value) ? 'var(--accent-blue-light)' : 'transparent' }}>
|
</p>
|
||||||
<input type="checkbox" checked={form.events.includes(evt.value)} onChange={() => toggleEvent(evt.value)} className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
|
)}
|
||||||
<div><div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{evt.label}</div><div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{evt.desc}</div></div>
|
</div>
|
||||||
</label>
|
<div>
|
||||||
))}
|
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>订阅事件(点击选择)</label>
|
||||||
</div></div>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{eventOptions.map(evt => {
|
||||||
|
const on = form.events.includes(evt.value);
|
||||||
|
return (
|
||||||
|
<button key={evt.value} type="button" title={evt.desc} onClick={() => toggleEvent(evt.value)}
|
||||||
|
className="text-[11px] px-2.5 py-[4px] rounded-full border font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: on ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||||
|
background: on ? 'var(--accent-blue-light)' : 'transparent',
|
||||||
|
color: on ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||||
|
}}>
|
||||||
|
{evt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button onClick={() => setShowForm(false)} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
|
<button onClick={closeForm} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
|
||||||
<button onClick={handleSave} className="px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>保存</button>
|
<button onClick={handleSave} disabled={saveMutation.isPending}
|
||||||
|
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||||
|
{saveMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
{isLoading ? (
|
||||||
{webhooks.map((wh) => (
|
<div className="py-12 flex items-center justify-center gap-2 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<div key={wh.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
<Loader2 className="w-4 h-4 animate-spin" />正在加载 Webhook 列表...
|
||||||
<div className="flex items-start justify-between">
|
</div>
|
||||||
<div className="flex-1">
|
) : webhooks.length === 0 ? (
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{wh.name}</span>
|
暂无 Webhook,点击右上角「新建 Webhook」创建
|
||||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: wh.active ? '#dafbe1' : '#f6f8fa', color: wh.active ? '#1a7f37' : '#6e7781' }}>
|
</div>
|
||||||
{wh.active ? '已启用' : '已禁用'}
|
) : (
|
||||||
</span>
|
<div className="space-y-2">
|
||||||
</div>
|
{webhooks.map((wh: WebhookItem) => (
|
||||||
<div className="flex items-center gap-1 text-[12px] font-mono mb-2" style={{ color: 'var(--text-tertiary)' }}>
|
<div key={wh.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||||
<Webhook className="w-3 h-3" /> {wh.url}
|
<div className="flex items-start justify-between">
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{wh.events.map(evt => {
|
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{wh.name}</span>
|
||||||
const opt = eventOptions.find(o => o.value === evt);
|
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: wh.active ? '#dafbe1' : '#f6f8fa', color: wh.active ? '#1a7f37' : '#6e7781' }}>
|
||||||
return (
|
{wh.active ? '已启用' : '已禁用'}
|
||||||
<span key={evt} className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
</span>
|
||||||
{opt?.label || evt}
|
{wh.secretSet && (
|
||||||
|
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||||
|
<Lock className="w-2.5 h-2.5" />已签名
|
||||||
</span>
|
</span>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[12px] font-mono mb-2 truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Webhook className="w-3 h-3 flex-shrink-0" /> {wh.url}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{parseEvents(wh.events).map(evt => {
|
||||||
|
const opt = eventOptions.find(o => o.value === evt);
|
||||||
|
return (
|
||||||
|
<span key={evt} className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||||
|
{opt?.label || evt}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{parseEvents(wh.events).length === 0 && (
|
||||||
|
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>未订阅任何事件</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => testMutation.mutate(wh.id)}
|
||||||
|
disabled={testMutation.isPending && testMutation.variables === wh.id}
|
||||||
|
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: 'var(--text-tertiary)' }} title="测试发送">
|
||||||
|
{testMutation.isPending && testMutation.variables === wh.id
|
||||||
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: <Send className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => startEdit(wh)}
|
||||||
|
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="编辑">
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => toggleMutation.mutate(wh)} disabled={toggleMutation.isPending}
|
||||||
|
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: wh.active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} title={wh.active ? '禁用' : '启用'}>
|
||||||
|
{wh.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(wh)} disabled={deleteMutation.isPending}
|
||||||
|
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: 'var(--text-tertiary)' }} title="删除">
|
||||||
|
{deleteMutation.isPending && deleteMutation.variables === wh.id
|
||||||
|
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
: <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-4 mt-2 pt-2 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>
|
||||||
<button onClick={() => toast.success('测试发送', { description: `${wh.name} 测试消息已发送` })}
|
<span>最后触发: {formatTime(wh.lastTriggered)}</span>
|
||||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="测试">
|
{wh.lastStatus !== 'none' && (
|
||||||
<Send className="w-3.5 h-3.5" />
|
<span className="flex items-center gap-1" style={{ color: wh.lastStatus === 'success' ? '#1a7f37' : '#cf222e' }}>
|
||||||
</button>
|
状态: {wh.lastStatus === 'success' ? <CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} /> : <AlertTriangle className="w-3 h-3" style={{ color: '#cf222e' }} />}
|
||||||
<button onClick={() => startEdit(wh)}
|
{wh.lastStatus === 'success' ? '成功' : '失败'}
|
||||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="编辑">
|
</span>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
)}
|
||||||
</button>
|
|
||||||
<button onClick={() => handleToggle(wh.id)}
|
|
||||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: wh.active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} title={wh.active ? '禁用' : '启用'}>
|
|
||||||
{wh.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleDelete(wh.id)}
|
|
||||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="删除">
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 mt-2 pt-2 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>
|
))}
|
||||||
<span>最后触发: {wh.lastTriggered}</span>
|
</div>
|
||||||
{wh.lastStatus !== 'none' && (
|
)}
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
状态: {wh.lastStatus === 'success' ? <CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} /> : <AlertTriangle className="w-3 h-3" style={{ color: '#cf222e' }} />}
|
|
||||||
{wh.lastStatus === 'success' ? '成功' : '失败'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'flowstack-theme';
|
||||||
|
|
||||||
|
function isThemeMode(v: string | null): v is ThemeMode {
|
||||||
|
return v === 'light' || v === 'dark' || v === 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMode(mode: ThemeMode) {
|
||||||
|
const dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深浅主题:本地缓存先行应用避免闪白,远端设置为最终来源 */
|
||||||
|
export function useTheme() {
|
||||||
|
const [mode, setMode] = useState<ThemeMode>(() => {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return isThemeMode(saved) ? saved : 'light';
|
||||||
|
});
|
||||||
|
const [synced, setSynced] = useState(false);
|
||||||
|
const [isDark, setIsDark] = useState(() =>
|
||||||
|
typeof document !== 'undefined' && document.documentElement.classList.contains('dark'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 立即应用 + system 模式跟随系统变化
|
||||||
|
useEffect(() => {
|
||||||
|
applyMode(mode);
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
localStorage.setItem(STORAGE_KEY, mode);
|
||||||
|
if (mode !== 'system') return;
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const onChange = () => {
|
||||||
|
applyMode('system');
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
return () => mq.removeEventListener('change', onChange);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
// 启动后用远端设置校准一次(多端一致)
|
||||||
|
useEffect(() => {
|
||||||
|
if (synced) return;
|
||||||
|
apiClient.getSettings().then((s: any) => {
|
||||||
|
try {
|
||||||
|
const theme = JSON.parse(s?.theme || '{}');
|
||||||
|
if (isThemeMode(theme.mode)) setMode(theme.mode);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setSynced(true);
|
||||||
|
}).catch(() => setSynced(true));
|
||||||
|
}, [synced]);
|
||||||
|
|
||||||
|
const change = useCallback(async (next: ThemeMode) => {
|
||||||
|
setMode(next);
|
||||||
|
// 持久化到后端(失败静默,本地已生效)
|
||||||
|
try {
|
||||||
|
const s = await apiClient.getSettings();
|
||||||
|
let theme: any = {};
|
||||||
|
try { theme = JSON.parse(s?.theme || '{}'); } catch { /* ignore */ }
|
||||||
|
await apiClient.updateSettings({ theme: JSON.stringify({ ...theme, mode: next }) });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
change(isDark ? 'light' : 'dark');
|
||||||
|
}, [change]);
|
||||||
|
|
||||||
|
return { mode, isDark, change, toggle };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user