feat: Docker部署 + 云主机导入 + 终端优化

Docker 部署:
- 新增根目录 docker-compose.yml(MySQL+Redis+前后端四容器)
- 新增 frontend/Dockerfile(多阶段构建)和 nginx.conf(SPA+API反代)
- 修复 backend/Dockerfile:GOPROXY国内加速、编译整个cmd包

云主机导入功能:
- 新增 backend/internal/cloud 包:Provider接口 + 阿里云API客户端
- 新增 cloud_sync.go:区域同步/主机发现/导入 API
- 重写 CloudServerImport.tsx:4步流程(选云→选账号→选区域→发现导入)
- 修复导入外键约束失败(region_id/cloud_id 设为 NULL)

终端优化:
- 修复双X关闭按钮重影(showCloseButton=false)
- 修复最大化→最小化不恢复(ResizeObserver + 禁用CSS过渡)

其他:
- GORM AutoMigrate 逐表迁移,避免索引冲突阻塞启动
- Makefile 更新为 AIOps + 新增 docker 命令组
- .env.example 移至 backend 目录
This commit is contained in:
Logan committed 2026-06-18 09:22:13 +08:00
1 parent 2e175daf80
commit d0344c2f52
14 files changed
+1265 -200

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# EZOps AIOps - 环境变量模板
# 复制为 .env 并根据需要修改
# 服务配置
SERVER_PORT=8080
SERVER_MODE=development
# 前端端口
FRONTEND_PORT=5173
# 数据库配置 - 默认 SQLite 零配置
DB_DRIVER=sqlite
DB_NAME=data/aiops.db
# MySQL 配置(可选)
# DB_DRIVER=mysql
# DB_HOST=localhost
# DB_PORT=3306
# DB_USER=root
# DB_PASSWORD=
# DB_NAME=aiops
# JWT 配置
JWT_SECRET=aiops-secret-key-change-in-production
JWT_EXPIRE_HOURS=24
+6 -12
View File
@@ -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
+277
View File
@@ -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(&region)
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(&region)
}
}
// 更新账号的 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(&regions)
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(&region).Error == nil {
regionID = &region.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,
},
})
}
+7
View File
@@ -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)
}
// 域名路由
+232
View File
@@ -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
}
+54
View File
@@ -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
}
}
+20 -6
View File
@@ -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
}