From 2354959811e839e3c2558e0c74f6ad55956def26 Mon Sep 17 00:00:00 2001 From: attacker Date: Thu, 18 Jun 2026 03:35:16 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20run.sh=E5=9B=9B=E6=A8=A1=E5=BC=8F(dev/d?= =?UTF-8?q?ocker/build/binary)=20+=20CLI=20flags=20+=20Redka=E5=86=85?= =?UTF-8?q?=E5=B5=8CRedis=20+=20Agent=E5=A4=8D=E5=88=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20+=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=BC=96=E8=BE=91IP=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20+=20=E5=AE=89=E8=A3=85Agent=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 183 ++++--- backend/cmd/main.go | 202 ++++++- backend/go.mod | 10 +- backend/go.sum | 13 + backend/internal/api/handlers/server.go | 3 + backend/internal/api/routes/routes.go | 5 +- backend/internal/models/server.go | 1 + backend/pkg/redis/go_redis.go | 66 +++ backend/pkg/redis/redis.go | 58 +++ backend/pkg/redis/redka.go | 98 ++++ frontend/src/components/agent/AgentWizard.tsx | 440 +++++++++------- .../src/components/server/ServerManager.tsx | 13 + .../src/components/server/ServerTableRow.tsx | 346 ++++++------ run.sh | 492 +++++++++++++----- 14 files changed, 1355 insertions(+), 575 deletions(-) create mode 100644 backend/pkg/redis/go_redis.go create mode 100644 backend/pkg/redis/redis.go create mode 100644 backend/pkg/redis/redka.go diff --git a/backend/Dockerfile b/backend/Dockerfile index 1a8329e..ddcb902 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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| \ No newline at end of file diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 022a2ab..294bde6 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -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 跨域中间件 diff --git a/backend/go.mod b/backend/go.mod index 245a8dc..4b1d050 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 709e1fb..75f62f9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/api/handlers/server.go b/backend/internal/api/handlers/server.go index 04291b8..0aa0e56 100644 --- a/backend/internal/api/handlers/server.go +++ b/backend/internal/api/handlers/server.go @@ -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 } diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 57cbb2b..256b8c9 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -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") diff --git a/backend/internal/models/server.go b/backend/internal/models/server.go index 0cecf75..022cdf0 100644 --- a/backend/internal/models/server.go +++ b/backend/internal/models/server.go @@ -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"` diff --git a/backend/pkg/redis/go_redis.go b/backend/pkg/redis/go_redis.go new file mode 100644 index 0000000..58c18fb --- /dev/null +++ b/backend/pkg/redis/go_redis.go @@ -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() +} diff --git a/backend/pkg/redis/redis.go b/backend/pkg/redis/redis.go new file mode 100644 index 0000000..de697f3 --- /dev/null +++ b/backend/pkg/redis/redis.go @@ -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) + } +} diff --git a/backend/pkg/redis/redka.go b/backend/pkg/redis/redka.go new file mode 100644 index 0000000..33f7a0a --- /dev/null +++ b/backend/pkg/redis/redka.go @@ -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() +} diff --git a/frontend/src/components/agent/AgentWizard.tsx b/frontend/src/components/agent/AgentWizard.tsx index 8971eac..577b027 100644 --- a/frontend/src/components/agent/AgentWizard.tsx +++ b/frontend/src/components/agent/AgentWizard.tsx @@ -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) || ''; - 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 ( - - - - 部署 Agent - - 在目标主机上执行以下命令即可完成 Agent 安装与注册 - - - - - - - - 安装Agent - - - 手动安装 - - - - {/* 自动安装 */} - -

- 在目标主机执行以下命令,Agent 将自动下载、安装并启动: -

-
-
-                {installCommand}
-              
- -
-
- - - 服务器地址已自动检测为当前访问地址({serverUrl})。Agent 安装成功后将自动连接并开始上报指标。 - -
-
- - {/* 手动安装 */} - -
- {manualSteps.map((step) => ( -
-
- - {step.title} -
-
- {step.commands.map((cmd, idx) => ( -
- $ - - {cmd} - -
- ))} -
-
- ))} -
-

- 完成上述步骤后,Agent 将作为 systemd 服务运行并自动上报指标。 -

-
-
-
-
- ); -} - -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) || ''; +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| +157| +158| +159| 部署 Agent +160| +161| 在目标主机上执行以下命令即可完成 Agent 安装与注册 +162| +163| +164| +165| +166| +167| +168| +169| 安装Agent +170| +171| +172| 手动安装 +173| +174| +175| +176| {/* 自动安装 */} +177| +178|

+179| 在目标主机执行以下命令,Agent 将自动下载、安装并启动: +180|

+181|
+182|
+183|                {installCommand}
+184|              
+185| +194|
+195| +196| {/* 手动复制模式:当自动复制失败时展示可全选的文本域 */} +197| {showManualInput && ( +198|
+199|

+200| ⚠️ 自动复制失败。请点击下方输入框全选 (Ctrl+A) 后复制 (Ctrl+C): +201|

+202|