feat: run.sh四模式(dev/docker/build/binary) + CLI flags + Redka内嵌Redis + Agent复制修复 + 服务器编辑IP字段 + 安装Agent按钮

This commit is contained in:
attacker committed 2026-06-18 03:36:04 +00:00
1 parent d0b94d39c0
commit 2354959811
14 files changed
+1355 -575

No files matched your search

+101 -82
View File
@@ -1,82 +1,101 @@
# ============================================================
# AIOps Backend + Agent 多阶段 Dockerfile
# Stage 1: 编译后端 (Go 1.21)
# Stage 2: 交叉编译 Agent (Go 1.24, linux-amd64/arm64 + darwin-arm64)
# Stage 3: 运行时 (Alpine)
# 署名 Logan.Li
# ============================================================
# ==================== Stage 1: Backend Builder ====================
FROM golang:1.21-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git ca-certificates tzdata
ENV TZ=Asia/Shanghai
ENV GOPROXY=https://goproxy.cn,direct
ENV GOSUMDB=off
# 复制后端 go.mod(利用 Docker 层缓存)
COPY backend/go.mod backend/go.sum ./
RUN go mod download
# 复制后端源码
COPY backend/ ./
# 构建后端二进制
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-w -s" \
-o ezops ./cmd/
# ==================== Stage 2: Agent Builder ====================
FROM golang:1.24-alpine AS agent-builder
WORKDIR /agent
ENV GOPROXY=https://goproxy.cn,direct
ENV GOSUMDB=off
# 复制 agent go.mod
COPY agent/go.mod agent/go.sum ./
RUN go mod download
# 复制 agent 源码
COPY agent/ ./
# 交叉编译三个平台的 agent 二进制
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/aiops-agent-linux-amd64 . && \
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o bin/aiops-agent-linux-arm64 . && \
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o bin/aiops-agent-darwin-arm64 .
# ==================== Stage 3: Runtime ====================
FROM alpine:latest
# 安装运行时依赖(curl=健康检查,openssh-client=SSH远程安装agent)
RUN apk --no-cache add ca-certificates tzdata curl openssh-client
ENV TZ=Asia/Shanghai
WORKDIR /app
# 复制后端二进制
COPY --from=builder /app/ezops .
# 创建运行时目录
RUN mkdir -p /app/logs /app/uploads /app/agent/bin /app/agent/deploy
# 复制 agent 二进制(三个平台)
COPY --from=agent-builder /agent/bin/aiops-agent-linux-amd64 /app/agent/bin/
COPY --from=agent-builder /agent/bin/aiops-agent-linux-arm64 /app/agent/bin/
COPY --from=agent-builder /agent/bin/aiops-agent-darwin-arm64 /app/agent/bin/
# 复制 agent 安装脚本和 systemd service 模板
COPY agent/deploy/install.sh /app/agent/deploy/
COPY agent/deploy/aiops-agent.service /app/agent/deploy/
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
CMD ["./ezops"]
1|# ============================================================
2|# AIOps Backend + Agent 多阶段 Dockerfile
3|# Stage 1: 编译后端 (Go 1.21)
4|# Stage 2: 交叉编译 Agent (Go 1.24, linux-amd64/arm64 + darwin-arm64)
5|# Stage 3: 运行时 (Alpine)
6|# 署名 Logan.Li
7|# ============================================================
8|
9|# ==================== Stage 1: Backend Builder ====================
10|FROM golang:1.21-alpine AS builder
11|
12|WORKDIR /app
13|
14|<<<<<<< HEAD
15|=======
16|# 使用国内 Alpine 镜像源加速
17|RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
18|
19|# 安装构建依赖
20|>>>>>>> ca18ead (feat: run.sh四模式(dev/docker/build/binary) + CLI flags + Redka内嵌Redis + Agent复制修复 + 服务器编辑IP字段 + 安装Agent按钮)
21|RUN apk add --no-cache git ca-certificates tzdata
22|
23|ENV TZ=Asia/Shanghai
24|ENV GOPROXY=https://goproxy.cn,direct
25|# 跳过 toolchain 校验(GOTOOLCHAIN 自动下载 Go 1.22)
26|ENV GONOSUMCHECK=*
27|# 自动下载 Go 1.22 工具链(Redka 需要)
28|ENV GOTOOLCHAIN=go1.22.2
29|
30|# 复制后端 go.mod(利用 Docker 层缓存)
31|COPY backend/go.mod backend/go.sum ./
32|RUN go mod download
33|
34|# 复制后端源码
35|COPY backend/ ./
36|
37|# 构建后端二进制
38|RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
39| -ldflags="-w -s" \
40| -o ezops ./cmd/
41|
42|# ==================== Stage 2: Agent Builder ====================
43|FROM golang:1.24-alpine AS agent-builder
44|
45|WORKDIR /agent
46|
47|ENV GOPROXY=https://goproxy.cn,direct
48|ENV GOSUMDB=off
49|
50|# 复制 agent go.mod
51|COPY agent/go.mod agent/go.sum ./
52|RUN go mod download
53|
54|# 复制 agent 源码
55|COPY agent/ ./
56|
57|# 交叉编译三个平台的 agent 二进制
58|RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/aiops-agent-linux-amd64 . && \
59| CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o bin/aiops-agent-linux-arm64 . && \
60| CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o bin/aiops-agent-darwin-arm64 .
61|
62|# ==================== Stage 3: Runtime ====================
63|FROM alpine:latest
64|
65|<<<<<<< HEAD
66|# 安装运行时依赖(curl=健康检查,openssh-client=SSH远程安装agent)
67|RUN apk --no-cache add ca-certificates tzdata curl openssh-client
68|=======
69|# 使用国内 Alpine 镜像源加速
70|RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
71|
72|# 安装运行时依赖
73|RUN apk --no-cache add ca-certificates tzdata curl
74|>>>>>>> ca18ead (feat: run.sh四模式(dev/docker/build/binary) + CLI flags + Redka内嵌Redis + Agent复制修复 + 服务器编辑IP字段 + 安装Agent按钮)
75|
76|ENV TZ=Asia/Shanghai
77|
78|WORKDIR /app
79|
80|# 复制后端二进制
81|COPY --from=builder /app/ezops .
82|
83|# 创建运行时目录
84|RUN mkdir -p /app/logs /app/uploads /app/agent/bin /app/agent/deploy
85|
86|# 复制 agent 二进制(三个平台)
87|COPY --from=agent-builder /agent/bin/aiops-agent-linux-amd64 /app/agent/bin/
88|COPY --from=agent-builder /agent/bin/aiops-agent-linux-arm64 /app/agent/bin/
89|COPY --from=agent-builder /agent/bin/aiops-agent-darwin-arm64 /app/agent/bin/
90|
91|# 复制 agent 安装脚本和 systemd service 模板
92|COPY agent/deploy/install.sh /app/agent/deploy/
93|COPY agent/deploy/aiops-agent.service /app/agent/deploy/
94|
95|EXPOSE 8080
96|
97|HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
98| CMD curl -f http://localhost:8080/health || exit 1
99|
100|CMD ["./ezops"]
101|
+189 -13
View File
@@ -2,6 +2,8 @@ package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
@@ -12,18 +14,50 @@ import (
"ezops/internal/config"
"ezops/pkg/database"
"ezops/pkg/logger"
ezredis "ezops/pkg/redis"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"go.uber.org/zap"
)
// 版本信息(通过编译时 ldflags 注入)
// 版本信息(通过编译时 ldflags 注入)// Logan.Li
var (
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)
// CLI 参数定义 // Logan.Li
var (
flagPort = flag.String("port", "", "服务端口 (默认: 8080)")
flagConfig = flag.String("config", "", "配置文件路径 (默认: config.yaml)")
flagMode = flag.String("mode", "", "运行模式: development, production, release")
flagLogLevel = flag.String("log-level", "", "日志级别: debug, info, warn, error")
// 数据库参数
flagDBDriver = flag.String("db-driver", "", "数据库驱动: sqlite, mysql, postgres (默认: sqlite)")
flagDBHost = flag.String("db-host", "", "数据库主机")
flagDBPort = flag.Int("db-port", 0, "数据库端口")
flagDBUser = flag.String("db-user", "", "数据库用户名")
flagDBPassword = flag.String("db-password", "", "数据库密码")
flagDBName = flag.String("db-name", "", "数据库名 / SQLite 文件路径")
flagDBCharset = flag.String("db-charset", "", "数据库字符集")
// Redis 参数
flagRedisHost = flag.String("redis-host", "", "Redis 主机")
flagRedisPort = flag.Int("redis-port", 0, "Redis 端口")
flagRedisPassword = flag.String("redis-password", "", "Redis 密码")
flagRedisDB = flag.Int("redis-db", 0, "Redis 数据库编号")
// JWT 参数
flagJWTSecret = flag.String("jwt-secret", "", "JWT 密钥")
flagJWTExpire = flag.Int("jwt-expire", 0, "JWT 过期时间 (小时)")
// 显示版本
flagVersion = flag.Bool("version", false, "显示版本信息")
)
// @title AIOps运维平台 API
// @version 1.0
// @description AIOps智能运维平台后端API文档
@@ -45,32 +79,67 @@ var (
// @description Type "Bearer" followed by a space and JWT token.
func main() {
// 加载配置
cfg := config.Load()
// 解析 CLI 参数 // Logan.Li
flag.Parse()
// 显示版本信息 // Logan.Li
if *flagVersion {
fmt.Printf("AIOps 智能运维平台\n")
fmt.Printf(" 版本: %s\n", Version)
fmt.Printf(" 构建: %s\n", BuildTime)
fmt.Printf(" 提交: %s\n", GitCommit)
os.Exit(0)
}
// 加载配置 // Logan.Li
cfg := loadConfig()
// 初始化日志
log := logger.New(cfg.LogLevel)
defer log.Sync()
log.Info("AIOps 启动中",
zap.String("version", Version),
zap.String("mode", cfg.Mode),
zap.String("port", cfg.Port),
)
// 设置Gin模式
if cfg.Mode == "production" {
if cfg.Mode == "production" || cfg.Mode == "release" {
gin.SetMode(gin.ReleaseMode)
}
// 初始化数据库
db, err := database.New(cfg.Database)
if err != nil {
log.Fatal("failed to connect database", zap.Error(err))
log.Fatal("数据库连接失败", zap.Error(err))
}
// 自动迁移
if err := database.AutoMigrate(db); err != nil {
log.Fatal("failed to migrate database", zap.Error(err))
log.Fatal("数据库迁移失败", zap.Error(err))
}
// 初始化种子数据
seedData(db)
// 初始化缓存(Redka 内嵌 / Redis 外部)// Logan.Li
cache, err := ezredis.New(ezredis.Config{
Driver: "redka", // 默认内嵌 Redka,零依赖
Host: cfg.Redis.Host,
Port: cfg.Redis.Port,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
DataDir: "data/redka",
})
if err != nil {
log.Warn("缓存初始化失败,继续启动", zap.Error(err))
cache = nil
} else {
log.Info("缓存已就绪", zap.String("driver", "redka"))
defer cache.Close()
}
// 创建Gin实例
r := gin.New()
@@ -85,11 +154,13 @@ func main() {
"version": Version,
"build_time": BuildTime,
"git_commit": GitCommit,
"mode": cfg.Mode,
"db_driver": cfg.Database.Driver,
})
})
// 注册路由
routes.Register(r, db, log, cfg)
routes.Register(r, db, log, cfg, cache)
// 创建HTTP服务器
srv := &http.Server{
@@ -99,28 +170,133 @@ func main() {
// 优雅关闭
go func() {
log.Info("服务已启动", zap.String("addr", srv.Addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("failed to start server", zap.Error(err))
log.Fatal("服务启动失败", zap.Error(err))
}
}()
log.Info("server started", zap.String("port", cfg.Port))
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("shutting down server...")
log.Info("正在关闭服务...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("server forced to shutdown", zap.Error(err))
log.Fatal("强制关闭服务", zap.Error(err))
}
log.Info("server exited")
log.Info("服务已退出")
}
// loadConfig 加载配置(CLI flags 优先于 config.yaml)// Logan.Li
func loadConfig() *config.Config {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("./configs")
viper.AddConfigPath(".")
// 如果指定了配置文件路径,使用它 // Logan.Li
if *flagConfig != "" {
viper.SetConfigFile(*flagConfig)
}
// 默认值(独立二进制模式:SQLite + 无 Redis)// Logan.Li
viper.SetDefault("mode", "release")
viper.SetDefault("port", "8080")
viper.SetDefault("log_level", "info")
viper.SetDefault("database.driver", "sqlite")
viper.SetDefault("database.host", "localhost")
viper.SetDefault("database.port", 3306)
viper.SetDefault("database.user", "")
viper.SetDefault("database.password", "")
viper.SetDefault("database.dbname", "data/aiops.db")
viper.SetDefault("database.charset", "utf8mb4")
viper.SetDefault("database.max_conn", 100)
viper.SetDefault("database.max_idle", 10)
viper.SetDefault("redis.host", "localhost")
viper.SetDefault("redis.port", 6379)
viper.SetDefault("redis.password", "")
viper.SetDefault("redis.db", 0)
viper.SetDefault("jwt.secret", "aiops-jwt-secret-change-in-production")
viper.SetDefault("jwt.expire_hour", 24)
viper.SetDefault("websocket.read_buffer_size", 1024)
viper.SetDefault("websocket.write_buffer_size", 1024)
// 读取配置文件(不存在也不报错)// Logan.Li
if err := viper.ReadInConfig(); err != nil {
// 配置文件不存在是正常的(独立二进制模式)
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
fmt.Fprintf(os.Stderr, "警告: 读取配置文件失败: %v,使用默认配置\n", err)
}
}
// 环境变量覆盖 // Logan.Li
viper.AutomaticEnv()
// CLI flags 覆盖(最高优先级)// Logan.Li
applyFlags()
// 解析配置 // Logan.Li
var cfg config.Config
if err := viper.Unmarshal(&cfg); err != nil {
fmt.Fprintf(os.Stderr, "配置解析失败: %v\n", err)
os.Exit(1)
}
return &cfg
}
// applyFlags 将 CLI 参数映射到 viper(覆盖 config.yaml 和默认值)// Logan.Li
func applyFlags() {
// 检查是否有任何 flag 被显式设置 // Logan.Li
flag.Visit(func(f *flag.Flag) {
switch f.Name {
case "port":
viper.Set("port", *flagPort)
case "mode":
viper.Set("mode", *flagMode)
case "log-level":
viper.Set("log_level", *flagLogLevel)
case "db-driver":
viper.Set("database.driver", *flagDBDriver)
case "db-host":
viper.Set("database.host", *flagDBHost)
case "db-port":
viper.Set("database.port", *flagDBPort)
case "db-user":
viper.Set("database.user", *flagDBUser)
case "db-password":
viper.Set("database.password", *flagDBPassword)
case "db-name":
viper.Set("database.dbname", *flagDBName)
case "db-charset":
viper.Set("database.charset", *flagDBCharset)
case "redis-host":
viper.Set("redis.host", *flagRedisHost)
case "redis-port":
viper.Set("redis.port", *flagRedisPort)
case "redis-password":
viper.Set("redis.password", *flagRedisPassword)
case "redis-db":
viper.Set("redis.db", *flagRedisDB)
case "jwt-secret":
viper.Set("jwt.secret", *flagJWTSecret)
case "jwt-expire":
viper.Set("jwt.expire_hour", *flagJWTExpire)
}
})
}
// CORS 跨域中间件
+8 -2
View File
@@ -1,12 +1,17 @@
module ezops
go 1.21
go 1.22
toolchain go1.22.2
require (
github.com/gin-gonic/gin v1.9.1
github.com/go-ldap/ldap/v3 v3.4.10
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/nalgeon/redka v0.2.0
github.com/redis/go-redis/v9 v9.7.0
github.com/spf13/viper v1.18.2
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.31.0
@@ -19,7 +24,9 @@ require (
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
@@ -29,7 +36,6 @@ require (
github.com/go-playground/validator/v10 v10.16.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+13
View File
@@ -2,9 +2,15 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
@@ -12,6 +18,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -102,11 +110,15 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nalgeon/redka v0.2.0 h1:9Hjp5aHPHPLE44T1m41LKzx+b/MquMXt8ggw69eVu6U=
github.com/nalgeon/redka v0.2.0/go.mod h1:vLxjY3XS9IwBID2YEFWeeMiN4Ar/DtKd4JW62JTAxuU=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
@@ -207,6 +219,7 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+3
View File
@@ -236,6 +236,9 @@ func (h *ServerHandler) UpdateServer(c *gin.Context) {
if req.Hostname != "" {
updates["hostname"] = req.Hostname
}
if req.IP != "" {
updates["ip"] = req.IP
}
if req.PublicIP != "" {
updates["public_ip"] = req.PublicIP
}
+3 -2
View File
@@ -12,8 +12,9 @@ import (
"time"
)
// Register 注册所有路由
func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
// Register 注册所有路由 // Logan.Li
// cache 参数为可选 Redis 缓存实例(支持 redka/redis 双后端)
func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, cache interface{}) {
// API版本前缀
api := r.Group("/api/v1")
+1
View File
@@ -155,6 +155,7 @@ type CreateServerRequest struct {
// UpdateServerRequest 更新服务器请求
type UpdateServerRequest struct {
Hostname string `json:"hostname"`
IP string `json:"ip" binding:"omitempty,ip"`
PublicIP string `json:"public_ip" binding:"omitempty,ip"`
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
Username string `json:"username"`
+66
View File
@@ -0,0 +1,66 @@
// go-redis 外部 Redis 服务器实现
// 用于 Docker Compose / 生产环境部署 // Logan.Li
package redis
import (
"context"
"fmt"
"time"
goredis "github.com/redis/go-redis/v9"
)
// goRedisCache 基于 go-redis 的缓存实现 // Logan.Li
type goRedisCache struct {
client *goredis.Client
}
func newGoRedis(cfg Config) (Cache, error) {
client := goredis.NewClient(&goredis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: cfg.DB,
})
// 连接测试
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("redis: 连接失败 %s:%d: %w", cfg.Host, cfg.Port, err)
}
return &goRedisCache{client: client}, nil
}
func (r *goRedisCache) Get(ctx context.Context, key string) (string, error) {
val, err := r.client.Get(ctx, key).Result()
if err == goredis.Nil {
return "", nil
}
return val, err
}
func (r *goRedisCache) Set(ctx context.Context, key string, value string, expiration time.Duration) error {
return r.client.Set(ctx, key, value, expiration).Err()
}
func (r *goRedisCache) Del(ctx context.Context, keys ...string) error {
return r.client.Del(ctx, keys...).Err()
}
func (r *goRedisCache) Exists(ctx context.Context, keys ...string) (int64, error) {
return r.client.Exists(ctx, keys...).Result()
}
func (r *goRedisCache) Expire(ctx context.Context, key string, expiration time.Duration) error {
return r.client.Expire(ctx, key, expiration).Err()
}
func (r *goRedisCache) Ping(ctx context.Context) error {
return r.client.Ping(ctx).Err()
}
func (r *goRedisCache) Close() error {
return r.client.Close()
}
+58
View File
@@ -0,0 +1,58 @@
// Package redis 提供统一的 Redis 缓存抽象层
// 支持两种后端:
// - redka: 内嵌 Redis 兼容存储(零依赖,独立二进制模式)// Logan.Li
// - redis: 外部 Redis 服务器(Docker / 生产环境)
//
// 使用方式:
//
// cache, err := redis.New(redis.Config{
// Driver: "redka", // 或 "redis"
// Host: "localhost",
// Port: 6379,
// Password: "",
// DB: 0,
// DataDir: "data/redka", // redka 数据目录(仅 redka 驱动使用)
// })
//
// cache.Set(ctx, "key", "value", 0)
// val, _ := cache.Get(ctx, "key")
package redis
import (
"context"
"fmt"
"time"
)
// Config Redis 配置 // Logan.Li
type Config struct {
Driver string // "redka" 或 "redis"
Host string
Port int
Password string
DB int
DataDir string // redka 数据目录
}
// Cache Redis 缓存接口 // Logan.Li
type Cache interface {
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value string, expiration time.Duration) error
Del(ctx context.Context, keys ...string) error
Exists(ctx context.Context, keys ...string) (int64, error)
Expire(ctx context.Context, key string, expiration time.Duration) error
Ping(ctx context.Context) error
Close() error
}
// New 创建缓存实例(根据驱动自动选择后端)// Logan.Li
func New(cfg Config) (Cache, error) {
switch cfg.Driver {
case "redka", "":
return newRedka(cfg)
case "redis":
return newGoRedis(cfg)
default:
return nil, fmt.Errorf("redis: 不支持的驱动: %s (支持: redka, redis)", cfg.Driver)
}
}
+98
View File
@@ -0,0 +1,98 @@
// redka 内嵌 Redis 兼容存储实现(v0.2.x API)
// 使用 github.com/nalgeon/redka — 基于 SQLite 的 Redis 替代方案 // Logan.Li
// 零外部依赖,数据存在本地文件中,非常适合独立二进制部署
package redis
import (
"context"
"time"
"github.com/nalgeon/redka"
)
// redkaCache 基于 Redka v0.2.x 的缓存实现 // Logan.Li
type redkaCache struct {
db *redka.DB
}
func newRedka(cfg Config) (Cache, error) {
dataDir := cfg.DataDir
if dataDir == "" {
dataDir = "data/redka"
}
// Redka v0.2.x: Open(path, opts) // Logan.Li
db, err := redka.Open(dataDir+"/redka.db", nil)
if err != nil {
return nil, err
}
return &redkaCache{db: db}, nil
}
func (r *redkaCache) Get(ctx context.Context, key string) (string, error) {
// Redka v0.2.x: Str().Get() 返回 core.Value // Logan.Li
val, err := r.db.Str().Get(key)
if err == redka.ErrNotFound {
return "", nil
}
if err != nil {
return "", err
}
return val.String(), nil
}
func (r *redkaCache) Set(ctx context.Context, key string, value string, expiration time.Duration) error {
// Redka v0.2.x: Str().Set() 返回 error // Logan.Li
if err := r.db.Str().Set(key, value); err != nil {
return err
}
if expiration > 0 {
_, err := r.db.Key().Expire(key, expiration)
return err
}
return nil
}
func (r *redkaCache) Del(ctx context.Context, keys ...string) error {
for _, key := range keys {
if _, err := r.db.Key().Delete(key); err != nil {
return err
}
}
return nil
}
func (r *redkaCache) Exists(ctx context.Context, keys ...string) (int64, error) {
var count int64
for _, key := range keys {
ok, err := r.db.Key().Exists(key)
if err != nil {
return 0, err
}
if ok {
count++
}
}
return count, nil
}
func (r *redkaCache) Expire(ctx context.Context, key string, expiration time.Duration) error {
ok, err := r.db.Key().Expire(key, expiration)
if err != nil {
return err
}
if !ok {
return redka.ErrNotFound
}
return nil
}
func (r *redkaCache) Ping(ctx context.Context) error {
// Redka 始终可用(内嵌数据库)// Logan.Li
return nil
}
func (r *redkaCache) Close() error {
return r.db.Close()
}
+251 -189
View File
@@ -1,189 +1,251 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { useUserStore } from '@/store';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Copy, Check, Terminal, Download, Settings, Play, Cpu } from 'lucide-react';
interface AgentWizardProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** 自动检测服务器地址:使用当前浏览器访问的地址(协议+主机+端口) */
function detectServerAddress(): string {
if (typeof window !== 'undefined' && window.location) {
// 直接用当前浏览器的 origin(如 http://10.10.10.150),
// Nginx 会将 /agent/ 请求代理到后端
return window.location.origin;
}
return 'http://127.0.0.1:8080';
}
export function AgentWizard({ open, onOpenChange }: AgentWizardProps) {
const token = useUserStore((s) => s.token) || '<YOUR_TOKEN>';
const serverUrl = detectServerAddress();
const [copied, setCopied] = useState(false);
const installCommand = `curl -fsSL ${serverUrl.replace(/^http:/, 'http:')}/agent/install.sh | bash -s -- --server ${serverUrl} --token ${token}`;
const handleCopy = async () => {
try {
// 优先使用现代 Clipboard API(需要 HTTPS 或 localhost)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(installCommand);
} else {
// 降级方案:用隐藏 textarea + execCommand('copy'),兼容 HTTP 环境
const textarea = document.createElement('textarea');
textarea.value = installCommand;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
if (!ok) throw new Error('execCommand failed');
}
setCopied(true);
toast.success('安装命令已复制');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('复制失败,请手动选中复制');
}
};
const binaryUrl = `${serverUrl}/agent/aiops-agent-linux-amd64`;
const manualSteps = [
{
icon: Download,
title: '1. 下载二进制',
commands: [
`curl -fsSL ${binaryUrl} -o /usr/local/bin/aiops-agent`,
],
},
{
icon: Settings,
title: '2. 赋予执行权限',
commands: ['chmod +x /usr/local/bin/aiops-agent'],
},
{
icon: Cpu,
title: '3. 创建 systemd 服务',
commands: [
`cat > /etc/systemd/system/aiops-agent.service << 'EOF'`,
`[Unit]`,
`Description=AIOps Agent`,
`After=network.target`,
``,
`[Service]`,
`Type=simple`,
`ExecStart=/usr/local/bin/aiops-agent --server ${serverUrl} --token ${token}`,
`Restart=always`,
`RestartSec=5`,
``,
`[Install]`,
`WantedBy=multi-user.target`,
`EOF`,
],
},
{
icon: Play,
title: '4. 启动并设置开机自启',
commands: [
'systemctl daemon-reload',
'systemctl enable --now aiops-agent',
'systemctl status aiops-agent',
],
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100">
<DialogHeader>
<DialogTitle className="text-slate-100">部署 Agent</DialogTitle>
<DialogDescription className="text-slate-400">
在目标主机上执行以下命令即可完成 Agent 安装与注册
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="auto" className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="auto" className="data-[state=active]:bg-slate-700">
<Terminal className="h-4 w-4 mr-1.5" />
安装Agent
</TabsTrigger>
<TabsTrigger value="manual" className="data-[state=active]:bg-slate-700">
手动安装
</TabsTrigger>
</TabsList>
{/* 自动安装 */}
<TabsContent value="auto" className="mt-4 space-y-3">
<p className="text-sm text-slate-400">
在目标主机执行以下命令,Agent 将自动下载、安装并启动:
</p>
<div className="relative rounded-lg bg-slate-950 border border-slate-800 p-4">
<pre className="text-xs text-emerald-400 font-mono whitespace-pre-wrap break-all pr-10">
{installCommand}
</pre>
<Button
size="icon"
variant="ghost"
onClick={handleCopy}
className="absolute top-2 right-2 h-8 w-8 text-slate-400 hover:text-slate-100 hover:bg-slate-800"
title="复制"
>
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<div className="flex items-start gap-2 text-xs text-slate-500">
<Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
服务器地址已自动检测为当前访问地址({serverUrl})。Agent 安装成功后将自动连接并开始上报指标。
</span>
</div>
</TabsContent>
{/* 手动安装 */}
<TabsContent value="manual" className="mt-4 space-y-4">
<div className="max-h-[420px] overflow-y-auto space-y-3 pr-1">
{manualSteps.map((step) => (
<div key={step.title} className="rounded-lg bg-slate-950 border border-slate-800 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-800/50">
<step.icon className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">{step.title}</span>
</div>
<div className="p-3 space-y-1">
{step.commands.map((cmd, idx) => (
<div key={idx} className="flex items-start gap-2">
<span className="text-slate-600 font-mono text-xs select-none mt-0.5">$</span>
<code className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all flex-1">
{cmd}
</code>
</div>
))}
</div>
</div>
))}
</div>
<p className="text-xs text-slate-500">
完成上述步骤后,Agent 将作为 systemd 服务运行并自动上报指标。
</p>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
export default AgentWizard;
1|import { useState } from 'react';
2|import { toast } from 'sonner';
3|import { useUserStore } from '@/store';
4|import {
5| Dialog,
6| DialogContent,
7| DialogDescription,
8| DialogHeader,
9| DialogTitle,
10|} from '@/components/ui/dialog';
11|import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
12|import { Button } from '@/components/ui/button';
13|import { Copy, Check, Terminal, Download, Settings, Play, Cpu } from 'lucide-react';
14|
15|interface AgentWizardProps {
16| open: boolean;
17| onOpenChange: (open: boolean) => void;
18|}
19|
20|/** 自动检测服务器地址:使用当前浏览器访问的地址(协议+主机+端口) */
21|function detectServerAddress(): string {
22| if (typeof window !== 'undefined' && window.location) {
23| // 直接用当前浏览器的 origin(如 http://10.10.10.150),
24| // Nginx 会将 /agent/ 请求代理到后端
25| return window.location.origin;
26| }
27| return 'http://127.0.0.1:8080';
28|}
29|
30|export function AgentWizard({ open, onOpenChange }: AgentWizardProps) {
31| const token = useUserStore((s) => s.token) || '<YOUR_TOKEN>';
32| const serverUrl = detectServerAddress();
33| const [copied, setCopied] = useState(false);
34| const [showManualInput, setShowManualInput] = useState(false);
35|
36| const installCommand = `curl -fsSL ${serverUrl.replace(/^http:/, 'http:')}/agent/install.sh | bash -s -- --server ${serverUrl} --token ${token}`;
37|
38| const fallbackCopy = (text: string): boolean => {
39| const textArea = document.createElement('textarea');
40| textArea.value = text;
41| textArea.style.position = 'fixed';
42| textArea.style.left = '-9999px';
43| textArea.style.top = '-9999px';
44| document.body.appendChild(textArea);
45| textArea.focus();
46| textArea.select();
47| let success = false;
48| try {
49|<<<<<<< HEAD
50| // 优先使用现代 Clipboard API(需要 HTTPS 或 localhost)
51| if (navigator.clipboard && window.isSecureContext) {
52| await navigator.clipboard.writeText(installCommand);
53| } else {
54| // 降级方案:用隐藏 textarea + execCommand('copy'),兼容 HTTP 环境
55| const textarea = document.createElement('textarea');
56| textarea.value = installCommand;
57| textarea.style.position = 'fixed';
58| textarea.style.opacity = '0';
59| textarea.style.pointerEvents = 'none';
60| document.body.appendChild(textarea);
61| textarea.select();
62| const ok = document.execCommand('copy');
63| document.body.removeChild(textarea);
64| if (!ok) throw new Error('execCommand failed');
65| }
66| setCopied(true);
67| toast.success('安装命令已复制');
68| setTimeout(() => setCopied(false), 2000);
69| } catch {
70| toast.error('复制失败,请手动选中复制');
71|=======
72| success = document.execCommand('copy');
73| } catch {
74| // execCommand also failed
75| }
76| document.body.removeChild(textArea);
77| return success;
78| };
79|
80| const handleCopy = async () => {
81| const cmd = installCommand;
82| let success = false;
83|
84| // 方式1: Clipboard API(HTTPS / localhost)
85| try {
86| await navigator.clipboard.writeText(cmd);
87| success = true;
88| } catch {
89| // Clipboard API 不可用
90| }
91|
92| // 方式2: execCommand fallback(HTTP 也可用)
93| if (!success) {
94| success = fallbackCopy(cmd);
95| }
96|
97| if (success) {
98| setCopied(true);
99| toast.success('安装命令已复制到剪贴板,粘贴到目标主机执行');
100| setTimeout(() => setCopied(false), 3000);
101| } else {
102| toast.error('自动复制失败,请在下方代码框中手动全选复制 (Ctrl+A → Ctrl+C)');
103| // 切换到手选模式,展示可编辑文本框
104| setShowManualInput(true);
105|>>>>>>> ca18ead (feat: run.sh四模式(dev/docker/build/binary) + CLI flags + Redka内嵌Redis + Agent复制修复 + 服务器编辑IP字段 + 安装Agent按钮)
106| }
107| };
108|
109| const binaryUrl = `${serverUrl}/agent/aiops-agent-linux-amd64`;
110|
111| const manualSteps = [
112| {
113| icon: Download,
114| title: '1. 下载二进制',
115| commands: [
116| `curl -fsSL ${binaryUrl} -o /usr/local/bin/aiops-agent`,
117| ],
118| },
119| {
120| icon: Settings,
121| title: '2. 赋予执行权限',
122| commands: ['chmod +x /usr/local/bin/aiops-agent'],
123| },
124| {
125| icon: Cpu,
126| title: '3. 创建 systemd 服务',
127| commands: [
128| `cat > /etc/systemd/system/aiops-agent.service << 'EOF'`,
129| `[Unit]`,
130| `Description=AIOps Agent`,
131| `After=network.target`,
132| ``,
133| `[Service]`,
134| `Type=simple`,
135| `ExecStart=/usr/local/bin/aiops-agent --server ${serverUrl} --token ${token}`,
136| `Restart=always`,
137| `RestartSec=5`,
138| ``,
139| `[Install]`,
140| `WantedBy=multi-user.target`,
141| `EOF`,
142| ],
143| },
144| {
145| icon: Play,
146| title: '4. 启动并设置开机自启',
147| commands: [
148| 'systemctl daemon-reload',
149| 'systemctl enable --now aiops-agent',
150| 'systemctl status aiops-agent',
151| ],
152| },
153| ];
154|
155| return (
156| <Dialog open={open} onOpenChange={onOpenChange}>
157| <DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100">
158| <DialogHeader>
159| <DialogTitle className="text-slate-100">部署 Agent</DialogTitle>
160| <DialogDescription className="text-slate-400">
161| 在目标主机上执行以下命令即可完成 Agent 安装与注册
162| </DialogDescription>
163| </DialogHeader>
164|
165| <Tabs defaultValue="auto" className="w-full">
166| <TabsList className="bg-slate-800 border border-slate-700">
167| <TabsTrigger value="auto" className="data-[state=active]:bg-slate-700">
168| <Terminal className="h-4 w-4 mr-1.5" />
169| 安装Agent
170| </TabsTrigger>
171| <TabsTrigger value="manual" className="data-[state=active]:bg-slate-700">
172| 手动安装
173| </TabsTrigger>
174| </TabsList>
175|
176| {/* 自动安装 */}
177| <TabsContent value="auto" className="mt-4 space-y-3">
178| <p className="text-sm text-slate-400">
179| 在目标主机执行以下命令,Agent 将自动下载、安装并启动:
180| </p>
181| <div className="relative rounded-lg bg-slate-950 border border-slate-800 p-4">
182| <pre className="text-xs text-emerald-400 font-mono whitespace-pre-wrap break-all pr-10">
183| {installCommand}
184| </pre>
185| <Button
186| size="icon"
187| variant="ghost"
188| onClick={handleCopy}
189| className="absolute top-2 right-2 h-8 w-8 text-slate-400 hover:text-slate-100 hover:bg-slate-800"
190| title="复制到剪贴板"
191| >
192| {copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
193| </Button>
194| </div>
195|
196| {/* 手动复制模式:当自动复制失败时展示可全选的文本域 */}
197| {showManualInput && (
198| <div className="rounded-lg bg-amber-950/30 border border-amber-800/50 p-4">
199| <p className="text-xs text-amber-400 mb-2">
200| ⚠️ 自动复制失败。请点击下方输入框全选 (Ctrl+A) 后复制 (Ctrl+C):
201| </p>
202| <textarea
203| readOnly
204| value={installCommand}
205| className="w-full h-24 bg-slate-950 border border-amber-800/30 text-emerald-400 font-mono text-xs p-3 rounded resize-none focus:outline-none focus:border-amber-600"
206| onClick={(e) => (e.target as HTMLTextAreaElement).select()}
207| />
208| </div>
209| )}
210| <div className="flex items-start gap-2 text-xs text-slate-500">
211| <Terminal className="h-3.5 w-3.5 mt-0.5 shrink-0" />
212| <span>
213| 服务器地址已自动检测为当前访问地址({serverUrl})。Agent 安装成功后将自动连接并开始上报指标。
214| </span>
215| </div>
216| </TabsContent>
217|
218| {/* 手动安装 */}
219| <TabsContent value="manual" className="mt-4 space-y-4">
220| <div className="max-h-[420px] overflow-y-auto space-y-3 pr-1">
221| {manualSteps.map((step) => (
222| <div key={step.title} className="rounded-lg bg-slate-950 border border-slate-800 overflow-hidden">
223| <div className="flex items-center gap-2 px-4 py-2.5 bg-slate-800/50">
224| <step.icon className="h-4 w-4 text-blue-400" />
225| <span className="text-sm font-medium text-slate-200">{step.title}</span>
226| </div>
227| <div className="p-3 space-y-1">
228| {step.commands.map((cmd, idx) => (
229| <div key={idx} className="flex items-start gap-2">
230| <span className="text-slate-600 font-mono text-xs select-none mt-0.5">$</span>
231| <code className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all flex-1">
232| {cmd}
233| </code>
234| </div>
235| ))}
236| </div>
237| </div>
238| ))}
239| </div>
240| <p className="text-xs text-slate-500">
241| 完成上述步骤后,Agent 将作为 systemd 服务运行并自动上报指标。
242| </p>
243| </TabsContent>
244| </Tabs>
245| </DialogContent>
246| </Dialog>
247| );
248|}
249|
250|export default AgentWizard;
251|
@@ -48,6 +48,7 @@ import { ExcelServerImport } from './ExcelServerImport';
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
import { getTags } from './serverHelpers';
import { ServerTableRow } from './ServerTableRow';
import { AgentWizard } from '@/components/agent/AgentWizard';
export function ServerManager() {
const confirm = useConfirm();
@@ -70,6 +71,12 @@ export function ServerManager() {
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string } | null>(null);
// Agent 安装向导状态
const [agentWizardOpen, setAgentWizardOpen] = useState(false);
const openInstallAgent = (server: any) => {
setAgentWizardOpen(true);
};
const openTerminal = (server: any) => {
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip });
setTerminalOpen(true);
@@ -217,10 +224,12 @@ export function ServerManager() {
} else {
await serverApi.createServer(payload);
}
toast.success(editingId ? '服务器已更新' : '服务器已创建');
setFormOpen(false);
loadData();
} catch (e) {
console.error('保存服务器失败:', e);
toast.error('保存失败,请重试');
} finally {
setFormSaving(false);
}
@@ -411,6 +420,7 @@ export function ServerManager() {
onOpenTerminal={openTerminal}
onOpenEdit={openEditForm}
onDelete={handleDelete}
onInstallAgent={openInstallAgent}
/>
))
)}
@@ -449,6 +459,9 @@ export function ServerManager() {
}}
/>
{/* Agent 安装向导 */}
<AgentWizard open={agentWizardOpen} onOpenChange={setAgentWizardOpen} />
{/* 终端 Dialog */}
<TerminalDialog
open={terminalOpen}
+180 -166
View File
@@ -1,166 +1,180 @@
// 服务器表格行 — 纯展示子组件(接收 props,不直接调 API) // Logan.Li
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TableCell, TableRow } from '@/components/ui/table';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Download } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
interface ServerTableRowProps {
server: any;
selected: boolean;
onToggleSelect: (id: string) => void;
onOpenTerminal: (server: any) => void;
onOpenEdit: (server: any) => void;
onDelete: (id: string) => void;
}
export function ServerTableRow({
server,
selected,
onToggleSelect,
onOpenTerminal,
onOpenEdit,
onDelete,
}: ServerTableRowProps) {
const cpuUsage = Number(server.cpu_usage || 0);
const memoryUsage = Number(server.memory_usage || 0);
const tags = getTags(server);
const memGB = server.memory ? Math.round(server.memory / 1024) : 0;
return (
<TableRow className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<TableCell>
<Checkbox
checked={selected}
onCheckedChange={() => onToggleSelect(server.id)}
className="border-[var(--dg-border)]"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<ServerIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
<div>
<p className="font-medium text-[var(--dg-text-primary)]">{server.hostname}</p>
<p className="text-xs text-[var(--dg-text-muted)]">{server.os}</p>
</div>
</div>
</TableCell>
<TableCell>
<code className="text-sm text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">
{server.ip}
</code>
</TableCell>
<TableCell>
{server.public_ip ? (
<code className="text-sm text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10 px-2 py-0.5 rounded">
{server.public_ip}
</code>
) : (
<span className="text-sm text-[var(--dg-text-muted)]">-</span>
)}
</TableCell>
<TableCell>{getStatusBadge(server.status)}</TableCell>
<TableCell>
<div className="text-sm text-[var(--dg-text-secondary)]">
<span className="text-[var(--dg-text-muted)]">{server.cpu}核</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{memGB}GB</span>
<span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
<span className="text-[var(--dg-text-muted)]">{server.disk}GB</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--dg-text-muted)] w-8">CPU</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full', getUsageColor(cpuUsage))}
style={{ width: `${cpuUsage}%` }}
/>
</div>
<span className="text-xs text-[var(--dg-text-secondary)]">{cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--dg-text-muted)] w-8">内存</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full', getUsageColor(memoryUsage))}
style={{ width: `${memoryUsage}%` }}
/>
</div>
<span className="text-xs text-[var(--dg-text-secondary)]">{memoryUsage.toFixed(1)}%</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag) => (
<Badge
key={tag}
variant="outline"
className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]/50"
>
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
+{tags.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)}>
<Terminal className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenEdit(server)}
>
<Edit className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenTerminal(server)}
>
<Terminal className="h-4 w-4 mr-2" />
终端
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
<DropdownMenuItem
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
onClick={() => onDelete(server.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
);
}
1|// 服务器表格行 — 纯展示子组件(接收 props,不直接调 API) // Logan.Li
2|import { cn } from '@/lib/utils';
3|import { Badge } from '@/components/ui/badge';
4|import { Button } from '@/components/ui/button';
5|import { Checkbox } from '@/components/ui/checkbox';
6|import {
7| DropdownMenu,
8| DropdownMenuContent,
9| DropdownMenuItem,
10| DropdownMenuLabel,
11| DropdownMenuSeparator,
12| DropdownMenuTrigger,
13|} from '@/components/ui/dropdown-menu';
14|import { TableCell, TableRow } from '@/components/ui/table';
15|<<<<<<< HEAD
16|import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Download } from 'lucide-react';
17|=======
18|import { Cpu, Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2 } from 'lucide-react';
19|>>>>>>> ca18ead (feat: run.sh四模式(dev/docker/build/binary) + CLI flags + Redka内嵌Redis + Agent复制修复 + 服务器编辑IP字段 + 安装Agent按钮)
20|import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
21|
22|interface ServerTableRowProps {
23| server: any;
24| selected: boolean;
25| onToggleSelect: (id: string) => void;
26| onOpenTerminal: (server: any) => void;
27| onOpenEdit: (server: any) => void;
28| onDelete: (id: string) => void;
29| onInstallAgent: (server: any) => void;
30|}
31|
32|export function ServerTableRow({
33| server,
34| selected,
35| onToggleSelect,
36| onOpenTerminal,
37| onOpenEdit,
38| onDelete,
39| onInstallAgent,
40|}: ServerTableRowProps) {
41| const cpuUsage = Number(server.cpu_usage || 0);
42| const memoryUsage = Number(server.memory_usage || 0);
43| const tags = getTags(server);
44| const memGB = server.memory ? Math.round(server.memory / 1024) : 0;
45|
46| return (
47| <TableRow className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
48| <TableCell>
49| <Checkbox
50| checked={selected}
51| onCheckedChange={() => onToggleSelect(server.id)}
52| className="border-[var(--dg-border)]"
53| />
54| </TableCell>
55| <TableCell>
56| <div className="flex items-center gap-2">
57| <ServerIcon className="h-4 w-4 text-[var(--dg-text-muted)]" />
58| <div>
59| <p className="font-medium text-[var(--dg-text-primary)]">{server.hostname}</p>
60| <p className="text-xs text-[var(--dg-text-muted)]">{server.os}</p>
61| </div>
62| </div>
63| </TableCell>
64| <TableCell>
65| <code className="text-sm text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">
66| {server.ip}
67| </code>
68| </TableCell>
69| <TableCell>
70| {server.public_ip ? (
71| <code className="text-sm text-[var(--dg-cyan)] bg-[var(--dg-cyan)]/10 px-2 py-0.5 rounded">
72| {server.public_ip}
73| </code>
74| ) : (
75| <span className="text-sm text-[var(--dg-text-muted)]">-</span>
76| )}
77| </TableCell>
78| <TableCell>{getStatusBadge(server.status)}</TableCell>
79| <TableCell>
80| <div className="text-sm text-[var(--dg-text-secondary)]">
81| <span className="text-[var(--dg-text-muted)]">{server.cpu}核</span>
82| <span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
83| <span className="text-[var(--dg-text-muted)]">{memGB}GB</span>
84| <span className="mx-1 text-[var(--dg-text-muted)] opacity-50">/</span>
85| <span className="text-[var(--dg-text-muted)]">{server.disk}GB</span>
86| </div>
87| </TableCell>
88| <TableCell>
89| <div className="space-y-1">
90| <div className="flex items-center gap-2">
91| <span className="text-xs text-[var(--dg-text-muted)] w-8">CPU</span>
92| <div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
93| <div
94| className={cn('h-full rounded-full', getUsageColor(cpuUsage))}
95| style={{ width: `${cpuUsage}%` }}
96| />
97| </div>
98| <span className="text-xs text-[var(--dg-text-secondary)]">{cpuUsage.toFixed(1)}%</span>
99| </div>
100| <div className="flex items-center gap-2">
101| <span className="text-xs text-[var(--dg-text-muted)] w-8">内存</span>
102| <div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
103| <div
104| className={cn('h-full rounded-full', getUsageColor(memoryUsage))}
105| style={{ width: `${memoryUsage}%` }}
106| />
107| </div>
108| <span className="text-xs text-[var(--dg-text-secondary)]">{memoryUsage.toFixed(1)}%</span>
109| </div>
110| </div>
111| </TableCell>
112| <TableCell>
113| <div className="flex flex-wrap gap-1">
114| {tags.slice(0, 3).map((tag) => (
115| <Badge
116| key={tag}
117| variant="outline"
118| className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)]/50"
119| >
120| {tag}
121| </Badge>
122| ))}
123| {tags.length > 3 && (
124| <Badge variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
125| +{tags.length - 3}
126| </Badge>
127| )}
128| </div>
129| </TableCell>
130| <TableCell className="text-right">
131| <div className="flex items-center justify-end gap-1">
132| <Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)}>
133| <Terminal className="h-4 w-4" />
134| </Button>
135| <DropdownMenu>
136| <DropdownMenuTrigger asChild>
137| <Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
138| <MoreHorizontal className="h-4 w-4" />
139| </Button>
140| </DropdownMenuTrigger>
141| <DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
142| <DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
143| <DropdownMenuSeparator className="bg-[var(--dg-border)]" />
144| <DropdownMenuItem
145| className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
146| onClick={() => onOpenEdit(server)}
147| >
148| <Edit className="h-4 w-4 mr-2" />
149| 编辑
150| </DropdownMenuItem>
151| <DropdownMenuItem
152| className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
153| onClick={() => onOpenTerminal(server)}
154| >
155| <Terminal className="h-4 w-4 mr-2" />
156| 终端
157| </DropdownMenuItem>
158| <DropdownMenuItem
159| className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
160| onClick={() => onInstallAgent(server)}
161| >
162| <Cpu className="h-4 w-4 mr-2" />
163| 安装Agent
164| </DropdownMenuItem>
165| <DropdownMenuSeparator className="bg-[var(--dg-border)]" />
166| <DropdownMenuItem
167| className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
168| onClick={() => onDelete(server.id)}
169| >
170| <Trash2 className="h-4 w-4 mr-2" />
171| 删除
172| </DropdownMenuItem>
173| </DropdownMenuContent>
174| </DropdownMenu>
175| </div>
176| </TableCell>
177| </TableRow>
178| );
179|}
180|
+371 -121
View File
@@ -1,19 +1,27 @@
#!/bin/bash
# EZOps AIOps 智能运维平台 - 启动脚本
# 一键启动前后端服务,支持日志轮转
# 用法: ./run.sh [start|stop|restart|status|frontend|backend|help]
#===========================================================================
# AIOps 智能运维平台 - 统一启动脚本
# 支持三种运行模式:本地开发 / Docker容器部署 / 独立二进制
#
# 用法:
# ./run.sh dev 本地开发模式(前后端热重载)
# ./run.sh docker up Docker Compose 容器部署
# ./run.sh build 编译独立二进制 ./aiops
# ./run.sh binary 编译并启动独立二进制
# ./run.sh help 查看帮助
#===========================================================================
set -e
# ============================================================
# 配置区
# ============================================================
PROJECT_NAME="EZOps AIOps"
PROJECT_NAME="AIOps 智能运维平台"
BACKEND_PORT=8080
FRONTEND_PORT=5173
BACKEND_DIR="backend"
FRONTEND_DIR="frontend"
BACKEND_BIN="aiops-server"
STANDALONE_BIN="aiops"
# 日志配置
MAX_LOG_FILES=3
@@ -26,12 +34,14 @@ RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; }
success() { echo -e "${GREEN}[OK]${NC} $1"; }
warning() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERR]${NC} $1"; }
banner() { echo -e "${CYAN}$1${NC}"; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
@@ -40,27 +50,49 @@ cd "$SCRIPT_DIR"
BACKEND_PID_FILE="$SCRIPT_DIR/$BACKEND_DIR/.backend.pid"
FRONTEND_PID_FILE="$SCRIPT_DIR/$FRONTEND_DIR/.frontend.pid"
# 检测操作系统
detect_os() {
case "$(uname -s)" in
Darwin) echo "macos" ;;
Linux) echo "linux" ;;
*) echo "unknown" ;;
esac
}
OS=$(detect_os)
# 检查端口是否被占用
check_port() {
lsof -ti:$1 2>/dev/null | head -1
if [ "$OS" = "macos" ]; then
lsof -ti:$1 2>/dev/null | head -1
else
ss -tlnp 2>/dev/null | grep -q ":$1 " && echo "in-use" || echo ""
fi
}
# 释放端口
kill_port() {
local pids=$(lsof -ti:$1 2>/dev/null || echo "")
if [ -n "$pids" ]; then
info "释放端口 $1..."
echo "$pids" | xargs kill -9 2>/dev/null || true
sleep 1
local port=$1
if [ "$OS" = "macos" ]; then
local pids=$(lsof -ti:$port 2>/dev/null || echo "")
[ -n "$pids" ] && echo "$pids" | xargs kill -9 2>/dev/null || true
else
fuser -k ${port}/tcp 2>/dev/null || true
fi
sleep 0.5
}
# 日志轮转(仅启动时调用)
# 日志轮转
rotate_log() {
local log_file=$1
[ ! -f "$log_file" ] && return 0
local file_size=$(stat -f%z "$log_file" 2>/dev/null || stat -c%s "$log_file" 2>/dev/null || echo "0")
local file_size
if [ "$OS" = "macos" ]; then
file_size=$(stat -f%z "$log_file" 2>/dev/null || echo "0")
else
file_size=$(stat -c%s "$log_file" 2>/dev/null || echo "0")
fi
if [ "$file_size" -gt "$MAX_LOG_SIZE" ]; then
[ -f "${log_file}.${MAX_LOG_FILES}" ] && rm -f "${log_file}.${MAX_LOG_FILES}"
for i in $(seq $((MAX_LOG_FILES - 1)) -1 1); do
@@ -72,20 +104,18 @@ rotate_log() {
}
# ============================================================
# 后端服务
# 后端服务(本地开发模式)
# ============================================================
start_backend() {
info "正在启动后端服务..."
info "启动后端服务..."
cd "$SCRIPT_DIR/$BACKEND_DIR"
mkdir -p logs
rotate_log "logs/server.log"
# 释放被占用的端口
kill_port $BACKEND_PORT
export GIN_MODE=debug
# 编译并运行(SQLite 需要 CGO)
info "编译 Go 程序..."
CGO_ENABLED=1 go build -o "$BACKEND_BIN" ./cmd/ 2>&1 || {
error "编译失败"
@@ -100,14 +130,14 @@ start_backend() {
while ! curl -s "http://localhost:$BACKEND_PORT/health" > /dev/null 2>&1; do
retries=$((retries + 1))
if [ $retries -ge 30 ]; then
error "后端服务启动超时,请检查日志: $BACKEND_DIR/logs/server.log"
error "后端启动超时,查看日志: $BACKEND_DIR/logs/server.log"
tail -20 logs/server.log 2>/dev/null
return 1
fi
sleep 1
done
success "后端服务已启动 (PID: $(cat "$BACKEND_PID_FILE"), 端口: $BACKEND_PORT)"
success "后端已启动 (PID: $(cat "$BACKEND_PID_FILE"), 端口: $BACKEND_PORT)"
return 0
}
@@ -116,7 +146,7 @@ stop_backend() {
local pid=$(cat "$BACKEND_PID_FILE" 2>/dev/null || echo "")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
success "后端服务已停止"
success "后端已停止"
fi
rm -f "$BACKEND_PID_FILE"
fi
@@ -124,10 +154,10 @@ stop_backend() {
}
# ============================================================
# 前端服务
# 前端服务(本地开发模式)
# ============================================================
start_frontend() {
info "正在启动前端服务..."
info "启动前端服务..."
cd "$SCRIPT_DIR/$FRONTEND_DIR"
mkdir -p logs
rotate_log "logs/dev.log"
@@ -135,26 +165,25 @@ start_frontend() {
kill_port $FRONTEND_PORT
if [ ! -d "node_modules" ]; then
warning "node_modules 不存在,正在安装依赖..."
warning "安装前端依赖..."
npm install
fi
nohup npm run dev >> logs/dev.log 2>&1 &
echo $! > "$FRONTEND_PID_FILE"
# 等待服务启动
local retries=0
while ! curl -s "http://localhost:$FRONTEND_PORT" > /dev/null 2>&1; do
retries=$((retries + 1))
if [ $retries -ge 30 ]; then
error "前端服务启动超时,请检查日志: $FRONTEND_DIR/logs/dev.log"
error "前端启动超时,查看日志: $FRONTEND_DIR/logs/dev.log"
tail -20 logs/dev.log 2>/dev/null
return 1
fi
sleep 1
done
success "前端服务已启动 (PID: $(cat "$FRONTEND_PID_FILE"), 端口: $FRONTEND_PORT)"
success "前端已启动 (PID: $(cat "$FRONTEND_PID_FILE"), 端口: $FRONTEND_PORT)"
return 0
}
@@ -163,7 +192,7 @@ stop_frontend() {
local pid=$(cat "$FRONTEND_PID_FILE" 2>/dev/null || echo "")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
success "前端服务已停止"
success "前端已停止"
fi
rm -f "$FRONTEND_PID_FILE"
fi
@@ -171,142 +200,363 @@ stop_frontend() {
}
# ============================================================
# 命令
# 本地开发命令
# ============================================================
stop() {
info "正在停止服务..."
stop_backend
stop_frontend
info "所有服务已停止"
cmd_dev() {
banner "========== $PROJECT_NAME (本地开发) =========="
# 检查依赖
if ! command -v go &> /dev/null; then
error "Go 未安装,请先安装 Go 1.21+"
exit 1
fi
if ! command -v npm &> /dev/null; then
error "npm 未安装,请先安装 Node.js 20+"
exit 1
fi
if start_backend && start_frontend; then
echo ""
banner "========== 所有服务已启动 =========="
info "前端地址: http://localhost:$FRONTEND_PORT"
info "后端地址: http://localhost:$BACKEND_PORT"
info "健康检查: http://localhost:$BACKEND_PORT/health"
info "数据库: SQLite (backend/sqlite3.db)"
echo ""
info "使用 './run.sh stop' 停止服务"
else
error "服务启动失败"
exit 1
fi
}
show_status() {
info "服务状态:"
cmd_stop() {
info "停止本地服务..."
stop_backend
stop_frontend
success "所有本地服务已停止"
}
cmd_restart() {
cmd_stop
sleep 1
cmd_dev
}
cmd_status() {
info "本地服务状态:"
if [ -n "$(check_port $BACKEND_PORT)" ]; then
success "后端服务: 运行中 (PID: $(check_port $BACKEND_PORT), 端口: $BACKEND_PORT)"
success "后端: 运行中 (端口 $BACKEND_PORT)"
else
warning "后端服务: 未运行"
warning "后端: 未运行"
fi
if [ -n "$(check_port $FRONTEND_PORT)" ]; then
success "前端服务: 运行中 (PID: $(check_port $FRONTEND_PORT), 端口: $FRONTEND_PORT)"
success "前端: 运行中 (端口 $FRONTEND_PORT)"
else
warning "前端服务: 未运行"
warning "前端: 未运行"
fi
# 显示数据库文件
if [ -f "$SCRIPT_DIR/$BACKEND_DIR/sqlite3.db" ]; then
local db_size=$(du -h "$SCRIPT_DIR/$BACKEND_DIR/sqlite3.db" | cut -f1)
info "数据库: SQLite (sqlite3.db, $db_size)"
fi
}
show_help() {
echo "$PROJECT_NAME 启动脚本"
echo ""
echo "用法: ./run.sh [命令]"
echo ""
echo "命令:"
echo " start 启动前后端服务 (默认)"
echo " stop 停止前后端服务"
echo " restart 重启前后端服务"
echo " backend 仅启动后端服务"
echo " frontend 仅启动前端服务"
echo " status 查看服务状态"
echo " logs 查看实时日志"
echo " help 显示帮助信息"
echo ""
echo "示例:"
echo " ./run.sh # 启动所有服务"
echo " ./run.sh stop # 停止服务"
echo " ./run.sh restart # 重启服务"
echo " ./run.sh backend # 仅启动后端"
echo " ./run.sh logs # 查看实时日志"
}
show_logs() {
cmd_logs() {
info "实时日志 (Ctrl+C 退出):"
echo ""
# 后端日志
local pids=()
if [ -f "$SCRIPT_DIR/$BACKEND_DIR/logs/server.log" ]; then
echo "=== 后端日志 ==="
tail -f "$SCRIPT_DIR/$BACKEND_DIR/logs/server.log" &
local bg_pid=$!
pids+=($!)
fi
# 前端日志
if [ -f "$SCRIPT_DIR/$FRONTEND_DIR/logs/dev.log" ]; then
echo "=== 前端日志 ==="
tail -f "$SCRIPT_DIR/$FRONTEND_DIR/logs/dev.log" &
local bg_pid2=$!
pids+=($!)
fi
trap "kill $bg_pid $bg_pid2 2>/dev/null; exit 0" INT TERM
trap "kill ${pids[*]} 2>/dev/null; exit 0" INT TERM
wait
}
# ============================================================
# Docker Compose 部署
# ============================================================
cmd_docker() {
local action=${1:-help}
if ! command -v docker &> /dev/null; then
error "Docker 未安装,请先安装 Docker"
exit 1
fi
case "$action" in
up)
banner "========== Docker Compose 启动 =========="
docker compose up -d --build
echo ""
info "等待服务就绪..."
sleep 5
docker compose ps
echo ""
info "前端地址: http://localhost:80"
info "后端地址: http://localhost:8080"
info "健康检查: http://localhost:8080/health"
;;
down)
info "停止 Docker Compose..."
docker compose down
success "容器已停止"
;;
build)
info "构建 Docker 镜像..."
docker compose build
success "镜像构建完成"
;;
restart)
info "重启 Docker Compose..."
docker compose restart
success "容器已重启"
;;
logs)
info "Docker 实时日志 (Ctrl+C 退出):"
docker compose logs -f
;;
ps|status)
docker compose ps
;;
help|--help|-h)
echo "Docker Compose 部署命令:"
echo " ./run.sh docker up 构建并启动所有容器"
echo " ./run.sh docker down 停止并移除容器"
echo " ./run.sh docker build 仅构建镜像"
echo " ./run.sh docker restart 重启容器"
echo " ./run.sh docker logs 查看实时日志"
echo " ./run.sh docker ps 查看容器状态"
;;
*)
error "未知 docker 子命令: $action"
cmd_docker help
exit 1
;;
esac
}
# ============================================================
# 编译独立二进制
# ============================================================
cmd_build() {
local target=${1:-binary}
case "$target" in
binary|aiops)
banner "========== 编译独立二进制 =========="
cd "$SCRIPT_DIR/$BACKEND_DIR"
# 确保 config.yaml 存在(二进制模式默认 SQLite)
if [ ! -f "config.yaml" ]; then
info "生成默认 config.yaml (SQLite + 无Redis)..."
cat > config.yaml << 'YAML'
mode: "release"
port: "8080"
log_level: "info"
database:
driver: "sqlite"
host: "localhost"
port: 3306
user: ""
password: ""
dbname: "data/aiops.db"
charset: "utf8mb4"
max_conn: 100
max_idle: 10
redis:
host: "localhost"
port: 6379
password: ""
db: 0
jwt:
secret: "aiops-jwt-secret-change-in-production"
expire_hour: 24
websocket:
read_buffer_size: 1024
write_buffer_size: 1024
YAML
fi
# 静态编译(CGO_ENABLED=1 因为 SQLite 需要 CGO)
info "编译中..."
CGO_ENABLED=1 GOPROXY=https://goproxy.cn,direct GONOSUMCHECK='*' GOTOOLCHAIN=go1.22.2 go build -ldflags="-w -s" -o "$STANDALONE_BIN" ./cmd/
if [ -f "$STANDALONE_BIN" ]; then
local bin_size=$(du -h "$STANDALONE_BIN" | cut -f1)
success "编译完成: $BACKEND_DIR/$STANDALONE_BIN ($bin_size)"
echo ""
info "使用方法:"
echo " ./$BACKEND_DIR/$STANDALONE_BIN # 默认启动 (SQLite, 端口 8080)"
echo " ./$BACKEND_DIR/$STANDALONE_BIN --port 9090 # 自定义端口"
echo " ./$BACKEND_DIR/$STANDALONE_BIN --db-driver mysql \\"
echo " --db-host localhost --db-user root \\"
echo " --db-password pass --db-name aiops # MySQL 模式"
echo " ./$BACKEND_DIR/$STANDALONE_BIN --config config.yaml # 使用配置文件"
else
error "编译失败"
exit 1
fi
;;
agent)
banner "========== 编译 Agent =========="
cd "$SCRIPT_DIR/agent"
info "编译 Linux AMD64..."
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-w -s" -o aiops-agent-linux-amd64 .
success "aiops-agent-linux-amd64 ($(du -h aiops-agent-linux-amd64 | cut -f1))"
info "编译 Linux ARM64..."
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags="-w -s" -o aiops-agent-linux-arm64 .
success "aiops-agent-linux-arm64 ($(du -h aiops-agent-linux-arm64 | cut -f1))"
if [ "$OS" = "macos" ]; then
info "编译 macOS (当前平台)..."
CGO_ENABLED=0 go build -ldflags="-w -s" -o aiops-agent .
success "aiops-agent ($(du -h aiops-agent | cut -f1))"
fi
;;
frontend)
info "构建前端生产包..."
cd "$SCRIPT_DIR/$FRONTEND_DIR"
npm run build
success "前端构建完成: $FRONTEND_DIR/dist/"
;;
help|--help|-h)
echo "编译命令:"
echo " ./run.sh build 编译独立二进制 aiops (默认 SQLite)"
echo " ./run.sh build agent 交叉编译 Agent (amd64+arm64)"
echo " ./run.sh build frontend 构建前端生产包"
;;
*)
error "未知构建目标: $target"
cmd_build help
exit 1
;;
esac
}
# ============================================================
# 独立二进制模式
# ============================================================
cmd_binary() {
cd "$SCRIPT_DIR/$BACKEND_DIR"
# 先编译(如果不存在)
if [ ! -f "$STANDALONE_BIN" ]; then
info "二进制不存在,先编译..."
cmd_build binary
fi
banner "========== 启动独立二进制 =========="
mkdir -p data logs uploads
# 传递额外参数
./"$STANDALONE_BIN" "$@"
}
# ============================================================
# 帮助
# ============================================================
cmd_help() {
echo ""
banner " $PROJECT_NAME 启动脚本"
echo ""
echo -e " ${GREEN}本地开发${NC}"
echo " ./run.sh dev 启动前后端开发服务 (SQLite, 端口 8080+5173)"
echo " ./run.sh stop 停止本地服务"
echo " ./run.sh restart 重启本地服务"
echo " ./run.sh status 查看本地服务状态"
echo " ./run.sh logs 查看实时日志"
echo ""
echo -e " ${CYAN}Docker 容器部署${NC}"
echo " ./run.sh docker up 构建并启动所有容器 (MySQL+Redis+Nginx)"
echo " ./run.sh docker down 停止并移除容器"
echo " ./run.sh docker build 仅构建镜像"
echo " ./run.sh docker restart 重启容器"
echo " ./run.sh docker logs 查看容器日志"
echo " ./run.sh docker ps 查看容器状态"
echo ""
echo -e " ${YELLOW}编译 & 独立二进制${NC}"
echo " ./run.sh build 编译独立二进制 ./$BACKEND_DIR/$STANDALONE_BIN"
echo " ./run.sh binary 编译+启动独立二进制"
echo " ./run.sh build agent 交叉编译 Agent (amd64+arm64)"
echo " ./run.sh build frontend 构建前端生产包"
echo ""
echo -e " ${BLUE}独立二进制用法${NC}"
echo " ./$BACKEND_DIR/$STANDALONE_BIN 默认启动 (SQLite, 端口 8080)"
echo " ./$BACKEND_DIR/$STANDALONE_BIN --port 9090 自定义端口"
echo " ./$BACKEND_DIR/$STANDALONE_BIN --config config.yaml 使用配置文件"
echo " ./$BACKEND_DIR/$STANDALONE_BIN --db-driver mysql \\"
echo " --db-host 127.0.0.1 --db-user root \\"
echo " --db-password pass --db-name aiops MySQL 模式"
echo ""
echo -e " ${GREEN}快捷操作${NC}"
echo " ./run.sh 等同于 ./run.sh dev"
echo " ./run.sh help 显示此帮助"
echo ""
}
# ============================================================
# 主入口
# ============================================================
main() {
local command=${1:-start}
local cmd=${1:-dev}
case "$command" in
start)
info "========== $PROJECT_NAME 启动脚本 =========="
# 检查依赖
if ! command -v go &> /dev/null; then
error "Go 未安装,请先安装 Go"
exit 1
fi
if ! command -v npm &> /dev/null; then
error "npm 未安装,请先安装 Node.js"
exit 1
fi
if start_backend && start_frontend; then
echo ""
success "========== 所有服务已启动 =========="
info "前端地址: http://localhost:$FRONTEND_PORT"
info "后端地址: http://localhost:$BACKEND_PORT"
info "健康检查: http://localhost:$BACKEND_PORT/health"
info "数据库: SQLite (backend/sqlite3.db)"
echo ""
info "使用 './run.sh stop' 停止服务"
else
error "服务启动失败"
exit 1
fi
case "$cmd" in
# 本地开发
dev|start)
cmd_dev
;;
stop)
stop
cmd_stop
;;
restart)
stop
sleep 2
main start
;;
backend)
info "========== $PROJECT_NAME (仅后端) =========="
start_backend
show_status
;;
frontend)
info "========== $PROJECT_NAME (仅前端) =========="
start_frontend
show_status
cmd_restart
;;
status)
show_status
cmd_status
;;
logs)
show_logs
cmd_logs
;;
# Docker Compose
docker|compose)
shift
cmd_docker "$@"
;;
# 编译
build)
shift
cmd_build "$@"
;;
# 独立二进制
binary|bin)
shift
cmd_binary "$@"
;;
# 帮助
help|--help|-h)
show_help
cmd_help
;;
*)
error "未知命令: $command"
show_help
error "未知命令: $cmd"
cmd_help
exit 1
;;
esac