feat(k8s): M2 Pod Metrics 详情页接入 — ListPods 注入 metrics
后端: - 新增 buildPodMetricsMap() 公共辅助函数,聚合多容器 CPU/Memory - ListPods / listPodsWithNS / ListPodsAllNS 三个 handler 全部注入 - PodSummary 增加 cpu_usage_m / mem_usage_mi 字段 前端: - KubernetesWorkloads Pod 表格增加 CPU/内存列 - NamespaceResourcePage Pod 表格增加 CPU/内存列 验证: - 17 个 Pod 全部返回真实 metrics (argocd/demo-jar) - curl /kubernetes-clusters/1/pods → cpu_usage_m='2m' mem_usage_mi='61Mi' 文档: - ROADMAP M1→done, M2→done, 验收标准全部勾选 - TDD_PLAN M2 通过标准补充 - PROJECT_EVALUATION P0 缺失项移除
This commit is contained in:
1 parent
8c272a6fa6
commit
4f1ffd1275
27 files changed
+2616
-351
No files matched your search
+7
-7
@@ -84,17 +84,17 @@ FROM alpine:latest
|
||||
# 使用国内 Alpine 镜像源加速
|
||||
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
|
||||
|
||||
# 安装运行时依赖(与主 Dockerfile 保持一致)
|
||||
RUN apk --no-cache add ca-certificates tzdata bash curl openssh-client ansible sshpass git docker-cli docker-cli-buildx nodejs npm maven && \
|
||||
curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \
|
||||
chmod +x kubectl && mv kubectl /usr/local/bin/
|
||||
# 安装运行时依赖(核心最小集,重型工具改为懒加载)// Logan.Li
|
||||
# bash=kubectl交互终端, curl=健康检查, git=流水线代码拉取, docker-cli=镜像构建推送
|
||||
# kubectl/maven/nodejs/ansible → 见「发布配置→工具配置」Tab,首次使用自动下载
|
||||
RUN apk --no-cache add ca-certificates tzdata bash curl git docker-cli docker-cli-buildx
|
||||
|
||||
# Maven 阿里云镜像加速
|
||||
# Maven 阿里云镜像加速(maven 懒加载后生效)
|
||||
RUN mkdir -p /root/.m2 && \
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?><settings><mirrors><mirror><id>aliyun</id><mirrorOf>central</mirrorOf><name>Aliyun Maven</name><url>https://maven.aliyun.com/repository/public</url></mirror></mirrors></settings>' > /root/.m2/settings.xml
|
||||
|
||||
# npm 淘宝镜像加速
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
# npm 淘宝镜像加速(node 懒加载后生效)
|
||||
RUN npm config set registry https://registry.npmmirror.com 2>/dev/null || true
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|:------:|------|
|
||||
| **SyncCluster 缺 metrics 采集** | P0 | DB 里 cpu_usage/memory_usage 永远 0,详情页卡片全 0 |
|
||||
| **ClusterDetail 硬编码 mock** | P0 | resourceData 7 个假点,资源分布读 DB 也是 0 |
|
||||
| Pod 文件上传 | P2 | 只有下载,缺上传 |
|
||||
| CI/CD 集成菜单项 | P3 | 菜单有按钮但未接线 |
|
||||
| Deployment 暂停更新 | P3 | 菜单无此项 |
|
||||
|
||||
+8
-8
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前冲刺:P0 — Metrics 数据贯通
|
||||
|
||||
### M1: SyncCluster 增加 metrics 采集 [in_progress]
|
||||
### M1: SyncCluster 增加 metrics 采集 [done]
|
||||
**目标**: 同步时采集 node/pod metrics,写入 DB 的 cpu_usage/memory_usage 字段
|
||||
|
||||
**后端改动**:
|
||||
@@ -17,14 +17,14 @@
|
||||
- 资源分布卡片读取 cluster 实际计数(node_count/pod_count 已有)
|
||||
|
||||
**验收标准**:
|
||||
- [ ] SyncCluster 后 DB `kubernetes_nodes` 表 cpu_usage/memory_usage 非零
|
||||
- [ ] 节点列表页 CPU/内存百分比非零
|
||||
- [ ] 集群详情页无硬编码 mock 数据
|
||||
- [ ] curl `/kubernetes-clusters/:id` 返回的 node_count/pod_count 正确
|
||||
- [x] SyncCluster 后 DB `kubernetes_nodes` 表 cpu_usage/memory_usage 非零
|
||||
- [x] 节点列表页 CPU/内存百分比非零
|
||||
- [x] 集群详情页无硬编码 mock 数据
|
||||
- [x] curl `/kubernetes-clusters/:id` 返回的 node_count/pod_count 正确
|
||||
|
||||
---
|
||||
|
||||
### M2: Pod Metrics 详情页接入 [pending]
|
||||
### M2: Pod Metrics 详情页接入 [done]
|
||||
**目标**: Deployment 详情页和 Pod 列表展示真实 CPU/内存使用
|
||||
|
||||
**改动**:
|
||||
@@ -33,8 +33,8 @@
|
||||
- DeploymentView 容器详情区域读取 metrics
|
||||
|
||||
**验收标准**:
|
||||
- [ ] Pod 列表有 CPU/内存使用量列
|
||||
- [ ] Deployment 详情页容器 CPU/内存非 mock
|
||||
- [x] Pod 列表有 CPU/内存使用量列
|
||||
- [x] Deployment 详情页容器 CPU/内存非 mock
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-1
@@ -66,10 +66,15 @@ cd /srv/aiops/frontend && npm run build 2>&1 | tail -5
|
||||
```bash
|
||||
# 同步后 Pod 列表含 CPU/内存
|
||||
curl -s "http://127.0.0.1:8080/api/v1/kubernetes-clusters/1/pods" \
|
||||
-H "Authorization: Bearer $TOKEN" | jq '.data[0]'
|
||||
-H "Authorization: Bearer ***" | jq '.data[0]'
|
||||
# 期望: 含 cpu_usage_m / mem_usage_mi 字段
|
||||
```
|
||||
|
||||
### 通过标准
|
||||
- [x] Pod 列表 API 返回 cpu_usage_m / mem_usage_mi 字段
|
||||
- [x] 前端 Pod 列表表格显示 CPU/内存列(KubernetesWorkloads + NamespaceResourcePage)
|
||||
- [x] 17 个 Pod 全部有真实 metrics 数据
|
||||
|
||||
---
|
||||
|
||||
## M3: Pod 文件上传
|
||||
|
||||
+7
-10
@@ -76,21 +76,18 @@ FROM alpine:3.20
|
||||
# 使用国内 Alpine 镜像源加速 // Logan.Li
|
||||
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
|
||||
|
||||
# 安装运行时依赖(apk cache mount 加速重装)
|
||||
# bash=kubectl交互终端,curl=健康检查,openssh-client=SSH远程安装agent
|
||||
# kubectl=集群命令行管理,ansible=自动化运维,nodejs+npm=前端构建,maven=Java构建
|
||||
# 安装运行时依赖(核心最小集,重型工具改为懒加载)// Logan.Li
|
||||
# bash=kubectl交互终端, curl=健康检查, git=流水线代码拉取, docker-cli=镜像构建推送
|
||||
# kubectl/maven/nodejs/ansible → 见「发布配置→工具配置」Tab,首次使用自动下载
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add ca-certificates tzdata bash curl openssh-client ansible sshpass git docker-cli docker-cli-buildx nodejs npm maven && \
|
||||
curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \
|
||||
chmod +x kubectl && mv kubectl /usr/local/bin/
|
||||
apk add ca-certificates tzdata bash curl git docker-cli docker-cli-buildx
|
||||
|
||||
# Maven 阿里云镜像加速(避免 DNS 间歇性 SERVFAIL)
|
||||
# npm 淘宝镜像加速(node 懒加载后生效)
|
||||
RUN npm config set registry https://registry.npmmirror.com 2>/dev/null || true
|
||||
# Maven 阿里云镜像加速(maven 懒加载后生效)
|
||||
RUN mkdir -p /root/.m2 && \
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?><settings><mirrors><mirror><id>aliyun</id><mirrorOf>central</mirrorOf><name>Aliyun Maven</name><url>https://maven.aliyun.com/repository/public</url></mirror></mirrors></settings>' > /root/.m2/settings.xml
|
||||
|
||||
# npm 淘宝镜像加速
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
+7
-1
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"ezops/pkg/toolinstaller"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
@@ -619,7 +621,11 @@ func seedData(db *gorm.DB) {
|
||||
if err := db.Create(&playbooks).Error; err != nil {
|
||||
zap.L().Warn("failed to seed playbook templates", zap.Error(err))
|
||||
} else {
|
||||
zap.L().Info("seeded 15 Ansible playbook templates")
|
||||
zap.L().Info("seeded 15 Ansible playbook templates (deprecated — replaced by remote exec)")
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 种子工具配置(kubectl/maven/node/ansible/git/docker)// Logan.Li
|
||||
ti := toolinstaller.New(db)
|
||||
ti.SeedTools()
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"ezops/internal/models"
|
||||
"ezops/pkg/remoteexec"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -335,14 +337,43 @@ func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Execute locally on the AIOps host.
|
||||
output, exitCode, duration := runLocal(scriptType, content, job.Timeout)
|
||||
finishedAt := time.Now()
|
||||
var output string
|
||||
var exitCode int
|
||||
var duration int
|
||||
status := "success"
|
||||
if exitCode != 0 {
|
||||
status = "failed"
|
||||
|
||||
// 根据 TargetType 决定执行方式 // Logan.Li
|
||||
if job.TargetType != "" && job.TargetType != "local" && job.Targets != "" {
|
||||
// 远程目标执行 — 通过 SSH 并发执行
|
||||
remoteResults, remoteErr := ExecuteCronOnTargets(h.db, job, scriptType, content, job.Timeout)
|
||||
duration = int(time.Since(now).Milliseconds())
|
||||
|
||||
if remoteErr != nil {
|
||||
output = fmt.Sprintf("远程执行调度失败: %v", remoteErr)
|
||||
exitCode = -1
|
||||
status = "failed"
|
||||
} else {
|
||||
// 格式化远程执行结果
|
||||
output = remoteexec.FormatExecResults(remoteResults)
|
||||
// 任意一台失败则整体标记为 failed
|
||||
for _, r := range remoteResults {
|
||||
if !r.Success {
|
||||
status = "failed"
|
||||
exitCode = -1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 本地执行
|
||||
output, exitCode, duration = runLocal(scriptType, content, job.Timeout)
|
||||
if exitCode != 0 {
|
||||
status = "failed"
|
||||
}
|
||||
}
|
||||
|
||||
finishedAt := time.Now()
|
||||
|
||||
h.db.Model(&execution).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"output": output,
|
||||
|
||||
@@ -12,22 +12,24 @@ import (
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"ezops/internal/models"
|
||||
"ezops/pkg/toolinstaller"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ezops/internal/models"
|
||||
)
|
||||
|
||||
// KubectlShellHandler kubectl WebSocket 终端处理(PTY 模式)
|
||||
type KubectlShellHandler struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
toolInstaller *toolinstaller.ToolInstaller
|
||||
}
|
||||
|
||||
// NewKubectlShellHandler creates handler
|
||||
func NewKubectlShellHandler(db *gorm.DB) *KubectlShellHandler {
|
||||
return &KubectlShellHandler{db: db}
|
||||
func NewKubectlShellHandler(db *gorm.DB, ti *toolinstaller.ToolInstaller) *KubectlShellHandler {
|
||||
return &KubectlShellHandler{db: db, toolInstaller: ti}
|
||||
}
|
||||
|
||||
// wsWriter wraps a websocket.Conn as an io.Writer (each write = one WSMessage)
|
||||
@@ -86,8 +88,14 @@ func (h *KubectlShellHandler) Terminal(c *gin.Context) {
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// 启动 bash -i(TERM=dumb,无 PS1 ANSI 码,不回显混乱)
|
||||
// 注意:不加 --noediting,否则无 TTY 时 bash 不回显输入
|
||||
// Lazy-load kubectl on first K8s terminal connection (uses ToolInstaller)
|
||||
if err := h.toolInstaller.EnsureAvailable("kubectl"); err != nil {
|
||||
log.Printf("[KubectlShell] kubectl not available: %v", err)
|
||||
conn.WriteJSON(WSMessage{Status: 0, Data: fmt.Sprintf("\r\n\x1b[31mkubectl 不可用: %v\x1b[0m\r\n", err)})
|
||||
// Continue — maybe user just wants bash without kubectl
|
||||
}
|
||||
|
||||
// 启动 bash -i
|
||||
cmd := exec.CommandContext(c.Request.Context(), "bash", "--norc", "-i")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"KUBECONFIG="+tmpFile.Name(),
|
||||
@@ -98,8 +106,7 @@ func (h *KubectlShellHandler) Terminal(c *gin.Context) {
|
||||
"LINES=30",
|
||||
)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
Setctty: true,
|
||||
Setsid: true,
|
||||
}
|
||||
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: 120, Rows: 30})
|
||||
@@ -109,14 +116,13 @@ func (h *KubectlShellHandler) Terminal(c *gin.Context) {
|
||||
}
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
// Kill the process group so child processes (kubectl, etc.) are cleaned up
|
||||
if cmd.Process != nil {
|
||||
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
cmd.Wait()
|
||||
}()
|
||||
|
||||
// Welcome message (already on PTY so bash will show PS1 right after)
|
||||
// Welcome message
|
||||
welcome := fmt.Sprintf("\r\n\x1b[32m=== kubectl shell — %s (%s) ===\x1b[0m\r\n", cluster.Name, cluster.Code)
|
||||
welcome += "\x1b[36mKUBECONFIG 已预配 | 输入 'exit' 退出\x1b[0m\r\n\r\n"
|
||||
conn.WriteJSON(WSMessage{Status: 0, Data: welcome})
|
||||
@@ -125,23 +131,22 @@ func (h *KubectlShellHandler) Terminal(c *gin.Context) {
|
||||
ctx, cancel := context.WithCancel(c.Request.Context())
|
||||
defer cancel()
|
||||
|
||||
// PTY → WebSocket (single goroutine via io.Copy)
|
||||
// PTY → WebSocket
|
||||
go func() {
|
||||
defer cancel() // unblock main goroutine when PTY closes
|
||||
defer cancel()
|
||||
n, err := io.Copy(wsWriter{conn: conn}, ptmx)
|
||||
if err != nil && err != io.EOF {
|
||||
log.Printf("[KubectlShell] PTY copy ended: %v (bytes=%d)", err, n)
|
||||
}
|
||||
}()
|
||||
|
||||
// WebSocket → PTY (read JSON messages, write raw to PTY)
|
||||
// WebSocket → PTY
|
||||
go func() {
|
||||
defer ptmx.Close() // close PTY to stop bash and unblock Copy goroutine
|
||||
defer ptmx.Close()
|
||||
for {
|
||||
msgType, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if !websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
// Normal close — client disconnected
|
||||
} else {
|
||||
log.Printf("[KubectlShell] WS read error: %v", err)
|
||||
}
|
||||
@@ -150,7 +155,6 @@ func (h *KubectlShellHandler) Terminal(c *gin.Context) {
|
||||
if msgType == websocket.TextMessage || msgType == websocket.BinaryMessage {
|
||||
var msg WSMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
// Binary data (rare) — pass through raw
|
||||
if msgType == websocket.BinaryMessage {
|
||||
ptmx.Write(data)
|
||||
}
|
||||
@@ -158,34 +162,22 @@ func (h *KubectlShellHandler) Terminal(c *gin.Context) {
|
||||
}
|
||||
switch msg.Status {
|
||||
case 0:
|
||||
// Keyboard input — write raw bytes to PTY
|
||||
if _, err := ptmx.Write([]byte(msg.Data)); err != nil {
|
||||
log.Printf("[KubectlShell] PTY write error: %v", err)
|
||||
return
|
||||
}
|
||||
case 1:
|
||||
// Terminal resize
|
||||
if msg.Cols > 0 && msg.Rows > 0 {
|
||||
if err := pty.Setsize(ptmx, &pty.Winsize{
|
||||
pty.Setsize(ptmx, &pty.Winsize{
|
||||
Cols: uint16(msg.Cols),
|
||||
Rows: uint16(msg.Rows),
|
||||
}); err != nil {
|
||||
log.Printf("[KubectlShell] PTY resize error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Block until PTY closes or context cancelled
|
||||
<-ctx.Done()
|
||||
log.Printf("[KubectlShell] session ended for cluster %s", cluster.Name)
|
||||
}
|
||||
|
||||
func init() {
|
||||
// ensure kubectl is available
|
||||
if _, err := exec.LookPath("kubectl"); err != nil {
|
||||
log.Printf("[KubectlShell] WARNING: kubectl not found in PATH — kubectl commands will not work")
|
||||
}
|
||||
}
|
||||
@@ -398,6 +398,9 @@ func (h *KubernetesHandler) ListPods(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// M2: inject pod metrics (best-effort) // Logan.Li
|
||||
podMetrics := buildPodMetricsMap(client, namespace)
|
||||
|
||||
type PodSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
@@ -406,6 +409,8 @@ func (h *KubernetesHandler) ListPods(c *gin.Context) {
|
||||
IP string `json:"ip"`
|
||||
RestartCount int `json:"restart_count"`
|
||||
Image string `json:"image"`
|
||||
CPUUsageM string `json:"cpu_usage_m,omitempty"` // M2: e.g. "50m"
|
||||
MemUsageMi string `json:"mem_usage_mi,omitempty"` // M2: e.g. "128Mi"
|
||||
}
|
||||
result := []PodSummary{}
|
||||
for _, p := range pods.Items {
|
||||
@@ -420,7 +425,7 @@ func (h *KubernetesHandler) ListPods(c *gin.Context) {
|
||||
if len(p.Status.ContainerStatuses) > 0 {
|
||||
restarts = p.Status.ContainerStatuses[0].RestartCount
|
||||
}
|
||||
result = append(result, PodSummary{
|
||||
ps := PodSummary{
|
||||
Namespace: p.Metadata.Namespace,
|
||||
Name: p.Metadata.Name,
|
||||
Status: p.Status.Phase,
|
||||
@@ -428,7 +433,12 @@ func (h *KubernetesHandler) ListPods(c *gin.Context) {
|
||||
IP: p.Status.PodIP,
|
||||
RestartCount: restarts,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok {
|
||||
ps.CPUUsageM = m.CPU
|
||||
ps.MemUsageMi = m.Mem
|
||||
}
|
||||
result = append(result, ps)
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "success", Data: result})
|
||||
}
|
||||
@@ -1534,6 +1544,8 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
pods, err := client.ListPods()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
// M2: inject pod metrics (best-effort) // Logan.Li
|
||||
podMetrics := buildPodMetricsMap(client, ns)
|
||||
type PodSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
@@ -1542,13 +1554,15 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
|
||||
IP string `json:"ip"`
|
||||
RestartCount int `json:"restart_count"`
|
||||
Image string `json:"image"`
|
||||
CPUUsageM string `json:"cpu_usage_m,omitempty"`
|
||||
MemUsageMi string `json:"mem_usage_mi,omitempty"`
|
||||
}
|
||||
result := []PodSummary{}
|
||||
for _, p := range pods.Items {
|
||||
if p.Metadata.Namespace != ns { continue }
|
||||
image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image }
|
||||
restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount }
|
||||
result = append(result, PodSummary{
|
||||
ps := PodSummary{
|
||||
Namespace: p.Metadata.Namespace,
|
||||
Name: p.Metadata.Name,
|
||||
Status: p.Status.Phase,
|
||||
@@ -1556,7 +1570,12 @@ func (h *KubernetesHandler) listPodsWithNS(c *gin.Context, ns string) {
|
||||
IP: p.Status.PodIP,
|
||||
RestartCount: restarts,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok {
|
||||
ps.CPUUsageM = m.CPU
|
||||
ps.MemUsageMi = m.Mem
|
||||
}
|
||||
result = append(result, ps)
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
@@ -1847,6 +1866,8 @@ func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) {
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
|
||||
pods, err := client.ListPods()
|
||||
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "K8s API error: " + err.Error()}); return }
|
||||
// M2: inject pod metrics (best-effort) // Logan.Li
|
||||
podMetrics := buildPodMetricsMap(client, "")
|
||||
type PodSummary struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Name string `json:"name"`
|
||||
@@ -1855,12 +1876,14 @@ func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) {
|
||||
IP string `json:"ip"`
|
||||
RestartCount int `json:"restart_count"`
|
||||
Image string `json:"image"`
|
||||
CPUUsageM string `json:"cpu_usage_m,omitempty"`
|
||||
MemUsageMi string `json:"mem_usage_mi,omitempty"`
|
||||
}
|
||||
result := []PodSummary{}
|
||||
for _, p := range pods.Items {
|
||||
image := ""; if len(p.Spec.Containers) > 0 { image = p.Spec.Containers[0].Image }
|
||||
restarts := 0; if len(p.Status.ContainerStatuses) > 0 { restarts = p.Status.ContainerStatuses[0].RestartCount }
|
||||
result = append(result, PodSummary{
|
||||
ps := PodSummary{
|
||||
Namespace: p.Metadata.Namespace,
|
||||
Name: p.Metadata.Name,
|
||||
Status: p.Status.Phase,
|
||||
@@ -1868,7 +1891,12 @@ func (h *KubernetesHandler) ListPodsAllNS(c *gin.Context) {
|
||||
IP: p.Status.PodIP,
|
||||
RestartCount: restarts,
|
||||
Image: image,
|
||||
})
|
||||
}
|
||||
if m, ok := podMetrics[p.Metadata.Namespace+"/"+p.Metadata.Name]; ok {
|
||||
ps.CPUUsageM = m.CPU
|
||||
ps.MemUsageMi = m.Mem
|
||||
}
|
||||
result = append(result, ps)
|
||||
}
|
||||
c.JSON(200, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
@@ -1920,6 +1948,30 @@ func (h *KubernetesHandler) ListHPAsAllNS(c *gin.Context) {
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
// buildPodMetricsMap fetches pod metrics and returns a map keyed by "namespace/podname" → aggregated CPU/Memory strings.
|
||||
// Best-effort: returns empty map if metrics-server is unavailable. // Logan.Li M2
|
||||
func buildPodMetricsMap(client *k8sclient.Client, namespace string) map[string]struct{ CPU, Mem string } {
|
||||
m := map[string]struct{ CPU, Mem string }{}
|
||||
if pm, err := client.ListPodMetrics(namespace); err == nil {
|
||||
for _, item := range pm.Items {
|
||||
totalCPU := 0
|
||||
totalMemMi := 0
|
||||
for _, c := range item.Containers {
|
||||
totalCPU += parseCpuNanoToMilli(c.Usage["cpu"])
|
||||
totalMemMi += parseMemKiToMi(c.Usage["memory"])
|
||||
}
|
||||
key := item.Metadata.Namespace + "/" + item.Metadata.Name
|
||||
m[key] = struct{ CPU, Mem string }{
|
||||
CPU: fmt.Sprintf("%dm", totalCPU),
|
||||
Mem: fmt.Sprintf("%dMi", totalMemMi),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("[K8s] ListPodMetrics failed (metrics-server not installed?): %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func parseIntResource(val string) int {
|
||||
if val == "" {
|
||||
return 0
|
||||
|
||||
@@ -61,6 +61,16 @@ func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache) *PipelineHandler {
|
||||
return &PipelineHandler{db: db, cache: cache}
|
||||
}
|
||||
|
||||
// PipelineListItem 列表项(含最近一次运行摘要)// Logan.Li
|
||||
type PipelineListItem struct {
|
||||
models.Pipeline
|
||||
LastRunStages string `json:"last_run_stages,omitempty"` // JSON: [{name,status}]
|
||||
LastImageTag string `json:"last_image_tag,omitempty"`
|
||||
LastCommitID string `json:"last_commit_id,omitempty"`
|
||||
LastRunDuration int `json:"last_duration,omitempty"`
|
||||
LastRunBranch string `json:"last_run_branch,omitempty"`
|
||||
}
|
||||
|
||||
// ListPipelines 获取流水线列表
|
||||
// @Summary 获取流水线列表
|
||||
// @Description 获取所有流水线列表
|
||||
@@ -99,9 +109,41 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) {
|
||||
var pipelines []models.Pipeline
|
||||
db.Preload("Environment").Offset(query.GetOffset()).Limit(query.GetLimit()).Order("updated_at DESC").Find(&pipelines)
|
||||
|
||||
// 批量获取每个流水线最近一次运行的摘要(阶段状态 + 镜像 + 耗时)// Logan.Li
|
||||
items := make([]PipelineListItem, len(pipelines))
|
||||
if len(pipelines) > 0 {
|
||||
var pids []uint
|
||||
for _, p := range pipelines {
|
||||
pids = append(pids, p.ID)
|
||||
}
|
||||
// 子查询:每个 pipeline_id 的最新 run id
|
||||
var latestRuns []models.PipelineRun
|
||||
h.db.Where("id IN (SELECT MAX(id) FROM ops_pipeline_runs WHERE pipeline_id IN ? AND status IN ('success','failed','running','pending') GROUP BY pipeline_id)", pids).
|
||||
Find(&latestRuns)
|
||||
runMap := map[uint]*models.PipelineRun{}
|
||||
for i := range latestRuns {
|
||||
runMap[latestRuns[i].PipelineID] = &latestRuns[i]
|
||||
}
|
||||
for i, p := range pipelines {
|
||||
item := PipelineListItem{Pipeline: p}
|
||||
if r, ok := runMap[p.ID]; ok {
|
||||
item.LastRunStages = r.Stages
|
||||
item.LastImageTag = r.ImageTag
|
||||
item.LastCommitID = r.CommitID
|
||||
item.LastRunDuration = r.Duration
|
||||
item.LastRunBranch = r.Branch
|
||||
}
|
||||
items[i] = item
|
||||
}
|
||||
} else {
|
||||
for i, p := range pipelines {
|
||||
items[i] = PipelineListItem{Pipeline: p}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
Data: models.ListResponse{List: pipelines, Total: total},
|
||||
Data: models.ListResponse{List: items, Total: total},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,7 +164,7 @@ func (h *PipelineHandler) GetPipeline(c *gin.Context) {
|
||||
}
|
||||
|
||||
var pipeline models.Pipeline
|
||||
if err := h.db.First(&pipeline, id).Error; err != nil {
|
||||
if err := h.db.Preload("Environment").First(&pipeline, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{
|
||||
Code: 404,
|
||||
Message: "流水线不存在",
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/internal/models"
|
||||
"ezops/pkg/remoteexec"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RemoteExecHandler 远程执行处理器 — Go 原生 SSH 批量执行,替代 Ansible // Logan.Li
|
||||
type RemoteExecHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewRemoteExecHandler 创建远程执行处理器
|
||||
func NewRemoteExecHandler(db *gorm.DB) *RemoteExecHandler {
|
||||
return &RemoteExecHandler{db: db}
|
||||
}
|
||||
|
||||
// ==================== 远程命令执行 ====================
|
||||
|
||||
// BatchExecRequest 批量执行请求
|
||||
type BatchExecRequest struct {
|
||||
ServerIDs []uint `json:"server_ids" binding:"required"` // 目标服务器 ID 列表
|
||||
Command string `json:"command" binding:"required"` // 要执行的命令
|
||||
Timeout int `json:"timeout"` // 超时(秒),默认 300
|
||||
}
|
||||
|
||||
// BatchExec 批量远程执行命令 // Logan.Li
|
||||
// POST /api/v1/remote-exec/execute
|
||||
func (h *RemoteExecHandler) BatchExec(c *gin.Context) {
|
||||
var req BatchExecRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询目标服务器
|
||||
var servers []models.Server
|
||||
if err := h.db.Where("id IN ?", req.ServerIDs).Find(&servers).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询服务器失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "未找到目标服务器"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建 SSH 配置
|
||||
configs := make([]remoteexec.SSHConfig, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
cfg, err := buildSSHConfig(h.db, s)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{
|
||||
Code: 400, Message: fmt.Sprintf("服务器 %s 认证配置错误: %v", s.Hostname, err),
|
||||
})
|
||||
return
|
||||
}
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
|
||||
timeout := req.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 300
|
||||
}
|
||||
|
||||
// 并发执行
|
||||
results := remoteexec.BatchExec(configs, req.Command, time.Duration(timeout)*time.Second, 10)
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: results})
|
||||
}
|
||||
|
||||
// ==================== 文件批量上传 ====================
|
||||
|
||||
// BatchUploadRequest 批量上传请求
|
||||
type BatchUploadRequest struct {
|
||||
ServerIDs []uint `json:"server_ids" binding:"required"` // 目标服务器 ID 列表
|
||||
LocalPath string `json:"local_path" binding:"required"` // AIOps 主机上的文件路径
|
||||
RemotePath string `json:"remote_path" binding:"required"` // 远程目标路径
|
||||
}
|
||||
|
||||
// BatchUpload 批量上传文件到远程服务器 // Logan.Li
|
||||
// POST /api/v1/remote-exec/upload
|
||||
func (h *RemoteExecHandler) BatchUpload(c *gin.Context) {
|
||||
var req BatchUploadRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(req.LocalPath, "/") {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "local_path 必须是绝对路径"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查询目标服务器
|
||||
var servers []models.Server
|
||||
if err := h.db.Where("id IN ?", req.ServerIDs).Find(&servers).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询服务器失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "未找到目标服务器"})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建 SSH 配置
|
||||
configs := make([]remoteexec.SSHConfig, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
cfg, err := buildSSHConfig(h.db, s)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{
|
||||
Code: 400, Message: fmt.Sprintf("服务器 %s 认证配置错误: %v", s.Hostname, err),
|
||||
})
|
||||
return
|
||||
}
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
|
||||
// 并发上传
|
||||
results := remoteexec.BatchUpload(configs, req.LocalPath, req.RemotePath, 5)
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: results})
|
||||
}
|
||||
|
||||
// ==================== 主机匹配 ====================
|
||||
|
||||
// MatchHosts 预览匹配的主机列表(从资产服务器表查询)// Logan.Li
|
||||
// GET /api/v1/remote-exec/hosts?pattern=web*&match_type=prefix
|
||||
func (h *RemoteExecHandler) MatchHosts(c *gin.Context) {
|
||||
pattern := c.Query("pattern")
|
||||
matchType := c.DefaultQuery("match_type", "contains")
|
||||
|
||||
query := h.db.Model(&models.Server{})
|
||||
|
||||
if pattern != "" {
|
||||
switch matchType {
|
||||
case "prefix":
|
||||
query = query.Where("hostname LIKE ? OR ip LIKE ?", pattern+"%", pattern+"%")
|
||||
case "regex":
|
||||
query = query.Where("hostname REGEXP ? OR ip REGEXP ?", pattern, pattern)
|
||||
default: // contains
|
||||
query = query.Where("hostname LIKE ? OR ip LIKE ? OR tags LIKE ?",
|
||||
"%"+pattern+"%", "%"+pattern+"%", "%"+pattern+"%")
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var items []models.Server
|
||||
query.Order("hostname").Find(&items)
|
||||
|
||||
// 补充 HasAuth 字段
|
||||
for i := range items {
|
||||
items[i].HasAuth = items[i].Password != "" || items[i].SSHKey != ""
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: total}})
|
||||
}
|
||||
|
||||
// ==================== 单机命令执行 ====================
|
||||
|
||||
// ExecOnServer 在指定服务器上执行命令并返回结果
|
||||
// POST /api/v1/remote-exec/server/:id/exec
|
||||
func (h *RemoteExecHandler) ExecOnServer(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的服务器 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Command string `json:"command" binding:"required"`
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var server models.Server
|
||||
if err := h.db.First(&server, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "服务器不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := buildSSHConfig(h.db, server)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "认证配置错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
timeout := req.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 300
|
||||
}
|
||||
|
||||
result, err := remoteexec.Exec(cfg, req.Command, time.Duration(timeout)*time.Second)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error(), Data: result})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
// buildSSHConfig 从 Server 模型构建 SSHConfig。
|
||||
// 优先使用 server 自带的 Password/SSHKey 字段,
|
||||
// 如果 server 关联了 credential_id,则从凭证表解密获取。
|
||||
func buildSSHConfig(db *gorm.DB, server models.Server) (remoteexec.SSHConfig, error) {
|
||||
cfg := remoteexec.SSHConfig{
|
||||
Host: server.IP,
|
||||
Port: server.Port,
|
||||
User: server.Username,
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 22
|
||||
}
|
||||
if cfg.User == "" {
|
||||
cfg.User = "root"
|
||||
}
|
||||
|
||||
// 优先使用 server 自带的认证信息
|
||||
if server.AuthType == "key" && server.SSHKey != "" {
|
||||
cfg.AuthType = remoteexec.AuthKey
|
||||
cfg.KeyData = server.SSHKey
|
||||
return cfg, nil
|
||||
}
|
||||
if server.Password != "" {
|
||||
cfg.AuthType = remoteexec.AuthPassword
|
||||
cfg.Password = server.Password
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// 从凭证表查找关联的 SSH 凭据
|
||||
// Server 模型目前没有 CredentialID 字段,通过 tags 或 name 匹配
|
||||
// 未来可扩展 server.credential_id FK
|
||||
|
||||
return cfg, fmt.Errorf("服务器 %s 未配置 SSH 认证(密码或密钥)", server.Hostname)
|
||||
}
|
||||
|
||||
// ==================== 定时任务远程执行(对接 CronJob target_type) ====================
|
||||
|
||||
// ExecuteCronOnTargets 根据 CronJob 的 target_type 在目标主机上执行脚本。
|
||||
// 由 automation_cron.go 调用的公共函数。
|
||||
func ExecuteCronOnTargets(db *gorm.DB, job models.CronJob, scriptType, content string, timeoutSec int) ([]remoteexec.ExecResult, error) {
|
||||
// 解析目标服务器
|
||||
var targetIDs []uint
|
||||
if job.Targets != "" {
|
||||
json.Unmarshal([]byte(job.Targets), &targetIDs)
|
||||
}
|
||||
|
||||
if len(targetIDs) == 0 {
|
||||
return nil, fmt.Errorf("cron job %s has no targets", job.Name)
|
||||
}
|
||||
|
||||
// 查询服务器
|
||||
var servers []models.Server
|
||||
switch job.TargetType {
|
||||
case "server":
|
||||
db.Where("id IN ?", targetIDs).Find(&servers)
|
||||
case "group":
|
||||
db.Where("id IN ?", targetIDs).Find(&servers)
|
||||
case "tag":
|
||||
// 标签匹配 — 通过 tags JSON 字段模糊查询
|
||||
tagStr := fmt.Sprintf("%v", targetIDs)
|
||||
db.Where("tags LIKE ?", "%"+tagStr+"%").Find(&servers)
|
||||
default:
|
||||
db.Where("id IN ?", targetIDs).Find(&servers)
|
||||
}
|
||||
|
||||
if len(servers) == 0 {
|
||||
return nil, fmt.Errorf("no target servers found for cron job %s", job.Name)
|
||||
}
|
||||
|
||||
// 构建 SSH 配置
|
||||
configs := make([]remoteexec.SSHConfig, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
cfg, err := buildSSHConfig(db, s)
|
||||
if err != nil {
|
||||
continue // 跳过认证失败的主机
|
||||
}
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
|
||||
if len(configs) == 0 {
|
||||
return nil, fmt.Errorf("all target servers have auth errors for cron job %s", job.Name)
|
||||
}
|
||||
|
||||
// 并发执行
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 300
|
||||
}
|
||||
results := remoteexec.BatchExec(configs, content, time.Duration(timeoutSec)*time.Second, 5)
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ==================== 工具配置 CRUD ====================
|
||||
|
||||
// ToolConfigHandler handles deploy tool configuration
|
||||
type ToolConfigHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewToolConfigHandler creates handler
|
||||
func NewToolConfigHandler(db *gorm.DB) *ToolConfigHandler {
|
||||
return &ToolConfigHandler{db: db}
|
||||
}
|
||||
|
||||
// ListTools returns all configured tools
|
||||
func (h *ToolConfigHandler) ListTools(c *gin.Context) {
|
||||
var tools []models.DeployTool
|
||||
h.db.Order("sort_order ASC").Find(&tools)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: tools, Total: int64(len(tools))}})
|
||||
}
|
||||
|
||||
// GetTool returns a single tool
|
||||
func (h *ToolConfigHandler) GetTool(c *gin.Context) {
|
||||
var tool models.DeployTool
|
||||
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "工具不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: tool})
|
||||
}
|
||||
|
||||
// UpdateTool updates a tool's configuration
|
||||
func (h *ToolConfigHandler) UpdateTool(c *gin.Context) {
|
||||
var tool models.DeployTool
|
||||
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "工具不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
Description *string `json:"description"`
|
||||
InstallMethod *models.ToolInstallMethod `json:"install_method"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req.DisplayName != nil {
|
||||
updates["display_name"] = *req.DisplayName
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.InstallMethod != nil {
|
||||
updates["install_method"] = *req.InstallMethod
|
||||
if *req.InstallMethod == models.ToolDisabled {
|
||||
updates["status"] = models.ToolNotInstalled
|
||||
}
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
updates["sort_order"] = *req.SortOrder
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
h.db.Model(&tool).Updates(updates)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已更新"})
|
||||
}
|
||||
|
||||
// InstallTool triggers lazy installation of a tool
|
||||
func (h *ToolConfigHandler) InstallTool(c *gin.Context) {
|
||||
var tool models.DeployTool
|
||||
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "工具不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if !tool.Enabled {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "工具已禁用"})
|
||||
return
|
||||
}
|
||||
|
||||
if tool.InstallMethod != models.ToolLazy {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "仅懒加载工具可手动安装"})
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("[ToolConfig] Background install triggered for %s", tool.Name)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "安装已触发"})
|
||||
}
|
||||
|
||||
// CheckToolStatus re-checks a tool's installation status
|
||||
func (h *ToolConfigHandler) CheckToolStatus(c *gin.Context) {
|
||||
var tool models.DeployTool
|
||||
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "工具不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
available := false
|
||||
version := ""
|
||||
if tool.CheckCommand != "" {
|
||||
cmd := exec.Command("sh", "-c", tool.CheckCommand)
|
||||
if err := cmd.Run(); err == nil {
|
||||
available = true
|
||||
version = "已安装"
|
||||
}
|
||||
}
|
||||
|
||||
status := models.ToolNotInstalled
|
||||
if available {
|
||||
status = models.ToolInstalled
|
||||
}
|
||||
h.db.Model(&tool).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"installed_version": version,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
|
||||
"available": available,
|
||||
"version": version,
|
||||
"tool": tool,
|
||||
}})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"ezops/internal/api/middleware"
|
||||
"ezops/internal/config"
|
||||
ezredis "ezops/pkg/redis"
|
||||
"ezops/pkg/toolinstaller"
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -81,8 +82,11 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
k8sHandler := handlers.NewKubernetesHandler(db)
|
||||
r.GET("/ws/kubernetes-exec/:id/namespaces/:namespace/pods/:podName", middleware.JWTAuth(&cfg.JWT), k8sHandler.ExecTerminal)
|
||||
|
||||
// ToolInstaller(工具懒加载服务 — kubectl/maven/node/helm)// Logan.Li
|
||||
ti := toolinstaller.New(db)
|
||||
|
||||
// WebSocket kubectl 命令行终端 // Logan.Li
|
||||
kubectlShellHandler := handlers.NewKubectlShellHandler(db)
|
||||
kubectlShellHandler := handlers.NewKubectlShellHandler(db, ti)
|
||||
r.GET("/ws/kubectl", middleware.JWTAuth(&cfg.JWT), kubectlShellHandler.Terminal)
|
||||
|
||||
// 公开路由
|
||||
@@ -496,6 +500,17 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
registries.DELETE("/:id", deployHandler.DeleteRegistry)
|
||||
}
|
||||
|
||||
// 工具配置路由(发布配置 Tab ④)// Logan.Li
|
||||
toolConfigHandler := handlers.NewToolConfigHandler(db)
|
||||
tools := authorized.Group("/deploy/tools")
|
||||
{
|
||||
tools.GET("", toolConfigHandler.ListTools)
|
||||
tools.GET("/:id", toolConfigHandler.GetTool)
|
||||
tools.PUT("/:id", toolConfigHandler.UpdateTool)
|
||||
tools.POST("/:id/install", toolConfigHandler.InstallTool)
|
||||
tools.POST("/:id/check", toolConfigHandler.CheckToolStatus)
|
||||
}
|
||||
|
||||
// 自动化运维路由
|
||||
automationHandler := handlers.NewAutomationHandler(db)
|
||||
cronJobs := authorized.Group("/cron-jobs")
|
||||
@@ -521,29 +536,14 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
scripts.POST("/:id/execute", automationHandler.ExecuteScript)
|
||||
}
|
||||
|
||||
// Ansible 自动化路由 // Logan.Li
|
||||
ansibleHandler := handlers.NewAnsibleHandler(db)
|
||||
ansible := authorized.Group("/ansible")
|
||||
// 远程执行路由 — Go 原生 SSH 批量执行替代 Ansible // Logan.Li
|
||||
remoteExecHandler := handlers.NewRemoteExecHandler(db)
|
||||
remoteExec := authorized.Group("/remote-exec")
|
||||
{
|
||||
ansible.GET("/playbooks", ansibleHandler.ListPlaybooks)
|
||||
ansible.POST("/playbooks", ansibleHandler.CreatePlaybook)
|
||||
ansible.GET("/playbooks/:id", ansibleHandler.GetPlaybook)
|
||||
ansible.PUT("/playbooks/:id", ansibleHandler.UpdatePlaybook)
|
||||
ansible.DELETE("/playbooks/:id", ansibleHandler.DeletePlaybook)
|
||||
ansible.DELETE("/playbooks/batch-delete", ansibleHandler.BatchDeletePlaybooks)
|
||||
ansible.POST("/playbooks/:id/execute", ansibleHandler.ExecutePlaybook)
|
||||
ansible.POST("/playbooks/:id/clone", ansibleHandler.ClonePlaybook)
|
||||
|
||||
ansible.GET("/jobs", ansibleHandler.ListJobs)
|
||||
ansible.GET("/jobs/:id", ansibleHandler.GetJob)
|
||||
|
||||
ansible.GET("/inventories", ansibleHandler.ListInventories)
|
||||
ansible.POST("/inventories", ansibleHandler.CreateInventory)
|
||||
ansible.PUT("/inventories/:id", ansibleHandler.UpdateInventory)
|
||||
ansible.DELETE("/inventories/:id", ansibleHandler.DeleteInventory)
|
||||
|
||||
// 主机匹配(资产服务器查询)// Logan.Li
|
||||
ansible.GET("/match-hosts", ansibleHandler.MatchHosts)
|
||||
remoteExec.POST("/execute", remoteExecHandler.BatchExec)
|
||||
remoteExec.POST("/upload", remoteExecHandler.BatchUpload)
|
||||
remoteExec.GET("/hosts", remoteExecHandler.MatchHosts)
|
||||
remoteExec.POST("/server/:id/exec", remoteExecHandler.ExecOnServer)
|
||||
}
|
||||
|
||||
// 任务执行记录(脚本/定时任务执行历史,不含 Ansible)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ============================================
|
||||
// DeployTool 工具配置(懒加载/预装/禁用)
|
||||
// 按 deploy_ 前缀,与 PipelineRepo/Registry 同属发布配置域
|
||||
// ============================================
|
||||
|
||||
// ToolInstallMethod 安装方式
|
||||
type ToolInstallMethod string
|
||||
|
||||
const (
|
||||
ToolPreinstall ToolInstallMethod = "preinstall" // 预装在 Dockerfile 中
|
||||
ToolLazy ToolInstallMethod = "lazy" // 懒加载:首次使用时自动下载
|
||||
ToolDisabled ToolInstallMethod = "disabled" // 禁用:不安装
|
||||
)
|
||||
|
||||
// ToolStatus 安装状态
|
||||
type ToolStatus string
|
||||
|
||||
const (
|
||||
ToolNotInstalled ToolStatus = "not_installed" // 未安装
|
||||
ToolInstalling ToolStatus = "installing" // 安装中
|
||||
ToolInstalled ToolStatus = "installed" // 已安装
|
||||
ToolFailed ToolStatus = "failed" // 安装失败
|
||||
)
|
||||
|
||||
// DeployTool 工具配置表
|
||||
type DeployTool struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
|
||||
Name string `gorm:"type:varchar(64);uniqueIndex;not null;comment:工具名称(英文标识)" json:"name"`
|
||||
DisplayName string `gorm:"type:varchar(128);not null;comment:显示名称" json:"display_name"`
|
||||
Description string `gorm:"type:varchar(256);comment:工具描述" json:"description,omitempty"`
|
||||
Category string `gorm:"type:varchar(32);default:other;comment:分类: k8s/build/deploy/automation" json:"category"`
|
||||
InstallMethod ToolInstallMethod `gorm:"type:varchar(16);default:lazy;comment:安装方式: preinstall/lazy/disabled" json:"install_method"`
|
||||
CheckCommand string `gorm:"type:varchar(256);comment:检测命令(如 kubectl version --client)" json:"check_command,omitempty"`
|
||||
BinaryName string `gorm:"type:varchar(64);comment:二进制文件名(如 kubectl/mvn/node)" json:"binary_name,omitempty"`
|
||||
InstallPath string `gorm:"type:varchar(256);comment:安装目标路径(如 /usr/local/bin)" json:"install_path,omitempty"`
|
||||
DownloadURL string `gorm:"type:varchar(512);comment:下载地址模板(支持 {version})" json:"download_url,omitempty"`
|
||||
VersionURL string `gorm:"type:varchar(512);comment:版本查询地址(如 stable.txt)" json:"version_url,omitempty"`
|
||||
InstallScript string `gorm:"type:text;comment:自定义安装脚本(Shell)" json:"install_script,omitempty"`
|
||||
DefaultVersion string `gorm:"type:varchar(32);comment:默认版本号" json:"default_version,omitempty"`
|
||||
Status ToolStatus `gorm:"type:varchar(16);default:not_installed;comment:当前安装状态" json:"status"`
|
||||
InstalledVer string `gorm:"type:varchar(128);comment:已安装版本号" json:"installed_version,omitempty"`
|
||||
LastCheck *time.Time `gorm:"comment:最后检测时间" json:"last_check,omitempty"`
|
||||
ErrorMsg string `gorm:"type:varchar(512);comment:安装失败原因" json:"error_msg,omitempty"`
|
||||
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
|
||||
SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"`
|
||||
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 表名
|
||||
func (DeployTool) TableName() string {
|
||||
return "deploy_tools"
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 预置工具定义(Seeder)
|
||||
// ============================================
|
||||
|
||||
// PredefinedTools 返回系统预置的工具配置列表
|
||||
func PredefinedTools() []DeployTool {
|
||||
return []DeployTool{
|
||||
{
|
||||
Name: "kubectl",
|
||||
DisplayName: "Kubectl",
|
||||
Description: "Kubernetes 命令行工具,用于 K8s 集群交互",
|
||||
Category: "k8s",
|
||||
InstallMethod: ToolLazy,
|
||||
BinaryName: "kubectl",
|
||||
InstallPath: "/usr/local/bin/kubectl",
|
||||
CheckCommand: "kubectl version --client --short 2>/dev/null || kubectl version --client 2>/dev/null",
|
||||
VersionURL: "https://dl.k8s.io/release/stable.txt",
|
||||
DownloadURL: "https://dl.k8s.io/release/{version}/bin/linux/amd64/kubectl",
|
||||
DefaultVersion: "v1.32.0",
|
||||
SortOrder: 1,
|
||||
},
|
||||
{
|
||||
Name: "maven",
|
||||
DisplayName: "Maven",
|
||||
Description: "Java 项目构建工具,流水线 Java 构建阶段使用",
|
||||
Category: "build",
|
||||
InstallMethod: ToolLazy,
|
||||
BinaryName: "mvn",
|
||||
InstallPath: "/usr/local/bin/mvn",
|
||||
CheckCommand: "mvn --version 2>/dev/null",
|
||||
DownloadURL: "https://dlcdn.apache.org/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz",
|
||||
VersionURL: "https://dlcdn.apache.org/maven/maven-3/",
|
||||
DefaultVersion: "3.9.9",
|
||||
InstallScript: `#!/bin/sh
|
||||
# Maven lazy install — download & extract
|
||||
MAVEN_VERSION="${TOOL_VERSION:-3.9.9}"
|
||||
MAVEN_URL="https://dlcdn.apache.org/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz"
|
||||
echo "[Maven] Downloading ${MAVEN_VERSION}..."
|
||||
cd /tmp
|
||||
curl -sL "$MAVEN_URL" -o maven.tar.gz
|
||||
tar xzf maven.tar.gz
|
||||
mkdir -p /usr/local/share
|
||||
mv apache-maven-${MAVEN_VERSION} /usr/local/share/maven
|
||||
ln -sf /usr/local/share/maven/bin/mvn /usr/local/bin/mvn
|
||||
rm -f maven.tar.gz
|
||||
echo "[Maven] Installed: $(mvn --version 2>&1 | head -1)"
|
||||
`,
|
||||
SortOrder: 2,
|
||||
},
|
||||
{
|
||||
Name: "nodejs",
|
||||
DisplayName: "Node.js + npm",
|
||||
Description: "前端构建工具,流水线 npm install/build 阶段使用",
|
||||
Category: "build",
|
||||
InstallMethod: ToolLazy,
|
||||
BinaryName: "node",
|
||||
InstallPath: "/usr/local/bin/node",
|
||||
CheckCommand: "node --version 2>/dev/null",
|
||||
DownloadURL: "https://nodejs.org/dist/{version}/node-{version}-linux-x64.tar.xz",
|
||||
VersionURL: "https://nodejs.org/dist/latest-v22.x/",
|
||||
DefaultVersion: "v22.14.0",
|
||||
InstallScript: `#!/bin/sh
|
||||
# Node.js lazy install — download & extract
|
||||
NODE_VERSION="${TOOL_VERSION:-v22.14.0}"
|
||||
NODE_URL="https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz"
|
||||
echo "[Node.js] Downloading ${NODE_VERSION}..."
|
||||
cd /tmp
|
||||
curl -sL "$NODE_URL" -o node.tar.xz
|
||||
tar xJf node.tar.xz
|
||||
mkdir -p /usr/local/share
|
||||
mv node-${NODE_VERSION}-linux-x64 /usr/local/share/node
|
||||
ln -sf /usr/local/share/node/bin/node /usr/local/bin/node
|
||||
ln -sf /usr/local/share/node/bin/npm /usr/local/bin/npm
|
||||
ln -sf /usr/local/share/node/bin/npx /usr/local/bin/npx
|
||||
rm -f node.tar.xz
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
echo "[Node.js] Installed: $(node --version) / npm $(npm --version)"
|
||||
`,
|
||||
SortOrder: 3,
|
||||
},
|
||||
{
|
||||
Name: "helm",
|
||||
DisplayName: "Helm",
|
||||
Description: "Kubernetes 包管理工具,Chart 部署使用",
|
||||
Category: "k8s",
|
||||
InstallMethod: ToolLazy,
|
||||
BinaryName: "helm",
|
||||
InstallPath: "/usr/local/bin/helm",
|
||||
CheckCommand: "helm version --short 2>/dev/null",
|
||||
VersionURL: "https://get.helm.sh/",
|
||||
DownloadURL: "https://get.helm.sh/helm-{version}-linux-amd64.tar.gz",
|
||||
DefaultVersion: "v3.16.0",
|
||||
InstallScript: `#!/bin/sh
|
||||
# Helm lazy install — download & extract
|
||||
HELM_VERSION="${TOOL_VERSION:-v3.16.0}"
|
||||
HELM_URL="https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz"
|
||||
echo "[Helm] Downloading ${HELM_VERSION}..."
|
||||
cd /tmp
|
||||
curl -sL "$HELM_URL" -o helm.tar.gz
|
||||
tar xzf helm.tar.gz
|
||||
mv linux-amd64/helm /usr/local/bin/helm
|
||||
rm -rf helm.tar.gz linux-amd64
|
||||
echo "[Helm] Installed: $(helm version --short 2>&1)"
|
||||
`,
|
||||
SortOrder: 4,
|
||||
},
|
||||
{
|
||||
Name: "git",
|
||||
DisplayName: "Git",
|
||||
Description: "版本控制工具,流水线代码拉取使用",
|
||||
Category: "build",
|
||||
InstallMethod: ToolPreinstall,
|
||||
BinaryName: "git",
|
||||
InstallPath: "/usr/bin/git",
|
||||
CheckCommand: "git --version 2>/dev/null",
|
||||
SortOrder: 5,
|
||||
},
|
||||
{
|
||||
Name: "docker-cli",
|
||||
DisplayName: "Docker CLI",
|
||||
Description: "Docker 命令行工具,流水线镜像构建/推送使用",
|
||||
Category: "deploy",
|
||||
InstallMethod: ToolPreinstall,
|
||||
BinaryName: "docker",
|
||||
InstallPath: "/usr/bin/docker",
|
||||
CheckCommand: "docker --version 2>/dev/null",
|
||||
SortOrder: 6,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.DeployUserGroupMember{}, // 用户组成员 // Logan.Li
|
||||
&models.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
|
||||
&models.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
|
||||
&models.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li
|
||||
&models.AlertCondition{}, // 告警条件 // Logan.Li
|
||||
&models.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li
|
||||
&models.AlertUpgrade{}, // 告警升级策略 // Logan.Li
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
// Package remoteexec 提供 Go 原生 SSH 远程命令执行和文件上传能力。
|
||||
// 基于 golang.org/x/crypto/ssh,零外部依赖,替代 ansible-playbook。
|
||||
// 支持密码认证、SSH 密钥认证、TOFU 主机密钥验证。
|
||||
package remoteexec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 类型定义
|
||||
// ============================================
|
||||
|
||||
// AuthMethod SSH 认证方式
|
||||
type AuthMethod int
|
||||
|
||||
const (
|
||||
AuthPassword AuthMethod = iota // 密码认证
|
||||
AuthKey // SSH 私钥认证
|
||||
)
|
||||
|
||||
// SSHConfig SSH 连接配置
|
||||
type SSHConfig struct {
|
||||
Host string // 目标 IP 或域名
|
||||
Port int // SSH 端口(默认 22)
|
||||
User string // SSH 用户(默认 root)
|
||||
AuthType AuthMethod // 认证方式
|
||||
Password string // 密码(AuthPassword 时使用)
|
||||
KeyData string // SSH 私钥内容(AuthKey 时使用)
|
||||
Timeout time.Duration // 连接超时(默认 10s)
|
||||
}
|
||||
|
||||
// ExecResult 远程命令执行结果
|
||||
type ExecResult struct {
|
||||
Host string `json:"host"` // 目标主机
|
||||
Port int `json:"port"` // 目标端口
|
||||
Output string `json:"output"` // stdout+stderr 合并输出
|
||||
ExitCode int `json:"exit_code"` // 退出码(0=成功)
|
||||
Duration int64 `json:"duration"` // 执行耗时(毫秒)
|
||||
Error string `json:"error,omitempty"` // 错误信息(连接失败等)
|
||||
Success bool `json:"success"` // 是否成功
|
||||
}
|
||||
|
||||
// UploadResult 文件上传结果
|
||||
type UploadResult struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
LocalPath string `json:"local_path"`
|
||||
RemotePath string `json:"remote_path"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 核心函数
|
||||
// ============================================
|
||||
|
||||
// Exec 在单台远程主机上执行命令。
|
||||
// 返回合并的 stdout+stderr 输出、退出码和错误。
|
||||
func Exec(cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) {
|
||||
start := time.Now()
|
||||
result := &ExecResult{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
}
|
||||
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 创建 session
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
|
||||
return result, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// 设置超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// 在 goroutine 中执行,支持超时
|
||||
type execOut struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
ch := make(chan execOut, 1)
|
||||
|
||||
go func() {
|
||||
var stdout, stderr bytes.Buffer
|
||||
session.Stdout = &stdout
|
||||
session.Stderr = &stderr
|
||||
err := session.Run(command)
|
||||
|
||||
// 合并输出
|
||||
output := stdout.Bytes()
|
||||
if stderr.Len() > 0 {
|
||||
if len(output) > 0 {
|
||||
output = append(output, '\n')
|
||||
}
|
||||
output = append(output, stderr.Bytes()...)
|
||||
}
|
||||
ch <- execOut{output: output, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Error = fmt.Sprintf("执行超时(%v)", timeout)
|
||||
result.ExitCode = -1
|
||||
return result, fmt.Errorf("exec timeout after %v", timeout)
|
||||
case out := <-ch:
|
||||
result.Duration = time.Since(start).Milliseconds()
|
||||
result.Output = string(out.output)
|
||||
|
||||
if out.err != nil {
|
||||
if exitErr, ok := out.err.(*ssh.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitStatus()
|
||||
result.Success = false
|
||||
} else {
|
||||
result.ExitCode = -1
|
||||
result.Error = out.err.Error()
|
||||
result.Success = false
|
||||
return result, out.err
|
||||
}
|
||||
} else {
|
||||
result.ExitCode = 0
|
||||
result.Success = true
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BatchExec 在多台远程主机上并发执行相同命令。
|
||||
// maxConcurrency 控制最大并发数(0 = 无限制)。
|
||||
func BatchExec(servers []SSHConfig, command string, timeout time.Duration, maxConcurrency int) []ExecResult {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = len(servers)
|
||||
}
|
||||
|
||||
results := make([]ExecResult, len(servers))
|
||||
sem := make(chan struct{}, maxConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, cfg := range servers {
|
||||
wg.Add(1)
|
||||
go func(idx int, config SSHConfig) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
result, err := Exec(config, command, timeout)
|
||||
if err != nil && result == nil {
|
||||
results[idx] = ExecResult{
|
||||
Host: config.Host,
|
||||
Port: config.Port,
|
||||
Error: err.Error(),
|
||||
Success: false,
|
||||
}
|
||||
} else {
|
||||
results[idx] = *result
|
||||
}
|
||||
}(i, cfg)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// UploadFile 上传本地文件到远程主机(使用 SFTP/SCP)。
|
||||
// 采用标准 SSH exec + base64 编码方式,避免依赖 SFTP 子协议。
|
||||
// 对于大文件(>1MB),建议改用 sftp 方式。
|
||||
func UploadFile(cfg SSHConfig, localPath, remotePath string) (*UploadResult, error) {
|
||||
result := &UploadResult{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
LocalPath: localPath,
|
||||
RemotePath: remotePath,
|
||||
}
|
||||
|
||||
// 读取本地文件
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("读取本地文件失败: %v", err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
if len(data) > 10*1024*1024 { // 10MB 限制
|
||||
result.Error = "文件超过 10MB,请使用其他传输方式"
|
||||
return result, fmt.Errorf("file too large: %d bytes", len(data))
|
||||
}
|
||||
|
||||
// 通过 SSH exec + base64 方式传输(兼容性好,不需要 SFTP)
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 步骤1: 确保远程目录存在
|
||||
remoteDir := filepath.Dir(remotePath)
|
||||
mkdirCmd := fmt.Sprintf("mkdir -p %s", remoteDir)
|
||||
Exec(cfg, mkdirCmd, 30*time.Second)
|
||||
|
||||
// 步骤2: base64 编码文件后写入
|
||||
encodedLen := len(data) // 实际要分块,这里简化处理
|
||||
_ = encodedLen
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("创建SSH会话失败: %v", err)
|
||||
return result, err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// 将文件内容直接写入远程(通过 stdin)
|
||||
session.Stdin = bytes.NewReader(data)
|
||||
writeCmd := fmt.Sprintf("cat > %s", remotePath)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
session.Stderr = &stderr
|
||||
|
||||
if err := session.Run(writeCmd); err != nil {
|
||||
result.Error = fmt.Sprintf("写入远程文件失败: %v, stderr: %s", err, stderr.String())
|
||||
return result, err
|
||||
}
|
||||
|
||||
// 验证文件已写入
|
||||
verifySession, _ := client.NewSession()
|
||||
verifyOut, verifyErr := verifySession.Output(fmt.Sprintf("wc -c < %s", remotePath))
|
||||
verifySession.Close()
|
||||
|
||||
if verifyErr == nil {
|
||||
log.Printf("[RemoteExec] Uploaded %s → %s:%s (%s bytes written)",
|
||||
localPath, cfg.Host, remotePath, strings.TrimSpace(string(verifyOut)))
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BatchUpload 批量上传文件到多台服务器(并发)。
|
||||
func BatchUpload(servers []SSHConfig, localPath, remotePath string, maxConcurrency int) []UploadResult {
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = len(servers)
|
||||
}
|
||||
|
||||
results := make([]UploadResult, len(servers))
|
||||
sem := make(chan struct{}, maxConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, cfg := range servers {
|
||||
wg.Add(1)
|
||||
go func(idx int, config SSHConfig) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
result, err := UploadFile(config, localPath, remotePath)
|
||||
if err != nil && result == nil {
|
||||
results[idx] = UploadResult{
|
||||
Host: config.Host,
|
||||
Port: config.Port,
|
||||
LocalPath: localPath,
|
||||
RemotePath: remotePath,
|
||||
Error: err.Error(),
|
||||
}
|
||||
} else {
|
||||
results[idx] = *result
|
||||
}
|
||||
}(i, cfg)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 内部辅助函数
|
||||
// ============================================
|
||||
|
||||
// connect 建立 SSH 连接
|
||||
func connect(cfg SSHConfig) (*ssh.Client, error) {
|
||||
// 默认值
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 22
|
||||
}
|
||||
if cfg.User == "" {
|
||||
cfg.User = "root"
|
||||
}
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
// 构建认证方法
|
||||
var authMethods []ssh.AuthMethod
|
||||
switch cfg.AuthType {
|
||||
case AuthKey:
|
||||
signer, err := ssh.ParsePrivateKey([]byte(cfg.KeyData))
|
||||
if err != nil {
|
||||
// 密钥解析失败,尝试用密码兜底
|
||||
if cfg.Password != "" {
|
||||
authMethods = []ssh.AuthMethod{ssh.Password(cfg.Password)}
|
||||
} else {
|
||||
return nil, fmt.Errorf("SSH密钥解析失败: %v", err)
|
||||
}
|
||||
} else {
|
||||
authMethods = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
||||
}
|
||||
default:
|
||||
authMethods = []ssh.AuthMethod{ssh.Password(cfg.Password)}
|
||||
}
|
||||
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: cfg.User,
|
||||
Auth: authMethods,
|
||||
Timeout: cfg.Timeout,
|
||||
HostKeyCallback: tofuHostKeyCallback(cfg.Host),
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||
client, err := ssh.Dial("tcp", addr, sshConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSH连接 %s 失败: %w", addr, err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// tofuHostKeyCallback Trust-On-First-Use 主机密钥验证。
|
||||
// 首次连接记录 fingerprint,后续连接校验。
|
||||
func tofuHostKeyCallback(host string) ssh.HostKeyCallback {
|
||||
var knownKey ssh.PublicKey
|
||||
var mu sync.Mutex
|
||||
|
||||
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
fp := ssh.FingerprintSHA256(key)
|
||||
|
||||
if knownKey == nil {
|
||||
// 首次连接 — 信任并记录
|
||||
knownKey = key
|
||||
log.Printf("[RemoteExec] TOFU: trusting new host key for %s (%s): %s", host, hostname, fp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 后续连接 — 校验
|
||||
if ssh.FingerprintSHA256(knownKey) == fp {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("SSH主机密钥变更!%s: expected %s, got %s",
|
||||
hostname, ssh.FingerprintSHA256(knownKey), fp)
|
||||
}
|
||||
}
|
||||
|
||||
// FormatExecResults 格式化批量执行结果为可读字符串
|
||||
func FormatExecResults(results []ExecResult) string {
|
||||
var buf bytes.Buffer
|
||||
successCount, failCount := 0, 0
|
||||
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString(fmt.Sprintf("==== 批量执行结果: %d 成功 / %d 失败 ====\n\n", successCount, failCount))
|
||||
|
||||
for _, r := range results {
|
||||
status := "✓ 成功"
|
||||
if !r.Success {
|
||||
status = "✗ 失败"
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("── %s:%d [%s] (%dms)\n", r.Host, r.Port, status, r.Duration))
|
||||
if r.Error != "" {
|
||||
buf.WriteString(fmt.Sprintf(" 错误: %s\n", r.Error))
|
||||
}
|
||||
if r.Output != "" && len(r.Output) < 2000 {
|
||||
buf.WriteString(fmt.Sprintf(" 输出:\n%s\n", indent(r.Output, " ")))
|
||||
} else if r.Output != "" {
|
||||
buf.WriteString(fmt.Sprintf(" 输出(截断):\n%s\n...(共 %d 字符)\n",
|
||||
r.Output[:1000], len(r.Output)))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func indent(s, prefix string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = prefix + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package toolinstaller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ToolInstaller handles lazy installation of devops tools.
|
||||
// Each tool gets a sync.Once to prevent concurrent installations.
|
||||
type ToolInstaller struct {
|
||||
db *gorm.DB
|
||||
onces map[string]*sync.Once
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// New creates a ToolInstaller.
|
||||
func New(db *gorm.DB) *ToolInstaller {
|
||||
return &ToolInstaller{
|
||||
db: db,
|
||||
onces: make(map[string]*sync.Once),
|
||||
}
|
||||
}
|
||||
|
||||
// ensureOnce returns a sync.Once for the given tool name.
|
||||
func (ti *ToolInstaller) ensureOnce(name string) *sync.Once {
|
||||
ti.mu.Lock()
|
||||
defer ti.mu.Unlock()
|
||||
if o, ok := ti.onces[name]; ok {
|
||||
return o
|
||||
}
|
||||
o := &sync.Once{}
|
||||
ti.onces[name] = o
|
||||
return o
|
||||
}
|
||||
|
||||
// EnsureAvailable checks if a tool is available and installs it if configured for lazy loading.
|
||||
// Returns nil if the tool is ready, or an error if installation failed.
|
||||
func (ti *ToolInstaller) EnsureAvailable(toolName string) error {
|
||||
// Find tool in DB
|
||||
var tool models.DeployTool
|
||||
if err := ti.db.Where("name = ?", toolName).First(&tool).Error; err != nil {
|
||||
// Tool not in DB — try to find it by path
|
||||
if _, lookErr := exec.LookPath(toolName); lookErr == nil {
|
||||
return nil // available even without DB config
|
||||
}
|
||||
return fmt.Errorf("tool %s not configured in DB", toolName)
|
||||
}
|
||||
|
||||
if !tool.Enabled {
|
||||
return fmt.Errorf("tool %s is disabled", toolName)
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
if tool.InstallMethod == models.ToolPreinstall || tool.InstallMethod == models.ToolLazy {
|
||||
if inst, err := ti.checkInstalled(tool); err == nil {
|
||||
// Update status if needed
|
||||
if tool.Status != models.ToolInstalled {
|
||||
ti.updateStatus(tool.ID, models.ToolInstalled, inst, "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
switch tool.InstallMethod {
|
||||
case models.ToolPreinstall:
|
||||
return fmt.Errorf("tool %s should be preinstalled but not found", toolName)
|
||||
case models.ToolDisabled:
|
||||
return fmt.Errorf("tool %s is disabled", toolName)
|
||||
case models.ToolLazy:
|
||||
// Use sync.Once to prevent concurrent installs
|
||||
var installErr error
|
||||
once := ti.ensureOnce(toolName)
|
||||
once.Do(func() {
|
||||
installErr = ti.installTool(&tool)
|
||||
})
|
||||
// If install failed, reset the Once so next call can retry
|
||||
if installErr != nil {
|
||||
ti.mu.Lock()
|
||||
delete(ti.onces, toolName)
|
||||
ti.mu.Unlock()
|
||||
}
|
||||
return installErr
|
||||
default:
|
||||
return fmt.Errorf("unknown install method: %s", tool.InstallMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// checkInstalled runs the tool's check command and returns the version string.
|
||||
func (ti *ToolInstaller) checkInstalled(tool models.DeployTool) (string, error) {
|
||||
// First try LookPath
|
||||
if tool.BinaryName != "" {
|
||||
if _, err := exec.LookPath(tool.BinaryName); err != nil {
|
||||
return "", fmt.Errorf("binary %s not found", tool.BinaryName)
|
||||
}
|
||||
}
|
||||
|
||||
// Run check command if provided
|
||||
if tool.CheckCommand != "" {
|
||||
cmd := exec.Command("sh", "-c", tool.CheckCommand)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(strings.Split(string(out), "\n")[0]), nil
|
||||
}
|
||||
|
||||
return "unknown", nil
|
||||
}
|
||||
|
||||
// installTool performs the actual installation.
|
||||
func (ti *ToolInstaller) installTool(tool *models.DeployTool) error {
|
||||
log.Printf("[ToolInstaller] Installing %s (method=%s)...", tool.Name, tool.InstallMethod)
|
||||
|
||||
// Mark as installing
|
||||
ti.updateStatus(tool.ID, models.ToolInstalling, "", "")
|
||||
|
||||
var err error
|
||||
if tool.InstallScript != "" {
|
||||
err = ti.runInstallScript(tool)
|
||||
} else if tool.DownloadURL != "" {
|
||||
err = ti.downloadBinary(tool)
|
||||
} else {
|
||||
err = fmt.Errorf("no install script or download URL configured for %s", tool.Name)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
log.Printf("[ToolInstaller] ERROR installing %s: %s", tool.Name, errMsg)
|
||||
ti.updateStatus(tool.ID, models.ToolFailed, "", errMsg)
|
||||
return fmt.Errorf("failed to install %s: %w", tool.Name, err)
|
||||
}
|
||||
|
||||
// Verify installation
|
||||
ver, checkErr := ti.checkInstalled(*tool)
|
||||
if checkErr != nil {
|
||||
log.Printf("[ToolInstaller] WARNING: %s installed but check failed: %v", tool.Name, checkErr)
|
||||
ti.updateStatus(tool.ID, models.ToolInstalled, "unknown", "")
|
||||
} else {
|
||||
log.Printf("[ToolInstaller] %s installed successfully: %s", tool.Name, ver)
|
||||
ti.updateStatus(tool.ID, models.ToolInstalled, ver, "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runInstallScript executes the tool's install script in a subshell.
|
||||
func (ti *ToolInstaller) runInstallScript(tool *models.DeployTool) error {
|
||||
// Write script to temp file
|
||||
tmpFile, err := os.CreateTemp("", fmt.Sprintf("tool-install-%s-*.sh", tool.Name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create temp script: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
version := tool.DefaultVersion
|
||||
if version == "" {
|
||||
version = "latest"
|
||||
}
|
||||
|
||||
script := fmt.Sprintf("#!/bin/sh\nset -e\nTOOL_VERSION=%s\n%s\n", version, tool.InstallScript)
|
||||
if _, err := tmpFile.WriteString(script); err != nil {
|
||||
return fmt.Errorf("cannot write temp script: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
os.Chmod(tmpFile.Name(), 0755)
|
||||
|
||||
cmd := exec.Command("sh", tmpFile.Name())
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// downloadBinary downloads a binary file to the install path.
|
||||
// Uses multi-mirror fallback: tries each mirror URL in order, with HEAD verification
|
||||
// to skip dead mirrors before attempting a full download.
|
||||
func (ti *ToolInstaller) downloadBinary(tool *models.DeployTool) error {
|
||||
version := tool.DefaultVersion
|
||||
if version == "" {
|
||||
// Try to get latest version
|
||||
if tool.VersionURL != "" {
|
||||
v, err := ti.fetchVersion(tool.VersionURL)
|
||||
if err != nil {
|
||||
log.Printf("[ToolInstaller] WARNING: cannot fetch version for %s: %v, using default", tool.Name, err)
|
||||
} else {
|
||||
version = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if version == "" {
|
||||
version = "latest"
|
||||
}
|
||||
|
||||
// Resolve mirror URLs — domestic mirrors prioritized when in China
|
||||
mirrorURLs := GetMirrorsForTool(tool.Name, tool.DownloadURL)
|
||||
if len(mirrorURLs) == 0 {
|
||||
return fmt.Errorf("no download URLs configured for %s", tool.Name)
|
||||
}
|
||||
|
||||
// Try each mirror in order
|
||||
var lastErr error
|
||||
for i, urlTemplate := range mirrorURLs {
|
||||
url := strings.ReplaceAll(urlTemplate, "{version}", version)
|
||||
log.Printf("[ToolInstaller] Downloading %s from mirror[%d]: %s", tool.Name, i, url)
|
||||
|
||||
err := ti.tryDownload(url, tool)
|
||||
if err == nil {
|
||||
return nil // success
|
||||
}
|
||||
lastErr = err
|
||||
log.Printf("[ToolInstaller] Mirror[%d] failed for %s: %v, trying next...", i, tool.Name, err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("all %d mirrors exhausted for %s, last error: %w", len(mirrorURLs), tool.Name, lastErr)
|
||||
}
|
||||
|
||||
// tryDownload attempts a single download from a URL.
|
||||
func (ti *ToolInstaller) tryDownload(url string, tool *models.DeployTool) error {
|
||||
// Use a client with timeout for the download phase.
|
||||
// Large files (kubectl ~50MB) can take minutes on slow networks.
|
||||
client := &http.Client{Timeout: 30 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Write to temp file first, then rename (atomic-ish)
|
||||
installPath := tool.InstallPath
|
||||
if installPath == "" {
|
||||
installPath = filepath.Join("/usr/local/bin", tool.BinaryName)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(installPath)
|
||||
tmpFile, err := os.CreateTemp(dir, fmt.Sprintf(".%s-download-*", tool.Name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
|
||||
tmpFile.Close()
|
||||
return fmt.Errorf("failed to save download: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
os.Chmod(tmpFile.Name(), 0755)
|
||||
|
||||
if err := os.Rename(tmpFile.Name(), installPath); err != nil {
|
||||
// Fallback: copy
|
||||
data, _ := os.ReadFile(tmpFile.Name())
|
||||
if writeErr := os.WriteFile(installPath, data, 0755); writeErr != nil {
|
||||
return fmt.Errorf("cannot move binary to %s: %w", installPath, writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchVersion gets the latest version string from a URL.
|
||||
func (ti *ToolInstaller) fetchVersion(url string) (string, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
// updateStatus updates the tool's status in the database.
|
||||
func (ti *ToolInstaller) updateStatus(toolID uint, status models.ToolStatus, version string, errMsg string) {
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
"last_check": &now,
|
||||
}
|
||||
if version != "" {
|
||||
updates["installed_version"] = version
|
||||
}
|
||||
if errMsg != "" {
|
||||
updates["error_msg"] = errMsg
|
||||
}
|
||||
ti.db.Model(&models.DeployTool{}).Where("id = ?", toolID).Updates(updates)
|
||||
}
|
||||
|
||||
// SeedTools inserts predefined tools into the database if the table is empty.
|
||||
func (ti *ToolInstaller) SeedTools() {
|
||||
var count int64
|
||||
ti.db.Model(&models.DeployTool{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tools := models.PredefinedTools()
|
||||
now := time.Now()
|
||||
for i := range tools {
|
||||
tools[i].CreatedAt = now
|
||||
tools[i].UpdatedAt = now
|
||||
// Check if preinstalled tools are actually available
|
||||
if tools[i].InstallMethod == models.ToolPreinstall {
|
||||
if ver, err := ti.checkInstalled(tools[i]); err == nil {
|
||||
tools[i].Status = models.ToolInstalled
|
||||
tools[i].InstalledVer = ver
|
||||
} else {
|
||||
tools[i].Status = models.ToolNotInstalled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := ti.db.Create(&tools).Error; err != nil {
|
||||
log.Printf("[ToolInstaller] Seed failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[ToolInstaller] Seeded %d tools", len(tools))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package toolinstaller
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 工具镜像源管理 — 每工具 ≥2 国内源 + 官方源
|
||||
// 下载前先 HEAD 验证 URL 可达,再 fallback 重试
|
||||
// ============================================
|
||||
|
||||
// ToolMirror 单个镜像源定义
|
||||
type ToolMirror struct {
|
||||
Name string // 镜像名称(日志用)
|
||||
URL string // 下载地址模板,{version} 占位符
|
||||
// Location 标记:domestic(国内)/global(国际)/internal(内网)
|
||||
Location string
|
||||
}
|
||||
|
||||
// 工具镜像源清单
|
||||
// 每个工具至少 2 个国内源 + 官方源
|
||||
var toolMirrors = map[string][]ToolMirror{
|
||||
"kubectl": {
|
||||
{Name: "K8s官方", URL: "https://dl.k8s.io/release/{version}/bin/linux/amd64/kubectl", Location: "global"},
|
||||
{Name: "GCS直链", URL: "https://storage.googleapis.com/kubernetes-release/release/{version}/bin/linux/amd64/kubectl", Location: "global"},
|
||||
// 国内无 kubectl 直链镜像(阿里云/清华只托管 yum/apt 源),此处预留给内网 Harbor
|
||||
// {Name: "内网Harbor", URL: "https://harbor.ops.net/static/kubectl-{version}-linux-amd64", Location: "internal"},
|
||||
},
|
||||
"maven": {
|
||||
{Name: "Apache官方", URL: "https://dlcdn.apache.org/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz", Location: "global"},
|
||||
{Name: "清华镜像", URL: "https://mirrors.tuna.tsinghua.edu.cn/apache/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz", Location: "domestic"},
|
||||
{Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/apache/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz", Location: "domestic"},
|
||||
{Name: "华为云镜像", URL: "https://repo.huaweicloud.com/apache/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz", Location: "domestic"},
|
||||
},
|
||||
"nodejs": {
|
||||
{Name: "Node官方", URL: "https://nodejs.org/dist/{version}/node-{version}-linux-x64.tar.xz", Location: "global"},
|
||||
{Name: "npmmirror", URL: "https://npmmirror.com/mirrors/node/{version}/node-{version}-linux-x64.tar.xz", Location: "domestic"},
|
||||
{Name: "清华镜像", URL: "https://mirrors.tuna.tsinghua.edu.cn/nodejs-release/{version}/node-{version}-linux-x64.tar.xz", Location: "domestic"},
|
||||
{Name: "华为云镜像", URL: "https://repo.huaweicloud.com/nodejs/{version}/node-{version}-linux-x64.tar.xz", Location: "domestic"},
|
||||
{Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/nodejs-release/{version}/node-{version}-linux-x64.tar.xz", Location: "domestic"},
|
||||
},
|
||||
"helm": {
|
||||
{Name: "Helm官方", URL: "https://get.helm.sh/helm-{version}-linux-amd64.tar.gz", Location: "global"},
|
||||
{Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/kubernetes/helm/{version}/helm-{version}-linux-amd64.tar.gz", Location: "domestic"},
|
||||
},
|
||||
}
|
||||
|
||||
// GetMirrorsForTool 返回工具的镜像源列表(按网络环境排序)。
|
||||
// 国内网络 → 国内源优先;国际网络 → 官方源优先。
|
||||
// 同时过滤掉不可达的镜像。
|
||||
func GetMirrorsForTool(toolName, downloadURL string) []string {
|
||||
mirrors, ok := toolMirrors[toolName]
|
||||
if !ok {
|
||||
// 未配置镜像列表的工具,只返回 downloadURL
|
||||
if downloadURL != "" {
|
||||
return []string{downloadURL}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
isChina := IsChinaNetwork()
|
||||
|
||||
// 排序:国内网络 → 国内源排前面;国际网络 → 官方源排前面
|
||||
sorted := make([]ToolMirror, len(mirrors))
|
||||
copy(sorted, mirrors)
|
||||
|
||||
// 冒泡排序(简单场景足够)
|
||||
for i := 0; i < len(sorted); i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
// 国内网络:domestic 优先
|
||||
if isChina && sorted[j].Location == "domestic" && sorted[i].Location != "domestic" {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
// 国际网络:global 优先
|
||||
if !isChina && sorted[j].Location == "global" && sorted[i].Location != "global" {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤掉不可达的镜像(HEAD 验证)
|
||||
urls := make([]string, 0, len(sorted))
|
||||
for _, m := range sorted {
|
||||
if verifyURLReachable(m.URL) {
|
||||
urls = append(urls, m.URL)
|
||||
log.Printf("[ToolMirror] ✓ %s (%s) reachable", m.Name, m.URL)
|
||||
} else {
|
||||
log.Printf("[ToolMirror] ✗ %s (%s) unreachable, skip", m.Name, m.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有镜像都不可达,仍然返回全部(让下载阶段报错,而非提前过滤掉)
|
||||
if len(urls) == 0 {
|
||||
log.Printf("[ToolMirror] WARNING: all mirrors unreachable for %s, falling back to raw list", toolName)
|
||||
for _, m := range sorted {
|
||||
urls = append(urls, m.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// 把 downloadURL 也作为兜底放在最后(如果不在 mirrors 中)
|
||||
if downloadURL != "" && !containsURL(urls, downloadURL) {
|
||||
urls = append(urls, downloadURL)
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
// verifyURLReachable 通过 HEAD 请求(带重定向跟随)验证 URL 是否可达。
|
||||
// 返回 true 表示 URL 可访问(HTTP 2xx/3xx)。
|
||||
func verifyURLReachable(urlTemplate string) bool {
|
||||
// 对 URL 模板做简单替换,用已知存在的版本测试
|
||||
testURL := strings.ReplaceAll(urlTemplate, "{version}", "v1.32.0")
|
||||
testURL = strings.ReplaceAll(testURL, "{version}", "3.9.9") // maven 版本格式不同
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Head(testURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 2xx 或 3xx 都算可达
|
||||
return resp.StatusCode >= 200 && resp.StatusCode < 400
|
||||
}
|
||||
|
||||
func containsURL(urls []string, target string) bool {
|
||||
for _, u := range urls {
|
||||
if u == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VerifyAndGetBestMirror 综合检测:先 HEAD 测试所有镜像,返回第一个可达的。
|
||||
// 如果全不可达,返回空字符串。
|
||||
func VerifyAndGetBestMirror(toolName string, mirrors []string) string {
|
||||
for _, url := range mirrors {
|
||||
if verifyURLReachable(url) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
log.Printf("[ToolMirror] WARNING: no reachable mirror found for %s", toolName)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package toolinstaller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 网络环境检测 — 多端点交叉验证判断是否在中国网络内
|
||||
// 原理:Google/YouTube 在中国被墙,阿里云/清华镜像仅国内可达
|
||||
// 4 条线路 ≥3 条一致才判定,防止单点故障误判
|
||||
// ============================================
|
||||
|
||||
// chinaEndpoint 检测端点定义
|
||||
type chinaEndpoint struct {
|
||||
URL string // 检测 URL
|
||||
Name string // 端点名称(日志用)
|
||||
Timeout time.Duration // 超时时间
|
||||
// 在中国网络内的预期可达性
|
||||
ReachableInChina bool
|
||||
}
|
||||
|
||||
var chinaEndpoints = []chinaEndpoint{
|
||||
{URL: "https://www.google.com", Name: "Google", Timeout: 2 * time.Second, ReachableInChina: false},
|
||||
{URL: "https://www.youtube.com", Name: "YouTube", Timeout: 2 * time.Second, ReachableInChina: false},
|
||||
{URL: "https://www.baidu.com", Name: "Baidu", Timeout: 2 * time.Second, ReachableInChina: true},
|
||||
{URL: "https://mirrors.aliyun.com", Name: "Aliyun", Timeout: 3 * time.Second, ReachableInChina: true},
|
||||
{URL: "https://mirrors.tuna.tsinghua.edu.cn", Name: "Tsinghua", Timeout: 3 * time.Second, ReachableInChina: true},
|
||||
}
|
||||
|
||||
var (
|
||||
networkChecked bool
|
||||
networkMu sync.Mutex
|
||||
isChina bool
|
||||
)
|
||||
|
||||
// IsChinaNetwork 检测当前是否在中国网络环境。
|
||||
// 使用 5 个端点交叉验证:Google/YouTube 不可达 + 百度/阿里云/清华可达 → 判定为国内。
|
||||
// 检测结果缓存,后续调用直接返回缓存值。
|
||||
func IsChinaNetwork() bool {
|
||||
networkMu.Lock()
|
||||
defer networkMu.Unlock()
|
||||
|
||||
if networkChecked {
|
||||
return isChina
|
||||
}
|
||||
|
||||
// 并发检测所有端点
|
||||
type result struct {
|
||||
name string
|
||||
reachable bool
|
||||
}
|
||||
results := make([]result, len(chinaEndpoints))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, ep := range chinaEndpoints {
|
||||
wg.Add(1)
|
||||
go func(idx int, endpoint chinaEndpoint) {
|
||||
defer wg.Done()
|
||||
client := &http.Client{Timeout: endpoint.Timeout}
|
||||
resp, err := client.Head(endpoint.URL)
|
||||
reachable := err == nil && resp != nil && resp.StatusCode < 500
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
results[idx] = result{name: endpoint.Name, reachable: reachable}
|
||||
}(i, ep)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 统计:预期不可达(国外)vs 预期可达(国内)
|
||||
var foreignUnreachable, domesticReachable int
|
||||
for i, r := range results {
|
||||
ep := chinaEndpoints[i]
|
||||
if ep.ReachableInChina {
|
||||
if r.reachable {
|
||||
domesticReachable++
|
||||
}
|
||||
} else {
|
||||
if !r.reachable {
|
||||
foreignUnreachable++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判定逻辑:国外端点全部不可达 && 国内端点至少 2 个可达
|
||||
isChina = foreignUnreachable >= 2 && domesticReachable >= 2
|
||||
networkChecked = true
|
||||
return isChina
|
||||
}
|
||||
|
||||
// ResetNetworkCheck 重置网络检测缓存(用于网络环境变化后重新检测)
|
||||
func ResetNetworkCheck() {
|
||||
networkMu.Lock()
|
||||
defer networkMu.Unlock()
|
||||
networkChecked = false
|
||||
isChina = false
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, Loader2, GitBranch, Container, Shield, Globe } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, Loader2, GitBranch, Container, Shield, Globe, Wrench } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -11,6 +11,7 @@ import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { deployApi } from '@/services/api';
|
||||
import CredentialFormDialog from './CredentialFormDialog';
|
||||
import ToolConfigTab from './ToolConfigTab';
|
||||
|
||||
interface Repo {
|
||||
id: number; name: string; description: string; url: string;
|
||||
@@ -152,6 +153,7 @@ export default function DeployCredentials() {
|
||||
<TabsTrigger value="repos" className="gap-1"><GitBranch className="w-4 h-4" />Git 仓库 ({repos.length})</TabsTrigger>
|
||||
<TabsTrigger value="registries" className="gap-1"><Container className="w-4 h-4" />镜像仓库 ({registries.length})</TabsTrigger>
|
||||
<TabsTrigger value="environments" className="gap-1"><Globe className="w-4 h-4" />环境管理 ({environments.length})</TabsTrigger>
|
||||
<TabsTrigger value="tools" className="gap-1"><Wrench className="w-4 h-4" />工具配置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="repos" className="mt-3">
|
||||
@@ -284,6 +286,11 @@ export default function DeployCredentials() {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tools config tab */}
|
||||
<TabsContent value="tools" className="mt-3">
|
||||
<ToolConfigTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<CredentialFormDialog
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Loader2, StopCircle, FileText, Trash2, ChevronDown,
|
||||
ChevronRight, Rocket, Box, Workflow, Code, Container, ShieldCheck, Tag,
|
||||
Globe, ArrowRight, RotateCcw, Layers, History, Server, Network,
|
||||
ChevronRight as ChevronRightSmall, Package,
|
||||
} from 'lucide-react';
|
||||
import { pipelineApi, deployApi } from '@/services/api';
|
||||
import http from '@/services/http';
|
||||
@@ -283,6 +284,35 @@ export function PipelineManager() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ══════════ 全流程可视化横幅 // Logan.Li ══════════ */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2.5 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<GitBranch className="h-3.5 w-3.5 text-indigo-400" />
|
||||
<span className="text-[11px] font-semibold text-indigo-400">Git 代码</span>
|
||||
</div>
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Box className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span className="text-[11px] font-semibold text-amber-400">构建</span>
|
||||
</div>
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Container className="h-3.5 w-3.5 text-cyan-400" />
|
||||
<span className="text-[11px] font-semibold text-cyan-400">镜像仓库</span>
|
||||
</div>
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Rocket className="h-3.5 w-3.5 text-red-400" />
|
||||
<span className="text-[11px] font-semibold text-red-400">部署</span>
|
||||
</div>
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Network className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span className="text-[11px] font-semibold text-emerald-400">K8s</span>
|
||||
</div>
|
||||
<span className="ml-auto text-[10px] text-[var(--dg-text-secondary)] opacity-40 tracking-wide">全流程可视化</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative max-w-md flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-secondary)]" />
|
||||
@@ -329,15 +359,43 @@ export function PipelineManager() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-[var(--dg-text-primary)]">{pipeline.name}</h3>
|
||||
{pipeline.run_count > 0 && <Badge variant="outline" className="text-[11px] text-[var(--dg-text-secondary)] border-[var(--dg-border)]">#{pipeline.run_count}</Badge>}
|
||||
{getStatusBadge(pipeline.last_status)}
|
||||
<Badge variant="outline" className="text-xs text-[var(--dg-text-secondary)] border-[var(--dg-border)]">
|
||||
{pipeline.run_count > 0 ? `运行 ${pipeline.run_count} 次` : '未执行'}
|
||||
</Badge>
|
||||
{pipeline.job_name && <Badge variant="secondary" className="text-xs font-mono opacity-60">{pipeline.job_name}</Badge>}
|
||||
{pipeline.environment && <Badge style={{backgroundColor: pipeline.environment.color + '20', color: pipeline.environment.color}} className="text-xs">{pipeline.environment.display_name}</Badge>}
|
||||
{pipeline.job_name && <Badge variant="secondary" className="text-xs font-mono opacity-60">{pipeline.job_name}</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mt-1 line-clamp-1">{pipeline.description || '暂无描述'}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--dg-text-secondary)] opacity-60">
|
||||
{/* 最近运行摘要行:branch + commit → image:tag // Logan.Li */}
|
||||
{(pipeline.last_run_branch || pipeline.last_commit_id || pipeline.last_image_tag) && (
|
||||
<div className="flex items-center gap-1 mt-1.5 text-xs font-mono">
|
||||
{pipeline.last_run_branch && (
|
||||
<span className="flex items-center gap-0.5 text-indigo-400">
|
||||
<GitBranch className="h-3 w-3" />{pipeline.last_run_branch}
|
||||
</span>
|
||||
)}
|
||||
{pipeline.last_run_branch && pipeline.last_commit_id && (
|
||||
<span className="text-[var(--dg-text-secondary)] opacity-50 text-[10px] mx-0.5">+</span>
|
||||
)}
|
||||
{pipeline.last_commit_id && (
|
||||
<code className="text-amber-400 bg-amber-400/10 px-1 py-0.5 rounded text-[11px]">{pipeline.last_commit_id.slice(0, 8)}</code>
|
||||
)}
|
||||
{(pipeline.last_run_branch || pipeline.last_commit_id) && pipeline.last_image_tag && (
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-40 mx-0.5" />
|
||||
)}
|
||||
{pipeline.last_image_tag && (
|
||||
<span className="flex items-center gap-0.5 text-cyan-400">
|
||||
<Package className="h-3 w-3" />
|
||||
<code className="bg-cyan-400/10 px-1 py-0.5 rounded text-[11px]">{pipeline.last_image_tag}</code>
|
||||
</span>
|
||||
)}
|
||||
{pipeline.last_duration > 0 && (
|
||||
<span className="flex items-center gap-0.5 ml-auto text-[var(--dg-text-secondary)] opacity-50">
|
||||
<Clock className="h-3 w-3" />{fmtDuration(pipeline.last_duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-[var(--dg-text-secondary)] opacity-60">
|
||||
<span className="flex items-center gap-1"><GitBranch className="h-3 w-3" />{pipeline.deploy_mode === 'tag' ? 'Tag发版' : pipeline.branch || 'main'}</span>
|
||||
<span className="flex items-center gap-1">{(pipeline.deploy_target || 'ssh') === 'k8s' ? <><Network className="h-3 w-3" />K8s</> : <><Server className="h-3 w-3" />SSH</>}</span>
|
||||
{repo && <span className="flex items-center gap-1"><Globe className="h-3 w-3" />{repo.name}</span>}
|
||||
@@ -365,18 +423,36 @@ export function PipelineManager() {
|
||||
</div>
|
||||
</div>
|
||||
{stages.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-3 flex-wrap">
|
||||
<div className="flex items-center mt-2.5">
|
||||
{stages.map((s: any, i: number) => {
|
||||
const def = STAGE_DEFS.find(d => d.label === s.name);
|
||||
const color = def?.color || '#6B7280';
|
||||
const Icon = def?.icon || Tag;
|
||||
// 从 last_run_stages 获取最新运行状态 // Logan.Li
|
||||
let runStatus: string = '';
|
||||
if (pipeline.last_run_stages) {
|
||||
try {
|
||||
const rs = typeof pipeline.last_run_stages === 'string' ? JSON.parse(pipeline.last_run_stages) : pipeline.last_run_stages;
|
||||
const match = Array.isArray(rs) ? rs.find((r: any) => r.name === s.name || r.type === def?.key) : null;
|
||||
runStatus = match?.status || '';
|
||||
} catch {}
|
||||
}
|
||||
const isSuccess = runStatus === 'success' || runStatus === 'completed';
|
||||
const isRunning = runStatus === 'running' || runStatus === 'in_progress';
|
||||
const isFailed = runStatus === 'failed' || runStatus === 'error';
|
||||
const color = isFailed ? '#EF4444' : isRunning ? '#3B82F6' : isSuccess ? '#10B981' : '#6B7280';
|
||||
const bgColor = isFailed ? '#EF444528' : isRunning ? '#3B82F628' : isSuccess ? '#10B98128' : '#6B728018';
|
||||
const icon = isFailed ? <XCircle className="h-3 w-3" /> : isRunning ? <Loader2 className="h-3 w-3 animate-spin" /> : isSuccess ? <CheckCircle className="h-3 w-3" /> : <span className="text-[10px] opacity-40">○</span>;
|
||||
return (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs"
|
||||
style={{ borderColor: color + '40', backgroundColor: color + '15', color: color }}>
|
||||
<Icon className="h-3 w-3" />{s.name}
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-semibold border shadow-sm"
|
||||
style={{ backgroundColor: bgColor, borderColor: color + '70', color, borderRadius: i === 0 ? '8px 0 0 8px' : i === stages.length - 1 ? '0 8px 8px 0' : '0' }}>
|
||||
{icon}{s.name}
|
||||
</div>
|
||||
{i < stages.length - 1 && <ArrowRight className="h-3 w-3 text-[var(--dg-border)] mx-0.5" />}
|
||||
{i < stages.length - 1 && (
|
||||
<div className="flex items-center justify-center w-5 h-5 -mx-px z-10">
|
||||
<ChevronRightSmall className="h-3.5 w-3.5" style={{ color: color + 'CC' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -583,15 +659,20 @@ export function PipelineManager() {
|
||||
})}
|
||||
</div>
|
||||
{selectedStages.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 flex-wrap">
|
||||
<div className="flex items-center mt-2">
|
||||
{selectedStages.map((key, i) => {
|
||||
const def = STAGE_DEFS.find(t => t.key === key)!;
|
||||
return (
|
||||
<div key={i} className="flex items-center">
|
||||
<Badge className="text-[10px] px-2 py-0.5" style={{ backgroundColor: def.color + '20', color: def.color, borderColor: def.color + '40' }}>
|
||||
<def.icon className="h-3 w-3 mr-0.5" />{def.label}
|
||||
</Badge>
|
||||
{i < selectedStages.length - 1 && <ArrowRight className="h-3 w-3 text-[var(--dg-border)] mx-0.5" />}
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium border"
|
||||
style={{ backgroundColor: def.color + '20', borderColor: def.color + '40', color: def.color, borderRadius: i === 0 ? '6px 0 0 6px' : i === selectedStages.length - 1 ? '0 6px 6px 0' : '0' }}>
|
||||
<def.icon className="h-3 w-3" />{def.label}
|
||||
</div>
|
||||
{i < selectedStages.length - 1 && (
|
||||
<div className="flex items-center justify-center w-4 h-5 -mx-px z-10">
|
||||
<ChevronRightSmall className="h-3 w-3" style={{ color: def.color }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2, Loader2, XCircle, History, FolderOpen, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2,
|
||||
Loader2, XCircle, History, FolderOpen, Trash2, ChevronRight as ChevronRightSmall,
|
||||
GitBranch, Box, Container, Rocket, ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import http from '@/services/http';
|
||||
import { FileBrowserDialog } from './FileBrowserDialog';
|
||||
|
||||
// ═══════════ 阶段定义 (与 PipelineManager 共用) ═══════════
|
||||
const STAGE_DEFS = [
|
||||
{ key: 'checkout', label: '代码检出', icon: GitBranch, color: '#6366F1', order: 1 },
|
||||
{ key: 'build', label: '构建', icon: Box, color: '#F59E0B', order: 2 },
|
||||
{ key: 'test', label: '测试', icon: CheckCircle2,color: '#10B981', order: 3 },
|
||||
{ key: 'image', label: '镜像打包', icon: Container, color: '#06B6D4', order: 4 },
|
||||
{ key: 'deploy', label: '部署', icon: Rocket, color: '#EF4444', order: 5 },
|
||||
{ key: 'verify', label: '验证', icon: ShieldCheck, color: '#3B82F6', order: 6 },
|
||||
].sort((a, b) => a.order - b.order);
|
||||
|
||||
interface PipelineRun {
|
||||
id: number;
|
||||
pipeline_id: number;
|
||||
@@ -22,6 +35,7 @@ interface PipelineRun {
|
||||
triggered_by: string;
|
||||
image_tag: string;
|
||||
duration: number;
|
||||
stages: string;
|
||||
started_at: string;
|
||||
finished_at: string;
|
||||
created_at: string;
|
||||
@@ -34,6 +48,11 @@ interface LogEntry {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface StageStatus {
|
||||
name: string;
|
||||
status: string; // success, failed, running, pending
|
||||
}
|
||||
|
||||
const STATUS_BADGES: Record<string, string> = {
|
||||
success: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
failed: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
@@ -50,13 +69,6 @@ const STATUS_ICONS: Record<string, any> = {
|
||||
cancelled: AlertCircle,
|
||||
};
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
info: 'text-[var(--dg-text-secondary)]',
|
||||
success: 'text-emerald-400',
|
||||
error: 'text-red-400',
|
||||
warn: 'text-amber-400',
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds) return '-';
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
@@ -68,16 +80,60 @@ function formatDuration(seconds: number): string {
|
||||
function formatTime(ts: string): string {
|
||||
if (!ts) return '-';
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function parseStageStatuses(raw: string): StageStatus[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const s = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(s) ? s : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** 从 run.stages (JSON) 推导每个阶段的运行状态 */
|
||||
function getRunStageStats(run: PipelineRun | null): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!run) return map;
|
||||
// 如果 run.stages 有数据,直接用
|
||||
const statuses = parseStageStatuses(run.stages);
|
||||
statuses.forEach(s => {
|
||||
if (s.name || (s as any).type) {
|
||||
const key = (s as any).type || (STAGE_DEFS.find(d => d.label === s.name)?.key);
|
||||
if (key) map.set(key, s.status);
|
||||
}
|
||||
});
|
||||
// 如果 stages 为空但 run 已结束,从整体状态推导
|
||||
if (map.size === 0 && run.status === 'success') {
|
||||
STAGE_DEFS.forEach(d => map.set(d.key, 'success'));
|
||||
} else if (map.size === 0 && run.status === 'failed') {
|
||||
STAGE_DEFS.forEach((d, i) => map.set(d.key, i === 0 ? 'success' : 'pending'));
|
||||
} else if (map.size === 0 && run.status === 'running') {
|
||||
map.set('checkout', 'running');
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 从日志中提取每个阶段的首条日志内容(阶段摘要) */
|
||||
function getStageLogSummary(logs: LogEntry[], stageKey: string): string {
|
||||
const first = logs.find(l => l.stage === stageKey);
|
||||
if (first) return first.message;
|
||||
const def = STAGE_DEFS.find(d => d.key === stageKey);
|
||||
if (def) return `${def.label}...`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function PipelineRunDetail() {
|
||||
const { code: pipelineCode } = useParams<{ code: string }>();
|
||||
const [pipeline, setPipeline] = useState<any>(null);
|
||||
const [runs, setRuns] = useState<PipelineRun[]>([]);
|
||||
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('records');
|
||||
const [selectedStage, setSelectedStage] = useState<string | null>(null); // null = 全部
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -88,14 +144,21 @@ export default function PipelineRunDetail() {
|
||||
const [browserOpen, setBrowserOpen] = useState(false);
|
||||
const [browserRun, setBrowserRun] = useState<{ id: number; buildNumber: number } | null>(null);
|
||||
|
||||
// 获取执行记录 (http.get 已自动解包 json.data → {list, total})
|
||||
// 加载流水线详情
|
||||
const fetchPipeline = useCallback(async () => {
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/${pipelineCode}`);
|
||||
setPipeline(data?.data || data);
|
||||
} catch { /* silent, pipeline info optional */ }
|
||||
}, [pipelineCode]);
|
||||
|
||||
// 获取执行记录
|
||||
const fetchRuns = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/${pipelineCode}/runs?page_size=${pageSize}`);
|
||||
const list = data?.list || [];
|
||||
setRuns(list);
|
||||
// 自动选中最新运行
|
||||
if (!selectedRun && list.length > 0) {
|
||||
setSelectedRun(list[0]);
|
||||
}
|
||||
@@ -103,10 +166,10 @@ export default function PipelineRunDetail() {
|
||||
finally { setLoading(false); }
|
||||
}, [pipelineCode, pageSize]);
|
||||
|
||||
useEffect(() => { fetchRuns(); }, []);
|
||||
useEffect(() => { fetchPipeline(); fetchRuns(); }, []);
|
||||
useEffect(() => { fetchRuns(); }, [pageSize]);
|
||||
|
||||
// 自动刷新(有运行中的记录时每5秒刷新)
|
||||
// 自动刷新
|
||||
useEffect(() => {
|
||||
const hasRunning = runs.some(r => r.status === 'running');
|
||||
if (!hasRunning) return;
|
||||
@@ -114,7 +177,7 @@ export default function PipelineRunDetail() {
|
||||
return () => clearInterval(t);
|
||||
}, [runs, fetchRuns]);
|
||||
|
||||
// 加载日志 (http.get 已自动解包 json.data)
|
||||
// 加载日志
|
||||
const fetchLogs = useCallback(async (run: PipelineRun) => {
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/runs/${run.id}/log-stream`);
|
||||
@@ -124,24 +187,16 @@ export default function PipelineRunDetail() {
|
||||
} else {
|
||||
setLogs([]);
|
||||
}
|
||||
// 如果运行中,连接 WebSocket
|
||||
if (run.status === 'running') {
|
||||
connectWS(run.id);
|
||||
}
|
||||
} catch {
|
||||
setLogs([]);
|
||||
}
|
||||
if (run.status === 'running') connectWS(run.id);
|
||||
} catch { setLogs([]); }
|
||||
}, []);
|
||||
|
||||
// WebSocket 连接
|
||||
// WebSocket
|
||||
const connectWS = (runId: number) => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = localStorage.getItem('token');
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/pipeline/${runId}/log?token=${token}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
@@ -154,9 +209,8 @@ export default function PipelineRunDetail() {
|
||||
} else if (data.time && data.level) {
|
||||
setLogs(prev => [...prev, data]);
|
||||
}
|
||||
} catch { /* binary frame, ignore */ }
|
||||
} catch { /* binary frame */ }
|
||||
};
|
||||
|
||||
ws.onclose = () => { wsRef.current = null; };
|
||||
ws.onerror = () => { wsRef.current = null; };
|
||||
wsRef.current = ws;
|
||||
@@ -165,18 +219,17 @@ export default function PipelineRunDetail() {
|
||||
useEffect(() => {
|
||||
if (selectedRun) {
|
||||
fetchLogs(selectedRun);
|
||||
setSelectedStage(null); // reset stage filter on run change
|
||||
}
|
||||
return () => {
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
};
|
||||
return () => { if (wsRef.current) wsRef.current.close(); };
|
||||
}, [selectedRun, fetchLogs]);
|
||||
|
||||
// 自动滚动到底部
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
// 触发流水线
|
||||
// 触发
|
||||
const triggerRun = async () => {
|
||||
try {
|
||||
await http.post(`/pipelines/${pipelineCode}/trigger`, {});
|
||||
@@ -185,7 +238,7 @@ export default function PipelineRunDetail() {
|
||||
} catch { toast.error('触发失败'); }
|
||||
};
|
||||
|
||||
// 停止流水线
|
||||
// 停止
|
||||
const stopRun = async (runId: number) => {
|
||||
try {
|
||||
await http.post(`/pipelines/${pipelineCode}/runs/${runId}/stop`, {});
|
||||
@@ -214,13 +267,9 @@ export default function PipelineRunDetail() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.size === runs.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(runs.map(r => r.id)));
|
||||
}
|
||||
if (selectedIds.size === runs.length) setSelectedIds(new Set());
|
||||
else setSelectedIds(new Set(runs.map(r => r.id)));
|
||||
};
|
||||
|
||||
const triggerLabel = (run: PipelineRun) => {
|
||||
@@ -229,16 +278,46 @@ export default function PipelineRunDetail() {
|
||||
return run.triggered_by || 'Manual';
|
||||
};
|
||||
|
||||
// ═══════════ 阶段状态映射 ═══════════
|
||||
const stageStats = getRunStageStats(selectedRun);
|
||||
|
||||
// ═══════════ 过滤日志(按阶段) ═══════════
|
||||
const filteredLogs = selectedStage
|
||||
? logs.filter(l => l.stage === selectedStage)
|
||||
: logs;
|
||||
|
||||
// 获取当前选中阶段的摘要日志
|
||||
const stageSummary = selectedStage ? getStageLogSummary(logs, selectedStage) : '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4 h-full flex flex-col">
|
||||
{/* 头部 */}
|
||||
{/* ═══════════ 页面头部 ═══════════ */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">
|
||||
<History className="w-5 h-5 inline mr-2" />
|
||||
执行记录
|
||||
流水线详情
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-0.5">流水线 #{pipelineCode}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{pipeline?.name && (
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
{pipeline.name}
|
||||
</span>
|
||||
)}
|
||||
{pipeline?.environment && (
|
||||
<Badge className="text-xs" style={{
|
||||
backgroundColor: pipeline.environment.color + '20',
|
||||
color: pipeline.environment.color,
|
||||
}}>
|
||||
{pipeline.environment.display_name}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedRun && (
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">
|
||||
发布 #{selectedRun.build_number}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{selectedIds.size > 0 && (
|
||||
@@ -248,7 +327,7 @@ export default function PipelineRunDetail() {
|
||||
)}
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => { setPageSize(Number(e.target.value)); }}
|
||||
onChange={e => setPageSize(Number(e.target.value))}
|
||||
className="h-8 px-2 text-xs rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[var(--dg-text-primary)]"
|
||||
>
|
||||
<option value={10}>10条/页</option>
|
||||
@@ -264,204 +343,310 @@ export default function PipelineRunDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
|
||||
<TabsList>
|
||||
<TabsTrigger value="records">执行记录 ({runs.length})</TabsTrigger>
|
||||
<TabsTrigger value="log" disabled={!selectedRun}>执行日志</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* ═══════════ 阶段进度条 (交互式) ═══════════ */}
|
||||
<div className="flex items-center gap-0 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] overflow-x-auto">
|
||||
{STAGE_DEFS.map((def, i) => {
|
||||
const status = stageStats.get(def.key) || '';
|
||||
const isSuccess = status === 'success' || status === 'completed';
|
||||
const isRunning = status === 'running' || status === 'in_progress';
|
||||
const isFailed = status === 'failed' || status === 'error';
|
||||
const isActive = selectedStage === def.key;
|
||||
const isSelected = selectedStage === def.key;
|
||||
const hasData = stageStats.size > 0;
|
||||
|
||||
{/* 执行记录列表 */}
|
||||
<TabsContent value="records" className="flex-1 mt-3">
|
||||
// 颜色: 失败红 / 运行中蓝 / 成功绿 / 未运行灰
|
||||
const color = isFailed ? '#EF4444'
|
||||
: isRunning ? '#3B82F6'
|
||||
: isSuccess ? '#207060' // teal 绿 (OCR 设计稿色)
|
||||
: '#6B7280';
|
||||
const bgColor = isFailed ? '#EF444420'
|
||||
: isRunning ? '#3B82F620'
|
||||
: isSuccess ? '#20706020'
|
||||
: '#6B728010';
|
||||
const borderColor = isActive ? color : (color + '50');
|
||||
|
||||
const Icon = def.icon;
|
||||
const icon = isFailed ? <XCircle className="h-3.5 w-3.5" />
|
||||
: isRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: isSuccess ? <CheckCircle2 className="h-3.5 w-3.5" />
|
||||
: hasData ? <span className="text-[10px] opacity-40">○</span>
|
||||
: <Icon className="h-3.5 w-3.5 opacity-40" />;
|
||||
|
||||
const isLast = i === STAGE_DEFS.length - 1;
|
||||
const borderRadius = i === 0 ? '8px 0 0 8px' : isLast ? '0 8px 8px 0' : '0';
|
||||
|
||||
return (
|
||||
<div key={def.key} className="flex items-center">
|
||||
<button
|
||||
onClick={() => setSelectedStage(selectedStage === def.key ? null : def.key)}
|
||||
title={`${def.label}${status ? ` (${status})` : ''} — 点击查看日志`}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] font-semibold border shadow-sm transition-all cursor-pointer
|
||||
${isActive ? 'ring-1 ring-offset-0 scale-[1.02]' : 'hover:brightness-110'}`}
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
borderColor,
|
||||
color,
|
||||
borderRadius,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span>{def.label}</span>
|
||||
</button>
|
||||
{!isLast && (
|
||||
<div className="flex items-center justify-center w-5 h-5 -mx-px z-10">
|
||||
<ChevronRightSmall className="h-3.5 w-3.5" style={{ color: color + '99' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{selectedRun && (
|
||||
<span className="ml-auto text-[10px] text-[var(--dg-text-secondary)] opacity-40 shrink-0 pl-3 whitespace-nowrap">
|
||||
{selectedStage ? STAGE_DEFS.find(d => d.key === selectedStage)?.label : '全阶段'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══════════ 主内容区: 执行记录表 + 日志 ═══════════ */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* ─── 执行记录表 ─── */}
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden mb-4">
|
||||
{loading && runs.length === 0 ? (
|
||||
<div className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />加载中...</div>
|
||||
<div className="text-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />
|
||||
加载中...
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg m-4">
|
||||
<History className="w-10 h-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-[var(--dg-text-secondary)]">暂无执行记录,点击"立即执行"触发构建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-2 py-2.5 w-10">
|
||||
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0} onChange={toggleAll} className="rounded" />
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">构建</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">状态</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">触发方式</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">用户</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">耗时</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">时间</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map(run => {
|
||||
const StatusIcon = STATUS_ICONS[run.status] || Clock;
|
||||
return (
|
||||
<tr key={run.id}
|
||||
className={`border-b border-[var(--dg-border)] cursor-pointer transition-colors
|
||||
${selectedRun?.id === run.id ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-tertiary)]/50'}`}
|
||||
onClick={() => { setSelectedRun(run); setActiveTab('log'); }}>
|
||||
<td className="px-2 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(run.id)} onChange={() => toggleSelect(run.id)} className="rounded" />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono font-semibold text-[var(--dg-text-primary)]">
|
||||
#{run.build_number}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
|
||||
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
{run.status === 'success' ? '成功' : run.status === 'failed' ? '失败' : run.status === 'running' ? '执行中' : run.status === 'cancelled' ? '已取消' : '待执行'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge variant="outline" className="text-xs">{triggerLabel(run)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{run.triggered_by || 'admin'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
|
||||
{run.status === 'running' ? (
|
||||
<span className="flex items-center gap-1 text-blue-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
执行中...
|
||||
</span>
|
||||
) : formatDuration(run.duration)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-muted)] text-xs hidden md:table-cell">
|
||||
{formatTime(run.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => { setSelectedRun(run); setActiveTab('log'); }}>
|
||||
执行日志
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" />
|
||||
浏览
|
||||
</Button>
|
||||
{run.status === 'running' && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
|
||||
onClick={() => stopRun(run.id)}>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 执行日志 - Console 输出 */}
|
||||
<TabsContent value="log" className="flex-1 mt-3">
|
||||
{!selectedRun ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">请先选择一条执行记录</div>
|
||||
) : (
|
||||
<Card className="flex-1 flex flex-col border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-sm font-mono">
|
||||
aiops-backend #{selectedRun.build_number}
|
||||
</CardTitle>
|
||||
{selectedRun.status === 'running' && (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs">
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />实时
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>触发方式: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 400 }}>
|
||||
{logs.length === 0 && selectedRun.status !== 'running' ? (
|
||||
<div className="text-center py-12 text-slate-500">暂无日志输出</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
let lastStage = '';
|
||||
const stageStyles: Record<string, string> = {
|
||||
checkout: 'border-indigo-500 bg-indigo-500/10',
|
||||
build: 'border-amber-500 bg-amber-500/10',
|
||||
test: 'border-emerald-500 bg-emerald-500/10',
|
||||
image: 'border-cyan-500 bg-cyan-500/10',
|
||||
deploy: 'border-red-500 bg-red-500/10',
|
||||
verify: 'border-blue-500 bg-blue-500/10',
|
||||
};
|
||||
const stageIcons: Record<string, string> = {
|
||||
checkout: '⬇', build: '⚙', test: '✓',
|
||||
image: '📦', deploy: '🚀', verify: '🛡',
|
||||
};
|
||||
return logs.map((entry, i) => {
|
||||
const showStage = entry.stage && entry.stage !== lastStage;
|
||||
if (showStage) lastStage = entry.stage || '';
|
||||
const isError = entry.level === 'error';
|
||||
const isWarn = entry.level === 'warn';
|
||||
const isSuccess = entry.level === 'success';
|
||||
const accent = isError ? 'border-l-2 border-red-500 bg-red-500/10' :
|
||||
isWarn ? 'border-l-2 border-amber-500 bg-amber-500/10' :
|
||||
isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
|
||||
const msgColor = isError ? 'text-red-200' :
|
||||
isWarn ? 'text-amber-200' :
|
||||
isSuccess ? 'text-emerald-200' : 'text-slate-300';
|
||||
const levelBadge = isError
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
|
||||
: isWarn
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
|
||||
: isSuccess
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
|
||||
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-slate-600/30 text-slate-400">INFO</span>;
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
{showStage && (
|
||||
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-slate-500 bg-slate-500/10'}`}>
|
||||
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
|
||||
<span className="text-[11px] font-semibold tracking-wider text-slate-200">{entry.stage}</span>
|
||||
<span className="flex-1 border-t border-slate-700/50"></span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
|
||||
<span className="text-slate-600 shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
|
||||
{levelBadge}
|
||||
<span className={`break-all ${msgColor}`}>{entry.message}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{selectedRun.status === 'running' && (
|
||||
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-300 font-medium">流水线执行中,实时输出...</span>
|
||||
<span className="flex gap-1 ml-1">
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'0ms'}}></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'150ms'}}></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'300ms'}}></span>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-2 py-2.5 w-10">
|
||||
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0}
|
||||
onChange={toggleAll} className="rounded" />
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">构建</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">状态</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">触发方式</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">用户</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">耗时</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">时间</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map(run => {
|
||||
const StatusIcon = STATUS_ICONS[run.status] || Clock;
|
||||
return (
|
||||
<tr key={run.id}
|
||||
className={`border-b border-[var(--dg-border)] cursor-pointer transition-colors
|
||||
${selectedRun?.id === run.id ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-tertiary)]/50'}`}
|
||||
onClick={() => { setSelectedRun(run); }}>
|
||||
<td className="px-2 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(run.id)}
|
||||
onChange={() => toggleSelect(run.id)} className="rounded" />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono font-semibold text-[var(--dg-text-primary)]">
|
||||
#{run.build_number}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
|
||||
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
{run.status === 'success' ? '成功'
|
||||
: run.status === 'failed' ? '失败'
|
||||
: run.status === 'running' ? '执行中'
|
||||
: run.status === 'cancelled' ? '已取消'
|
||||
: '待执行'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge variant="outline" className="text-xs">{triggerLabel(run)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{run.triggered_by || 'admin'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
|
||||
{run.status === 'running' ? (
|
||||
<span className="flex items-center gap-1 text-blue-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />执行中...
|
||||
</span>
|
||||
) : formatDuration(run.duration)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-muted)] text-xs hidden md:table-cell">
|
||||
{formatTime(run.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => setSelectedRun(run)}>
|
||||
日志
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" />浏览
|
||||
</Button>
|
||||
{run.status === 'running' && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
|
||||
onClick={() => stopRun(run.id)}>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 工作区文件浏览器 */}
|
||||
{/* ─── 阶段日志 Console ─── */}
|
||||
{selectedRun && (
|
||||
<Card className="flex-1 flex flex-col min-h-0 border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-sm font-mono">
|
||||
{pipeline?.name || `#${pipelineCode}`} #{selectedRun.build_number}
|
||||
</CardTitle>
|
||||
{selectedRun.status === 'running' && (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs">
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />实时
|
||||
</Badge>
|
||||
)}
|
||||
{selectedStage && (
|
||||
<Badge className="text-xs border-[var(--dg-border)]" variant="outline">
|
||||
{STAGE_DEFS.find(d => d.key === selectedStage)?.label || selectedStage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>触发: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
|
||||
<span className="opacity-40">|</span>
|
||||
<span>分支: {selectedRun.branch || '-'}</span>
|
||||
{selectedRun.commit_id && (
|
||||
<>
|
||||
<span className="opacity-40">|</span>
|
||||
<code className="bg-amber-400/10 text-amber-400 px-1 py-0.5 rounded text-[10px]">
|
||||
{selectedRun.commit_id.slice(0, 8)}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
{selectedRun.image_tag && (
|
||||
<>
|
||||
<span className="opacity-40">|</span>
|
||||
<code className="bg-cyan-400/10 text-cyan-400 px-1 py-0.5 rounded text-[10px]">
|
||||
📦 {selectedRun.image_tag}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0 overflow-hidden min-h-0">
|
||||
{/* ─── 阶段摘要 (OCR 设计稿特性: [代码检出] 命令 + 结果) ─── */}
|
||||
{selectedStage && stageSummary && (
|
||||
<div className="px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[var(--dg-text-secondary)]">
|
||||
[{STAGE_DEFS.find(d => d.key === selectedStage)?.label || selectedStage}]
|
||||
</span>
|
||||
{(() => {
|
||||
const st = stageStats.get(selectedStage);
|
||||
if (st === 'success' || st === 'completed') return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />;
|
||||
if (st === 'failed' || st === 'error') return <XCircle className="h-3.5 w-3.5 text-red-400" />;
|
||||
if (st === 'running' || st === 'in_progress') return <Loader2 className="h-3.5 w-3.5 text-blue-400 animate-spin" />;
|
||||
return null;
|
||||
})()}
|
||||
<span className="text-xs text-[var(--dg-text-muted)] truncate font-mono">
|
||||
{stageSummary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 280 }}>
|
||||
{filteredLogs.length === 0 && selectedRun.status !== 'running' ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
{selectedStage ? '该阶段暂无日志输出' : '暂无日志输出'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
let lastStage = '';
|
||||
const stageStyles: Record<string, string> = {
|
||||
checkout: 'border-indigo-500 bg-indigo-500/10',
|
||||
build: 'border-amber-500 bg-amber-500/10',
|
||||
test: 'border-emerald-500 bg-emerald-500/10',
|
||||
image: 'border-cyan-500 bg-cyan-500/10',
|
||||
deploy: 'border-red-500 bg-red-500/10',
|
||||
verify: 'border-blue-500 bg-blue-500/10',
|
||||
};
|
||||
const stageIcons: Record<string, string> = {
|
||||
checkout: '⬇', build: '⚙', test: '✓',
|
||||
image: '📦', deploy: '🚀', verify: '🛡',
|
||||
};
|
||||
return filteredLogs.map((entry, i) => {
|
||||
const showStage = !selectedStage && entry.stage && entry.stage !== lastStage;
|
||||
if (showStage) lastStage = entry.stage || '';
|
||||
const isError = entry.level === 'error';
|
||||
const isWarn = entry.level === 'warn';
|
||||
const isSuccess = entry.level === 'success';
|
||||
const accent = isError ? 'border-l-2 border-red-500 bg-red-500/10'
|
||||
: isWarn ? 'border-l-2 border-amber-500 bg-amber-500/10'
|
||||
: isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
|
||||
const msgColor = isError ? 'text-red-200'
|
||||
: isWarn ? 'text-amber-200'
|
||||
: isSuccess ? 'text-emerald-200' : 'text-slate-300';
|
||||
const levelBadge = isError
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
|
||||
: isWarn
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
|
||||
: isSuccess
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
|
||||
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-slate-600/30 text-slate-400">INFO</span>;
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
{showStage && (
|
||||
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-slate-500 bg-slate-500/10'}`}>
|
||||
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
|
||||
<span className="text-[11px] font-semibold tracking-wider text-slate-200">{entry.stage}</span>
|
||||
<span className="flex-1 border-t border-slate-700/50"></span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
|
||||
<span className="text-slate-600 shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
|
||||
{levelBadge}
|
||||
<span className={`break-all ${msgColor}`}>{entry.message}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{selectedRun.status === 'running' && (
|
||||
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-300 font-medium">流水线执行中,实时输出...</span>
|
||||
<span className="flex gap-1 ml-1">
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '0ms' }}></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '150ms' }}></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '300ms' }}></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══════════ 工作区文件浏览器 ═══════════ */}
|
||||
<FileBrowserDialog
|
||||
open={browserOpen}
|
||||
onOpenChange={setBrowserOpen}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Wrench, RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { deployApi } from '@/services/api';
|
||||
|
||||
interface ToolConfig {
|
||||
id: number; name: string; display_name: string; description: string;
|
||||
category: string; install_method: 'preinstall' | 'lazy' | 'disabled';
|
||||
binary_name: string; install_path: string; status: string;
|
||||
installed_version: string; error_msg: string; enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
const categoryLabel: Record<string, string> = {
|
||||
k8s: 'K8s', build: '构建', deploy: '部署', automation: '自动化', other: '其他',
|
||||
};
|
||||
const categoryColor: Record<string, string> = {
|
||||
k8s: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
build: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
deploy: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
|
||||
automation: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
other: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
};
|
||||
const methodLabel: Record<string, string> = {
|
||||
preinstall: '预装', lazy: '懒加载', disabled: '禁用',
|
||||
};
|
||||
const methodColor: Record<string, string> = {
|
||||
preinstall: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
lazy: 'bg-violet-500/20 text-violet-400 border-violet-500/30',
|
||||
disabled: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
};
|
||||
|
||||
export default function ToolConfigTab() {
|
||||
const [tools, setTools] = useState<ToolConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [installing, setInstalling] = useState<number | null>(null);
|
||||
|
||||
const fetchTools = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await deployApi.getTools();
|
||||
setTools((res as any)?.list || []);
|
||||
} catch { toast.error('获取工具配置失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchTools(); }, []);
|
||||
|
||||
const handleInstall = async (tool: ToolConfig) => {
|
||||
setInstalling(tool.id);
|
||||
try {
|
||||
await deployApi.installTool(tool.id);
|
||||
toast.success(`${tool.display_name} 安装已触发`);
|
||||
// Poll for status
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await deployApi.checkTool(tool.id);
|
||||
fetchTools();
|
||||
} catch { /* ignore */ }
|
||||
setInstalling(null);
|
||||
}, 3000);
|
||||
} catch {
|
||||
toast.error('触发安装失败');
|
||||
setInstalling(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckStatus = async (tool: ToolConfig) => {
|
||||
try {
|
||||
const res: any = await deployApi.checkTool(tool.id);
|
||||
toast.success(res?.available ? `${tool.display_name} 可用` : `${tool.display_name} 不可用`);
|
||||
fetchTools();
|
||||
} catch { toast.error('检测失败'); }
|
||||
};
|
||||
|
||||
const handleMethodChange = async (tool: ToolConfig, method: 'preinstall' | 'lazy' | 'disabled') => {
|
||||
try {
|
||||
await deployApi.updateTool(tool.id, { install_method: method });
|
||||
toast.success('已更新');
|
||||
fetchTools();
|
||||
} catch { toast.error('更新失败'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--dg-text-secondary)]">
|
||||
管理 DevOps 工具的安装策略:预装在镜像中、首次使用自动下载(懒加载)、或禁用不安装
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchTools}>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
|
||||
暂无工具配置,请检查后端种子数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">工具</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">分类</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">安装方式</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tools.map(tool => (
|
||||
<tr key={tool.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-[var(--dg-text-primary)]">{tool.display_name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] font-mono">{tool.name}</div>
|
||||
{tool.installed_version && tool.status === 'installed' && (
|
||||
<div className="text-xs text-emerald-400 font-mono mt-0.5">v{tool.installed_version}</div>
|
||||
)}
|
||||
{tool.error_msg && (
|
||||
<div className="text-xs text-red-400 mt-0.5 max-w-[200px] truncate" title={tool.error_msg}>{tool.error_msg}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden sm:table-cell">
|
||||
<Badge className={`text-xs ${categoryColor[tool.category] || categoryColor.other}`}>
|
||||
{categoryLabel[tool.category] || tool.category}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Select
|
||||
value={tool.install_method}
|
||||
onValueChange={(v) => handleMethodChange(tool, v as 'preinstall' | 'lazy' | 'disabled')}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[90px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="preinstall">预装</SelectItem>
|
||||
<SelectItem value="lazy">懒加载</SelectItem>
|
||||
<SelectItem value="disabled">禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{tool.status === 'installed' ? (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs gap-1">
|
||||
<CheckCircle className="w-3 h-3" />已安装
|
||||
</Badge>
|
||||
) : tool.status === 'installing' ? (
|
||||
<Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30 text-xs gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />安装中
|
||||
</Badge>
|
||||
) : tool.status === 'failed' ? (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-xs gap-1">
|
||||
<XCircle className="w-3 h-3" />失败
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 border-slate-500/30 text-xs gap-1">
|
||||
<Clock className="w-3 h-3" />未安装
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{tool.install_method === 'lazy' && tool.status !== 'installed' && (
|
||||
<Button
|
||||
variant="outline" size="sm" className="h-7 text-xs"
|
||||
onClick={() => handleInstall(tool)}
|
||||
disabled={installing === tool.id}
|
||||
>
|
||||
{installing === tool.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
安装
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCheckStatus(tool)} title="检测">
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -275,15 +275,17 @@ export function KubernetesWorkloads() {
|
||||
<TableHead className="text-slate-400">命名空间</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">节点</TableHead>
|
||||
<TableHead className="text-slate-400">CPU</TableHead>
|
||||
<TableHead className="text-slate-400">内存</TableHead>
|
||||
<TableHead className="text-slate-400">重启</TableHead>
|
||||
<TableHead className="text-slate-400">镜像</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
) : filteredPods.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-12"><Container className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 Pod</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Container className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 Pod</TableCell></TableRow>
|
||||
) : (
|
||||
filteredPods.map((p) => (
|
||||
<TableRow key={p.id} className="border-slate-800 hover:bg-slate-800/30">
|
||||
@@ -291,6 +293,8 @@ export function KubernetesWorkloads() {
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
|
||||
<TableCell>{getPodStatusBadge(p.status)}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{p.node_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-cyan-400">{p.cpu_usage_m || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-amber-400">{p.mem_usage_mi || '-'}</TableCell>
|
||||
<TableCell className={p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500'}>{p.restart_count || 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -64,7 +64,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
},
|
||||
pods: {
|
||||
title: 'Pods', subtitle: 'Pod 实例运行状态', icon: Container,
|
||||
columns: ['名称', '命名空间', '状态', '节点', '重启', '镜像', '操作'],
|
||||
columns: ['名称', '命名空间', '状态', '节点', 'CPU', '内存', '重启', '镜像', '操作'],
|
||||
renderRow: (p, opts) => {
|
||||
const statusMap: Record<string, string> = { Running: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', Pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', Failed: 'bg-red-500/20 text-red-400 border-red-500/30' };
|
||||
return (
|
||||
@@ -73,6 +73,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
|
||||
<TableCell><Badge className={statusMap[p.status] || 'bg-slate-500/20 text-slate-400'}>{p.status || 'Unknown'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{p.node_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-cyan-400">{p.cpu_usage_m || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-amber-400">{p.mem_usage_mi || '-'}</TableCell>
|
||||
<TableCell className={(p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500')}>{p.restart_count || 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
CheckSquare, PlayCircle, Settings, ChevronDown, ChevronRight,
|
||||
Terminal, Cpu, Database, FileCode, Shield, Users,
|
||||
Menu, Network, CloudCog, Globe, MapPin, Key,
|
||||
Brain, FileTerminal, Activity, BarChart3, GitBranch,
|
||||
Brain, Activity, BarChart3, GitBranch,
|
||||
Bot, AlertTriangle, Container, Boxes, Clock, Rocket,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -93,7 +93,7 @@ const menuItems: MenuItem[] = [
|
||||
children: [
|
||||
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' },
|
||||
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' },
|
||||
{ id: 'ansible', name: 'Ansible', icon: FileTerminal, path: '/ansible' },
|
||||
{ id: 'ansible', name: '远程执行', icon: Terminal, path: '/remote-exec' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -904,6 +904,12 @@ export const deployApi = {
|
||||
getGroupMembers: (id: number | string) => http.get(`/deploy/groups/${id}/members`),
|
||||
addGroupMember: (id: number | string, data: { username: string }) => http.post(`/deploy/groups/${id}/members`, data),
|
||||
removeGroupMember: (id: number | string, memberId: number | string) => http.delete(`/deploy/groups/${id}/members/${memberId}`),
|
||||
// 工具配置 // Logan.Li
|
||||
getTools: () => http.get<ListResponse>('/deploy/tools'),
|
||||
getTool: (id: number | string) => http.get(`/deploy/tools/${id}`),
|
||||
updateTool: (id: number | string, data: any) => http.put(`/deploy/tools/${id}`, data),
|
||||
installTool: (id: number | string) => http.post(`/deploy/tools/${id}/install`),
|
||||
checkTool: (id: number | string) => http.post(`/deploy/tools/${id}/check`),
|
||||
};
|
||||
|
||||
// ==================== Ansible 自动化 API ====================
|
||||
|
||||
Reference in new issue
Block a user