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
|
||||
.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
@@ -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
|
||||
|
||||
@@ -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.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)
|
||||
}
|
||||
|
||||
// 域名路由
|
||||
|
||||
@@ -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 自动迁移数据库表
|
||||
// 逐表迁移:单张表的迁移错误(如索引名冲突)不会阻塞其他表,
|
||||
// 避免 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
|
||||
}
|
||||
@@ -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 { 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' }}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user