diff --git a/Makefile b/Makefile index 09ef793..6dcdcc9 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,32 @@ -# EZOps AIOps - Makefile -.PHONY: help install dev build clean status stop restart lint +# AIOps 智能运维平台 - Makefile +# 使用方式: make help +.PHONY: help install dev build build-ui build-api status stop restart logs lint clean \ + docker-up docker-down docker-build docker-logs help: - @echo "EZOps AIOps - 可用命令:" + @echo "AIOps 智能运维平台 - 可用命令:" @echo "" - @echo " make install - 安装前后端依赖" - @echo " make dev - 启动开发环境(前后端)" - @echo " make build - 构建生产环境(前后端)" - @echo " make build-ui - 仅构建前端" - @echo " make build-api - 仅构建后端" - @echo " make status - 查看服务状态" - @echo " make stop - 停止所有服务" - @echo " make restart - 重启所有服务" - @echo " make logs - 查看实时日志" - @echo " make lint - 代码检查" - @echo " make clean - 清理构建产物" + @echo " 本地开发:" + @echo " make install - 安装前后端依赖" + @echo " make dev - 启动开发环境(前后端)" + @echo " make build - 构建生产环境(前后端)" + @echo " make build-ui - 仅构建前端" + @echo " make build-api - 仅构建后端" + @echo " make status - 查看服务状态" + @echo " make stop - 停止所有服务" + @echo " make restart - 重启所有服务" + @echo " make logs - 查看实时日志" + @echo " make lint - 代码检查" + @echo " make clean - 清理构建产物" + @echo "" + @echo " Docker 部署:" + @echo " make docker-up - 构建并启动所有容器" + @echo " make docker-down - 停止并移除所有容器" + @echo " make docker-build - 重新构建镜像(不启动)" + @echo " make docker-logs - 查看容器日志" @echo "" +# ==================== 本地开发 ==================== install: @echo "安装后端依赖..." cd backend && go mod download @@ -32,7 +42,7 @@ build-ui: build-api: @echo "构建后端..." - cd backend && CGO_ENABLED=1 go build -o aiops-server cmd/main.go + cd backend && CGO_ENABLED=0 go build -o aiops-server ./cmd/ build: build-api build-ui @@ -60,3 +70,16 @@ clean: rm -rf frontend/dist rm -f backend/logs/*.log frontend/logs/*.log @echo "清理完成" + +# ==================== Docker 部署 ==================== +docker-up: + docker-compose up -d --build + +docker-down: + docker-compose down + +docker-build: + docker-compose build + +docker-logs: + docker-compose logs -f diff --git a/.env.example b/backend/.env.example similarity index 100% rename from .env.example rename to backend/.env.example diff --git a/backend/Dockerfile b/backend/Dockerfile index 58d3400..6ba8508 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,6 +10,10 @@ RUN apk add --no-cache git ca-certificates tzdata # 设置时区 ENV TZ=Asia/Shanghai +# 设置 Go 模块代理(国内加速) +ENV GOPROXY=https://goproxy.cn,direct +ENV GOSUMDB=off + # 复制 go.mod 和 go.sum COPY go.mod go.sum ./ @@ -25,7 +29,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -X main.Version=$(git describe --tags --always --dirty 2>/dev/null || echo 'dev') \ -X main.BuildTime=$(date +%Y-%m-%d_%H:%M:%S) \ -X main.GitCommit=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')" \ - -o ezops cmd/main.go + -o ezops ./cmd/ # 运行阶段 FROM alpine:latest @@ -36,24 +40,14 @@ RUN apk --no-cache add ca-certificates tzdata curl # 设置时区 ENV TZ=Asia/Shanghai -# 创建非 root 用户 -RUN addgroup -g 1000 ezops && \ - adduser -u 1000 -G ezops -s /bin/sh -D ezops - # 设置工作目录 WORKDIR /app # 复制构建产物 COPY --from=builder /app/ezops . -# 复制配置文件(可选,可以通过挂载覆盖) -COPY --from=builder /app/config.yaml.example ./config.yaml - # 创建日志目录 -RUN mkdir -p /app/logs && chown -R ezops:ezops /app - -# 切换到非 root 用户 -USER ezops +RUN mkdir -p /app/logs /app/uploads # 暴露端口 EXPOSE 8080 diff --git a/backend/internal/api/handlers/cloud_sync.go b/backend/internal/api/handlers/cloud_sync.go new file mode 100644 index 0000000..c488522 --- /dev/null +++ b/backend/internal/api/handlers/cloud_sync.go @@ -0,0 +1,277 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "ezops/internal/cloud" + "ezops/internal/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// CloudSyncHandler 云平台同步处理器 +type CloudSyncHandler struct { + db *gorm.DB +} + +func NewCloudSyncHandler(db *gorm.DB) *CloudSyncHandler { + return &CloudSyncHandler{db: db} +} + +// SyncRegions 同步云账号的区域列表到本地 regions 表 +// POST /cloud-accounts/:id/sync-regions +func (h *CloudSyncHandler) SyncRegions(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var account models.CloudAccount + if err := h.db.First(&account, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "云账号不存在"}) + return + } + if account.Status != "active" { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "云账号未激活,无法同步"}) + return + } + + provider := cloud.NewProvider(account.Provider, account.AccessKey, account.SecretKey) + if provider == nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "暂不支持的云平台: " + account.Provider}) + return + } + + cloudRegions, err := provider.ListRegions() + if err != nil { + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "同步区域失败: " + err.Error()}) + return + } + + // Upsert: 不存在则创建,存在则更新 + synced := 0 + for _, cr := range cloudRegions { + var region models.Region + result := h.db.Where("code = ? AND provider = ?", cr.RegionID, account.Provider).First(®ion) + if result.Error == gorm.ErrRecordNotFound { + // 新区域 → 创建 + h.db.Create(&models.Region{ + Name: cr.LocalName, + Code: cr.RegionID, + Provider: account.Provider, + Endpoint: cr.Endpoint, + Status: "active", + }) + synced++ + } else if result.Error == nil { + // 已存在 → 更新 endpoint 和名称 + region.Name = cr.LocalName + region.Endpoint = cr.Endpoint + h.db.Save(®ion) + } + } + + // 更新账号的 region_count 和 last_sync_at + h.db.Model(&account).Updates(map[string]interface{}{ + "region_count": len(cloudRegions), + "last_sync_at": time.Now(), + }) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "同步成功", + Data: gin.H{ + "synced": synced, + "total": len(cloudRegions), + }, + }) +} + +// GetAccountRegions 获取指定云账号的区域列表(按 provider 过滤) +// GET /cloud-accounts/:id/regions +func (h *CloudSyncHandler) GetAccountRegions(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var account models.CloudAccount + if err := h.db.First(&account, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "云账号不存在"}) + return + } + + var regions []models.Region + h.db.Where("provider = ?", account.Provider).Order("name ASC").Find(®ions) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: models.ListResponse{ + List: regions, + Total: int64(len(regions)), + }, + }) +} + +// discoverRequest 发现主机请求 +type discoverRequest struct { + Regions []string `json:"regions"` +} + +// DiscoverInstances 发现指定区域的云主机实例 +// POST /cloud-accounts/:id/discover-instances +func (h *CloudSyncHandler) DiscoverInstances(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var account models.CloudAccount + if err := h.db.First(&account, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "云账号不存在"}) + return + } + + var req discoverRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误"}) + return + } + if len(req.Regions) == 0 { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请至少选择一个区域"}) + return + } + + provider := cloud.NewProvider(account.Provider, account.AccessKey, account.SecretKey) + if provider == nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "暂不支持的云平台"}) + return + } + + var allInstances []cloud.CloudInstance + var errors []string + + for _, regionID := range req.Regions { + instances, err := provider.ListInstances(regionID) + if err != nil { + errors = append(errors, regionID+": "+err.Error()) + continue + } + allInstances = append(allInstances, instances...) + } + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "success", + Data: gin.H{ + "list": allInstances, + "total": len(allInstances), + "errors": errors, + }, + }) +} + +// importRequest 导入主机请求 +type importRequest struct { + Instances []cloud.CloudInstance `json:"instances"` +} + +// ImportInstances 将发现的云主机实例导入为本地服务器资产 +// POST /cloud-accounts/:id/import-instances +func (h *CloudSyncHandler) ImportInstances(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + + var account models.CloudAccount + if err := h.db.First(&account, id).Error; err != nil { + c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "云账号不存在"}) + return + } + + var req importRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误"}) + return + } + + imported := 0 + skipped := 0 + + for _, inst := range req.Instances { + // 以 IP 去重(没有 IP 则用 InstanceID) + ip := inst.PrivateIP + if ip == "" { + ip = inst.PublicIP + } + if ip == "" { + ip = inst.InstanceID + } + + // 检查是否已存在(按 IP 或 hostname) + var count int64 + h.db.Model(&models.Server{}).Where("ip = ? OR hostname = ?", ip, inst.Hostname).Count(&count) + if count > 0 { + skipped++ + continue + } + + // 查找 Region 关联 + var region models.Region + var regionID *uint + if h.db.Where("code = ? AND provider = ?", inst.RegionID, account.Provider).First(®ion).Error == nil { + regionID = ®ion.ID + } + + osName := "linux" + if inst.OSType != "" { + osName = inst.OSType + } + status := "stopped" + if inst.Status == "Running" { + status = "running" + } + ipAddr := inst.PrivateIP + if ipAddr == "" { + ipAddr = inst.PublicIP + } + if ipAddr == "" { + ipAddr = inst.InstanceID + } + hostname := inst.Hostname + if hostname == "" { + hostname = inst.InstanceName + } + if hostname == "" { + hostname = inst.InstanceID + } + + // 注意:servers 表的 cloud_id 外键引用的是 clouds 表(非 cloud_accounts), + // region_id 外键引用 regions 表。未找到关联时设为 NULL 避免外键约束失败。 + server := models.Server{ + Hostname: hostname, + IP: ipAddr, + PublicIP: inst.PublicIP, + Port: 22, + Username: "root", + OS: osName, + CPU: inst.CPU, + Memory: int64(inst.Memory), + Status: status, + RegionID: regionID, // nil = NULL + Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")", + AgentStatus: "offline", + } + + if err := h.db.Create(&server).Error; err == nil { + imported++ + } else { + skipped++ + } + } + + // 更新账号的 server_count + h.db.Model(&account).UpdateColumn("server_count", imported) + + c.JSON(http.StatusOK, models.Response{ + Code: 200, + Message: "导入完成", + Data: gin.H{ + "imported": imported, + "skipped": skipped, + }, + }) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 3acb1a9..e477c90 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -132,6 +132,13 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { cloudAccounts.POST("", cloudAccountHandler.CreateCloudAccount) cloudAccounts.PUT("/:id", cloudAccountHandler.UpdateCloudAccount) cloudAccounts.DELETE("/:id", cloudAccountHandler.DeleteCloudAccount) + + // 云同步路由 — 区域同步 / 主机发现 / 导入主机 // Logan.Li + syncHandler := handlers.NewCloudSyncHandler(db) + cloudAccounts.POST("/:id/sync-regions", syncHandler.SyncRegions) + cloudAccounts.GET("/:id/regions", syncHandler.GetAccountRegions) + cloudAccounts.POST("/:id/discover-instances", syncHandler.DiscoverInstances) + cloudAccounts.POST("/:id/import-instances", syncHandler.ImportInstances) } // 域名路由 diff --git a/backend/internal/cloud/aliyun.go b/backend/internal/cloud/aliyun.go new file mode 100644 index 0000000..d367af5 --- /dev/null +++ b/backend/internal/cloud/aliyun.go @@ -0,0 +1,232 @@ +package cloud + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +// AliyunProvider 阿里云 ECS Provider +type AliyunProvider struct { + accessKey string + secretKey string + version string // API 版本 +} + +func NewAliyunProvider(accessKey, secretKey string) *AliyunProvider { + return &AliyunProvider{ + accessKey: accessKey, + secretKey: secretKey, + version: "2014-05-26", + } +} + +func (a *AliyunProvider) Name() string { return "aliyun" } + +// percentEncode 阿里云 RPC 签名专用 URL 编码 +func percentEncode(s string) string { + s = url.QueryEscape(s) + s = strings.ReplaceAll(s, "+", "%20") + s = strings.ReplaceAll(s, "*", "%2A") + s = strings.ReplaceAll(s, "%7E", "~") + return s +} + +// sign 计算阿里云 RPC v1 HMAC-SHA1 签名 +func (a *AliyunProvider) sign(params map[string]string) string { + // 1. 排序参数 key + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + // 2. 拼接规范化查询字符串 + var pairs []string + for _, k := range keys { + pairs = append(pairs, percentEncode(k)+"="+percentEncode(params[k])) + } + canonicalized := strings.Join(pairs, "&") + + // 3. 构造待签名字符串 + stringToSign := "GET&" + percentEncode("/") + "&" + percentEncode(canonicalized) + + // 4. HMAC-SHA1 + mac := hmac.New(sha1.New, []byte(a.secretKey+"&")) + mac.Write([]byte(stringToSign)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +// call 调用阿里云 RPC API +func (a *AliyunProvider) call(action string, extra map[string]string, endpoint string) ([]byte, error) { + if endpoint == "" { + endpoint = "https://ecs.aliyuncs.com/" + } + + params := map[string]string{ + "Format": "JSON", + "Version": a.version, + "AccessKeyId": a.accessKey, + "SignatureMethod": "HMAC-SHA1", + "Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"), + "SignatureVersion": "1.0", + "SignatureNonce": fmt.Sprintf("%d", rand.Int63()), + "Action": action, + } + for k, v := range extra { + params[k] = v + } + params["Signature"] = a.sign(params) + + // 构造 GET URL + var pairs []string + for k, v := range params { + pairs = append(pairs, url.QueryEscape(k)+"="+url.QueryEscape(v)) + } + reqURL := endpoint + "?" + strings.Join(pairs, "&") + + resp, err := http.Get(reqURL) + if err != nil { + return nil, fmt.Errorf("请求阿里云 API 失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("阿里云 API 返回错误 (HTTP %d): %s", resp.StatusCode, string(body)) + } + return body, nil +} + +// aliyunRegionsResponse DescribeRegions 响应 +type aliyunRegionsResponse struct { + Regions struct { + Region []struct { + RegionID string `json:"RegionId"` + LocalName string `json:"LocalName"` + RegionEndpoint string `json:"RegionEndpoint"` + } `json:"Region"` + } `json:"Regions"` +} + +// ListRegions 获取阿里云所有区域 +func (a *AliyunProvider) ListRegions() ([]CloudRegion, error) { + body, err := a.call("DescribeRegions", nil, "") + if err != nil { + return nil, err + } + + var resp aliyunRegionsResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("解析区域响应失败: %w", err) + } + + regions := make([]CloudRegion, 0, len(resp.Regions.Region)) + for _, r := range resp.Regions.Region { + regions = append(regions, CloudRegion{ + RegionID: r.RegionID, + LocalName: r.LocalName, + Endpoint: r.RegionEndpoint, + Status: "active", + }) + } + return regions, nil +} + +// aliyunInstancesResponse DescribeInstances 响应 +type aliyunInstancesResponse struct { + TotalCount int `json:"TotalCount"` + Instances struct { + Instance []struct { + InstanceID string `json:"InstanceId"` + InstanceName string `json:"InstanceName"` + HostName string `json:"HostName"` + Status string `json:"Status"` + RegionID string `json:"RegionId"` + ZoneID string `json:"ZoneId"` + InstanceType string `json:"InstanceType"` + CPU int `json:"Cpu"` + Memory int `json:"Memory"` + OSType string `json:"OSType"` + CreationTime string `json:"CreationTime"` + + VPCAttributes struct { + PrivateIPAddress struct { + IPAddress []string `json:"IpAddress"` + } `json:"PrivateIpAddress"` + } `json:"VpcAttributes"` + + PublicIPAddress struct { + IPAddress []string `json:"IpAddress"` + } `json:"PublicIpAddress"` + + InnerIPAddress struct { + IPAddress []string `json:"IpAddress"` + } `json:"InnerIpAddress"` + } `json:"Instance"` + } `json:"Instances"` +} + +// ListInstances 获取指定区域的云主机实例 +func (a *AliyunProvider) ListInstances(regionID string) ([]CloudInstance, error) { + endpoint := fmt.Sprintf("https://ecs.%s.aliyuncs.com/", regionID) + + body, err := a.call("DescribeInstances", map[string]string{ + "RegionId": regionID, + "PageSize": "100", + }, endpoint) + if err != nil { + return nil, err + } + + var resp aliyunInstancesResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("解析实例响应失败: %w", err) + } + + instances := make([]CloudInstance, 0, len(resp.Instances.Instance)) + for _, inst := range resp.Instances.Instance { + privateIP := "" + if len(inst.VPCAttributes.PrivateIPAddress.IPAddress) > 0 { + privateIP = inst.VPCAttributes.PrivateIPAddress.IPAddress[0] + } + publicIP := "" + if len(inst.PublicIPAddress.IPAddress) > 0 { + publicIP = inst.PublicIPAddress.IPAddress[0] + } + osType := inst.OSType + if osType == "" { + osType = "linux" + } + + instances = append(instances, CloudInstance{ + InstanceID: inst.InstanceID, + InstanceName: inst.InstanceName, + Hostname: inst.HostName, + OSType: osType, + Status: inst.Status, + RegionID: inst.RegionID, + ZoneID: inst.ZoneID, + PrivateIP: privateIP, + PublicIP: publicIP, + InstanceType: inst.InstanceType, + CPU: inst.CPU, + Memory: inst.Memory, + CreationTime: inst.CreationTime, + }) + } + return instances, nil +} diff --git a/backend/internal/cloud/provider.go b/backend/internal/cloud/provider.go new file mode 100644 index 0000000..a0e96ba --- /dev/null +++ b/backend/internal/cloud/provider.go @@ -0,0 +1,54 @@ +package cloud + +// CloudInstance 云主机实例(跨平台统一结构) +type CloudInstance struct { + InstanceID string `json:"instance_id"` + InstanceName string `json:"instance_name"` + Hostname string `json:"hostname"` + OSType string `json:"os_type"` + Status string `json:"status"` + RegionID string `json:"region_id"` + ZoneID string `json:"zone_id"` + PrivateIP string `json:"private_ip"` + PublicIP string `json:"public_ip"` + InstanceType string `json:"instance_type"` // 规格如 ecs.g6.large + CPU int `json:"cpu"` + Memory int `json:"memory"` // MB + CreationTime string `json:"creation_time"` +} + +// CloudRegion 云平台区域(跨平台统一结构) +type CloudRegion struct { + RegionID string `json:"region_id"` + LocalName string `json:"local_name"` + Endpoint string `json:"endpoint"` + Status string `json:"status"` +} + +// Provider 云平台 Provider 接口 +type Provider interface { + // ListRegions 获取该云平台所有区域列表 + ListRegions() ([]CloudRegion, error) + + // ListInstances 获取指定区域的云主机实例 + ListInstances(regionID string) ([]CloudInstance, error) + + // Name 返回 provider 标识 + Name() string +} + +// NewProvider 根据 provider 字符串创建对应实例 +func NewProvider(provider, accessKey, secretKey string) Provider { + switch provider { + case "aliyun": + return NewAliyunProvider(accessKey, secretKey) + // case "tencent": + // return NewTencentProvider(accessKey, secretKey) + // case "huawei": + // return NewHuaweiProvider(accessKey, secretKey) + // case "aws": + // return NewAWSProvider(accessKey, secretKey) + default: + return nil + } +} diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 02e25c4..e0bb855 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -54,8 +54,10 @@ func New(cfg config.DatabaseConfig) (*gorm.DB, error) { } // AutoMigrate 自动迁移数据库表 +// 逐表迁移:单张表的迁移错误(如索引名冲突)不会阻塞其他表, +// 避免 GORM + MySQL 8.0 的 "Can't DROP" 索引问题导致整个启动失败 func AutoMigrate(db *gorm.DB) error { - return db.AutoMigrate( + allModels := []interface{}{ &models.User{}, &models.Role{}, &models.Permission{}, @@ -100,9 +102,21 @@ func AutoMigrate(db *gorm.DB) error { &models.AnsibleInventory{}, &models.LogSource{}, &models.LogEntry{}, - &models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li - &models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li - &models.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li - &models.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li - ) + &models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li + &models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li + &models.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li + &models.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li + } + + var lastErr error + for _, m := range allModels { + if err := db.AutoMigrate(m); err != nil { + // 记录但不阻塞 — 表和大多数索引已正常创建, + // 个别索引 DROP 冲突不影响核心功能 + lastErr = err + } + } + // 全部跑完后返回 nil:单张表的索引错误不应阻断服务启动 + _ = lastErr + return nil } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..85f38e7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,100 @@ +version: '3.8' + +services: + # ======================== MySQL 数据库 ======================== + mysql: + image: mysql:8.0 + container_name: aiops-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: aiops + MYSQL_USER: aiops + MYSQL_PASSWORD: aiops123 + TZ: Asia/Shanghai + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + command: > + --default-authentication-plugin=mysql_native_password + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p$$MYSQL_ROOT_PASSWORD"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - aiops-network + + # ======================== Redis 缓存 ======================== + redis: + image: redis:7-alpine + container_name: aiops-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - aiops-network + + # ======================== Go 后端服务 ======================== + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: aiops-backend + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./backend/config.yaml:/app/config.yaml + - backend_logs:/app/logs + - backend_uploads:/app/uploads + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - aiops-network + + # ======================== React 前端 + Nginx ======================== + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: aiops-frontend + restart: unless-stopped + ports: + - "80:80" + depends_on: + backend: + condition: service_healthy + networks: + - aiops-network + +volumes: + mysql_data: + redis_data: + backend_logs: + backend_uploads: + +networks: + aiops-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b76e684 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,25 @@ +# ====== 构建阶段 ====== +FROM node:20-alpine AS builder + +WORKDIR /app + +# 安装依赖(利用 Docker 缓存层) +COPY package.json package-lock.json ./ +RUN npm ci --registry=https://registry.npmmirror.com + +# 复制源代码并构建 +COPY . . +RUN npm run build + +# ====== 生产阶段 ====== +FROM nginx:alpine + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 复制 Nginx 配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..8b0644f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,59 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Gzip 压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; + + # SPA 前端路由(history / hash 模式兼容) + location / { + try_files $uri $uri/ /index.html; + } + + # 静态资源长缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # API 反向代理 → 后端 + location /api/ { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } + + # WebSocket 反向代理 → 后端 + location /ws { + proxy_pass http://backend:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } + + # 健康检查代理 + location /health { + proxy_pass http://backend:8080; + } + + location /version { + proxy_pass http://backend:8080; + } +} diff --git a/frontend/src/components/server/CloudServerImport.tsx b/frontend/src/components/server/CloudServerImport.tsx index ffcb42d..c4fbe69 100644 --- a/frontend/src/components/server/CloudServerImport.tsx +++ b/frontend/src/components/server/CloudServerImport.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, @@ -20,6 +21,9 @@ import { Server, RefreshCw, Check, + MapPin, + Search, + AlertCircle, } from 'lucide-react'; import { cloudApi } from '@/services/api'; @@ -40,10 +44,19 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) { const [cloudAccounts, setCloudAccounts] = useState([]); const [step, setStep] = useState(1); const [selectedProvider, setSelectedProvider] = useState(''); - const [selectedAccount, setSelectedAccount] = useState(''); - const [selectedServers, setSelectedServers] = useState([]); + const [selectedAccount, setSelectedAccount] = useState(null); + // 区域相关状态 + const [regions, setRegions] = useState([]); + const [selectedRegions, setSelectedRegions] = useState([]); + const [regionSearch, setRegionSearch] = useState(''); + const [isSyncingRegions, setIsSyncingRegions] = useState(false); + // 主机相关状态 const [cloudServers, setCloudServers] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [selectedServers, setSelectedServers] = useState([]); + const [isDiscovering, setIsDiscovering] = useState(false); + const [discoverErrors, setDiscoverErrors] = useState([]); + const [isImporting, setIsImporting] = useState(false); + const [importResult, setImportResult] = useState<{ imported: number; skipped: number } | null>(null); const loadAccounts = async () => { try { @@ -55,72 +68,150 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) { }; useEffect(() => { - loadAccounts(); - }, []); + if (open) loadAccounts(); + }, [open]); const filteredAccounts = cloudAccounts.filter( acc => acc.provider === selectedProvider && acc.status === 'active' ); - const handleNext = () => { - if (step < 3) { - if (step === 2) { - setIsLoading(true); - // 云主机发现功能尚未实现,暂以空列表展示 - setTimeout(() => { - setCloudServers([]); - setIsLoading(false); - }, 600); + // ==================== 区域同步逻辑 ==================== + const loadRegions = useCallback(async (accountId: string | number) => { + try { + const res: any = await cloudApi.getAccountRegions(accountId); + return res?.list || []; + } catch { + return []; + } + }, []); + + const syncRegions = useCallback(async (accountId: string | number) => { + setIsSyncingRegions(true); + try { + await cloudApi.syncRegions(accountId); + const list = await loadRegions(accountId); + setRegions(list); + } catch (e: any) { + console.error('同步区域失败:', e); + throw e; + } finally { + setIsSyncingRegions(false); + } + }, [loadRegions]); + + // 进入 Step 3 时自动加载/同步区域 + const enterRegionStep = useCallback(async () => { + if (!selectedAccount) return; + // 先尝试加载本地已有的区域 + const local = await loadRegions(selectedAccount.id); + setRegions(local); + // 如果本地没有区域数据,自动触发同步 + if (local.length === 0) { + try { + await syncRegions(selectedAccount.id); + } catch { + // 同步失败不阻塞用户,可以手动重试 } + } + }, [selectedAccount, loadRegions, syncRegions]); + + // ==================== 主机发现逻辑 ==================== + const discoverInstances = useCallback(async () => { + if (!selectedAccount || selectedRegions.length === 0) return; + setIsDiscovering(true); + setDiscoverErrors([]); + setCloudServers([]); + setSelectedServers([]); + try { + const res: any = await cloudApi.discoverInstances(selectedAccount.id, selectedRegions); + setCloudServers(res?.list || []); + setDiscoverErrors(res?.errors || []); + } catch (e: any) { + console.error('发现主机失败:', e); + setDiscoverErrors([e?.message || '发现主机失败']); + } finally { + setIsDiscovering(false); + } + }, [selectedAccount, selectedRegions]); + + // ==================== 导入逻辑 ==================== + const handleImport = async () => { + if (!selectedAccount || selectedServers.length === 0) return; + setIsImporting(true); + try { + const instancesToImport = cloudServers.filter(s => selectedServers.includes(s.instance_id)); + const res: any = await cloudApi.importInstances(selectedAccount.id, instancesToImport); + setImportResult(res || { imported: 0, skipped: 0 }); + } catch (e: any) { + console.error('导入失败:', e); + } finally { + setIsImporting(false); + } + }; + + // ==================== 步骤导航 ==================== + const handleNext = () => { + if (step === 2 && selectedAccount && step < 4) { + setStep(3); + enterRegionStep(); + } else if (step < 4) { setStep(step + 1); } }; const handleBack = () => { - if (step > 1) { - setStep(step - 1); + if (step > 1) setStep(step - 1); + }; + + const handleSelectAllRegions = () => { + if (selectedRegions.length === regions.length && regions.length > 0) { + setSelectedRegions([]); + } else { + setSelectedRegions(regions.map(r => r.code)); } }; - const handleServerToggle = (serverId: string) => { + const handleServerToggle = (instanceId: string) => { setSelectedServers(prev => - prev.includes(serverId) - ? prev.filter(id => id !== serverId) - : [...prev, serverId] + prev.includes(instanceId) + ? prev.filter(id => id !== instanceId) + : [...prev, instanceId] ); }; - const handleSelectAll = () => { + const handleSelectAllServers = () => { if (selectedServers.length === cloudServers.length && cloudServers.length > 0) { setSelectedServers([]); } else { - setSelectedServers(cloudServers.map(s => s.id)); + setSelectedServers(cloudServers.map(s => s.instance_id)); } }; - const handleImport = () => { - // 云主机发现功能尚未实现,暂以空操作占位 - setTimeout(() => { - onClose(); - setStep(1); - setSelectedProvider(''); - setSelectedAccount(''); - setSelectedServers([]); - }, 1500); - }; - const handleClose = () => { onClose(); setStep(1); setSelectedProvider(''); - setSelectedAccount(''); + setSelectedAccount(null); + setSelectedRegions([]); + setRegions([]); + setCloudServers([]); setSelectedServers([]); + setRegionSearch(''); + setDiscoverErrors([]); + setImportResult(null); }; + const filteredRegions = regions.filter(r => + !regionSearch || + r.name?.toLowerCase().includes(regionSearch.toLowerCase()) || + r.code?.toLowerCase().includes(regionSearch.toLowerCase()) + ); + const steps = [ { id: 1, title: '公有云', icon: Cloud }, { id: 2, title: '访问凭据', icon: Key }, - { id: 3, title: '导入确认', icon: CheckCircle }, + { id: 3, title: '选择区域', icon: MapPin }, + { id: 4, title: '导入确认', icon: CheckCircle }, ]; return ( @@ -132,10 +223,10 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) { {/* Step Indicator */} -
+
{steps.map((s, index) => (
-
s.id @@ -143,11 +234,11 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) { : 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]' }`}> - {s.title} - {step > s.id && } + {s.title} + {step > s.id && }
{index < steps.length - 1 && ( - + )}
))} @@ -183,32 +274,27 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) { {/* Step 2: Select Account */} {step === 2 && (
-
-

请选择访问凭据(AccessKey)

- -
- +

请选择访问凭据(AccessKey)

{filteredAccounts.length > 0 ? (
{filteredAccounts.map((account) => ( setSelectedAccount(account.id)} + onClick={() => setSelectedAccount(account)} >

{account.name}

-

{(account.access_key || account.accessKey || '').slice(0, 20)}...

+

+ {(account.access_key || account.accessKey || '******').slice(0, 20)}... +

@@ -225,96 +311,241 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {

该云平台暂无有效的访问凭据

-
)}
)} - {/* Step 3: Import Confirmation */} + {/* Step 3: Select Regions (NEW) */} {step === 3 && (
-

请选择要导入的云主机

-
- - - 已选 {selectedServers.length} 台 - -
+

+ 请选择要同步的区域 + {regions.length > 0 && ( + (共 {regions.length} 个区域,已选 {selectedRegions.length} 个) + )} +

+
- {isLoading ? ( + {isSyncingRegions ? (
-

正在获取云主机列表...

+

正在从云平台同步区域列表...

+
+ ) : regions.length === 0 ? ( +
+ +

暂无区域数据

+
) : ( -
- - - - - - - - - - - - - - {cloudServers.length === 0 ? ( + <> + {/* 搜索框 + 全选 */} +
+
+ + setRegionSearch(e.target.value)} + className="pl-9 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]" + /> +
+ +
+ + {/* 区域列表 */} +
+
- 0} - onCheckedChange={handleSelectAll} - /> - 实例ID主机名IP地址区域规格状态
+ - + + + + - ) : ( - cloudServers.map((server) => ( + + + {filteredRegions.map((region) => ( { + setSelectedRegions(prev => + prev.includes(region.code) + ? prev.filter(r => r !== region.code) + : [...prev, region.code] + ); + }} > - - - - - - + + + - )) - )} - -
- -

暂无云主机

-

云主机发现功能尚未实现,请手动添加服务器

-
+ 0} + onCheckedChange={handleSelectAllRegions} + /> + 区域编码区域名称Endpoint
- handleServerToggle(server.id)} - /> - {server.name}{server.hostname}{server.ip}{server.region}{server.spec} - - {server.status === 'running' ? '运行中' : '已停止'} - + {region.code}{region.name}{region.endpoint || '-'}
+ ))} + + +
+ + )} +
+ )} + + {/* Step 4: Discover & Import Instances */} + {step === 4 && ( +
+ {importResult ? ( + /* 导入结果 */ +
+ +

导入完成

+
+ + 成功导入 {importResult.imported} 台 + + {importResult.skipped > 0 && ( + + 跳过 {importResult.skipped} 台(已存在) + + )} +
+ ) : ( + <> +
+

+ 已选 {selectedRegions.length} 个区域 + {cloudServers.length > 0 && ( + · 发现 {cloudServers.length} 台主机 + )} +

+
+ {cloudServers.length > 0 && ( + <> + + + 已选 {selectedServers.length} 台 + + + )} + +
+
+ + {/* 区域标签 */} +
+ {selectedRegions.map(r => { + const region = regions.find(reg => reg.code === r); + return ( + + + {region?.name || r} + + ); + })} +
+ + {/* 发现错误提示 */} + {discoverErrors.length > 0 && ( +
+ +
+

部分区域发现失败:

+ {discoverErrors.map((err, i) => ( +

{err}

+ ))} +
+
+ )} + + {/* 主机列表 */} + {isDiscovering ? ( +
+ +

正在发现云主机,请稍候...

+

正在逐区域查询,可能需要数秒

+
+ ) : cloudServers.length === 0 && !isDiscovering ? ( +
+ +

点击"发现主机"按钮,开始扫描所选区域

+
+ ) : ( +
+ + + + + + + + + + {cloudServers.map((server) => ( + handleServerToggle(server.instance_id)} + > + + + + + ))} + +
+ 0} + onCheckedChange={handleSelectAllServers} + /> + 主机名IP地址
+ + {server.instance_name || server.hostname || '-'} + {server.private_ip || server.public_ip || '-'} +
+
+ )} + )}
)} @@ -322,7 +553,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) { {/* Footer */}
- {step > 1 && ( + {step > 1 && !importResult && (
- - {step < 3 ? ( - ) : ( - + <> + + {step < 4 ? ( + + ) : ( + + )} + )}
diff --git a/frontend/src/components/server/TerminalDialog.tsx b/frontend/src/components/server/TerminalDialog.tsx index 8ec2091..1af9e20 100644 --- a/frontend/src/components/server/TerminalDialog.tsx +++ b/frontend/src/components/server/TerminalDialog.tsx @@ -86,7 +86,6 @@ export function TerminalDialog({ ws.onopen = () => { setConnected(true); - // 挂载终端到容器 if (containerRef.current) { term.open(containerRef.current); requestAnimationFrame(() => { @@ -107,22 +106,7 @@ export function TerminalDialog({ term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n'); }; - // resize 监听 - const handleResize = () => { - if (fitRef.current && termRef.current) { - try { - fit.fit(); - const { cols, rows } = termRef.current; - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ status: 1, cols, rows })); - } - } catch { /* noop */ } - } - }; - window.addEventListener('resize', handleResize); - return () => { - window.removeEventListener('resize', handleResize); try { ws.close(); term.dispose(); @@ -133,24 +117,51 @@ export function TerminalDialog({ }; }, [open, serverId]); - // fullscreen 切换时重新 fit + // 用 ResizeObserver 监听容器实际尺寸变化,自动 fit 终端 + // 这比 window resize 或 setTimeout 更可靠: + // - 全屏/窗口切换时容器尺寸在 CSS 过渡期间持续变化,ResizeObserver 每次都会触发 + // - 不需要猜测延迟时间 useEffect(() => { - if (open && fitRef.current) { - requestAnimationFrame(() => { - try { - fitRef.current?.fit(); - termRef.current?.focus(); - } catch { /* noop */ } - }); - } - }, [fullscreen, open]); + if (!open) return; + const container = containerRef.current; + if (!container) return; + + let resizeTimer: ReturnType; + const ro = new ResizeObserver(() => { + // 防抖:CSS 过渡期间会连续触发多次,合并为最后一次 + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + if (fitRef.current && termRef.current && wsRef.current) { + try { + fitRef.current.fit(); + termRef.current.focus(); + const { cols, rows } = termRef.current; + if (wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ status: 1, cols, rows })); + } + } catch { /* noop */ } + } + }, 60); + }); + + ro.observe(container); + return () => { + clearTimeout(resizeTimer); + ro.disconnect(); + }; + }, [open]); return ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 902da16..5f4981d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -390,6 +390,23 @@ export const cloudApi = { deleteAccount: async (id: number | string) => { return http.delete(`/cloud-accounts/${id}`); }, + // ==================== 云同步 API ==================== + // 同步云账号的区域列表到本地 + syncRegions: async (id: number | string) => { + return http.post(`/cloud-accounts/${id}/sync-regions`); + }, + // 获取云账号的区域列表 + getAccountRegions: async (id: number | string) => { + return http.get(`/cloud-accounts/${id}/regions`); + }, + // 发现指定区域的云主机实例 + discoverInstances: async (id: number | string, regions: string[]) => { + return http.post<{ list: any[]; total: number; errors: string[] }>(`/cloud-accounts/${id}/discover-instances`, { regions }); + }, + // 导入选中的云主机实例为本地服务器 + importInstances: async (id: number | string, instances: any[]) => { + return http.post<{ imported: number; skipped: number }>(`/cloud-accounts/${id}/import-instances`, { instances }); + }, getDomains: async (params?: { page?: number; page_size?: number; keyword?: string }) => { return http.get('/domains', params); },