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:
1 parent
2e175daf80
commit
d0344c2f52
14 files changed
+1265
-200
No files matched your search
@@ -1,22 +1,32 @@
|
|||||||
# EZOps AIOps - Makefile
|
# AIOps 智能运维平台 - Makefile
|
||||||
.PHONY: help install dev build clean status stop restart lint
|
# 使用方式: 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:
|
help:
|
||||||
@echo "EZOps AIOps - 可用命令:"
|
@echo "AIOps 智能运维平台 - 可用命令:"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " make install - 安装前后端依赖"
|
@echo " 本地开发:"
|
||||||
@echo " make dev - 启动开发环境(前后端)"
|
@echo " make install - 安装前后端依赖"
|
||||||
@echo " make build - 构建生产环境(前后端)"
|
@echo " make dev - 启动开发环境(前后端)"
|
||||||
@echo " make build-ui - 仅构建前端"
|
@echo " make build - 构建生产环境(前后端)"
|
||||||
@echo " make build-api - 仅构建后端"
|
@echo " make build-ui - 仅构建前端"
|
||||||
@echo " make status - 查看服务状态"
|
@echo " make build-api - 仅构建后端"
|
||||||
@echo " make stop - 停止所有服务"
|
@echo " make status - 查看服务状态"
|
||||||
@echo " make restart - 重启所有服务"
|
@echo " make stop - 停止所有服务"
|
||||||
@echo " make logs - 查看实时日志"
|
@echo " make restart - 重启所有服务"
|
||||||
@echo " make lint - 代码检查"
|
@echo " make logs - 查看实时日志"
|
||||||
@echo " make clean - 清理构建产物"
|
@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 ""
|
@echo ""
|
||||||
|
|
||||||
|
# ==================== 本地开发 ====================
|
||||||
install:
|
install:
|
||||||
@echo "安装后端依赖..."
|
@echo "安装后端依赖..."
|
||||||
cd backend && go mod download
|
cd backend && go mod download
|
||||||
@@ -32,7 +42,7 @@ build-ui:
|
|||||||
|
|
||||||
build-api:
|
build-api:
|
||||||
@echo "构建后端..."
|
@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
|
build: build-api build-ui
|
||||||
|
|
||||||
@@ -60,3 +70,16 @@ clean:
|
|||||||
rm -rf frontend/dist
|
rm -rf frontend/dist
|
||||||
rm -f backend/logs/*.log frontend/logs/*.log
|
rm -f backend/logs/*.log frontend/logs/*.log
|
||||||
@echo "清理完成"
|
@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
@@ -10,6 +10,10 @@ RUN apk add --no-cache git ca-certificates tzdata
|
|||||||
# 设置时区
|
# 设置时区
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 设置 Go 模块代理(国内加速)
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
ENV GOSUMDB=off
|
||||||
|
|
||||||
# 复制 go.mod 和 go.sum
|
# 复制 go.mod 和 go.sum
|
||||||
COPY 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.Version=$(git describe --tags --always --dirty 2>/dev/null || echo 'dev') \
|
||||||
-X main.BuildTime=$(date +%Y-%m-%d_%H:%M:%S) \
|
-X main.BuildTime=$(date +%Y-%m-%d_%H:%M:%S) \
|
||||||
-X main.GitCommit=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')" \
|
-X main.GitCommit=$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')" \
|
||||||
-o ezops cmd/main.go
|
-o ezops ./cmd/
|
||||||
|
|
||||||
# 运行阶段
|
# 运行阶段
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
@@ -36,24 +40,14 @@ RUN apk --no-cache add ca-certificates tzdata curl
|
|||||||
# 设置时区
|
# 设置时区
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
# 创建非 root 用户
|
|
||||||
RUN addgroup -g 1000 ezops && \
|
|
||||||
adduser -u 1000 -G ezops -s /bin/sh -D ezops
|
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制构建产物
|
# 复制构建产物
|
||||||
COPY --from=builder /app/ezops .
|
COPY --from=builder /app/ezops .
|
||||||
|
|
||||||
# 复制配置文件(可选,可以通过挂载覆盖)
|
|
||||||
COPY --from=builder /app/config.yaml.example ./config.yaml
|
|
||||||
|
|
||||||
# 创建日志目录
|
# 创建日志目录
|
||||||
RUN mkdir -p /app/logs && chown -R ezops:ezops /app
|
RUN mkdir -p /app/logs /app/uploads
|
||||||
|
|
||||||
# 切换到非 root 用户
|
|
||||||
USER ezops
|
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -132,6 +132,13 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
|||||||
cloudAccounts.POST("", cloudAccountHandler.CreateCloudAccount)
|
cloudAccounts.POST("", cloudAccountHandler.CreateCloudAccount)
|
||||||
cloudAccounts.PUT("/:id", cloudAccountHandler.UpdateCloudAccount)
|
cloudAccounts.PUT("/:id", cloudAccountHandler.UpdateCloudAccount)
|
||||||
cloudAccounts.DELETE("/:id", cloudAccountHandler.DeleteCloudAccount)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 域名路由
|
// 域名路由
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,8 +54,10 @@ func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AutoMigrate 自动迁移数据库表
|
// AutoMigrate 自动迁移数据库表
|
||||||
|
// 逐表迁移:单张表的迁移错误(如索引名冲突)不会阻塞其他表,
|
||||||
|
// 避免 GORM + MySQL 8.0 的 "Can't DROP" 索引问题导致整个启动失败
|
||||||
func AutoMigrate(db *gorm.DB) error {
|
func AutoMigrate(db *gorm.DB) error {
|
||||||
return db.AutoMigrate(
|
allModels := []interface{}{
|
||||||
&models.User{},
|
&models.User{},
|
||||||
&models.Role{},
|
&models.Role{},
|
||||||
&models.Permission{},
|
&models.Permission{},
|
||||||
@@ -100,9 +102,21 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.AnsibleInventory{},
|
&models.AnsibleInventory{},
|
||||||
&models.LogSource{},
|
&models.LogSource{},
|
||||||
&models.LogEntry{},
|
&models.LogEntry{},
|
||||||
&models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
&models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
||||||
&models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
&models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
||||||
&models.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
&models.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
||||||
&models.ChatHistory{}, // 对话历史记录(按用户隔离) // 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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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;"]
|
||||||
@@ -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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -20,6 +21,9 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Check,
|
Check,
|
||||||
|
MapPin,
|
||||||
|
Search,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cloudApi } from '@/services/api';
|
import { cloudApi } from '@/services/api';
|
||||||
|
|
||||||
@@ -40,10 +44,19 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
|||||||
const [cloudAccounts, setCloudAccounts] = useState<any[]>([]);
|
const [cloudAccounts, setCloudAccounts] = useState<any[]>([]);
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string>('');
|
const [selectedProvider, setSelectedProvider] = useState<string>('');
|
||||||
const [selectedAccount, setSelectedAccount] = useState<string>('');
|
const [selectedAccount, setSelectedAccount] = useState<any>(null);
|
||||||
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
// 区域相关状态
|
||||||
|
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 [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 () => {
|
const loadAccounts = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -55,72 +68,150 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAccounts();
|
if (open) loadAccounts();
|
||||||
}, []);
|
}, [open]);
|
||||||
|
|
||||||
const filteredAccounts = cloudAccounts.filter(
|
const filteredAccounts = cloudAccounts.filter(
|
||||||
acc => acc.provider === selectedProvider && acc.status === 'active'
|
acc => acc.provider === selectedProvider && acc.status === 'active'
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNext = () => {
|
// ==================== 区域同步逻辑 ====================
|
||||||
if (step < 3) {
|
const loadRegions = useCallback(async (accountId: string | number) => {
|
||||||
if (step === 2) {
|
try {
|
||||||
setIsLoading(true);
|
const res: any = await cloudApi.getAccountRegions(accountId);
|
||||||
// 云主机发现功能尚未实现,暂以空列表展示
|
return res?.list || [];
|
||||||
setTimeout(() => {
|
} catch {
|
||||||
setCloudServers([]);
|
return [];
|
||||||
setIsLoading(false);
|
}
|
||||||
}, 600);
|
}, []);
|
||||||
|
|
||||||
|
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);
|
setStep(step + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (step > 1) {
|
if (step > 1) setStep(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 =>
|
setSelectedServers(prev =>
|
||||||
prev.includes(serverId)
|
prev.includes(instanceId)
|
||||||
? prev.filter(id => id !== serverId)
|
? prev.filter(id => id !== instanceId)
|
||||||
: [...prev, serverId]
|
: [...prev, instanceId]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAllServers = () => {
|
||||||
if (selectedServers.length === cloudServers.length && cloudServers.length > 0) {
|
if (selectedServers.length === cloudServers.length && cloudServers.length > 0) {
|
||||||
setSelectedServers([]);
|
setSelectedServers([]);
|
||||||
} else {
|
} 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 = () => {
|
const handleClose = () => {
|
||||||
onClose();
|
onClose();
|
||||||
setStep(1);
|
setStep(1);
|
||||||
setSelectedProvider('');
|
setSelectedProvider('');
|
||||||
setSelectedAccount('');
|
setSelectedAccount(null);
|
||||||
|
setSelectedRegions([]);
|
||||||
|
setRegions([]);
|
||||||
|
setCloudServers([]);
|
||||||
setSelectedServers([]);
|
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 = [
|
const steps = [
|
||||||
{ id: 1, title: '公有云', icon: Cloud },
|
{ id: 1, title: '公有云', icon: Cloud },
|
||||||
{ id: 2, title: '访问凭据', icon: Key },
|
{ id: 2, title: '访问凭据', icon: Key },
|
||||||
{ id: 3, title: '导入确认', icon: CheckCircle },
|
{ id: 3, title: '选择区域', icon: MapPin },
|
||||||
|
{ id: 4, title: '导入确认', icon: CheckCircle },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,10 +223,10 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Step Indicator */}
|
{/* 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) => (
|
{steps.map((s, index) => (
|
||||||
<div key={s.id} className="flex items-center">
|
<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
|
step === s.id
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: step > s.id
|
: step > s.id
|
||||||
@@ -143,11 +234,11 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
|||||||
: 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]'
|
: 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]'
|
||||||
}`}>
|
}`}>
|
||||||
<s.icon className="h-4 w-4" />
|
<s.icon className="h-4 w-4" />
|
||||||
<span className="text-sm font-medium">{s.title}</span>
|
<span className="font-medium hidden sm:inline">{s.title}</span>
|
||||||
{step > s.id && <Check className="h-4 w-4 ml-1" />}
|
{step > s.id && <Check className="h-3 w-3 ml-1" />}
|
||||||
</div>
|
</div>
|
||||||
{index < steps.length - 1 && (
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -183,32 +274,27 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
|||||||
{/* Step 2: Select Account */}
|
{/* Step 2: Select Account */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-[var(--dg-text-secondary)] text-sm">请选择访问凭据(AccessKey)</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
{filteredAccounts.length > 0 ? (
|
{filteredAccounts.length > 0 ? (
|
||||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
{filteredAccounts.map((account) => (
|
{filteredAccounts.map((account) => (
|
||||||
<Card
|
<Card
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className={`cursor-pointer transition-all ${
|
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-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)]'
|
: '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">
|
<CardContent className="p-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Key className="h-5 w-5 text-blue-400" />
|
<Key className="h-5 w-5 text-blue-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-[var(--dg-text-primary)]">{account.name}</p>
|
<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>
|
</div>
|
||||||
<div className="text-right">
|
<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)]">
|
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||||
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||||
<p>该云平台暂无有效的访问凭据</p>
|
<p>该云平台暂无有效的访问凭据</p>
|
||||||
<Button variant="outline" className="mt-3 border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
|
||||||
添加访问凭据
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Import Confirmation */}
|
{/* Step 3: Select Regions (NEW) */}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[var(--dg-text-secondary)] text-sm">请选择要导入的云主机</p>
|
<p className="text-[var(--dg-text-secondary)] text-sm">
|
||||||
<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}>
|
{regions.length > 0 && (
|
||||||
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? '取消全选' : '全选'}
|
<span className="ml-2 text-[var(--dg-text-muted)]">(共 {regions.length} 个区域,已选 {selectedRegions.length} 个)</span>
|
||||||
</Button>
|
)}
|
||||||
<Badge className="bg-blue-500/20 text-blue-400 border-0">
|
</p>
|
||||||
已选 {selectedServers.length} 台
|
<Button
|
||||||
</Badge>
|
variant="outline"
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isSyncingRegions ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin text-blue-400" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<div className="flex items-center gap-2">
|
||||||
<tr>
|
<div className="relative flex-1">
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)] w-10">
|
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||||||
<Checkbox
|
<Input
|
||||||
checked={selectedServers.length === cloudServers.length && cloudServers.length > 0}
|
placeholder="搜索区域名称或编码..."
|
||||||
onCheckedChange={handleSelectAll}
|
value={regionSearch}
|
||||||
/>
|
onChange={(e) => setRegionSearch(e.target.value)}
|
||||||
</th>
|
className="pl-9 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
|
||||||
<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>
|
</div>
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
|
<Button variant="outline" size="sm" onClick={handleSelectAllRegions} className="border-[var(--dg-border)]">
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域</th>
|
{selectedRegions.length === regions.length && regions.length > 0 ? '取消全选' : '全选'}
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">规格</th>
|
</Button>
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
|
</div>
|
||||||
</tr>
|
|
||||||
</thead>
|
{/* 区域列表 */}
|
||||||
<tbody className="divide-y divide-[var(--dg-border)]">
|
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[300px] overflow-y-auto">
|
||||||
{cloudServers.length === 0 ? (
|
<table className="w-full">
|
||||||
|
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-3 py-12 text-center">
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)] w-10">
|
||||||
<Server className="h-10 w-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
|
<Checkbox
|
||||||
<p className="text-[var(--dg-text-muted)]">暂无云主机</p>
|
checked={selectedRegions.length === regions.length && regions.length > 0}
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">云主机发现功能尚未实现,请手动添加服务器</p>
|
onCheckedChange={handleSelectAllRegions}
|
||||||
</td>
|
/>
|
||||||
|
</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>
|
</tr>
|
||||||
) : (
|
</thead>
|
||||||
cloudServers.map((server) => (
|
<tbody className="divide-y divide-[var(--dg-border)]">
|
||||||
|
{filteredRegions.map((region) => (
|
||||||
<tr
|
<tr
|
||||||
key={server.id}
|
key={region.id || region.code}
|
||||||
className={`hover:bg-[var(--dg-bg-hover)] ${selectedServers.includes(server.id) ? 'bg-blue-500/10' : ''}`}
|
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">
|
<td className="px-3 py-2">
|
||||||
<Checkbox
|
<Checkbox checked={selectedRegions.includes(region.code)} />
|
||||||
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>
|
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))
|
))}
|
||||||
)}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -322,7 +553,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<DialogFooter className="flex justify-between items-center">
|
<DialogFooter className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
{step > 1 && (
|
{step > 1 && !importResult && (
|
||||||
<Button variant="outline" onClick={handleBack} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
<Button variant="outline" onClick={handleBack} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
上一步
|
上一步
|
||||||
@@ -330,27 +561,48 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
{importResult ? (
|
||||||
取消
|
<Button onClick={handleClose} className="bg-blue-600 hover:bg-blue-700">
|
||||||
</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" />
|
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<>
|
||||||
onClick={handleImport}
|
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||||
disabled={selectedServers.length === 0}
|
取消
|
||||||
className="bg-emerald-600 hover:bg-emerald-700"
|
</Button>
|
||||||
>
|
{step < 4 ? (
|
||||||
<Check className="h-4 w-4 mr-1" />
|
<Button
|
||||||
确认导入 ({selectedServers.length})
|
onClick={handleNext}
|
||||||
</Button>
|
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>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export function TerminalDialog({
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
// 挂载终端到容器
|
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
term.open(containerRef.current);
|
term.open(containerRef.current);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -107,22 +106,7 @@ export function TerminalDialog({
|
|||||||
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
try {
|
try {
|
||||||
ws.close();
|
ws.close();
|
||||||
term.dispose();
|
term.dispose();
|
||||||
@@ -133,24 +117,51 @@ export function TerminalDialog({
|
|||||||
};
|
};
|
||||||
}, [open, serverId]);
|
}, [open, serverId]);
|
||||||
|
|
||||||
// fullscreen 切换时重新 fit
|
// 用 ResizeObserver 监听容器实际尺寸变化,自动 fit 终端
|
||||||
|
// 这比 window resize 或 setTimeout 更可靠:
|
||||||
|
// - 全屏/窗口切换时容器尺寸在 CSS 过渡期间持续变化,ResizeObserver 每次都会触发
|
||||||
|
// - 不需要猜测延迟时间
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && fitRef.current) {
|
if (!open) return;
|
||||||
requestAnimationFrame(() => {
|
const container = containerRef.current;
|
||||||
try {
|
if (!container) return;
|
||||||
fitRef.current?.fit();
|
|
||||||
termRef.current?.focus();
|
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||||
} catch { /* noop */ }
|
const ro = new ResizeObserver(() => {
|
||||||
});
|
// 防抖:CSS 过渡期间会连续触发多次,合并为最后一次
|
||||||
}
|
clearTimeout(resizeTimer);
|
||||||
}, [fullscreen, open]);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
'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' }}
|
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -390,6 +390,23 @@ export const cloudApi = {
|
|||||||
deleteAccount: async (id: number | string) => {
|
deleteAccount: async (id: number | string) => {
|
||||||
return http.delete(`/cloud-accounts/${id}`);
|
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 }) => {
|
getDomains: async (params?: { page?: number; page_size?: number; keyword?: string }) => {
|
||||||
return http.get<ListResponse>('/domains', params);
|
return http.get<ListResponse>('/domains', params);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user