8 Commits
Author SHA1 Message Date
Logan.Li 97193eb090 feat: v0.4.0 主题切换 + LLM 全面优化 + 登录会话 + 用户管理/LDAP
主题:
- 深色/浅色/跟随系统(useTheme:本地缓存先行+远端同步+matchMedia),
  侧边栏快捷切换,设置页三档即选即生效

LLM(参考 opsxdb/aiops):
- llm.Client.ChatStream 流式(SSE 增量解析,透传 reasoning_content,
  ctx 控制超时);POST /api/ai/chat/stream(平台实时数据注入降幻觉,
  未配 LLM 降级规则 JSON,指令解析 action 不变)
- test-llm 支持草稿直测(保存前验证,apiKey 空/掩码回退已存)
- 发布失败自动 AI 分析日志尾部(结论/根因/建议),发布记录与发布
  Tab 展示分析卡片
- 设置页 Provider 预设(DeepSeek/GLM/GLM编码/Qwen/Kimi/Ollama/自定义)
  自动填 endpoint+模型+获取 Key 直达链接

登录与会话:
- HMAC 签名 token(7 天,密钥派生自 encryption.key);
  authMiddleware 保护 /api/*(豁免登录/健康/SSE日志流/入站webhook)
- 登录页 /login + 未登录跳转 + 登出;401 自动清理跳登录

用户管理与 LDAP(借鉴 LokiStack):
- /users 页面:本地用户 CRUD、一次性密码、角色切换、来源徽章
  (本地·蓝 | LDAP导入·紫);LDAP 导入按钮仅在启用且配置齐备时显示
- LDAP 配置卡(未启用仅提示语)、测试连接草稿直测+可选测试账号
- go-ldap:管理 Bind/用户搜索(防注入)/用户 DN 直连验证;
  批量导入冲突策略(已LDAP跳过/本地同名转LDAP/新建);
  登录分流本地 argon2id 或 LDAP 目录验证 + 首登 JIT 建号
2026-10-27 12:58:21 +08:00
Logan.Li 555934cc64 feat: 构建节点磁盘性能测试(dd 实测读写)
- 新增 internal/sshexec(x/crypto/ssh):远程命令通道,凭证来自
  Credential(type=ssh) 解密(私钥/密码/端口/用户)
- POST /api/build-nodes/:id/disk-test:写 dd(256MB, oflag=direct,
  不支持自动降级含缓存并标注) + 读 dd + 清理;IP 为本机时直接本地
  exec;输出解析兼容 Linux/Mac 两种 dd 格式;60s 超时保护
- BuildNode 新增 credentialId 关联 SSH 凭证 + 磁盘测速结果持久字段
  (显式 column 标签修正 GORM MBps 命名)
- 节点卡片新增仪表盘按钮触发测试;磁盘行显示 ↑写 ↓读 MB/s
  (>200 绿 SSD 级 / 50-200 黄 / <50 红),hover 看测试模式与时间
- 编辑弹窗新增 SSH 凭证下拉
- 本机实测:local-mac 节点 写 2336 MB/s · 读 1793 MB/s(direct)
2026-10-27 12:21:39 +08:00
Logan.Li 6841c1104a feat: 工具配置 Ubuntu 化 + nvm 管理 Node 版本
- 工具目录重写为 Ubuntu 构建环境导向(7 项),每项带安装命令提示
  (apt/nvm,未安装时一键复制)
- 新增 nvm 工具条目(真实检测,本机验证 0.40.6);Node.js 改为
  nvm 管理:默认版本可调,构建时通过 nvm install $NODE_VERSION
  灵活指定版本
- DeployTool 新增 installHint 字段(模型/更新白名单/seed)
- 新增 deploy/Dockerfile.buildnode:Ubuntu 22.04 + JDK17/Maven/Git/
  Docker CLI/Golang + nvm 默认 Node(--build-arg NODE_VERSION 可改)
- 工具页顶部说明条更新构建环境建议
2026-10-27 12:15:16 +08:00
Logan.Li d9d1dd5e99 feat: 发布成功/失败飞书&Lark 群卡片通知
- internal/lark:飞书自定义机器人交互卡片(wide_screen_mode + 彩色头部
  green/red/yellow + lark_md 字段区 + 查看构建详情按钮),支持签名加签
  (timestamp+sign HMAC-SHA256,密钥 enc:v1: 加密存储)
- 双引擎收口:原生模拟引擎与 Jenkins 真实引擎运行结束均推送卡片;
  应用/分支/构建(#N 或 runId)/状态/时间;Jenkins 构建附
  externalURL 按钮直达构建详情页
- Webhook 优先级:流水线「通知 Webhook」> 全局设置(需启用)
- 系统设置新增飞书通知配置区(启用开关/Webhook/签名密钥只写/
  发送测试卡片);POST /api/settings/test-lark
- E2E 已验证:本地模拟机器人收到完整卡片 JSON,测试卡片正常
2026-10-27 09:37:23 +08:00
Logan.Li 6e97b55015 feat: SPA 真实路由(/release、/pipelines/:id 等直达)
- 路由映射:/pipelines 列表、/pipelines/:id 编辑器、/release 批量发布、
  /records 发布记录、/gitops、/environments、/credentials、/build-nodes、
  /tools、/jenkins-import、/webhooks、/plugins、/settings
- 侧边栏/助手跳转同步更新 URL;直接输入/刷新/分享链接恢复对应页面;
  未知路径回 /pipelines
2026-10-27 09:24:24 +08:00
Logan.Li a3b4637e19 feat: 设置语言精简中英+补全时区;构建节点磁盘显示;浏览器 favicon
- 系统设置:界面语言仅保留 简体中文/English;时区按 亚/欧/美/大洋洲
  分组补全 22 个常用时区
- 构建节点:BuildNode 新增 diskInfo 字段(模型/CRUD/seed),卡片显示
  磁盘用量(未上报显示 —),添加/编辑弹窗可维护
- 新增 favicon.svg(主题渐变火箭图标),index.html 注入 icon 链接
2026-10-27 09:16:42 +08:00
Logan.Li 581d0a8351 feat: Jenkins Job 批量发布 + 全局 AI 智能助手 (v0.3.0)
参考 opsx-jenkins 发布清单:
- 批量发布页新增「粘贴清单」模式:多行 Job 名粘贴(#注释/内联对/
  名称版本交替)、TAG/分支切换、分支快捷 chips、700ms 防抖解析预览、
  串行/并发发布;未导入的 Jenkins Job 自动创建流水线后触发
- 解析器移植 opsx-jenkins parser(参数头识别/成对校验/重名检测)

全局 AI 智能助手(右下角悬浮):
- 自然语言「发布一下 cex-agent-server 等 5 个」→ 解析服务+分支+环境
  → 发布计划卡片(匹配状态着色)→ 确认 → 真实触发 → runId 回显 +
  跳转发布记录
- LLM 接入:OpenAI 兼容接口(DeepSeek/GLM/Qwen),系统设置页配置
  API Key(加密存储)+ 测试连接;未配置时自动降级本地规则模式,
  服务名/分支关键词匹配,功能不中断
- 候选池 = 流水线 + Jenkins Job(60s 缓存);精确/忽略大小写/包含
  三级匹配

新端点:POST /api/ai/chat、/api/ai/release、/api/batch/parse、
POST /api/settings/test-llm;SystemSetting 扩展 LLM 字段
2026-10-27 09:07:42 +08:00
Logan.Li 88d8fc282e feat: 流水线编辑器 Task 任务库增加搜索(按任务名/描述/参数过滤) 2026-10-27 08:43:27 +08:00
40 changed files with 4659 additions and 88 deletions

No files matched your search

+14 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+11 -10
View File
@@ -203,9 +203,9 @@ func seedResources() {
if n == 0 { if n == 0 {
hb := time.Now().Add(-10 * time.Second) hb := time.Now().Add(-10 * time.Second)
DB.Create([]model.BuildNode{ DB.Create([]model.BuildNode{
{Name: "build-host-01", IP: "10.0.8.11", Labels: `["docker","go","java"]`, MaxConcurrency: 4, Status: "online", CurrentJobs: 1, LastHeartbeat: &hb}, {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, 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", 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) DB.Model(&model.WebhookEndpoint{}).Count(&n)
@@ -224,7 +224,7 @@ func seedResources() {
} }
} }
// seedTools 工具清单(CheckCommand 为真实版本探测命令,安装走保守检测策略) // seedTools 工具清单(Ubuntu 构建环境导向;Node 由 nvm 管理默认版本,构建时可按需 nvm install)
func seedTools() { func seedTools() {
var n int64 var n int64
DB.Model(&model.DeployTool{}).Count(&n) DB.Model(&model.DeployTool{}).Count(&n)
@@ -232,11 +232,12 @@ func seedTools() {
return return
} }
DB.Create([]model.DeployTool{ DB.Create([]model.DeployTool{
{ToolKey: "jdk", DisplayName: "JDK", Category: "build", Version: "17", InstallMode: "preinstalled", Path: "/usr/lib/jvm/java-17", CheckCommand: "java -version", SortOrder: 1}, {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.9", InstallMode: "lazy", Path: "/opt/maven", CheckCommand: "mvn -version", SortOrder: 2}, {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", SortOrder: 3}, {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: "nodejs", DisplayName: "Node.js", Category: "build", Version: "20", InstallMode: "lazy", Path: "/usr/local/nvm", CheckCommand: "node -v", SortOrder: 4}, {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: "git", DisplayName: "Git", Category: "build", Version: "2.40", InstallMode: "preinstalled", Path: "/usr/bin/git", CheckCommand: "git --version", SortOrder: 5}, {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: "docker-cli", DisplayName: "Docker CLI", Category: "deploy", Version: "26", InstallMode: "lazy", Path: "/usr/bin/docker", CheckCommand: "docker --version", SortOrder: 6}, {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},
}) })
} }
+486
View File
@@ -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
}
+187
View File
@@ -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)
}
+126 -8
View File
@@ -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": "用户名或密码错误"})
} }
+176
View File
@@ -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]),
})
}
+7
View File
@@ -209,6 +209,13 @@ func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, j
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(), "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 + "\"") emit("$ jenkins build \"" + jobName + "\"")
+96
View File
@@ -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": "测试卡片已发送到群"})
}
+97 -1
View File
@@ -1,12 +1,16 @@
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"
) )
@@ -83,9 +87,12 @@ func GetSummary(c *gin.Context) {
func GetSettings(c *gin.Context) { func GetSettings(c *gin.Context) {
var s model.SystemSetting var s model.SystemSetting
if err := database.DB.First(&s, 1).Error; err != nil { if err := database.DB.First(&s, 1).Error; err != nil {
s = model.SystemSetting{ID: 1} s = model.SystemSetting{ID: 1, LlmEndpoint: "https://api.deepseek.com/v1", LlmModel: "deepseek-chat", LlmMaxTokens: 1200}
database.DB.Create(&s) database.DB.Create(&s)
} }
s.LlmApiKeySet = s.LlmApiKey != ""
s.LarkSecretSet = s.LarkSecret != ""
s.LdapBindPasswordSet = s.LdapBindPassword != ""
c.JSON(http.StatusOK, s) c.JSON(http.StatusOK, s)
} }
@@ -114,10 +121,99 @@ func UpdateSettings(c *gin.Context) {
if v, ok := body["extra"].(string); ok { if v, ok := body["extra"].(string); ok {
updates["extra"] = v 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.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) 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 {
+2 -2
View File
@@ -394,7 +394,7 @@ func UpdateBuildNode(c *gin.Context) {
} }
var raw map[string]any var raw map[string]any
_ = c.ShouldBindJSON(&raw) _ = c.ShouldBindJSON(&raw)
for _, k := range []string{"name", "ip", "labels", "maxConcurrency", "workspacePath", "enabled"} { for _, k := range []string{"name", "ip", "labels", "maxConcurrency", "workspacePath", "diskInfo", "credentialId", "enabled"} {
if v, ok := raw[k]; ok { if v, ok := raw[k]; ok {
database.DB.Model(&n).Update(k, v) database.DB.Model(&n).Update(k, v)
} }
@@ -448,7 +448,7 @@ func UpdateTool(c *gin.Context) {
} }
var raw map[string]any var raw map[string]any
_ = c.ShouldBindJSON(&raw) _ = c.ShouldBindJSON(&raw)
for _, k := range []string{"installMode", "version", "path", "enabled"} { for _, k := range []string{"installMode", "version", "path", "installHint", "enabled"} {
if v, ok := raw[k]; ok { if v, ok := raw[k]; ok {
database.DB.Model(&t).Update(k, v) database.DB.Model(&t).Update(k, v)
} }
+5 -1
View File
@@ -127,13 +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 // 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook + 飞书发布卡片
if status == "success" || status == "failed" { if status == "success" || status == "failed" {
NotifyWebhookEvent("pipeline."+status, map[string]any{ NotifyWebhookEvent("pipeline."+status, map[string]any{
"runId": run.RunID, "pipeline": run.PipelineName, "runId": run.RunID, "pipeline": run.PipelineName,
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(), "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)
+380
View File
@@ -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 连接成功(管理员绑定通过)"})
}
+125
View File
@@ -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
}
+148
View File
@@ -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)
}
+220
View File
@@ -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
}
+32
View File
@@ -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"`
@@ -79,6 +81,7 @@ type PipelineRun struct {
Status string `gorm:"default:pending" json:"status"` // pending|queued|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"` BuildNumber int `json:"buildNumber"`
Analysis string `gorm:"type:text" json:"analysis"` // 发布失败时的 AI 分析
ExternalRunID string `json:"externalRunId"` ExternalRunID string `json:"externalRunId"`
ExternalURL string `json:"externalUrl"` ExternalURL string `json:"externalUrl"`
NodesSnapshot string `json:"nodesSnapshot"` NodesSnapshot string `json:"nodesSnapshot"`
@@ -204,6 +207,12 @@ type BuildNode struct {
Labels string `gorm:"type:text" json:"labels"` // JSON ["docker","go"] Labels string `gorm:"type:text" json:"labels"` // JSON ["docker","go"]
MaxConcurrency int `gorm:"default:2" json:"maxConcurrency"` MaxConcurrency int `gorm:"default:2" json:"maxConcurrency"`
WorkspacePath string `gorm:"default:/data/flowstack-workspace" json:"workspacePath"` 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"` Enabled bool `gorm:"default:true" json:"enabled"`
Status string `gorm:"default:offline" json:"status"` // online|offline|busy Status string `gorm:"default:offline" json:"status"` // online|offline|busy
CurrentJobs int `gorm:"default:0" json:"currentJobs"` CurrentJobs int `gorm:"default:0" json:"currentJobs"`
@@ -224,6 +233,7 @@ type DeployTool struct {
InstallMode string `gorm:"default:lazy" json:"installMode"` // preinstalled|lazy|disabled InstallMode string `gorm:"default:lazy" json:"installMode"` // preinstalled|lazy|disabled
Path string `json:"path"` Path string `json:"path"`
CheckCommand string `gorm:"type:text" json:"checkCommand"` // 真实版本探测命令 CheckCommand string `gorm:"type:text" json:"checkCommand"` // 真实版本探测命令
InstallHint string `gorm:"type:text" json:"installHint"` // 安装命令提示(Ubuntu apt / nvm)
Enabled bool `gorm:"default:true" json:"enabled"` Enabled bool `gorm:"default:true" json:"enabled"`
LastCheck *time.Time `json:"lastCheck"` LastCheck *time.Time `json:"lastCheck"`
ErrorMsg string `json:"errorMsg"` ErrorMsg string `json:"errorMsg"`
@@ -267,6 +277,28 @@ type SystemSetting struct {
Theme string `gorm:"type:text" json:"theme"` // JSON {mode,primaryColor,...} Theme string `gorm:"type:text" json:"theme"` // JSON {mode,primaryColor,...}
Notification string `gorm:"type:text" json:"notification"` // JSON {email,browser,...} Notification string `gorm:"type:text" json:"notification"` // JSON {email,browser,...}
ToolMode string `gorm:"default:local" json:"toolMode"` // local|jenkins 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"` Extra string `gorm:"type:text" json:"extra"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
+168
View File
@@ -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
}
+76
View File
@@ -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)
}
}
+25
View File
@@ -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,10 +53,31 @@ 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("/summary", handler.GetSummary)
api.GET("/settings", handler.GetSettings) api.GET("/settings", handler.GetSettings)
api.PUT("/settings", handler.UpdateSettings) 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)
@@ -104,6 +128,7 @@ func main() {
api.PUT("/build-nodes/:id", handler.UpdateBuildNode) api.PUT("/build-nodes/:id", handler.UpdateBuildNode)
api.DELETE("/build-nodes/:id", handler.DeleteBuildNode) api.DELETE("/build-nodes/:id", handler.DeleteBuildNode)
api.POST("/build-nodes/:id/test", handler.TestBuildNode) api.POST("/build-nodes/:id/test", handler.TestBuildNode)
api.POST("/build-nodes/:id/disk-test", handler.TestBuildNodeDisk)
api.GET("/tools", handler.ListTools) api.GET("/tools", handler.ListTools)
api.PUT("/tools/:id", handler.UpdateTool) api.PUT("/tools/:id", handler.UpdateTool)
+1 -1
View File
@@ -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>
+46
View File
@@ -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"]
+1
View File
@@ -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>
+15
View File
@@ -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

+102 -8
View File
@@ -1,5 +1,6 @@
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 { 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';
@@ -20,12 +21,66 @@ 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 { apiClient } from '@/lib/api'; 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);
@@ -37,6 +92,35 @@ export default function App() {
const [saveTick, setSaveTick] = useState(0); const [saveTick, setSaveTick] = useState(0);
const [runTick, setRunTick] = useState(0); const [runTick, setRunTick] = useState(0);
// 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 goToModule = useCallback((m: ModuleType) => {
setActiveModule(m);
navigate(MODULE_TO_ROUTE[m]);
}, [navigate]);
const { data: pipelines = [] } = useQuery({ const { data: pipelines = [] } = useQuery({
queryKey: ['pipelines'], queryKey: ['pipelines'],
queryFn: apiClient.listPipelines, queryFn: apiClient.listPipelines,
@@ -58,12 +142,14 @@ export default function App() {
setCurrentPipelineId(Number(id) || 1); setCurrentPipelineId(Number(id) || 1);
setPipelineView('editor'); setPipelineView('editor');
setActiveTab('流程配置'); setActiveTab('流程配置');
}, []); navigate(`/pipelines/${Number(id) || 1}`);
}, [navigate]);
const handlePipelineSwitch = useCallback((id: number) => { const handlePipelineSwitch = useCallback((id: number) => {
setCurrentPipelineId(id); setCurrentPipelineId(id);
setActiveTab('流程配置'); setActiveTab('流程配置');
}, []); navigate(`/pipelines/${id}`);
}, [navigate]);
const handleOnlySave = useCallback(() => { const handleOnlySave = useCallback(() => {
if (activeTab !== '流程配置') setActiveTab('流程配置'); if (activeTab !== '流程配置') setActiveTab('流程配置');
@@ -96,7 +182,7 @@ export default function App() {
onSaveAndRun={handleSaveAndRun} onSaveAndRun={handleSaveAndRun}
isRunning={isPipelineRunning} isRunning={isPipelineRunning}
executionStatus={(latestStatus as 'running' | 'success' | 'failed' | 'cancelled') || 'idle'} executionStatus={(latestStatus as 'running' | 'success' | 'failed' | 'cancelled') || 'idle'}
onBack={() => setPipelineView('list')} onBack={() => { setPipelineView('list'); navigate('/pipelines'); }}
/> />
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{activeTab === '流程配置' && ( {activeTab === '流程配置' && (
@@ -138,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 onViewRecords={() => setActiveModule('release-records')} />; return <BatchRelease onViewRecords={() => goToModule('release-records')} />;
case 'release-records': case 'release-records':
return <ReleaseRecords />; return <ReleaseRecords />;
case 'webhooks': case 'webhooks':
@@ -147,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:
@@ -154,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>
); );
} }
+19 -3
View File
@@ -1,14 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket } from 'lucide-react'; import { CheckCircle2, XCircle, Loader2, Clock, RotateCcw, ExternalLink, Rocket, Sparkles } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api'; import { apiClient, type PipelineRunDTO } from '@/lib/api';
import { usePipelineLogs } from '@/hooks/usePipelineLogs'; import { usePipelineLogs } from '@/hooks/usePipelineLogs';
interface Props { interface Props {
pipelineId: number; pipelineId: number;
} }
// 列表行:PipelineRunDTO + 失败时自动生成的 AI 分析字段
type RunRow = PipelineRunDTO & { analysis?: string };
const statusMap: Record<string, { color: string; bg: string; label: string }> = { const statusMap: Record<string, { color: string; bg: string; label: string }> = {
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' }, success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' }, failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
@@ -32,7 +35,7 @@ export default function ReleasePanel({ pipelineId }: Props) {
const { data: runs = [], isLoading } = useQuery({ const { data: runs = [], isLoading } = useQuery({
queryKey: ['runs', { pipelineId }], queryKey: ['runs', { pipelineId }],
queryFn: () => apiClient.listRuns({ pipelineId }), queryFn: () => apiClient.listRuns({ pipelineId }) as Promise<RunRow[]>,
}); });
const { data: detail } = useQuery({ const { data: detail } = useQuery({
@@ -132,6 +135,19 @@ export default function ReleasePanel({ pipelineId }: Props) {
})} })}
</div> </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="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)' }}> <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> <span className="text-[11px] font-semibold" style={{ color: 'var(--text-primary)' }}>执行日志</span>
+30 -6
View File
@@ -13,18 +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 { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api'; 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 }[] }[] = [
@@ -57,18 +63,20 @@ 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({ const { data: summary } = useQuery({
queryKey: ['summary'], queryKey: ['summary'],
queryFn: apiClient.getSummary, queryFn: apiClient.getSummary,
refetchInterval: 15000, refetchInterval: 15000,
}); });
const { toggle: themeToggle, isDark } = useTheme();
const badgeFor = (id: ModuleType): string | null => { const badgeFor = (id: ModuleType): string | null => {
if (id === 'build-nodes') return summary?.buildNodes != null ? String(summary.buildNodes) : null; if (id === 'build-nodes') return summary?.buildNodes != null ? String(summary.buildNodes) : null;
@@ -167,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"
+418
View File
@@ -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>
);
}
@@ -67,6 +67,7 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
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);
@@ -255,9 +256,16 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
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)' }}>
@@ -269,6 +277,15 @@ function FlowEditorInner({ pipelineId, saveSignal = 0, runSignal = 0 }: FlowEdit
<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>
+42 -1
View File
@@ -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 {
@@ -125,6 +144,7 @@ export const apiClient = {
updateBuildNode: (id: any, b: any) => api(`/api/build-nodes/${id}`, { method: 'PUT', 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' }), deleteBuildNode: (id: any) => api(`/api/build-nodes/${id}`, { method: 'DELETE' }),
testBuildNode: (id: any) => api<any>(`/api/build-nodes/${id}/test`, { method: 'POST' }), 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'), listTools: () => api<any[]>('/api/tools'),
updateTool: (id: any, b: any) => api(`/api/tools/${id}`, { method: 'PUT', body: JSON.stringify(b) }), updateTool: (id: any, b: any) => api(`/api/tools/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
@@ -145,4 +165,25 @@ export const apiClient = {
listJenkinsJobs: (id: any) => api<any[]>(`/api/jenkins-servers/${id}/jobs`), listJenkinsJobs: (id: any) => api<any[]>(`/api/jenkins-servers/${id}/jobs`),
importJenkinsJobs: (id: any, jobs: string[]) => importJenkinsJobs: (id: any, jobs: string[]) =>
api<any>(`/api/jenkins-servers/${id}/import`, { method: 'POST', body: JSON.stringify({ jobs }) }), 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) }),
} }
+20 -1
View File
@@ -6,6 +6,7 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api'; import { apiClient } from '@/lib/api';
import PasteRelease from '@/components/batch/PasteRelease';
// ---- 类型(与后端 GET /api/pipelines、GET /api/runs 对齐)---- // ---- 类型(与后端 GET /api/pipelines、GET /api/runs 对齐)----
interface PipelineDTO { interface PipelineDTO {
@@ -64,6 +65,7 @@ const stamp = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
export default function BatchRelease({ onViewRecords }: { onViewRecords?: () => void } = {}) { export default function BatchRelease({ onViewRecords }: { onViewRecords?: () => void } = {}) {
const queryClient = useQueryClient(); 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[]>([]);
// 发布配置(本地状态) // 发布配置(本地状态)
@@ -220,10 +222,25 @@ export default function BatchRelease({ onViewRecords }: { onViewRecords?: () =>
<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;
@@ -446,6 +463,8 @@ export default function BatchRelease({ onViewRecords }: { onViewRecords?: () =>
</div> </div>
)} )}
</div> </div>
</>
)}
</div> </div>
); );
} }
+69 -4
View File
@@ -10,6 +10,8 @@ import {
Trash2, Trash2,
Tag, Tag,
Zap, 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -60,9 +62,10 @@ interface NodeForm {
labels: string; labels: string;
maxConcurrency: string; maxConcurrency: string;
workspacePath: string; workspacePath: string;
diskInfo: string;
} }
const emptyForm: NodeForm = { name: '', ip: '', labels: '', maxConcurrency: '2', workspacePath: '/var/lib/flowstack/workspace' }; const emptyForm: NodeForm = { name: '', ip: '', labels: '', maxConcurrency: '2', workspacePath: '/var/lib/flowstack/workspace', diskInfo: '' };
const inputStyle = { const inputStyle = {
background: 'var(--panel-bg-secondary)', background: 'var(--panel-bg-secondary)',
@@ -75,6 +78,9 @@ export default function BuildNodeManager() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const nodesQuery = useQuery({ queryKey: ['build-nodes'], queryFn: () => apiClient.listBuildNodes() }); 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 nodes: any[] = nodesQuery.data ?? [];
// ---- 弹窗 ---- // ---- 弹窗 ----
@@ -92,6 +98,7 @@ export default function BuildNodeManager() {
labels: parseJsonArray(node.labels).join(','), labels: parseJsonArray(node.labels).join(','),
maxConcurrency: String(node.maxConcurrency ?? 2), maxConcurrency: String(node.maxConcurrency ?? 2),
workspacePath: node.workspacePath ?? '', workspacePath: node.workspacePath ?? '',
diskInfo: node.diskInfo ?? '',
} }
: emptyForm, : emptyForm,
); );
@@ -130,6 +137,17 @@ export default function BuildNodeManager() {
onError: (err: Error) => toast.error(err.message), 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({ const toggleMutation = useMutation({
mutationFn: (vars: { id: any; enabled: boolean }) => apiClient.updateBuildNode(vars.id, { enabled: vars.enabled }), mutationFn: (vars: { id: any; enabled: boolean }) => apiClient.updateBuildNode(vars.id, { enabled: vars.enabled }),
onSuccess: (_data, vars) => { onSuccess: (_data, vars) => {
@@ -149,9 +167,11 @@ export default function BuildNodeManager() {
body: { body: {
name: form.name.trim(), name: form.name.trim(),
ip: form.ip.trim(), ip: form.ip.trim(),
credentialId: editing?.credentialId ?? null,
labels: JSON.stringify(splitComma(form.labels)), labels: JSON.stringify(splitComma(form.labels)),
maxConcurrency: Number(form.maxConcurrency) || 1, maxConcurrency: Number(form.maxConcurrency) || 1,
workspacePath: form.workspacePath.trim(), workspacePath: form.workspacePath.trim(),
diskInfo: form.diskInfo.trim(),
}, },
}); });
}; };
@@ -248,6 +268,18 @@ export default function BuildNodeManager() {
onCheckedChange={(checked) => toggleMutation.mutate({ id: node.id, enabled: checked })} onCheckedChange={(checked) => toggleMutation.mutate({ id: node.id, enabled: checked })}
/> />
</div> </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 <button
className="p-1 rounded hover:bg-black/5" className="p-1 rounded hover:bg-black/5"
title="测试连接" title="测试连接"
@@ -292,10 +324,25 @@ export default function BuildNodeManager() {
</div> </div>
</div> </div>
{/* Info: 工作空间 / 心跳(后端暂无 CPU/内存/磁盘数据) */} {/* Info: 磁盘(含性能) / 工作空间 / 心跳 */}
<div className="grid grid-cols-2 gap-3 text-[11px]"> <div className="grid grid-cols-3 gap-3 text-[11px]">
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>工作空间</span> <HardDrive className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<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 className="flex items-center gap-1.5 min-w-0">
<span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>空间</span>
<span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }} title={node.workspacePath}> <span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }} title={node.workspacePath}>
{node.workspacePath || '—'} {node.workspacePath || '—'}
</span> </span>
@@ -366,6 +413,24 @@ export default function BuildNodeManager() {
<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" /> <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> </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> </div>
<DialogFooter> <DialogFooter>
<button <button
+88
View File
@@ -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>
);
}
+17 -1
View File
@@ -3,7 +3,7 @@ import {
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, Loader2 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -54,6 +54,7 @@ interface RunRecord {
durationMs?: number; durationMs?: number;
buildNumber?: number; buildNumber?: number;
externalUrl?: string; externalUrl?: string;
analysis?: string; // 失败时后端自动生成的 AI 失败分析
} }
interface RunStage { interface RunStage {
@@ -144,6 +145,8 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
const logsToRender: RunLog[] = isRunning ? liveLogs : (detail?.logs ?? []); 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)' }}>
<div className="flex items-center gap-0 px-4 border-b" style={{ borderColor: 'var(--border-color-light)' }}> <div className="flex items-center gap-0 px-4 border-b" style={{ borderColor: 'var(--border-color-light)' }}>
@@ -165,6 +168,19 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
))} ))}
</div> </div>
<div className="p-4"> <div className="p-4">
{/* AI 失败分析(日志区上方) */}
{analysis ? (
<div className="mb-4 rounded-lg border p-3" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
<div className="flex items-center gap-1.5 text-[12px] font-semibold mb-1.5" style={{ color: '#8250df' }}>
<Sparkles className="w-3.5 h-3.5" />AI 失败分析
</div>
<div className="text-[12px] leading-5 whitespace-pre-wrap" style={{ color: 'var(--text-primary)' }}>{analysis}</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' && ( {activeTab === 'stages' && (
detailLoading && stages.length === 0 ? ( detailLoading && stages.length === 0 ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
+416 -5
View File
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Palette, Monitor, Moon, Sun, Palette, Monitor, Moon, Sun,
Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api'; import { apiClient } from '@/lib/api';
import { useTheme, type ThemeMode } from '@/providers/theme';
interface ThemeConfig { interface ThemeConfig {
mode: 'light' | 'dark' | 'system'; mode: 'light' | 'dark' | 'system';
@@ -62,10 +63,48 @@ 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 queryClient = useQueryClient(); const queryClient = useQueryClient();
const { change: themeChange } = useTheme();
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme); const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications); const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
const [llmProvider, setLlmProvider] = useState('custom');
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 }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
@@ -74,8 +113,136 @@ export default function SystemSettings() {
if (!settings) return; if (!settings) return;
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme)); setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications)); 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]); }, [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 testLarkMutation = useMutation({
mutationFn: () => apiClient.testLark(),
onSuccess: (res: any) => {
if (res.success) toast.success('测试卡片已发送', { description: '请到飞书群查看' });
else toast.error('发送失败', { description: res.message });
},
onError: (err: Error) => toast.error('测试失败', { description: err.message }),
});
const saveLdapMutation = useMutation({
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({ const saveMutation = useMutation({
mutationFn: () => apiClient.updateSettings({ mutationFn: () => apiClient.updateSettings({
theme: JSON.stringify(theme), theme: JSON.stringify(theme),
@@ -130,14 +297,14 @@ 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 ThemeConfig['mode'] }))} <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)',
@@ -271,17 +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> <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>
); );
+15 -2
View File
@@ -10,6 +10,7 @@ import {
RefreshCw, RefreshCw,
Terminal, Terminal,
AlertCircle, 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -26,6 +27,7 @@ interface DeployTool {
installedVersion: string; installedVersion: string;
installMode: 'preinstalled' | 'lazy' | 'disabled'; installMode: 'preinstalled' | 'lazy' | 'disabled';
path: string; path: string;
installHint: string;
enabled: boolean; enabled: boolean;
lastCheck: string | null; lastCheck: string | null;
errorMsg: string; errorMsg: string;
@@ -176,7 +178,7 @@ export default function ToolConfig() {
{/* 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 */}
@@ -236,6 +238,17 @@ export default function ToolConfig() {
<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>
{tool.installHint && 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 && ( {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>
)} )}
@@ -262,7 +275,7 @@ export default function ToolConfig() {
) : ( ) : (
tool.version && ( tool.version && (
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={tool.errorMsg || undefined}> <div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={tool.errorMsg || undefined}>
期望 v{tool.version} {tool.key === 'nodejs' ? `默认 v${tool.version}(构建时可覆盖)` : `期望 v${tool.version}`}
</div> </div>
) )
)} )}
+623
View File
@@ -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>
);
}
+72
View File
@@ -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 };
}