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:
attacker committed 2026-06-23 00:54:54 +08:00
1 parent 8c272a6fa6
commit 4f1ffd1275
27 files changed
+2617 -352

No files matched your search

+7 -7
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+23 -31
View File
@@ -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")
}
}
+59 -7
View File
@@ -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,7 +1544,9 @@ 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 }
type PodSummary struct {
// M2: inject pod metrics (best-effort) // Logan.Li
podMetrics := buildPodMetricsMap(client, ns)
type PodSummary struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
Status string `json:"status"`
@@ -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
+44 -2
View File
@@ -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,
}})
}
+23 -23
View File
@@ -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)
+188
View File
@@ -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,
},
}
}
+1
View File
@@ -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
+420
View File
@@ -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")
}
+336
View File
@@ -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))
}
}
+157
View File
@@ -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 ""
}
+99
View File
@@ -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>
+2 -2
View File
@@ -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' },
],
},
{
+6
View File
@@ -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 ====================