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

+38 -15
View File
@@ -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
File renamed without changes.
+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
}
+100
View File
@@ -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
+25
View File
@@ -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;"]
+59
View File
@@ -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;
}
}
@@ -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<any[]>([]);
const [step, setStep] = useState(1);
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedAccount, setSelectedAccount] = useState<string>('');
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [selectedAccount, setSelectedAccount] = useState<any>(null);
// 区域相关状态
const [regions, setRegions] = useState<any[]>([]);
const [selectedRegions, setSelectedRegions] = useState<string[]>([]);
const [regionSearch, setRegionSearch] = useState('');
const [isSyncingRegions, setIsSyncingRegions] = useState(false);
// 主机相关状态
const [cloudServers, setCloudServers] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
const [discoverErrors, setDiscoverErrors] = useState<string[]>([]);
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) {
</DialogHeader>
{/* Step Indicator */}
<div className="flex items-center justify-center mb-6">
<div className="flex items-center justify-center mb-6 flex-wrap gap-1">
{steps.map((s, index) => (
<div key={s.id} className="flex items-center">
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg ${
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${
step === s.id
? 'bg-blue-600 text-white'
: step > s.id
@@ -143,11 +234,11 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
: 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]'
}`}>
<s.icon className="h-4 w-4" />
<span className="text-sm font-medium">{s.title}</span>
{step > s.id && <Check className="h-4 w-4 ml-1" />}
<span className="font-medium hidden sm:inline">{s.title}</span>
{step > s.id && <Check className="h-3 w-3 ml-1" />}
</div>
{index < steps.length - 1 && (
<ChevronRight className="h-5 w-5 text-[var(--dg-text-muted)] mx-2" />
<ChevronRight className="h-4 w-4 text-[var(--dg-text-muted)] mx-1" />
)}
</div>
))}
@@ -183,32 +274,27 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
{/* Step 2: Select Account */}
{step === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[var(--dg-text-secondary)] text-sm">请选择访问凭据(AccessKey)</p>
<Button variant="outline" size="sm" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
<RefreshCw className="h-3 w-3 mr-1" />
刷新
</Button>
</div>
<p className="text-[var(--dg-text-secondary)] text-sm">请选择访问凭据(AccessKey)</p>
{filteredAccounts.length > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{filteredAccounts.map((account) => (
<Card
key={account.id}
className={`cursor-pointer transition-all ${
selectedAccount === account.id
selectedAccount?.id === account.id
? 'border-blue-500 ring-1 ring-blue-500/30 bg-[var(--dg-bg-secondary)]'
: 'border-[var(--dg-border)] hover:border-blue-500 bg-[var(--dg-bg-secondary)]'
}`}
onClick={() => setSelectedAccount(account.id)}
onClick={() => setSelectedAccount(account)}
>
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 text-blue-400" />
<div>
<p className="font-medium text-[var(--dg-text-primary)]">{account.name}</p>
<p className="text-xs text-[var(--dg-text-muted)]">{(account.access_key || account.accessKey || '').slice(0, 20)}...</p>
<p className="text-xs text-[var(--dg-text-muted)]">
{(account.access_key || account.accessKey || '******').slice(0, 20)}...
</p>
</div>
</div>
<div className="text-right">
@@ -225,96 +311,241 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>该云平台暂无有效的访问凭据</p>
<Button variant="outline" className="mt-3 border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
添加访问凭据
</Button>
</div>
)}
</div>
)}
{/* Step 3: Import Confirmation */}
{/* Step 3: Select Regions (NEW) */}
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[var(--dg-text-secondary)] text-sm">请选择要导入的云主机</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={handleSelectAll} disabled={cloudServers.length === 0}>
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? '取消全选' : '全选'}
</Button>
<Badge className="bg-blue-500/20 text-blue-400 border-0">
已选 {selectedServers.length} 台
</Badge>
</div>
<p className="text-[var(--dg-text-secondary)] text-sm">
请选择要同步的区域
{regions.length > 0 && (
<span className="ml-2 text-[var(--dg-text-muted)]">(共 {regions.length} 个区域,已选 {selectedRegions.length} 个)</span>
)}
</p>
<Button
variant="outline"
size="sm"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={() => selectedAccount && syncRegions(selectedAccount.id)}
disabled={isSyncingRegions}
>
<RefreshCw className={`h-3 w-3 mr-1 ${isSyncingRegions ? 'animate-spin' : ''}`} />
{isSyncingRegions ? '同步中...' : '刷新区域'}
</Button>
</div>
{isLoading ? (
{isSyncingRegions ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin text-blue-400" />
<p className="text-[var(--dg-text-secondary)]">正在获取云主机列表...</p>
<p className="text-[var(--dg-text-secondary)]">正在从云平台同步区域列表...</p>
</div>
) : regions.length === 0 ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<MapPin className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p>暂无区域数据</p>
<Button
variant="outline"
size="sm"
className="mt-3 border-[var(--dg-border)] text-blue-400"
onClick={() => selectedAccount && syncRegions(selectedAccount.id)}
>
<RefreshCw className="h-3 w-3 mr-1" />
立即同步
</Button>
</div>
) : (
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[350px] overflow-y-auto">
<table className="w-full">
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)] w-10">
<Checkbox
checked={selectedServers.length === cloudServers.length && cloudServers.length > 0}
onCheckedChange={handleSelectAll}
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">实例ID</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">规格</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{cloudServers.length === 0 ? (
<>
{/* 搜索框 + 全选 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<Input
placeholder="搜索区域名称或编码..."
value={regionSearch}
onChange={(e) => setRegionSearch(e.target.value)}
className="pl-9 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
/>
</div>
<Button variant="outline" size="sm" onClick={handleSelectAllRegions} className="border-[var(--dg-border)]">
{selectedRegions.length === regions.length && regions.length > 0 ? '取消全选' : '全选'}
</Button>
</div>
{/* 区域列表 */}
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[300px] overflow-y-auto">
<table className="w-full">
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
<tr>
<td colSpan={7} className="px-3 py-12 text-center">
<Server className="h-10 w-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-muted)]">暂无云主机</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">云主机发现功能尚未实现,请手动添加服务器</p>
</td>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)] w-10">
<Checkbox
checked={selectedRegions.length === regions.length && regions.length > 0}
onCheckedChange={handleSelectAllRegions}
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域编码</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域名称</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">Endpoint</th>
</tr>
) : (
cloudServers.map((server) => (
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{filteredRegions.map((region) => (
<tr
key={server.id}
className={`hover:bg-[var(--dg-bg-hover)] ${selectedServers.includes(server.id) ? 'bg-blue-500/10' : ''}`}
key={region.id || region.code}
className={`cursor-pointer hover:bg-[var(--dg-bg-hover)] ${selectedRegions.includes(region.code) ? 'bg-blue-500/10' : ''}`}
onClick={() => {
setSelectedRegions(prev =>
prev.includes(region.code)
? prev.filter(r => r !== region.code)
: [...prev, region.code]
);
}}
>
<td className="px-3 py-2">
<Checkbox
checked={selectedServers.includes(server.id)}
onCheckedChange={() => handleServerToggle(server.id)}
/>
</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{server.name}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{server.hostname}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{server.ip}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{server.region}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{server.spec}</td>
<td className="px-3 py-2">
<Badge
variant="outline"
className={server.status === 'running'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}
>
{server.status === 'running' ? '运行中' : '已停止'}
</Badge>
<Checkbox checked={selectedRegions.includes(region.code)} />
</td>
<td className="px-3 py-2 text-sm text-blue-400 font-mono">{region.code}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{region.name}</td>
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)] font-mono truncate max-w-[200px]">{region.endpoint || '-'}</td>
</tr>
))
)}
</tbody>
</table>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)}
{/* Step 4: Discover & Import Instances */}
{step === 4 && (
<div className="space-y-4">
{importResult ? (
/* 导入结果 */
<div className="text-center py-12">
<CheckCircle className="h-12 w-12 mx-auto mb-3 text-emerald-400" />
<p className="text-lg font-medium text-[var(--dg-text-primary)]">导入完成</p>
<div className="flex items-center justify-center gap-6 mt-4">
<Badge className="bg-emerald-500/20 text-emerald-400 border-0 text-base px-4 py-1">
成功导入 {importResult.imported} 台
</Badge>
{importResult.skipped > 0 && (
<Badge className="bg-amber-500/20 text-amber-400 border-0 text-base px-4 py-1">
跳过 {importResult.skipped} 台(已存在)
</Badge>
)}
</div>
</div>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-[var(--dg-text-secondary)] text-sm">
已选 {selectedRegions.length} 个区域
{cloudServers.length > 0 && (
<span className="ml-2">· 发现 {cloudServers.length} 台主机</span>
)}
</p>
<div className="flex items-center gap-2">
{cloudServers.length > 0 && (
<>
<Button variant="outline" size="sm" onClick={handleSelectAllServers} className="border-[var(--dg-border)]">
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? '取消全选' : '全选'}
</Button>
<Badge className="bg-blue-500/20 text-blue-400 border-0">
已选 {selectedServers.length} 台
</Badge>
</>
)}
<Button
variant="outline"
size="sm"
onClick={discoverInstances}
disabled={isDiscovering || selectedRegions.length === 0}
className="border-[var(--dg-border)] text-blue-400"
>
<RefreshCw className={`h-3 w-3 mr-1 ${isDiscovering ? 'animate-spin' : ''}`} />
{isDiscovering ? '发现中...' : cloudServers.length > 0 ? '重新发现' : '发现主机'}
</Button>
</div>
</div>
{/* 区域标签 */}
<div className="flex flex-wrap gap-1">
{selectedRegions.map(r => {
const region = regions.find(reg => reg.code === r);
return (
<Badge key={r} className="bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)] border border-[var(--dg-border)] text-xs">
<MapPin className="h-3 w-3 mr-1" />
{region?.name || r}
</Badge>
);
})}
</div>
{/* 发现错误提示 */}
{discoverErrors.length > 0 && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium">部分区域发现失败:</p>
{discoverErrors.map((err, i) => (
<p key={i} className="text-xs mt-1 opacity-80">{err}</p>
))}
</div>
</div>
)}
{/* 主机列表 */}
{isDiscovering ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin text-blue-400" />
<p className="text-[var(--dg-text-secondary)]">正在发现云主机,请稍候...</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">正在逐区域查询,可能需要数秒</p>
</div>
) : cloudServers.length === 0 && !isDiscovering ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p>点击"发现主机"按钮,开始扫描所选区域</p>
</div>
) : (
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[300px] overflow-y-auto">
<table className="w-full">
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)] w-10">
<Checkbox
checked={selectedServers.length === cloudServers.length && cloudServers.length > 0}
onCheckedChange={handleSelectAllServers}
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{cloudServers.map((server) => (
<tr
key={server.instance_id}
className={`cursor-pointer hover:bg-[var(--dg-bg-hover)] ${selectedServers.includes(server.instance_id) ? 'bg-blue-500/10' : ''}`}
onClick={() => handleServerToggle(server.instance_id)}
>
<td className="px-3 py-2">
<Checkbox checked={selectedServers.includes(server.instance_id)} />
</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{server.instance_name || server.hostname || '-'}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">
{server.private_ip || server.public_ip || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
)}
@@ -322,7 +553,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
{/* Footer */}
<DialogFooter className="flex justify-between items-center">
<div>
{step > 1 && (
{step > 1 && !importResult && (
<Button variant="outline" onClick={handleBack} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
<ChevronLeft className="h-4 w-4 mr-1" />
上一步
@@ -330,27 +561,48 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
取消
</Button>
{step < 3 ? (
<Button
onClick={handleNext}
disabled={(step === 1 && !selectedProvider) || (step === 2 && !selectedAccount)}
className="bg-blue-600 hover:bg-blue-700"
>
下一步
<ChevronRight className="h-4 w-4 ml-1" />
{importResult ? (
<Button onClick={handleClose} className="bg-blue-600 hover:bg-blue-700">
完成
</Button>
) : (
<Button
onClick={handleImport}
disabled={selectedServers.length === 0}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Check className="h-4 w-4 mr-1" />
确认导入 ({selectedServers.length})
</Button>
<>
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
取消
</Button>
{step < 4 ? (
<Button
onClick={handleNext}
disabled={
(step === 1 && !selectedProvider) ||
(step === 2 && !selectedAccount) ||
(step === 3 && selectedRegions.length === 0)
}
className="bg-blue-600 hover:bg-blue-700"
>
下一步
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button
onClick={handleImport}
disabled={selectedServers.length === 0 || isImporting}
className="bg-emerald-600 hover:bg-emerald-700"
>
{isImporting ? (
<>
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
导入中...
</>
) : (
<>
<Check className="h-4 w-4 mr-1" />
确认导入 ({selectedServers.length})
</>
)}
</Button>
)}
</>
)}
</div>
</DialogFooter>
@@ -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<typeof setTimeout>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
fullscreen && '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
// 禁用 CSS 过渡:终端窗口应该即时缩放,不需要动画
'!duration-0 !transition-none',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: ''
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
>
+17
View File
@@ -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<ListResponse>(`/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<ListResponse>('/domains', params);
},