Browse Source

feat: ionet integrate (#2105)

* wip ionet integrate

* wip ionet integrate

* wip ionet integrate

* ollama wip

* wip

* feat: ionet integration & ollama manage

* fix merge conflict

* wip

* fix: test conn cors

* wip

* fix ionet

* fix ionet

* wip

* fix model select

* refactor: Remove `pkg/ionet` test files and update related Go source and web UI model deployment components.

* feat: Enhance model deployment UI with styling improvements, updated text, and a new description component.

* Revert "feat: Enhance model deployment UI with styling improvements, updated text, and a new description component."

This reverts commit 8b75cb5bf0d1a534b339df8c033be9a6c7df7964.
Seefs 1 week ago
parent
commit
725d61c5d3
51 changed files with 11894 additions and 368 deletions
  1. 2 1
      .dockerignore
  2. 3 1
      .gitignore
  3. 342 8
      controller/channel.go
  4. 781 0
      controller/deployment.go
  5. 7 0
      docs/ionet-client.md
  6. 4 0
      go.mod
  7. 41 41
      model/main.go
  8. 219 0
      pkg/ionet/client.go
  9. 302 0
      pkg/ionet/container.go
  10. 377 0
      pkg/ionet/deployment.go
  11. 202 0
      pkg/ionet/hardware.go
  12. 96 0
      pkg/ionet/jsonutil.go
  13. 353 0
      pkg/ionet/types.go
  14. 37 0
      relay/channel/ollama/dto.go
  15. 245 0
      relay/channel/ollama/relay-ollama.go
  16. 44 0
      router/api-router.go
  17. 9 0
      web/src/App.jsx
  18. 7 0
      web/src/components/layout/SiderBar.jsx
  19. 5 18
      web/src/components/layout/components/SkeletonWrapper.jsx
  20. 377 0
      web/src/components/model-deployments/DeploymentAccessGuard.jsx
  21. 85 0
      web/src/components/settings/ModelDeploymentSetting.jsx
  22. 68 8
      web/src/components/table/channels/ChannelsColumnDefs.jsx
  23. 3 0
      web/src/components/table/channels/ChannelsTable.jsx
  24. 394 287
      web/src/components/table/channels/modals/EditChannelModal.jsx
  25. 16 3
      web/src/components/table/channels/modals/ModelSelectModal.jsx
  26. 806 0
      web/src/components/table/channels/modals/OllamaModelModal.jsx
  27. 109 0
      web/src/components/table/model-deployments/DeploymentsActions.jsx
  28. 672 0
      web/src/components/table/model-deployments/DeploymentsColumnDefs.jsx
  29. 130 0
      web/src/components/table/model-deployments/DeploymentsFilters.jsx
  30. 247 0
      web/src/components/table/model-deployments/DeploymentsTable.jsx
  31. 147 0
      web/src/components/table/model-deployments/index.jsx
  32. 127 0
      web/src/components/table/model-deployments/modals/ColumnSelectorModal.jsx
  33. 99 0
      web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx
  34. 1462 0
      web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx
  35. 241 0
      web/src/components/table/model-deployments/modals/EditDeploymentModal.jsx
  36. 548 0
      web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx
  37. 475 0
      web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx
  38. 517 0
      web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx
  39. 660 0
      web/src/components/table/model-deployments/modals/ViewLogsModal.jsx
  40. 3 0
      web/src/helpers/render.jsx
  41. 63 1
      web/src/hooks/channels/useChannelsData.jsx
  42. 1 0
      web/src/hooks/common/useSidebar.js
  43. 266 0
      web/src/hooks/model-deployments/useDeploymentResources.js
  44. 507 0
      web/src/hooks/model-deployments/useDeploymentsData.jsx
  45. 249 0
      web/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx
  46. 143 0
      web/src/hooks/model-deployments/useModelDeploymentSettings.js
  47. 52 0
      web/src/pages/ModelDeployment/index.jsx
  48. 334 0
      web/src/pages/Setting/Model/SettingModelDeployment.jsx
  49. 4 0
      web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
  50. 1 0
      web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx
  51. 12 0
      web/src/pages/Setting/index.jsx

+ 2 - 1
.dockerignore

@@ -6,4 +6,5 @@
 Makefile
 docs
 .eslintcache
-.gocache
+.gocache
+/web/node_modules

+ 3 - 1
.gitignore

@@ -23,4 +23,6 @@ web/bun.lock
 electron/node_modules
 electron/dist
 data/
-.gomodcache/
+.gomodcache/
+.gocache-temp
+.gopath

+ 342 - 8
controller/channel.go

@@ -11,16 +11,18 @@ import (
 	"github.com/QuantumNous/new-api/constant"
 	"github.com/QuantumNous/new-api/dto"
 	"github.com/QuantumNous/new-api/model"
+	"github.com/QuantumNous/new-api/relay/channel/ollama"
 	"github.com/QuantumNous/new-api/service"
 
 	"github.com/gin-gonic/gin"
 )
 
 type OpenAIModel struct {
-	ID         string `json:"id"`
-	Object     string `json:"object"`
-	Created    int64  `json:"created"`
-	OwnedBy    string `json:"owned_by"`
+	ID         string         `json:"id"`
+	Object     string         `json:"object"`
+	Created    int64          `json:"created"`
+	OwnedBy    string         `json:"owned_by"`
+	Metadata   map[string]any `json:"metadata,omitempty"`
 	Permission []struct {
 		ID                 string `json:"id"`
 		Object             string `json:"object"`
@@ -207,6 +209,57 @@ func FetchUpstreamModels(c *gin.Context) {
 		baseURL = channel.GetBaseURL()
 	}
 
+	// 对于 Ollama 渠道,使用特殊处理
+	if channel.Type == constant.ChannelTypeOllama {
+		key := strings.Split(channel.Key, "\n")[0]
+		models, err := ollama.FetchOllamaModels(baseURL, key)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()),
+			})
+			return
+		}
+
+		result := OpenAIModelsResponse{
+			Data: make([]OpenAIModel, 0, len(models)),
+		}
+
+		for _, modelInfo := range models {
+			metadata := map[string]any{}
+			if modelInfo.Size > 0 {
+				metadata["size"] = modelInfo.Size
+			}
+			if modelInfo.Digest != "" {
+				metadata["digest"] = modelInfo.Digest
+			}
+			if modelInfo.ModifiedAt != "" {
+				metadata["modified_at"] = modelInfo.ModifiedAt
+			}
+			details := modelInfo.Details
+			if details.ParentModel != "" || details.Format != "" || details.Family != "" || len(details.Families) > 0 || details.ParameterSize != "" || details.QuantizationLevel != "" {
+				metadata["details"] = modelInfo.Details
+			}
+			if len(metadata) == 0 {
+				metadata = nil
+			}
+
+			result.Data = append(result.Data, OpenAIModel{
+				ID:       modelInfo.Name,
+				Object:   "model",
+				Created:  0,
+				OwnedBy:  "ollama",
+				Metadata: metadata,
+			})
+		}
+
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"data":    result.Data,
+		})
+		return
+	}
+
 	var url string
 	switch channel.Type {
 	case constant.ChannelTypeGemini:
@@ -975,6 +1028,32 @@ func FetchModels(c *gin.Context) {
 		baseURL = constant.ChannelBaseURLs[req.Type]
 	}
 
+	// remove line breaks and extra spaces.
+	key := strings.TrimSpace(req.Key)
+	key = strings.Split(key, "\n")[0]
+
+	if req.Type == constant.ChannelTypeOllama {
+		models, err := ollama.FetchOllamaModels(baseURL, key)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()),
+			})
+			return
+		}
+
+		names := make([]string, 0, len(models))
+		for _, modelInfo := range models {
+			names = append(names, modelInfo.Name)
+		}
+
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"data":    names,
+		})
+		return
+	}
+
 	client := &http.Client{}
 	url := fmt.Sprintf("%s/v1/models", baseURL)
 
@@ -987,10 +1066,6 @@ func FetchModels(c *gin.Context) {
 		return
 	}
 
-	// remove line breaks and extra spaces.
-	key := strings.TrimSpace(req.Key)
-	// If the key contains a line break, only take the first part.
-	key = strings.Split(key, "\n")[0]
 	request.Header.Set("Authorization", "Bearer "+key)
 
 	response, err := client.Do(request)
@@ -1640,3 +1715,262 @@ func ManageMultiKeys(c *gin.Context) {
 		return
 	}
 }
+
+// OllamaPullModel 拉取 Ollama 模型
+func OllamaPullModel(c *gin.Context) {
+	var req struct {
+		ChannelID int    `json:"channel_id"`
+		ModelName string `json:"model_name"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "Invalid request parameters",
+		})
+		return
+	}
+
+	if req.ChannelID == 0 || req.ModelName == "" {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "Channel ID and model name are required",
+		})
+		return
+	}
+
+	// 获取渠道信息
+	channel, err := model.GetChannelById(req.ChannelID, true)
+	if err != nil {
+		c.JSON(http.StatusNotFound, gin.H{
+			"success": false,
+			"message": "Channel not found",
+		})
+		return
+	}
+
+	// 检查是否是 Ollama 渠道
+	if channel.Type != constant.ChannelTypeOllama {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "This operation is only supported for Ollama channels",
+		})
+		return
+	}
+
+	baseURL := constant.ChannelBaseURLs[channel.Type]
+	if channel.GetBaseURL() != "" {
+		baseURL = channel.GetBaseURL()
+	}
+
+	key := strings.Split(channel.Key, "\n")[0]
+	err = ollama.PullOllamaModel(baseURL, key, req.ModelName)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"success": false,
+			"message": fmt.Sprintf("Failed to pull model: %s", err.Error()),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": fmt.Sprintf("Model %s pulled successfully", req.ModelName),
+	})
+}
+
+// OllamaPullModelStream 流式拉取 Ollama 模型
+func OllamaPullModelStream(c *gin.Context) {
+	var req struct {
+		ChannelID int    `json:"channel_id"`
+		ModelName string `json:"model_name"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "Invalid request parameters",
+		})
+		return
+	}
+
+	if req.ChannelID == 0 || req.ModelName == "" {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "Channel ID and model name are required",
+		})
+		return
+	}
+
+	// 获取渠道信息
+	channel, err := model.GetChannelById(req.ChannelID, true)
+	if err != nil {
+		c.JSON(http.StatusNotFound, gin.H{
+			"success": false,
+			"message": "Channel not found",
+		})
+		return
+	}
+
+	// 检查是否是 Ollama 渠道
+	if channel.Type != constant.ChannelTypeOllama {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "This operation is only supported for Ollama channels",
+		})
+		return
+	}
+
+	baseURL := constant.ChannelBaseURLs[channel.Type]
+	if channel.GetBaseURL() != "" {
+		baseURL = channel.GetBaseURL()
+	}
+
+	// 设置 SSE 头部
+	c.Header("Content-Type", "text/event-stream")
+	c.Header("Cache-Control", "no-cache")
+	c.Header("Connection", "keep-alive")
+	c.Header("Access-Control-Allow-Origin", "*")
+
+	key := strings.Split(channel.Key, "\n")[0]
+
+	// 创建进度回调函数
+	progressCallback := func(progress ollama.OllamaPullResponse) {
+		data, _ := json.Marshal(progress)
+		fmt.Fprintf(c.Writer, "data: %s\n\n", string(data))
+		c.Writer.Flush()
+	}
+
+	// 执行拉取
+	err = ollama.PullOllamaModelStream(baseURL, key, req.ModelName, progressCallback)
+
+	if err != nil {
+		errorData, _ := json.Marshal(gin.H{
+			"error": err.Error(),
+		})
+		fmt.Fprintf(c.Writer, "data: %s\n\n", string(errorData))
+	} else {
+		successData, _ := json.Marshal(gin.H{
+			"message": fmt.Sprintf("Model %s pulled successfully", req.ModelName),
+		})
+		fmt.Fprintf(c.Writer, "data: %s\n\n", string(successData))
+	}
+
+	// 发送结束标志
+	fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
+	c.Writer.Flush()
+}
+
+// OllamaDeleteModel 删除 Ollama 模型
+func OllamaDeleteModel(c *gin.Context) {
+	var req struct {
+		ChannelID int    `json:"channel_id"`
+		ModelName string `json:"model_name"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "Invalid request parameters",
+		})
+		return
+	}
+
+	if req.ChannelID == 0 || req.ModelName == "" {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "Channel ID and model name are required",
+		})
+		return
+	}
+
+	// 获取渠道信息
+	channel, err := model.GetChannelById(req.ChannelID, true)
+	if err != nil {
+		c.JSON(http.StatusNotFound, gin.H{
+			"success": false,
+			"message": "Channel not found",
+		})
+		return
+	}
+
+	// 检查是否是 Ollama 渠道
+	if channel.Type != constant.ChannelTypeOllama {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "This operation is only supported for Ollama channels",
+		})
+		return
+	}
+
+	baseURL := constant.ChannelBaseURLs[channel.Type]
+	if channel.GetBaseURL() != "" {
+		baseURL = channel.GetBaseURL()
+	}
+
+	key := strings.Split(channel.Key, "\n")[0]
+	err = ollama.DeleteOllamaModel(baseURL, key, req.ModelName)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"success": false,
+			"message": fmt.Sprintf("Failed to delete model: %s", err.Error()),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": fmt.Sprintf("Model %s deleted successfully", req.ModelName),
+	})
+}
+
+// OllamaVersion 获取 Ollama 服务版本信息
+func OllamaVersion(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "Invalid channel id",
+		})
+		return
+	}
+
+	channel, err := model.GetChannelById(id, true)
+	if err != nil {
+		c.JSON(http.StatusNotFound, gin.H{
+			"success": false,
+			"message": "Channel not found",
+		})
+		return
+	}
+
+	if channel.Type != constant.ChannelTypeOllama {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"success": false,
+			"message": "This operation is only supported for Ollama channels",
+		})
+		return
+	}
+
+	baseURL := constant.ChannelBaseURLs[channel.Type]
+	if channel.GetBaseURL() != "" {
+		baseURL = channel.GetBaseURL()
+	}
+
+	key := strings.Split(channel.Key, "\n")[0]
+	version, err := ollama.FetchOllamaVersion(baseURL, key)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": fmt.Sprintf("获取Ollama版本失败: %s", err.Error()),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data": gin.H{
+			"version": version,
+		},
+	})
+}

+ 781 - 0
controller/deployment.go

@@ -0,0 +1,781 @@
+package controller
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/QuantumNous/new-api/common"
+	"github.com/QuantumNous/new-api/pkg/ionet"
+	"github.com/gin-gonic/gin"
+)
+
+func getIoAPIKey(c *gin.Context) (string, bool) {
+	common.OptionMapRWMutex.RLock()
+	enabled := common.OptionMap["model_deployment.ionet.enabled"] == "true"
+	apiKey := common.OptionMap["model_deployment.ionet.api_key"]
+	common.OptionMapRWMutex.RUnlock()
+	if !enabled || strings.TrimSpace(apiKey) == "" {
+		common.ApiErrorMsg(c, "io.net model deployment is not enabled or api key missing")
+		return "", false
+	}
+	return apiKey, true
+}
+
+func getIoClient(c *gin.Context) (*ionet.Client, bool) {
+	apiKey, ok := getIoAPIKey(c)
+	if !ok {
+		return nil, false
+	}
+	return ionet.NewClient(apiKey), true
+}
+
+func getIoEnterpriseClient(c *gin.Context) (*ionet.Client, bool) {
+	apiKey, ok := getIoAPIKey(c)
+	if !ok {
+		return nil, false
+	}
+	return ionet.NewEnterpriseClient(apiKey), true
+}
+
+func TestIoNetConnection(c *gin.Context) {
+	var req struct {
+		APIKey string `json:"api_key"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiErrorMsg(c, "invalid request payload")
+		return
+	}
+
+	apiKey := strings.TrimSpace(req.APIKey)
+	if apiKey == "" {
+		common.ApiErrorMsg(c, "api_key is required")
+		return
+	}
+
+	client := ionet.NewEnterpriseClient(apiKey)
+	result, err := client.GetMaxGPUsPerContainer()
+	if err != nil {
+		if apiErr, ok := err.(*ionet.APIError); ok {
+			message := strings.TrimSpace(apiErr.Message)
+			if message == "" {
+				message = "failed to validate api key"
+			}
+			common.ApiErrorMsg(c, message)
+			return
+		}
+		common.ApiError(c, err)
+		return
+	}
+
+	totalHardware := 0
+	totalAvailable := 0
+	if result != nil {
+		totalHardware = len(result.Hardware)
+		totalAvailable = result.Total
+		if totalAvailable == 0 {
+			for _, hw := range result.Hardware {
+				totalAvailable += hw.Available
+			}
+		}
+	}
+
+	common.ApiSuccess(c, gin.H{
+		"hardware_count":  totalHardware,
+		"total_available": totalAvailable,
+	})
+}
+
+func requireDeploymentID(c *gin.Context) (string, bool) {
+	deploymentID := strings.TrimSpace(c.Param("id"))
+	if deploymentID == "" {
+		common.ApiErrorMsg(c, "deployment ID is required")
+		return "", false
+	}
+	return deploymentID, true
+}
+
+func requireContainerID(c *gin.Context) (string, bool) {
+	containerID := strings.TrimSpace(c.Param("container_id"))
+	if containerID == "" {
+		common.ApiErrorMsg(c, "container ID is required")
+		return "", false
+	}
+	return containerID, true
+}
+
+func mapIoNetDeployment(d ionet.Deployment) map[string]interface{} {
+	var created int64
+	if d.CreatedAt.IsZero() {
+		created = time.Now().Unix()
+	} else {
+		created = d.CreatedAt.Unix()
+	}
+
+	timeRemainingHours := d.ComputeMinutesRemaining / 60
+	timeRemainingMins := d.ComputeMinutesRemaining % 60
+	var timeRemaining string
+	if timeRemainingHours > 0 {
+		timeRemaining = fmt.Sprintf("%d hour %d minutes", timeRemainingHours, timeRemainingMins)
+	} else if timeRemainingMins > 0 {
+		timeRemaining = fmt.Sprintf("%d minutes", timeRemainingMins)
+	} else {
+		timeRemaining = "completed"
+	}
+
+	hardwareInfo := fmt.Sprintf("%s %s x%d", d.BrandName, d.HardwareName, d.HardwareQuantity)
+
+	return map[string]interface{}{
+		"id":                        d.ID,
+		"deployment_name":           d.Name,
+		"container_name":            d.Name,
+		"status":                    strings.ToLower(d.Status),
+		"type":                      "Container",
+		"time_remaining":            timeRemaining,
+		"time_remaining_minutes":    d.ComputeMinutesRemaining,
+		"hardware_info":             hardwareInfo,
+		"hardware_name":             d.HardwareName,
+		"brand_name":                d.BrandName,
+		"hardware_quantity":         d.HardwareQuantity,
+		"completed_percent":         d.CompletedPercent,
+		"compute_minutes_served":    d.ComputeMinutesServed,
+		"compute_minutes_remaining": d.ComputeMinutesRemaining,
+		"created_at":                created,
+		"updated_at":                created,
+		"model_name":                "",
+		"model_version":             "",
+		"instance_count":            d.HardwareQuantity,
+		"resource_config": map[string]interface{}{
+			"cpu":    "",
+			"memory": "",
+			"gpu":    strconv.Itoa(d.HardwareQuantity),
+		},
+		"description": "",
+		"provider":    "io.net",
+	}
+}
+
+func computeStatusCounts(total int, deployments []ionet.Deployment) map[string]int64 {
+	counts := map[string]int64{
+		"all": int64(total),
+	}
+
+	for _, status := range []string{"running", "completed", "failed", "deployment requested", "termination requested", "destroyed"} {
+		counts[status] = 0
+	}
+
+	for _, d := range deployments {
+		status := strings.ToLower(strings.TrimSpace(d.Status))
+		counts[status] = counts[status] + 1
+	}
+
+	return counts
+}
+
+func GetAllDeployments(c *gin.Context) {
+	pageInfo := common.GetPageQuery(c)
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	status := c.Query("status")
+	opts := &ionet.ListDeploymentsOptions{
+		Status:    strings.ToLower(strings.TrimSpace(status)),
+		Page:      pageInfo.GetPage(),
+		PageSize:  pageInfo.GetPageSize(),
+		SortBy:    "created_at",
+		SortOrder: "desc",
+	}
+
+	dl, err := client.ListDeployments(opts)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	items := make([]map[string]interface{}, 0, len(dl.Deployments))
+	for _, d := range dl.Deployments {
+		items = append(items, mapIoNetDeployment(d))
+	}
+
+	data := gin.H{
+		"page":          pageInfo.GetPage(),
+		"page_size":     pageInfo.GetPageSize(),
+		"total":         dl.Total,
+		"items":         items,
+		"status_counts": computeStatusCounts(dl.Total, dl.Deployments),
+	}
+	common.ApiSuccess(c, data)
+}
+
+func SearchDeployments(c *gin.Context) {
+	pageInfo := common.GetPageQuery(c)
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	status := strings.ToLower(strings.TrimSpace(c.Query("status")))
+	keyword := strings.TrimSpace(c.Query("keyword"))
+
+	dl, err := client.ListDeployments(&ionet.ListDeploymentsOptions{
+		Status:    status,
+		Page:      pageInfo.GetPage(),
+		PageSize:  pageInfo.GetPageSize(),
+		SortBy:    "created_at",
+		SortOrder: "desc",
+	})
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	filtered := make([]ionet.Deployment, 0, len(dl.Deployments))
+	if keyword == "" {
+		filtered = dl.Deployments
+	} else {
+		kw := strings.ToLower(keyword)
+		for _, d := range dl.Deployments {
+			if strings.Contains(strings.ToLower(d.Name), kw) {
+				filtered = append(filtered, d)
+			}
+		}
+	}
+
+	items := make([]map[string]interface{}, 0, len(filtered))
+	for _, d := range filtered {
+		items = append(items, mapIoNetDeployment(d))
+	}
+
+	total := dl.Total
+	if keyword != "" {
+		total = len(filtered)
+	}
+
+	data := gin.H{
+		"page":      pageInfo.GetPage(),
+		"page_size": pageInfo.GetPageSize(),
+		"total":     total,
+		"items":     items,
+	}
+	common.ApiSuccess(c, data)
+}
+
+func GetDeployment(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	deploymentID, ok := requireDeploymentID(c)
+	if !ok {
+		return
+	}
+
+	details, err := client.GetDeployment(deploymentID)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	data := map[string]interface{}{
+		"id":              details.ID,
+		"deployment_name": details.ID,
+		"model_name":      "",
+		"model_version":   "",
+		"status":          strings.ToLower(details.Status),
+		"instance_count":  details.TotalContainers,
+		"hardware_id":     details.HardwareID,
+		"resource_config": map[string]interface{}{
+			"cpu":    "",
+			"memory": "",
+			"gpu":    strconv.Itoa(details.TotalGPUs),
+		},
+		"created_at":                details.CreatedAt.Unix(),
+		"updated_at":                details.CreatedAt.Unix(),
+		"description":               "",
+		"amount_paid":               details.AmountPaid,
+		"completed_percent":         details.CompletedPercent,
+		"gpus_per_container":        details.GPUsPerContainer,
+		"total_gpus":                details.TotalGPUs,
+		"total_containers":          details.TotalContainers,
+		"hardware_name":             details.HardwareName,
+		"brand_name":                details.BrandName,
+		"compute_minutes_served":    details.ComputeMinutesServed,
+		"compute_minutes_remaining": details.ComputeMinutesRemaining,
+		"locations":                 details.Locations,
+		"container_config":          details.ContainerConfig,
+	}
+
+	common.ApiSuccess(c, data)
+}
+
+func UpdateDeploymentName(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	deploymentID, ok := requireDeploymentID(c)
+	if !ok {
+		return
+	}
+
+	var req struct {
+		Name string `json:"name" binding:"required"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	updateReq := &ionet.UpdateClusterNameRequest{
+		Name: strings.TrimSpace(req.Name),
+	}
+
+	if updateReq.Name == "" {
+		common.ApiErrorMsg(c, "deployment name cannot be empty")
+		return
+	}
+
+	available, err := client.CheckClusterNameAvailability(updateReq.Name)
+	if err != nil {
+		common.ApiError(c, fmt.Errorf("failed to check name availability: %w", err))
+		return
+	}
+
+	if !available {
+		common.ApiErrorMsg(c, "deployment name is not available, please choose a different name")
+		return
+	}
+
+	resp, err := client.UpdateClusterName(deploymentID, updateReq)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	data := gin.H{
+		"status":  resp.Status,
+		"message": resp.Message,
+		"id":      deploymentID,
+		"name":    updateReq.Name,
+	}
+	common.ApiSuccess(c, data)
+}
+
+func UpdateDeployment(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	deploymentID, ok := requireDeploymentID(c)
+	if !ok {
+		return
+	}
+
+	var req ionet.UpdateDeploymentRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	resp, err := client.UpdateDeployment(deploymentID, &req)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	data := gin.H{
+		"status":        resp.Status,
+		"deployment_id": resp.DeploymentID,
+	}
+	common.ApiSuccess(c, data)
+}
+
+func ExtendDeployment(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	deploymentID, ok := requireDeploymentID(c)
+	if !ok {
+		return
+	}
+
+	var req ionet.ExtendDurationRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	details, err := client.ExtendDeployment(deploymentID, &req)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	data := mapIoNetDeployment(ionet.Deployment{
+		ID:                      details.ID,
+		Status:                  details.Status,
+		Name:                    deploymentID,
+		CompletedPercent:        float64(details.CompletedPercent),
+		HardwareQuantity:        details.TotalGPUs,
+		BrandName:               details.BrandName,
+		HardwareName:            details.HardwareName,
+		ComputeMinutesServed:    details.ComputeMinutesServed,
+		ComputeMinutesRemaining: details.ComputeMinutesRemaining,
+		CreatedAt:               details.CreatedAt,
+	})
+
+	common.ApiSuccess(c, data)
+}
+
+func DeleteDeployment(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	deploymentID, ok := requireDeploymentID(c)
+	if !ok {
+		return
+	}
+
+	resp, err := client.DeleteDeployment(deploymentID)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	data := gin.H{
+		"status":        resp.Status,
+		"deployment_id": resp.DeploymentID,
+		"message":       "Deployment termination requested successfully",
+	}
+	common.ApiSuccess(c, data)
+}
+
+func CreateDeployment(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	var req ionet.DeploymentRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	resp, err := client.DeployContainer(&req)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	data := gin.H{
+		"deployment_id": resp.DeploymentID,
+		"status":        resp.Status,
+		"message":       "Deployment created successfully",
+	}
+	common.ApiSuccess(c, data)
+}
+
+func GetHardwareTypes(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	hardwareTypes, totalAvailable, err := client.ListHardwareTypes()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	data := gin.H{
+		"hardware_types":  hardwareTypes,
+		"total":           len(hardwareTypes),
+		"total_available": totalAvailable,
+	}
+	common.ApiSuccess(c, data)
+}
+
+func GetLocations(c *gin.Context) {
+	client, ok := getIoClient(c)
+	if !ok {
+		return
+	}
+
+	locationsResp, err := client.ListLocations()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	total := locationsResp.Total
+	if total == 0 {
+		total = len(locationsResp.Locations)
+	}
+
+	data := gin.H{
+		"locations": locationsResp.Locations,
+		"total":     total,
+	}
+	common.ApiSuccess(c, data)
+}
+
+func GetAvailableReplicas(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	hardwareIDStr := c.Query("hardware_id")
+	gpuCountStr := c.Query("gpu_count")
+
+	if hardwareIDStr == "" {
+		common.ApiErrorMsg(c, "hardware_id parameter is required")
+		return
+	}
+
+	hardwareID, err := strconv.Atoi(hardwareIDStr)
+	if err != nil || hardwareID <= 0 {
+		common.ApiErrorMsg(c, "invalid hardware_id parameter")
+		return
+	}
+
+	gpuCount := 1
+	if gpuCountStr != "" {
+		if parsed, err := strconv.Atoi(gpuCountStr); err == nil && parsed > 0 {
+			gpuCount = parsed
+		}
+	}
+
+	replicas, err := client.GetAvailableReplicas(hardwareID, gpuCount)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	common.ApiSuccess(c, replicas)
+}
+
+func GetPriceEstimation(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	var req ionet.PriceEstimationRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	priceResp, err := client.GetPriceEstimation(&req)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	common.ApiSuccess(c, priceResp)
+}
+
+func CheckClusterNameAvailability(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	clusterName := strings.TrimSpace(c.Query("name"))
+	if clusterName == "" {
+		common.ApiErrorMsg(c, "name parameter is required")
+		return
+	}
+
+	available, err := client.CheckClusterNameAvailability(clusterName)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	data := gin.H{
+		"available": available,
+		"name":      clusterName,
+	}
+	common.ApiSuccess(c, data)
+}
+
+func GetDeploymentLogs(c *gin.Context) {
+	client, ok := getIoClient(c)
+	if !ok {
+		return
+	}
+
+	deploymentID, ok := requireDeploymentID(c)
+	if !ok {
+		return
+	}
+
+	containerID := c.Query("container_id")
+	if containerID == "" {
+		common.ApiErrorMsg(c, "container_id parameter is required")
+		return
+	}
+	level := c.Query("level")
+	stream := c.Query("stream")
+	cursor := c.Query("cursor")
+	limitStr := c.Query("limit")
+	follow := c.Query("follow") == "true"
+
+	var limit int = 100
+	if limitStr != "" {
+		if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
+			limit = parsedLimit
+			if limit > 1000 {
+				limit = 1000
+			}
+		}
+	}
+
+	opts := &ionet.GetLogsOptions{
+		Level:  level,
+		Stream: stream,
+		Limit:  limit,
+		Cursor: cursor,
+		Follow: follow,
+	}
+
+	if startTime := c.Query("start_time"); startTime != "" {
+		if t, err := time.Parse(time.RFC3339, startTime); err == nil {
+			opts.StartTime = &t
+		}
+	}
+	if endTime := c.Query("end_time"); endTime != "" {
+		if t, err := time.Parse(time.RFC3339, endTime); err == nil {
+			opts.EndTime = &t
+		}
+	}
+
+	rawLogs, err := client.GetContainerLogsRaw(deploymentID, containerID, opts)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	common.ApiSuccess(c, rawLogs)
+}
+
+func ListDeploymentContainers(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	deploymentID, ok := requireDeploymentID(c)
+	if !ok {
+		return
+	}
+
+	containers, err := client.ListContainers(deploymentID)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	items := make([]map[string]interface{}, 0)
+	if containers != nil {
+		items = make([]map[string]interface{}, 0, len(containers.Workers))
+		for _, ctr := range containers.Workers {
+			events := make([]map[string]interface{}, 0, len(ctr.ContainerEvents))
+			for _, event := range ctr.ContainerEvents {
+				events = append(events, map[string]interface{}{
+					"time":    event.Time.Unix(),
+					"message": event.Message,
+				})
+			}
+
+			items = append(items, map[string]interface{}{
+				"container_id":       ctr.ContainerID,
+				"device_id":          ctr.DeviceID,
+				"status":             strings.ToLower(strings.TrimSpace(ctr.Status)),
+				"hardware":           ctr.Hardware,
+				"brand_name":         ctr.BrandName,
+				"created_at":         ctr.CreatedAt.Unix(),
+				"uptime_percent":     ctr.UptimePercent,
+				"gpus_per_container": ctr.GPUsPerContainer,
+				"public_url":         ctr.PublicURL,
+				"events":             events,
+			})
+		}
+	}
+
+	response := gin.H{
+		"total":      0,
+		"containers": items,
+	}
+	if containers != nil {
+		response["total"] = containers.Total
+	}
+
+	common.ApiSuccess(c, response)
+}
+
+func GetContainerDetails(c *gin.Context) {
+	client, ok := getIoEnterpriseClient(c)
+	if !ok {
+		return
+	}
+
+	deploymentID, ok := requireDeploymentID(c)
+	if !ok {
+		return
+	}
+
+	containerID, ok := requireContainerID(c)
+	if !ok {
+		return
+	}
+
+	details, err := client.GetContainerDetails(deploymentID, containerID)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if details == nil {
+		common.ApiErrorMsg(c, "container details not found")
+		return
+	}
+
+	events := make([]map[string]interface{}, 0, len(details.ContainerEvents))
+	for _, event := range details.ContainerEvents {
+		events = append(events, map[string]interface{}{
+			"time":    event.Time.Unix(),
+			"message": event.Message,
+		})
+	}
+
+	data := gin.H{
+		"deployment_id":      deploymentID,
+		"container_id":       details.ContainerID,
+		"device_id":          details.DeviceID,
+		"status":             strings.ToLower(strings.TrimSpace(details.Status)),
+		"hardware":           details.Hardware,
+		"brand_name":         details.BrandName,
+		"created_at":         details.CreatedAt.Unix(),
+		"uptime_percent":     details.UptimePercent,
+		"gpus_per_container": details.GPUsPerContainer,
+		"public_url":         details.PublicURL,
+		"events":             events,
+	}
+
+	common.ApiSuccess(c, data)
+}

+ 7 - 0
docs/ionet-client.md

@@ -0,0 +1,7 @@
+Request URL
+https://api.io.solutions/v1/io-cloud/clusters/654fc0a9-0d4a-4db4-9b95-3f56189348a2/update-name
+Request Method
+PUT
+
+{"status":"succeeded","message":"Cluster name updated successfully"}
+

+ 4 - 0
go.mod

@@ -37,6 +37,7 @@ require (
 	github.com/samber/lo v1.52.0
 	github.com/shirou/gopsutil v3.21.11+incompatible
 	github.com/shopspring/decimal v1.4.0
+	github.com/stretchr/testify v1.11.1
 	github.com/stripe/stripe-go/v81 v81.4.0
 	github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
 	github.com/thanhpk/randstr v1.0.6
@@ -63,6 +64,7 @@ require (
 	github.com/bytedance/sonic/loader v0.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cloudwego/base64x v0.1.6 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
@@ -103,7 +105,9 @@ require (
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/ncruces/go-strftime v0.1.9 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.1 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/stretchr/objx v0.5.2 // indirect
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.0 // indirect
 	github.com/tklauser/go-sysconf v0.3.12 // indirect

+ 41 - 41
model/main.go

@@ -248,26 +248,26 @@ func InitLogDB() (err error) {
 }
 
 func migrateDB() error {
-	err := DB.AutoMigrate(
-		&Channel{},
-		&Token{},
-		&User{},
-		&PasskeyCredential{},
+    err := DB.AutoMigrate(
+        &Channel{},
+        &Token{},
+        &User{},
+        &PasskeyCredential{},
 		&Option{},
-		&Redemption{},
-		&Ability{},
-		&Log{},
-		&Midjourney{},
-		&TopUp{},
-		&QuotaData{},
-		&Task{},
-		&Model{},
-		&Vendor{},
-		&PrefillGroup{},
-		&Setup{},
-		&TwoFA{},
-		&TwoFABackupCode{},
-	)
+        &Redemption{},
+        &Ability{},
+        &Log{},
+        &Midjourney{},
+        &TopUp{},
+        &QuotaData{},
+        &Task{},
+        &Model{},
+        &Vendor{},
+        &PrefillGroup{},
+        &Setup{},
+        &TwoFA{},
+        &TwoFABackupCode{},
+    )
 	if err != nil {
 		return err
 	}
@@ -278,29 +278,29 @@ func migrateDBFast() error {
 
 	var wg sync.WaitGroup
 
-	migrations := []struct {
-		model interface{}
-		name  string
-	}{
-		{&Channel{}, "Channel"},
-		{&Token{}, "Token"},
-		{&User{}, "User"},
-		{&PasskeyCredential{}, "PasskeyCredential"},
+    migrations := []struct {
+        model interface{}
+        name  string
+    }{
+        {&Channel{}, "Channel"},
+        {&Token{}, "Token"},
+        {&User{}, "User"},
+        {&PasskeyCredential{}, "PasskeyCredential"},
 		{&Option{}, "Option"},
-		{&Redemption{}, "Redemption"},
-		{&Ability{}, "Ability"},
-		{&Log{}, "Log"},
-		{&Midjourney{}, "Midjourney"},
-		{&TopUp{}, "TopUp"},
-		{&QuotaData{}, "QuotaData"},
-		{&Task{}, "Task"},
-		{&Model{}, "Model"},
-		{&Vendor{}, "Vendor"},
-		{&PrefillGroup{}, "PrefillGroup"},
-		{&Setup{}, "Setup"},
-		{&TwoFA{}, "TwoFA"},
-		{&TwoFABackupCode{}, "TwoFABackupCode"},
-	}
+        {&Redemption{}, "Redemption"},
+        {&Ability{}, "Ability"},
+        {&Log{}, "Log"},
+        {&Midjourney{}, "Midjourney"},
+        {&TopUp{}, "TopUp"},
+        {&QuotaData{}, "QuotaData"},
+        {&Task{}, "Task"},
+        {&Model{}, "Model"},
+        {&Vendor{}, "Vendor"},
+        {&PrefillGroup{}, "PrefillGroup"},
+        {&Setup{}, "Setup"},
+        {&TwoFA{}, "TwoFA"},
+        {&TwoFABackupCode{}, "TwoFABackupCode"},
+    }
 	// 动态计算migration数量,确保errChan缓冲区足够大
 	errChan := make(chan error, len(migrations))
 

+ 219 - 0
pkg/ionet/client.go

@@ -0,0 +1,219 @@
+package ionet
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+)
+
+const (
+	DefaultEnterpriseBaseURL = "https://api.io.solutions/enterprise/v1/io-cloud/caas"
+	DefaultBaseURL           = "https://api.io.solutions/v1/io-cloud/caas"
+	DefaultTimeout           = 30 * time.Second
+)
+
+// DefaultHTTPClient is the default HTTP client implementation
+type DefaultHTTPClient struct {
+	client *http.Client
+}
+
+// NewDefaultHTTPClient creates a new default HTTP client
+func NewDefaultHTTPClient(timeout time.Duration) *DefaultHTTPClient {
+	return &DefaultHTTPClient{
+		client: &http.Client{
+			Timeout: timeout,
+		},
+	}
+}
+
+// Do executes an HTTP request
+func (c *DefaultHTTPClient) Do(req *HTTPRequest) (*HTTPResponse, error) {
+	httpReq, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(req.Body))
+	if err != nil {
+		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
+	}
+
+	// Set headers
+	for key, value := range req.Headers {
+		httpReq.Header.Set(key, value)
+	}
+
+	resp, err := c.client.Do(httpReq)
+	if err != nil {
+		return nil, fmt.Errorf("HTTP request failed: %w", err)
+	}
+	defer resp.Body.Close()
+
+	// Read response body
+	var body bytes.Buffer
+	_, err = body.ReadFrom(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read response body: %w", err)
+	}
+
+	// Convert headers
+	headers := make(map[string]string)
+	for key, values := range resp.Header {
+		if len(values) > 0 {
+			headers[key] = values[0]
+		}
+	}
+
+	return &HTTPResponse{
+		StatusCode: resp.StatusCode,
+		Headers:    headers,
+		Body:       body.Bytes(),
+	}, nil
+}
+
+// NewEnterpriseClient creates a new IO.NET API client targeting the enterprise API base URL.
+func NewEnterpriseClient(apiKey string) *Client {
+	return NewClientWithConfig(apiKey, DefaultEnterpriseBaseURL, nil)
+}
+
+// NewClient creates a new IO.NET API client targeting the public API base URL.
+func NewClient(apiKey string) *Client {
+	return NewClientWithConfig(apiKey, DefaultBaseURL, nil)
+}
+
+// NewClientWithConfig creates a new IO.NET API client with custom configuration
+func NewClientWithConfig(apiKey, baseURL string, httpClient HTTPClient) *Client {
+	if baseURL == "" {
+		baseURL = DefaultBaseURL
+	}
+	if httpClient == nil {
+		httpClient = NewDefaultHTTPClient(DefaultTimeout)
+	}
+	return &Client{
+		BaseURL:    baseURL,
+		APIKey:     apiKey,
+		HTTPClient: httpClient,
+	}
+}
+
+// makeRequest performs an HTTP request and handles common response processing
+func (c *Client) makeRequest(method, endpoint string, body interface{}) (*HTTPResponse, error) {
+	var reqBody []byte
+	var err error
+
+	if body != nil {
+		reqBody, err = json.Marshal(body)
+		if err != nil {
+			return nil, fmt.Errorf("failed to marshal request body: %w", err)
+		}
+	}
+
+	headers := map[string]string{
+		"X-API-KEY":    c.APIKey,
+		"Content-Type": "application/json",
+	}
+
+	req := &HTTPRequest{
+		Method:  method,
+		URL:     c.BaseURL + endpoint,
+		Headers: headers,
+		Body:    reqBody,
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("request failed: %w", err)
+	}
+
+	// Handle API errors
+	if resp.StatusCode >= 400 {
+		var apiErr APIError
+		if len(resp.Body) > 0 {
+			// Try to parse the actual error format: {"detail": "message"}
+			var errorResp struct {
+				Detail string `json:"detail"`
+			}
+			if err := json.Unmarshal(resp.Body, &errorResp); err == nil && errorResp.Detail != "" {
+				apiErr = APIError{
+					Code:    resp.StatusCode,
+					Message: errorResp.Detail,
+				}
+			} else {
+				// Fallback: use raw body as details
+				apiErr = APIError{
+					Code:    resp.StatusCode,
+					Message: fmt.Sprintf("API request failed with status %d", resp.StatusCode),
+					Details: string(resp.Body),
+				}
+			}
+		} else {
+			apiErr = APIError{
+				Code:    resp.StatusCode,
+				Message: fmt.Sprintf("API request failed with status %d", resp.StatusCode),
+			}
+		}
+		return nil, &apiErr
+	}
+
+	return resp, nil
+}
+
+// buildQueryParams builds query parameters for GET requests
+func buildQueryParams(params map[string]interface{}) string {
+	if len(params) == 0 {
+		return ""
+	}
+
+	values := url.Values{}
+	for key, value := range params {
+		if value == nil {
+			continue
+		}
+		switch v := value.(type) {
+		case string:
+			if v != "" {
+				values.Add(key, v)
+			}
+		case int:
+			if v != 0 {
+				values.Add(key, strconv.Itoa(v))
+			}
+		case int64:
+			if v != 0 {
+				values.Add(key, strconv.FormatInt(v, 10))
+			}
+		case float64:
+			if v != 0 {
+				values.Add(key, strconv.FormatFloat(v, 'f', -1, 64))
+			}
+		case bool:
+			values.Add(key, strconv.FormatBool(v))
+		case time.Time:
+			if !v.IsZero() {
+				values.Add(key, v.Format(time.RFC3339))
+			}
+		case *time.Time:
+			if v != nil && !v.IsZero() {
+				values.Add(key, v.Format(time.RFC3339))
+			}
+		case []int:
+			if len(v) > 0 {
+				if encoded, err := json.Marshal(v); err == nil {
+					values.Add(key, string(encoded))
+				}
+			}
+		case []string:
+			if len(v) > 0 {
+				if encoded, err := json.Marshal(v); err == nil {
+					values.Add(key, string(encoded))
+				}
+			}
+		default:
+			values.Add(key, fmt.Sprint(v))
+		}
+	}
+
+	if len(values) > 0 {
+		return "?" + values.Encode()
+	}
+	return ""
+}

+ 302 - 0
pkg/ionet/container.go

@@ -0,0 +1,302 @@
+package ionet
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/samber/lo"
+)
+
+// ListContainers retrieves all containers for a specific deployment
+func (c *Client) ListContainers(deploymentID string) (*ContainerList, error) {
+	if deploymentID == "" {
+		return nil, fmt.Errorf("deployment ID cannot be empty")
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s/containers", deploymentID)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list containers: %w", err)
+	}
+
+	var containerList ContainerList
+	if err := decodeDataWithFlexibleTimes(resp.Body, &containerList); err != nil {
+		return nil, fmt.Errorf("failed to parse containers list: %w", err)
+	}
+
+	return &containerList, nil
+}
+
+// GetContainerDetails retrieves detailed information about a specific container
+func (c *Client) GetContainerDetails(deploymentID, containerID string) (*Container, error) {
+	if deploymentID == "" {
+		return nil, fmt.Errorf("deployment ID cannot be empty")
+	}
+	if containerID == "" {
+		return nil, fmt.Errorf("container ID cannot be empty")
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s/container/%s", deploymentID, containerID)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get container details: %w", err)
+	}
+
+	// API response format not documented, assuming direct format
+	var container Container
+	if err := decodeWithFlexibleTimes(resp.Body, &container); err != nil {
+		return nil, fmt.Errorf("failed to parse container details: %w", err)
+	}
+
+	return &container, nil
+}
+
+// GetContainerJobs retrieves containers jobs for a specific container (similar to containers endpoint)
+func (c *Client) GetContainerJobs(deploymentID, containerID string) (*ContainerList, error) {
+	if deploymentID == "" {
+		return nil, fmt.Errorf("deployment ID cannot be empty")
+	}
+	if containerID == "" {
+		return nil, fmt.Errorf("container ID cannot be empty")
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s/containers-jobs/%s", deploymentID, containerID)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get container jobs: %w", err)
+	}
+
+	var containerList ContainerList
+	if err := decodeDataWithFlexibleTimes(resp.Body, &containerList); err != nil {
+		return nil, fmt.Errorf("failed to parse container jobs: %w", err)
+	}
+
+	return &containerList, nil
+}
+
+// buildLogEndpoint constructs the request path for fetching logs
+func buildLogEndpoint(deploymentID, containerID string, opts *GetLogsOptions) (string, error) {
+	if deploymentID == "" {
+		return "", fmt.Errorf("deployment ID cannot be empty")
+	}
+	if containerID == "" {
+		return "", fmt.Errorf("container ID cannot be empty")
+	}
+
+	params := make(map[string]interface{})
+
+	if opts != nil {
+		if opts.Level != "" {
+			params["level"] = opts.Level
+		}
+		if opts.Stream != "" {
+			params["stream"] = opts.Stream
+		}
+		if opts.Limit > 0 {
+			params["limit"] = opts.Limit
+		}
+		if opts.Cursor != "" {
+			params["cursor"] = opts.Cursor
+		}
+		if opts.Follow {
+			params["follow"] = true
+		}
+
+		if opts.StartTime != nil {
+			params["start_time"] = opts.StartTime
+		}
+		if opts.EndTime != nil {
+			params["end_time"] = opts.EndTime
+		}
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s/log/%s", deploymentID, containerID)
+	endpoint += buildQueryParams(params)
+
+	return endpoint, nil
+}
+
+// GetContainerLogs retrieves logs for containers in a deployment and normalizes them
+func (c *Client) GetContainerLogs(deploymentID, containerID string, opts *GetLogsOptions) (*ContainerLogs, error) {
+	raw, err := c.GetContainerLogsRaw(deploymentID, containerID, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	logs := &ContainerLogs{
+		ContainerID: containerID,
+	}
+
+	if raw == "" {
+		return logs, nil
+	}
+
+	normalized := strings.ReplaceAll(raw, "\r\n", "\n")
+	lines := strings.Split(normalized, "\n")
+	logs.Logs = lo.FilterMap(lines, func(line string, _ int) (LogEntry, bool) {
+		if strings.TrimSpace(line) == "" {
+			return LogEntry{}, false
+		}
+		return LogEntry{Message: line}, true
+	})
+
+	return logs, nil
+}
+
+// GetContainerLogsRaw retrieves the raw text logs for a specific container
+func (c *Client) GetContainerLogsRaw(deploymentID, containerID string, opts *GetLogsOptions) (string, error) {
+	endpoint, err := buildLogEndpoint(deploymentID, containerID, opts)
+	if err != nil {
+		return "", err
+	}
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return "", fmt.Errorf("failed to get container logs: %w", err)
+	}
+
+	return string(resp.Body), nil
+}
+
+// StreamContainerLogs streams real-time logs for a specific container
+// This method uses a callback function to handle incoming log entries
+func (c *Client) StreamContainerLogs(deploymentID, containerID string, opts *GetLogsOptions, callback func(*LogEntry) error) error {
+	if deploymentID == "" {
+		return fmt.Errorf("deployment ID cannot be empty")
+	}
+	if containerID == "" {
+		return fmt.Errorf("container ID cannot be empty")
+	}
+	if callback == nil {
+		return fmt.Errorf("callback function cannot be nil")
+	}
+
+	// Set follow to true for streaming
+	if opts == nil {
+		opts = &GetLogsOptions{}
+	}
+	opts.Follow = true
+
+	endpoint, err := buildLogEndpoint(deploymentID, containerID, opts)
+	if err != nil {
+		return err
+	}
+
+	// Note: This is a simplified implementation. In a real scenario, you might want to use
+	// Server-Sent Events (SSE) or WebSocket for streaming logs
+	for {
+		resp, err := c.makeRequest("GET", endpoint, nil)
+		if err != nil {
+			return fmt.Errorf("failed to stream container logs: %w", err)
+		}
+
+		var logs ContainerLogs
+		if err := decodeWithFlexibleTimes(resp.Body, &logs); err != nil {
+			return fmt.Errorf("failed to parse container logs: %w", err)
+		}
+
+		// Call the callback for each log entry
+		for _, logEntry := range logs.Logs {
+			if err := callback(&logEntry); err != nil {
+				return fmt.Errorf("callback error: %w", err)
+			}
+		}
+
+		// If there are no more logs or we have a cursor, continue polling
+		if !logs.HasMore && logs.NextCursor == "" {
+			break
+		}
+
+		// Update cursor for next request
+		if logs.NextCursor != "" {
+			opts.Cursor = logs.NextCursor
+			endpoint, err = buildLogEndpoint(deploymentID, containerID, opts)
+			if err != nil {
+				return err
+			}
+		}
+
+		// Wait a bit before next poll to avoid overwhelming the API
+		time.Sleep(2 * time.Second)
+	}
+
+	return nil
+}
+
+// RestartContainer restarts a specific container (if supported by the API)
+func (c *Client) RestartContainer(deploymentID, containerID string) error {
+	if deploymentID == "" {
+		return fmt.Errorf("deployment ID cannot be empty")
+	}
+	if containerID == "" {
+		return fmt.Errorf("container ID cannot be empty")
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s/container/%s/restart", deploymentID, containerID)
+
+	_, err := c.makeRequest("POST", endpoint, nil)
+	if err != nil {
+		return fmt.Errorf("failed to restart container: %w", err)
+	}
+
+	return nil
+}
+
+// StopContainer stops a specific container (if supported by the API)
+func (c *Client) StopContainer(deploymentID, containerID string) error {
+	if deploymentID == "" {
+		return fmt.Errorf("deployment ID cannot be empty")
+	}
+	if containerID == "" {
+		return fmt.Errorf("container ID cannot be empty")
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s/container/%s/stop", deploymentID, containerID)
+
+	_, err := c.makeRequest("POST", endpoint, nil)
+	if err != nil {
+		return fmt.Errorf("failed to stop container: %w", err)
+	}
+
+	return nil
+}
+
+// ExecuteInContainer executes a command in a specific container (if supported by the API)
+func (c *Client) ExecuteInContainer(deploymentID, containerID string, command []string) (string, error) {
+	if deploymentID == "" {
+		return "", fmt.Errorf("deployment ID cannot be empty")
+	}
+	if containerID == "" {
+		return "", fmt.Errorf("container ID cannot be empty")
+	}
+	if len(command) == 0 {
+		return "", fmt.Errorf("command cannot be empty")
+	}
+
+	reqBody := map[string]interface{}{
+		"command": command,
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s/container/%s/exec", deploymentID, containerID)
+
+	resp, err := c.makeRequest("POST", endpoint, reqBody)
+	if err != nil {
+		return "", fmt.Errorf("failed to execute command in container: %w", err)
+	}
+
+	var result map[string]interface{}
+	if err := json.Unmarshal(resp.Body, &result); err != nil {
+		return "", fmt.Errorf("failed to parse execution result: %w", err)
+	}
+
+	if output, ok := result["output"].(string); ok {
+		return output, nil
+	}
+
+	return string(resp.Body), nil
+}

+ 377 - 0
pkg/ionet/deployment.go

@@ -0,0 +1,377 @@
+package ionet
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/samber/lo"
+)
+
+// DeployContainer deploys a new container with the specified configuration
+func (c *Client) DeployContainer(req *DeploymentRequest) (*DeploymentResponse, error) {
+	if req == nil {
+		return nil, fmt.Errorf("deployment request cannot be nil")
+	}
+
+	// Validate required fields
+	if req.ResourcePrivateName == "" {
+		return nil, fmt.Errorf("resource_private_name is required")
+	}
+	if len(req.LocationIDs) == 0 {
+		return nil, fmt.Errorf("location_ids is required")
+	}
+	if req.HardwareID <= 0 {
+		return nil, fmt.Errorf("hardware_id is required")
+	}
+	if req.RegistryConfig.ImageURL == "" {
+		return nil, fmt.Errorf("registry_config.image_url is required")
+	}
+	if req.GPUsPerContainer < 1 {
+		return nil, fmt.Errorf("gpus_per_container must be at least 1")
+	}
+	if req.DurationHours < 1 {
+		return nil, fmt.Errorf("duration_hours must be at least 1")
+	}
+	if req.ContainerConfig.ReplicaCount < 1 {
+		return nil, fmt.Errorf("container_config.replica_count must be at least 1")
+	}
+
+	resp, err := c.makeRequest("POST", "/deploy", req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to deploy container: %w", err)
+	}
+
+	// API returns direct format:
+	// {"status": "string", "deployment_id": "..."}
+	var deployResp DeploymentResponse
+	if err := json.Unmarshal(resp.Body, &deployResp); err != nil {
+		return nil, fmt.Errorf("failed to parse deployment response: %w", err)
+	}
+
+	return &deployResp, nil
+}
+
+// ListDeployments retrieves a list of deployments with optional filtering
+func (c *Client) ListDeployments(opts *ListDeploymentsOptions) (*DeploymentList, error) {
+	params := make(map[string]interface{})
+
+	if opts != nil {
+		params["status"] = opts.Status
+		params["location_id"] = opts.LocationID
+		params["page"] = opts.Page
+		params["page_size"] = opts.PageSize
+		params["sort_by"] = opts.SortBy
+		params["sort_order"] = opts.SortOrder
+	}
+
+	endpoint := "/deployments" + buildQueryParams(params)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list deployments: %w", err)
+	}
+
+	var deploymentList DeploymentList
+	if err := decodeData(resp.Body, &deploymentList); err != nil {
+		return nil, fmt.Errorf("failed to parse deployments list: %w", err)
+	}
+
+	deploymentList.Deployments = lo.Map(deploymentList.Deployments, func(deployment Deployment, _ int) Deployment {
+		deployment.GPUCount = deployment.HardwareQuantity
+		deployment.Replicas = deployment.HardwareQuantity // Assuming 1:1 mapping for now
+		return deployment
+	})
+
+	return &deploymentList, nil
+}
+
+// GetDeployment retrieves detailed information about a specific deployment
+func (c *Client) GetDeployment(deploymentID string) (*DeploymentDetail, error) {
+	if deploymentID == "" {
+		return nil, fmt.Errorf("deployment ID cannot be empty")
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s", deploymentID)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get deployment details: %w", err)
+	}
+
+	var deploymentDetail DeploymentDetail
+	if err := decodeDataWithFlexibleTimes(resp.Body, &deploymentDetail); err != nil {
+		return nil, fmt.Errorf("failed to parse deployment details: %w", err)
+	}
+
+	return &deploymentDetail, nil
+}
+
+// UpdateDeployment updates the configuration of an existing deployment
+func (c *Client) UpdateDeployment(deploymentID string, req *UpdateDeploymentRequest) (*UpdateDeploymentResponse, error) {
+	if deploymentID == "" {
+		return nil, fmt.Errorf("deployment ID cannot be empty")
+	}
+	if req == nil {
+		return nil, fmt.Errorf("update request cannot be nil")
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s", deploymentID)
+
+	resp, err := c.makeRequest("PATCH", endpoint, req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to update deployment: %w", err)
+	}
+
+	// API returns direct format:
+	// {"status": "string", "deployment_id": "..."}
+	var updateResp UpdateDeploymentResponse
+	if err := json.Unmarshal(resp.Body, &updateResp); err != nil {
+		return nil, fmt.Errorf("failed to parse update deployment response: %w", err)
+	}
+
+	return &updateResp, nil
+}
+
+// ExtendDeployment extends the duration of an existing deployment
+func (c *Client) ExtendDeployment(deploymentID string, req *ExtendDurationRequest) (*DeploymentDetail, error) {
+	if deploymentID == "" {
+		return nil, fmt.Errorf("deployment ID cannot be empty")
+	}
+	if req == nil {
+		return nil, fmt.Errorf("extend request cannot be nil")
+	}
+	if req.DurationHours < 1 {
+		return nil, fmt.Errorf("duration_hours must be at least 1")
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s/extend", deploymentID)
+
+	resp, err := c.makeRequest("POST", endpoint, req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to extend deployment: %w", err)
+	}
+
+	var deploymentDetail DeploymentDetail
+	if err := decodeDataWithFlexibleTimes(resp.Body, &deploymentDetail); err != nil {
+		return nil, fmt.Errorf("failed to parse extended deployment details: %w", err)
+	}
+
+	return &deploymentDetail, nil
+}
+
+// DeleteDeployment deletes an active deployment
+func (c *Client) DeleteDeployment(deploymentID string) (*UpdateDeploymentResponse, error) {
+	if deploymentID == "" {
+		return nil, fmt.Errorf("deployment ID cannot be empty")
+	}
+
+	endpoint := fmt.Sprintf("/deployment/%s", deploymentID)
+
+	resp, err := c.makeRequest("DELETE", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to delete deployment: %w", err)
+	}
+
+	// API returns direct format:
+	// {"status": "string", "deployment_id": "..."}
+	var deleteResp UpdateDeploymentResponse
+	if err := json.Unmarshal(resp.Body, &deleteResp); err != nil {
+		return nil, fmt.Errorf("failed to parse delete deployment response: %w", err)
+	}
+
+	return &deleteResp, nil
+}
+
+// GetPriceEstimation calculates the estimated cost for a deployment
+func (c *Client) GetPriceEstimation(req *PriceEstimationRequest) (*PriceEstimationResponse, error) {
+	if req == nil {
+		return nil, fmt.Errorf("price estimation request cannot be nil")
+	}
+
+	// Validate required fields
+	if len(req.LocationIDs) == 0 {
+		return nil, fmt.Errorf("location_ids is required")
+	}
+	if req.HardwareID == 0 {
+		return nil, fmt.Errorf("hardware_id is required")
+	}
+	if req.ReplicaCount < 1 {
+		return nil, fmt.Errorf("replica_count must be at least 1")
+	}
+
+	currency := strings.TrimSpace(req.Currency)
+	if currency == "" {
+		currency = "usdc"
+	}
+
+	durationType := strings.TrimSpace(req.DurationType)
+	if durationType == "" {
+		durationType = "hour"
+	}
+	durationType = strings.ToLower(durationType)
+
+	apiDurationType := ""
+
+	durationQty := req.DurationQty
+	if durationQty < 1 {
+		durationQty = req.DurationHours
+	}
+	if durationQty < 1 {
+		return nil, fmt.Errorf("duration_qty must be at least 1")
+	}
+
+	hardwareQty := req.HardwareQty
+	if hardwareQty < 1 {
+		hardwareQty = req.GPUsPerContainer
+	}
+	if hardwareQty < 1 {
+		return nil, fmt.Errorf("hardware_qty must be at least 1")
+	}
+
+	durationHoursForRate := req.DurationHours
+	if durationHoursForRate < 1 {
+		durationHoursForRate = durationQty
+	}
+	switch durationType {
+	case "hour", "hours", "hourly":
+		durationHoursForRate = durationQty
+		apiDurationType = "hourly"
+	case "day", "days", "daily":
+		durationHoursForRate = durationQty * 24
+		apiDurationType = "daily"
+	case "week", "weeks", "weekly":
+		durationHoursForRate = durationQty * 24 * 7
+		apiDurationType = "weekly"
+	case "month", "months", "monthly":
+		durationHoursForRate = durationQty * 24 * 30
+		apiDurationType = "monthly"
+	}
+	if durationHoursForRate < 1 {
+		durationHoursForRate = 1
+	}
+	if apiDurationType == "" {
+		apiDurationType = "hourly"
+	}
+
+	params := map[string]interface{}{
+		"location_ids":       req.LocationIDs,
+		"hardware_id":        req.HardwareID,
+		"hardware_qty":       hardwareQty,
+		"gpus_per_container": req.GPUsPerContainer,
+		"duration_type":      apiDurationType,
+		"duration_qty":       durationQty,
+		"duration_hours":     req.DurationHours,
+		"replica_count":      req.ReplicaCount,
+		"currency":           currency,
+	}
+
+	endpoint := "/price" + buildQueryParams(params)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get price estimation: %w", err)
+	}
+
+	// Parse according to the actual API response format from docs:
+	// {
+	//   "data": {
+	//     "replica_count": 0,
+	//     "gpus_per_container": 0,
+	//     "available_replica_count": [0],
+	//     "discount": 0,
+	//     "ionet_fee": 0,
+	//     "ionet_fee_percent": 0,
+	//     "currency_conversion_fee": 0,
+	//     "currency_conversion_fee_percent": 0,
+	//     "total_cost_usdc": 0
+	//   }
+	// }
+	var pricingData struct {
+		ReplicaCount                 int     `json:"replica_count"`
+		GPUsPerContainer             int     `json:"gpus_per_container"`
+		AvailableReplicaCount        []int   `json:"available_replica_count"`
+		Discount                     float64 `json:"discount"`
+		IonetFee                     float64 `json:"ionet_fee"`
+		IonetFeePercent              float64 `json:"ionet_fee_percent"`
+		CurrencyConversionFee        float64 `json:"currency_conversion_fee"`
+		CurrencyConversionFeePercent float64 `json:"currency_conversion_fee_percent"`
+		TotalCostUSDC                float64 `json:"total_cost_usdc"`
+	}
+
+	if err := decodeData(resp.Body, &pricingData); err != nil {
+		return nil, fmt.Errorf("failed to parse price estimation response: %w", err)
+	}
+
+	// Convert to our internal format
+	durationHoursFloat := float64(durationHoursForRate)
+	if durationHoursFloat <= 0 {
+		durationHoursFloat = 1
+	}
+
+	priceResp := &PriceEstimationResponse{
+		EstimatedCost:   pricingData.TotalCostUSDC,
+		Currency:        strings.ToUpper(currency),
+		EstimationValid: true,
+		PriceBreakdown: PriceBreakdown{
+			ComputeCost: pricingData.TotalCostUSDC - pricingData.IonetFee - pricingData.CurrencyConversionFee,
+			TotalCost:   pricingData.TotalCostUSDC,
+			HourlyRate:  pricingData.TotalCostUSDC / durationHoursFloat,
+		},
+	}
+
+	return priceResp, nil
+}
+
+// CheckClusterNameAvailability checks if a cluster name is available
+func (c *Client) CheckClusterNameAvailability(clusterName string) (bool, error) {
+	if clusterName == "" {
+		return false, fmt.Errorf("cluster name cannot be empty")
+	}
+
+	params := map[string]interface{}{
+		"cluster_name": clusterName,
+	}
+
+	endpoint := "/clusters/check_cluster_name_availability" + buildQueryParams(params)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return false, fmt.Errorf("failed to check cluster name availability: %w", err)
+	}
+
+	var availabilityResp bool
+	if err := json.Unmarshal(resp.Body, &availabilityResp); err != nil {
+		return false, fmt.Errorf("failed to parse cluster name availability response: %w", err)
+	}
+
+	return availabilityResp, nil
+}
+
+// UpdateClusterName updates the name of an existing cluster/deployment
+func (c *Client) UpdateClusterName(clusterID string, req *UpdateClusterNameRequest) (*UpdateClusterNameResponse, error) {
+	if clusterID == "" {
+		return nil, fmt.Errorf("cluster ID cannot be empty")
+	}
+	if req == nil {
+		return nil, fmt.Errorf("update cluster name request cannot be nil")
+	}
+	if req.Name == "" {
+		return nil, fmt.Errorf("cluster name cannot be empty")
+	}
+
+	endpoint := fmt.Sprintf("/clusters/%s/update-name", clusterID)
+
+	resp, err := c.makeRequest("PUT", endpoint, req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to update cluster name: %w", err)
+	}
+
+	// Parse the response directly without data wrapper based on API docs
+	var updateResp UpdateClusterNameResponse
+	if err := json.Unmarshal(resp.Body, &updateResp); err != nil {
+		return nil, fmt.Errorf("failed to parse update cluster name response: %w", err)
+	}
+
+	return &updateResp, nil
+}

+ 202 - 0
pkg/ionet/hardware.go

@@ -0,0 +1,202 @@
+package ionet
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/samber/lo"
+)
+
+// GetAvailableReplicas retrieves available replicas per location for specified hardware
+func (c *Client) GetAvailableReplicas(hardwareID int, gpuCount int) (*AvailableReplicasResponse, error) {
+	if hardwareID <= 0 {
+		return nil, fmt.Errorf("hardware_id must be greater than 0")
+	}
+	if gpuCount < 1 {
+		return nil, fmt.Errorf("gpu_count must be at least 1")
+	}
+
+	params := map[string]interface{}{
+		"hardware_id":  hardwareID,
+		"hardware_qty": gpuCount,
+	}
+
+	endpoint := "/available-replicas" + buildQueryParams(params)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get available replicas: %w", err)
+	}
+
+	type availableReplicaPayload struct {
+		ID                int    `json:"id"`
+		ISO2              string `json:"iso2"`
+		Name              string `json:"name"`
+		AvailableReplicas int    `json:"available_replicas"`
+	}
+	var payload []availableReplicaPayload
+
+	if err := decodeData(resp.Body, &payload); err != nil {
+		return nil, fmt.Errorf("failed to parse available replicas response: %w", err)
+	}
+
+	replicas := lo.Map(payload, func(item availableReplicaPayload, _ int) AvailableReplica {
+		return AvailableReplica{
+			LocationID:     item.ID,
+			LocationName:   item.Name,
+			HardwareID:     hardwareID,
+			HardwareName:   "",
+			AvailableCount: item.AvailableReplicas,
+			MaxGPUs:        gpuCount,
+		}
+	})
+
+	return &AvailableReplicasResponse{Replicas: replicas}, nil
+}
+
+// GetMaxGPUsPerContainer retrieves the maximum number of GPUs available per hardware type
+func (c *Client) GetMaxGPUsPerContainer() (*MaxGPUResponse, error) {
+	resp, err := c.makeRequest("GET", "/hardware/max-gpus-per-container", nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get max GPUs per container: %w", err)
+	}
+
+	var maxGPUResp MaxGPUResponse
+	if err := decodeData(resp.Body, &maxGPUResp); err != nil {
+		return nil, fmt.Errorf("failed to parse max GPU response: %w", err)
+	}
+
+	return &maxGPUResp, nil
+}
+
+// ListHardwareTypes retrieves available hardware types using the max GPUs endpoint
+func (c *Client) ListHardwareTypes() ([]HardwareType, int, error) {
+	maxGPUResp, err := c.GetMaxGPUsPerContainer()
+	if err != nil {
+		return nil, 0, fmt.Errorf("failed to list hardware types: %w", err)
+	}
+
+	mapped := lo.Map(maxGPUResp.Hardware, func(hw MaxGPUInfo, _ int) HardwareType {
+		name := strings.TrimSpace(hw.HardwareName)
+		if name == "" {
+			name = fmt.Sprintf("Hardware %d", hw.HardwareID)
+		}
+
+		return HardwareType{
+			ID:             hw.HardwareID,
+			Name:           name,
+			GPUType:        "",
+			GPUMemory:      0,
+			MaxGPUs:        hw.MaxGPUsPerContainer,
+			CPU:            "",
+			Memory:         0,
+			Storage:        0,
+			HourlyRate:     0,
+			Available:      hw.Available > 0,
+			BrandName:      strings.TrimSpace(hw.BrandName),
+			AvailableCount: hw.Available,
+		}
+	})
+
+	totalAvailable := maxGPUResp.Total
+	if totalAvailable == 0 {
+		totalAvailable = lo.SumBy(maxGPUResp.Hardware, func(hw MaxGPUInfo) int {
+			return hw.Available
+		})
+	}
+
+	return mapped, totalAvailable, nil
+}
+
+// ListLocations retrieves available deployment locations (if supported by the API)
+func (c *Client) ListLocations() (*LocationsResponse, error) {
+	resp, err := c.makeRequest("GET", "/locations", nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list locations: %w", err)
+	}
+
+	var locations LocationsResponse
+	if err := decodeData(resp.Body, &locations); err != nil {
+		return nil, fmt.Errorf("failed to parse locations response: %w", err)
+	}
+
+	locations.Locations = lo.Map(locations.Locations, func(location Location, _ int) Location {
+		location.ISO2 = strings.ToUpper(strings.TrimSpace(location.ISO2))
+		return location
+	})
+
+	if locations.Total == 0 {
+		locations.Total = lo.SumBy(locations.Locations, func(location Location) int {
+			return location.Available
+		})
+	}
+
+	return &locations, nil
+}
+
+// GetHardwareType retrieves details about a specific hardware type
+func (c *Client) GetHardwareType(hardwareID int) (*HardwareType, error) {
+	if hardwareID <= 0 {
+		return nil, fmt.Errorf("hardware ID must be greater than 0")
+	}
+
+	endpoint := fmt.Sprintf("/hardware/types/%d", hardwareID)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get hardware type: %w", err)
+	}
+
+	// API response format not documented, assuming direct format
+	var hardwareType HardwareType
+	if err := json.Unmarshal(resp.Body, &hardwareType); err != nil {
+		return nil, fmt.Errorf("failed to parse hardware type: %w", err)
+	}
+
+	return &hardwareType, nil
+}
+
+// GetLocation retrieves details about a specific location
+func (c *Client) GetLocation(locationID int) (*Location, error) {
+	if locationID <= 0 {
+		return nil, fmt.Errorf("location ID must be greater than 0")
+	}
+
+	endpoint := fmt.Sprintf("/locations/%d", locationID)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get location: %w", err)
+	}
+
+	// API response format not documented, assuming direct format
+	var location Location
+	if err := json.Unmarshal(resp.Body, &location); err != nil {
+		return nil, fmt.Errorf("failed to parse location: %w", err)
+	}
+
+	return &location, nil
+}
+
+// GetLocationAvailability retrieves real-time availability for a specific location
+func (c *Client) GetLocationAvailability(locationID int) (*LocationAvailability, error) {
+	if locationID <= 0 {
+		return nil, fmt.Errorf("location ID must be greater than 0")
+	}
+
+	endpoint := fmt.Sprintf("/locations/%d/availability", locationID)
+
+	resp, err := c.makeRequest("GET", endpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get location availability: %w", err)
+	}
+
+	// API response format not documented, assuming direct format
+	var availability LocationAvailability
+	if err := json.Unmarshal(resp.Body, &availability); err != nil {
+		return nil, fmt.Errorf("failed to parse location availability: %w", err)
+	}
+
+	return &availability, nil
+}

+ 96 - 0
pkg/ionet/jsonutil.go

@@ -0,0 +1,96 @@
+package ionet
+
+import (
+	"encoding/json"
+	"strings"
+	"time"
+
+	"github.com/samber/lo"
+)
+
+// decodeWithFlexibleTimes unmarshals API responses while tolerating timestamp strings
+// that omit timezone information by normalizing them to RFC3339Nano.
+func decodeWithFlexibleTimes(data []byte, target interface{}) error {
+	var intermediate interface{}
+	if err := json.Unmarshal(data, &intermediate); err != nil {
+		return err
+	}
+
+	normalized := normalizeTimeValues(intermediate)
+	reencoded, err := json.Marshal(normalized)
+	if err != nil {
+		return err
+	}
+
+	return json.Unmarshal(reencoded, target)
+}
+
+func decodeData[T any](data []byte, target *T) error {
+	var wrapper struct {
+		Data T `json:"data"`
+	}
+	if err := json.Unmarshal(data, &wrapper); err != nil {
+		return err
+	}
+	*target = wrapper.Data
+	return nil
+}
+
+func decodeDataWithFlexibleTimes[T any](data []byte, target *T) error {
+	var wrapper struct {
+		Data T `json:"data"`
+	}
+	if err := decodeWithFlexibleTimes(data, &wrapper); err != nil {
+		return err
+	}
+	*target = wrapper.Data
+	return nil
+}
+
+func normalizeTimeValues(value interface{}) interface{} {
+	switch v := value.(type) {
+	case map[string]interface{}:
+		return lo.MapValues(v, func(val interface{}, _ string) interface{} {
+			return normalizeTimeValues(val)
+		})
+	case []interface{}:
+		return lo.Map(v, func(item interface{}, _ int) interface{} {
+			return normalizeTimeValues(item)
+		})
+	case string:
+		if normalized, changed := normalizeTimeString(v); changed {
+			return normalized
+		}
+		return v
+	default:
+		return value
+	}
+}
+
+func normalizeTimeString(input string) (string, bool) {
+	trimmed := strings.TrimSpace(input)
+	if trimmed == "" {
+		return input, false
+	}
+
+	if _, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
+		return trimmed, trimmed != input
+	}
+	if _, err := time.Parse(time.RFC3339, trimmed); err == nil {
+		return trimmed, trimmed != input
+	}
+
+	layouts := []string{
+		"2006-01-02T15:04:05.999999999",
+		"2006-01-02T15:04:05.999999",
+		"2006-01-02T15:04:05",
+	}
+
+	for _, layout := range layouts {
+		if parsed, err := time.Parse(layout, trimmed); err == nil {
+			return parsed.UTC().Format(time.RFC3339Nano), true
+		}
+	}
+
+	return input, false
+}

+ 353 - 0
pkg/ionet/types.go

@@ -0,0 +1,353 @@
+package ionet
+
+import (
+	"time"
+)
+
+// Client represents the IO.NET API client
+type Client struct {
+	BaseURL    string
+	APIKey     string
+	HTTPClient HTTPClient
+}
+
+// HTTPClient interface for making HTTP requests
+type HTTPClient interface {
+	Do(req *HTTPRequest) (*HTTPResponse, error)
+}
+
+// HTTPRequest represents an HTTP request
+type HTTPRequest struct {
+	Method  string
+	URL     string
+	Headers map[string]string
+	Body    []byte
+}
+
+// HTTPResponse represents an HTTP response
+type HTTPResponse struct {
+	StatusCode int
+	Headers    map[string]string
+	Body       []byte
+}
+
+// DeploymentRequest represents a container deployment request
+type DeploymentRequest struct {
+	ResourcePrivateName string          `json:"resource_private_name"`
+	DurationHours       int             `json:"duration_hours"`
+	GPUsPerContainer    int             `json:"gpus_per_container"`
+	HardwareID          int             `json:"hardware_id"`
+	LocationIDs         []int           `json:"location_ids"`
+	ContainerConfig     ContainerConfig `json:"container_config"`
+	RegistryConfig      RegistryConfig  `json:"registry_config"`
+}
+
+// ContainerConfig represents container configuration
+type ContainerConfig struct {
+	ReplicaCount       int               `json:"replica_count"`
+	EnvVariables       map[string]string `json:"env_variables,omitempty"`
+	SecretEnvVariables map[string]string `json:"secret_env_variables,omitempty"`
+	Entrypoint         []string          `json:"entrypoint,omitempty"`
+	TrafficPort        int               `json:"traffic_port,omitempty"`
+	Args               []string          `json:"args,omitempty"`
+}
+
+// RegistryConfig represents registry configuration
+type RegistryConfig struct {
+	ImageURL         string `json:"image_url"`
+	RegistryUsername string `json:"registry_username,omitempty"`
+	RegistrySecret   string `json:"registry_secret,omitempty"`
+}
+
+// DeploymentResponse represents the response from deployment creation
+type DeploymentResponse struct {
+	DeploymentID string `json:"deployment_id"`
+	Status       string `json:"status"`
+}
+
+// DeploymentDetail represents detailed deployment information
+type DeploymentDetail struct {
+	ID                      string                    `json:"id"`
+	Status                  string                    `json:"status"`
+	CreatedAt               time.Time                 `json:"created_at"`
+	StartedAt               *time.Time                `json:"started_at,omitempty"`
+	FinishedAt              *time.Time                `json:"finished_at,omitempty"`
+	AmountPaid              float64                   `json:"amount_paid"`
+	CompletedPercent        float64                   `json:"completed_percent"`
+	TotalGPUs               int                       `json:"total_gpus"`
+	GPUsPerContainer        int                       `json:"gpus_per_container"`
+	TotalContainers         int                       `json:"total_containers"`
+	HardwareName            string                    `json:"hardware_name"`
+	HardwareID              int                       `json:"hardware_id"`
+	Locations               []DeploymentLocation      `json:"locations"`
+	BrandName               string                    `json:"brand_name"`
+	ComputeMinutesServed    int                       `json:"compute_minutes_served"`
+	ComputeMinutesRemaining int                       `json:"compute_minutes_remaining"`
+	ContainerConfig         DeploymentContainerConfig `json:"container_config"`
+}
+
+// DeploymentLocation represents a location in deployment details
+type DeploymentLocation struct {
+	ID   int    `json:"id"`
+	ISO2 string `json:"iso2"`
+	Name string `json:"name"`
+}
+
+// DeploymentContainerConfig represents container config in deployment details
+type DeploymentContainerConfig struct {
+	Entrypoint   []string               `json:"entrypoint"`
+	EnvVariables map[string]interface{} `json:"env_variables"`
+	TrafficPort  int                    `json:"traffic_port"`
+	ImageURL     string                 `json:"image_url"`
+}
+
+// Container represents a container within a deployment
+type Container struct {
+	DeviceID         string           `json:"device_id"`
+	ContainerID      string           `json:"container_id"`
+	Hardware         string           `json:"hardware"`
+	BrandName        string           `json:"brand_name"`
+	CreatedAt        time.Time        `json:"created_at"`
+	UptimePercent    int              `json:"uptime_percent"`
+	GPUsPerContainer int              `json:"gpus_per_container"`
+	Status           string           `json:"status"`
+	ContainerEvents  []ContainerEvent `json:"container_events"`
+	PublicURL        string           `json:"public_url"`
+}
+
+// ContainerEvent represents a container event
+type ContainerEvent struct {
+	Time    time.Time `json:"time"`
+	Message string    `json:"message"`
+}
+
+// ContainerList represents a list of containers
+type ContainerList struct {
+	Total   int         `json:"total"`
+	Workers []Container `json:"workers"`
+}
+
+// Deployment represents a deployment in the list
+type Deployment struct {
+	ID                      string    `json:"id"`
+	Status                  string    `json:"status"`
+	Name                    string    `json:"name"`
+	CompletedPercent        float64   `json:"completed_percent"`
+	HardwareQuantity        int       `json:"hardware_quantity"`
+	BrandName               string    `json:"brand_name"`
+	HardwareName            string    `json:"hardware_name"`
+	Served                  string    `json:"served"`
+	Remaining               string    `json:"remaining"`
+	ComputeMinutesServed    int       `json:"compute_minutes_served"`
+	ComputeMinutesRemaining int       `json:"compute_minutes_remaining"`
+	CreatedAt               time.Time `json:"created_at"`
+	GPUCount                int       `json:"-"` // Derived from HardwareQuantity
+	Replicas                int       `json:"-"` // Derived from HardwareQuantity
+}
+
+// DeploymentList represents a list of deployments with pagination
+type DeploymentList struct {
+	Deployments []Deployment `json:"deployments"`
+	Total       int          `json:"total"`
+	Statuses    []string     `json:"statuses"`
+}
+
+// AvailableReplica represents replica availability for a location
+type AvailableReplica struct {
+	LocationID     int    `json:"location_id"`
+	LocationName   string `json:"location_name"`
+	HardwareID     int    `json:"hardware_id"`
+	HardwareName   string `json:"hardware_name"`
+	AvailableCount int    `json:"available_count"`
+	MaxGPUs        int    `json:"max_gpus"`
+}
+
+// AvailableReplicasResponse represents the response for available replicas
+type AvailableReplicasResponse struct {
+	Replicas []AvailableReplica `json:"replicas"`
+}
+
+// MaxGPUResponse represents the response for maximum GPUs per container
+type MaxGPUResponse struct {
+	Hardware []MaxGPUInfo `json:"hardware"`
+	Total    int          `json:"total"`
+}
+
+// MaxGPUInfo represents max GPU information for a hardware type
+type MaxGPUInfo struct {
+	MaxGPUsPerContainer int    `json:"max_gpus_per_container"`
+	Available           int    `json:"available"`
+	HardwareID          int    `json:"hardware_id"`
+	HardwareName        string `json:"hardware_name"`
+	BrandName           string `json:"brand_name"`
+}
+
+// PriceEstimationRequest represents a price estimation request
+type PriceEstimationRequest struct {
+	LocationIDs      []int  `json:"location_ids"`
+	HardwareID       int    `json:"hardware_id"`
+	GPUsPerContainer int    `json:"gpus_per_container"`
+	DurationHours    int    `json:"duration_hours"`
+	ReplicaCount     int    `json:"replica_count"`
+	Currency         string `json:"currency"`
+	DurationType     string `json:"duration_type"`
+	DurationQty      int    `json:"duration_qty"`
+	HardwareQty      int    `json:"hardware_qty"`
+}
+
+// PriceEstimationResponse represents the price estimation response
+type PriceEstimationResponse struct {
+	EstimatedCost   float64        `json:"estimated_cost"`
+	Currency        string         `json:"currency"`
+	PriceBreakdown  PriceBreakdown `json:"price_breakdown"`
+	EstimationValid bool           `json:"estimation_valid"`
+}
+
+// PriceBreakdown represents detailed cost breakdown
+type PriceBreakdown struct {
+	ComputeCost float64 `json:"compute_cost"`
+	NetworkCost float64 `json:"network_cost,omitempty"`
+	StorageCost float64 `json:"storage_cost,omitempty"`
+	TotalCost   float64 `json:"total_cost"`
+	HourlyRate  float64 `json:"hourly_rate"`
+}
+
+// ContainerLogs represents container log entries
+type ContainerLogs struct {
+	ContainerID string     `json:"container_id"`
+	Logs        []LogEntry `json:"logs"`
+	HasMore     bool       `json:"has_more"`
+	NextCursor  string     `json:"next_cursor,omitempty"`
+}
+
+// LogEntry represents a single log entry
+type LogEntry struct {
+	Timestamp time.Time `json:"timestamp"`
+	Level     string    `json:"level,omitempty"`
+	Message   string    `json:"message"`
+	Source    string    `json:"source,omitempty"`
+}
+
+// UpdateDeploymentRequest represents request to update deployment configuration
+type UpdateDeploymentRequest struct {
+	EnvVariables       map[string]string `json:"env_variables,omitempty"`
+	SecretEnvVariables map[string]string `json:"secret_env_variables,omitempty"`
+	Entrypoint         []string          `json:"entrypoint,omitempty"`
+	TrafficPort        *int              `json:"traffic_port,omitempty"`
+	ImageURL           string            `json:"image_url,omitempty"`
+	RegistryUsername   string            `json:"registry_username,omitempty"`
+	RegistrySecret     string            `json:"registry_secret,omitempty"`
+	Args               []string          `json:"args,omitempty"`
+	Command            string            `json:"command,omitempty"`
+}
+
+// ExtendDurationRequest represents request to extend deployment duration
+type ExtendDurationRequest struct {
+	DurationHours int `json:"duration_hours"`
+}
+
+// UpdateDeploymentResponse represents response from deployment update
+type UpdateDeploymentResponse struct {
+	Status       string `json:"status"`
+	DeploymentID string `json:"deployment_id"`
+}
+
+// UpdateClusterNameRequest represents request to update cluster name
+type UpdateClusterNameRequest struct {
+	Name string `json:"cluster_name"`
+}
+
+// UpdateClusterNameResponse represents response from cluster name update
+type UpdateClusterNameResponse struct {
+	Status  string `json:"status"`
+	Message string `json:"message"`
+}
+
+// APIError represents an API error response
+type APIError struct {
+	Code    int    `json:"code"`
+	Message string `json:"message"`
+	Details string `json:"details,omitempty"`
+}
+
+// Error implements the error interface
+func (e *APIError) Error() string {
+	if e.Details != "" {
+		return e.Message + ": " + e.Details
+	}
+	return e.Message
+}
+
+// ListDeploymentsOptions represents options for listing deployments
+type ListDeploymentsOptions struct {
+	Status     string `json:"status,omitempty"`      // filter by status
+	LocationID int    `json:"location_id,omitempty"` // filter by location
+	Page       int    `json:"page,omitempty"`        // pagination
+	PageSize   int    `json:"page_size,omitempty"`   // pagination
+	SortBy     string `json:"sort_by,omitempty"`     // sort field
+	SortOrder  string `json:"sort_order,omitempty"`  // asc/desc
+}
+
+// GetLogsOptions represents options for retrieving container logs
+type GetLogsOptions struct {
+	StartTime *time.Time `json:"start_time,omitempty"`
+	EndTime   *time.Time `json:"end_time,omitempty"`
+	Level     string     `json:"level,omitempty"`  // filter by log level
+	Stream    string     `json:"stream,omitempty"` // filter by stdout/stderr streams
+	Limit     int        `json:"limit,omitempty"`  // max number of log entries
+	Cursor    string     `json:"cursor,omitempty"` // pagination cursor
+	Follow    bool       `json:"follow,omitempty"` // stream logs
+}
+
+// HardwareType represents a hardware type available for deployment
+type HardwareType struct {
+	ID             int     `json:"id"`
+	Name           string  `json:"name"`
+	Description    string  `json:"description,omitempty"`
+	GPUType        string  `json:"gpu_type"`
+	GPUMemory      int     `json:"gpu_memory"` // in GB
+	MaxGPUs        int     `json:"max_gpus"`
+	CPU            string  `json:"cpu,omitempty"`
+	Memory         int     `json:"memory,omitempty"`  // in GB
+	Storage        int     `json:"storage,omitempty"` // in GB
+	HourlyRate     float64 `json:"hourly_rate"`
+	Available      bool    `json:"available"`
+	BrandName      string  `json:"brand_name,omitempty"`
+	AvailableCount int     `json:"available_count,omitempty"`
+}
+
+// Location represents a deployment location
+type Location struct {
+	ID          int     `json:"id"`
+	Name        string  `json:"name"`
+	ISO2        string  `json:"iso2,omitempty"`
+	Region      string  `json:"region,omitempty"`
+	Country     string  `json:"country,omitempty"`
+	Latitude    float64 `json:"latitude,omitempty"`
+	Longitude   float64 `json:"longitude,omitempty"`
+	Available   int     `json:"available,omitempty"`
+	Description string  `json:"description,omitempty"`
+}
+
+// LocationsResponse represents the list of locations and aggregated metadata.
+type LocationsResponse struct {
+	Locations []Location `json:"locations"`
+	Total     int        `json:"total"`
+}
+
+// LocationAvailability represents real-time availability for a location
+type LocationAvailability struct {
+	LocationID           int                    `json:"location_id"`
+	LocationName         string                 `json:"location_name"`
+	Available            bool                   `json:"available"`
+	HardwareAvailability []HardwareAvailability `json:"hardware_availability"`
+	UpdatedAt            time.Time              `json:"updated_at"`
+}
+
+// HardwareAvailability represents availability for specific hardware at a location
+type HardwareAvailability struct {
+	HardwareID     int    `json:"hardware_id"`
+	HardwareName   string `json:"hardware_name"`
+	AvailableCount int    `json:"available_count"`
+	MaxGPUs        int    `json:"max_gpus"`
+}

+ 37 - 0
relay/channel/ollama/dto.go

@@ -67,3 +67,40 @@ type OllamaEmbeddingResponse struct {
 	Embeddings      [][]float64 `json:"embeddings"`
 	PromptEvalCount int         `json:"prompt_eval_count,omitempty"`
 }
+
+type OllamaTagsResponse struct {
+	Models []OllamaModel `json:"models"`
+}
+
+type OllamaModel struct {
+	Name       string            `json:"name"`
+	Size       int64             `json:"size"`
+	Digest     string            `json:"digest,omitempty"`
+	ModifiedAt string            `json:"modified_at"`
+	Details    OllamaModelDetail `json:"details,omitempty"`
+}
+
+type OllamaModelDetail struct {
+	ParentModel       string   `json:"parent_model,omitempty"`
+	Format            string   `json:"format,omitempty"`
+	Family            string   `json:"family,omitempty"`
+	Families          []string `json:"families,omitempty"`
+	ParameterSize     string   `json:"parameter_size,omitempty"`
+	QuantizationLevel string   `json:"quantization_level,omitempty"`
+}
+
+type OllamaPullRequest struct {
+	Name   string `json:"name"`
+	Stream bool   `json:"stream,omitempty"`
+}
+
+type OllamaPullResponse struct {
+	Status    string `json:"status"`
+	Digest    string `json:"digest,omitempty"`
+	Total     int64  `json:"total,omitempty"`
+	Completed int64  `json:"completed,omitempty"`
+}
+
+type OllamaDeleteRequest struct {
+	Name string `json:"name"`
+}

+ 245 - 0
relay/channel/ollama/relay-ollama.go

@@ -1,11 +1,13 @@
 package ollama
 
 import (
+	"bufio"
 	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"strings"
+	"time"
 
 	"github.com/QuantumNous/new-api/common"
 	"github.com/QuantumNous/new-api/dto"
@@ -283,3 +285,246 @@ func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
 	service.IOCopyBytesGracefully(c, resp, out)
 	return usage, nil
 }
+
+func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) {
+	url := fmt.Sprintf("%s/api/tags", baseURL)
+
+	client := &http.Client{}
+	request, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, fmt.Errorf("创建请求失败: %v", err)
+	}
+
+	// Ollama 通常不需要 Bearer token,但为了兼容性保留
+	if apiKey != "" {
+		request.Header.Set("Authorization", "Bearer "+apiKey)
+	}
+
+	response, err := client.Do(request)
+	if err != nil {
+		return nil, fmt.Errorf("请求失败: %v", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(response.Body)
+		return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body))
+	}
+
+	var tagsResponse OllamaTagsResponse
+	body, err := io.ReadAll(response.Body)
+	if err != nil {
+		return nil, fmt.Errorf("读取响应失败: %v", err)
+	}
+
+	err = common.Unmarshal(body, &tagsResponse)
+	if err != nil {
+		return nil, fmt.Errorf("解析响应失败: %v", err)
+	}
+
+	return tagsResponse.Models, nil
+}
+
+// 拉取 Ollama 模型 (非流式)
+func PullOllamaModel(baseURL, apiKey, modelName string) error {
+	url := fmt.Sprintf("%s/api/pull", baseURL)
+
+	pullRequest := OllamaPullRequest{
+		Name:   modelName,
+		Stream: false, // 非流式,简化处理
+	}
+
+	requestBody, err := common.Marshal(pullRequest)
+	if err != nil {
+		return fmt.Errorf("序列化请求失败: %v", err)
+	}
+
+	client := &http.Client{
+		Timeout: 30 * 60 * 1000 * time.Millisecond, // 30分钟超时,支持大模型
+	}
+	request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
+	if err != nil {
+		return fmt.Errorf("创建请求失败: %v", err)
+	}
+
+	request.Header.Set("Content-Type", "application/json")
+	if apiKey != "" {
+		request.Header.Set("Authorization", "Bearer "+apiKey)
+	}
+
+	response, err := client.Do(request)
+	if err != nil {
+		return fmt.Errorf("请求失败: %v", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(response.Body)
+		return fmt.Errorf("拉取模型失败 %d: %s", response.StatusCode, string(body))
+	}
+
+	return nil
+}
+
+// 流式拉取 Ollama 模型 (支持进度回调)
+func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback func(OllamaPullResponse)) error {
+	url := fmt.Sprintf("%s/api/pull", baseURL)
+
+	pullRequest := OllamaPullRequest{
+		Name:   modelName,
+		Stream: true, // 启用流式
+	}
+
+	requestBody, err := common.Marshal(pullRequest)
+	if err != nil {
+		return fmt.Errorf("序列化请求失败: %v", err)
+	}
+
+	client := &http.Client{
+		Timeout: 60 * 60 * 1000 * time.Millisecond, // 1小时超时,支持超大模型
+	}
+	request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
+	if err != nil {
+		return fmt.Errorf("创建请求失败: %v", err)
+	}
+
+	request.Header.Set("Content-Type", "application/json")
+	if apiKey != "" {
+		request.Header.Set("Authorization", "Bearer "+apiKey)
+	}
+
+	response, err := client.Do(request)
+	if err != nil {
+		return fmt.Errorf("请求失败: %v", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(response.Body)
+		return fmt.Errorf("拉取模型失败 %d: %s", response.StatusCode, string(body))
+	}
+
+	// 读取流式响应
+	scanner := bufio.NewScanner(response.Body)
+	successful := false
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.TrimSpace(line) == "" {
+			continue
+		}
+
+		var pullResponse OllamaPullResponse
+		if err := common.Unmarshal([]byte(line), &pullResponse); err != nil {
+			continue // 忽略解析失败的行
+		}
+
+		if progressCallback != nil {
+			progressCallback(pullResponse)
+		}
+
+		// 检查是否出现错误或完成
+		if strings.EqualFold(pullResponse.Status, "error") {
+			return fmt.Errorf("拉取模型失败: %s", strings.TrimSpace(line))
+		}
+		if strings.EqualFold(pullResponse.Status, "success") {
+			successful = true
+			break
+		}
+	}
+
+	if err := scanner.Err(); err != nil {
+		return fmt.Errorf("读取流式响应失败: %v", err)
+	}
+
+	if !successful {
+		return fmt.Errorf("拉取模型未完成: 未收到成功状态")
+	}
+
+	return nil
+}
+
+// 删除 Ollama 模型
+func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
+	url := fmt.Sprintf("%s/api/delete", baseURL)
+
+	deleteRequest := OllamaDeleteRequest{
+		Name: modelName,
+	}
+
+	requestBody, err := common.Marshal(deleteRequest)
+	if err != nil {
+		return fmt.Errorf("序列化请求失败: %v", err)
+	}
+
+	client := &http.Client{}
+	request, err := http.NewRequest("DELETE", url, strings.NewReader(string(requestBody)))
+	if err != nil {
+		return fmt.Errorf("创建请求失败: %v", err)
+	}
+
+	request.Header.Set("Content-Type", "application/json")
+	if apiKey != "" {
+		request.Header.Set("Authorization", "Bearer "+apiKey)
+	}
+
+	response, err := client.Do(request)
+	if err != nil {
+		return fmt.Errorf("请求失败: %v", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(response.Body)
+		return fmt.Errorf("删除模型失败 %d: %s", response.StatusCode, string(body))
+	}
+
+	return nil
+}
+
+func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
+	trimmedBase := strings.TrimRight(baseURL, "/")
+	if trimmedBase == "" {
+		return "", fmt.Errorf("baseURL 为空")
+	}
+
+	url := fmt.Sprintf("%s/api/version", trimmedBase)
+
+	client := &http.Client{Timeout: 10 * time.Second}
+	request, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return "", fmt.Errorf("创建请求失败: %v", err)
+	}
+
+	if apiKey != "" {
+		request.Header.Set("Authorization", "Bearer "+apiKey)
+	}
+
+	response, err := client.Do(request)
+	if err != nil {
+		return "", fmt.Errorf("请求失败: %v", err)
+	}
+	defer response.Body.Close()
+
+	body, err := io.ReadAll(response.Body)
+	if err != nil {
+		return "", fmt.Errorf("读取响应失败: %v", err)
+	}
+
+	if response.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("查询版本失败 %d: %s", response.StatusCode, string(body))
+	}
+
+	var versionResp struct {
+		Version string `json:"version"`
+	}
+
+	if err := json.Unmarshal(body, &versionResp); err != nil {
+		return "", fmt.Errorf("解析响应失败: %v", err)
+	}
+
+	if versionResp.Version == "" {
+		return "", fmt.Errorf("未返回版本信息")
+	}
+
+	return versionResp.Version, nil
+}

+ 44 - 0
router/api-router.go

@@ -152,6 +152,10 @@ func SetApiRouter(router *gin.Engine) {
 			channelRoute.POST("/fix", controller.FixChannelsAbilities)
 			channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels)
 			channelRoute.POST("/fetch_models", controller.FetchModels)
+			channelRoute.POST("/ollama/pull", controller.OllamaPullModel)
+			channelRoute.POST("/ollama/pull/stream", controller.OllamaPullModelStream)
+			channelRoute.DELETE("/ollama/delete", controller.OllamaDeleteModel)
+			channelRoute.GET("/ollama/version/:id", controller.OllamaVersion)
 			channelRoute.POST("/batch/tag", controller.BatchSetChannelTag)
 			channelRoute.GET("/tag/models", controller.GetTagModels)
 			channelRoute.POST("/copy/:id", controller.CopyChannel)
@@ -256,5 +260,45 @@ func SetApiRouter(router *gin.Engine) {
 			modelsRoute.PUT("/", controller.UpdateModelMeta)
 			modelsRoute.DELETE("/:id", controller.DeleteModelMeta)
 		}
+
+		// Deployments (model deployment management)
+		deploymentsRoute := apiRouter.Group("/deployments")
+		deploymentsRoute.Use(middleware.AdminAuth())
+		{
+			// List and search deployments
+			deploymentsRoute.GET("/", controller.GetAllDeployments)
+			deploymentsRoute.GET("/search", controller.SearchDeployments)
+
+			// Connection utilities
+			deploymentsRoute.POST("/test-connection", controller.TestIoNetConnection)
+
+			// Resource and configuration endpoints
+			deploymentsRoute.GET("/hardware-types", controller.GetHardwareTypes)
+			deploymentsRoute.GET("/locations", controller.GetLocations)
+			deploymentsRoute.GET("/available-replicas", controller.GetAvailableReplicas)
+			deploymentsRoute.POST("/price-estimation", controller.GetPriceEstimation)
+			deploymentsRoute.GET("/check-name", controller.CheckClusterNameAvailability)
+
+			// Create new deployment
+			deploymentsRoute.POST("/", controller.CreateDeployment)
+
+			// Individual deployment operations
+			deploymentsRoute.GET("/:id", controller.GetDeployment)
+			deploymentsRoute.GET("/:id/logs", controller.GetDeploymentLogs)
+			deploymentsRoute.GET("/:id/containers", controller.ListDeploymentContainers)
+			deploymentsRoute.GET("/:id/containers/:container_id", controller.GetContainerDetails)
+			deploymentsRoute.PUT("/:id", controller.UpdateDeployment)
+			deploymentsRoute.PUT("/:id/name", controller.UpdateDeploymentName)
+			deploymentsRoute.POST("/:id/extend", controller.ExtendDeployment)
+			deploymentsRoute.DELETE("/:id", controller.DeleteDeployment)
+
+			// Future batch operations:
+			// deploymentsRoute.POST("/:id/start", controller.StartDeployment)
+			// deploymentsRoute.POST("/:id/stop", controller.StopDeployment)
+			// deploymentsRoute.POST("/:id/restart", controller.RestartDeployment)
+			// deploymentsRoute.POST("/batch_delete", controller.BatchDeleteDeployments)
+			// deploymentsRoute.POST("/batch_start", controller.BatchStartDeployments)
+			// deploymentsRoute.POST("/batch_stop", controller.BatchStopDeployments)
+		}
 	}
 }

+ 9 - 0
web/src/App.jsx

@@ -42,6 +42,7 @@ import Midjourney from './pages/Midjourney';
 import Pricing from './pages/Pricing';
 import Task from './pages/Task';
 import ModelPage from './pages/Model';
+import ModelDeploymentPage from './pages/ModelDeployment';
 import Playground from './pages/Playground';
 import OAuth2Callback from './components/auth/OAuth2Callback';
 import PersonalSetting from './components/settings/PersonalSetting';
@@ -108,6 +109,14 @@ function App() {
             </AdminRoute>
           }
         />
+        <Route
+          path='/console/deployment'
+          element={
+            <AdminRoute>
+              <ModelDeploymentPage />
+            </AdminRoute>
+          }
+        />
         <Route
           path='/console/channel'
           element={

+ 7 - 0
web/src/components/layout/SiderBar.jsx

@@ -45,6 +45,7 @@ const routerMap = {
   pricing: '/pricing',
   task: '/console/task',
   models: '/console/models',
+  deployment: '/console/deployment',
   playground: '/console/playground',
   personal: '/console/personal',
 };
@@ -157,6 +158,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
         to: '/console/models',
         className: isAdmin() ? '' : 'tableHiddle',
       },
+      {
+        text: t('模型部署'),
+        itemKey: 'deployment',
+        to: '/deployment',
+        className: isAdmin() ? '' : 'tableHiddle',
+      },
       {
         text: t('兑换码管理'),
         itemKey: 'redemption',

+ 5 - 18
web/src/components/layout/components/SkeletonWrapper.jsx

@@ -52,7 +52,6 @@ const SkeletonWrapper = ({
             active
             placeholder={
               <Skeleton.Title
-                active
                 style={{ width: isMobile ? 40 : width, height }}
               />
             }
@@ -71,7 +70,7 @@ const SkeletonWrapper = ({
           loading={true}
           active
           placeholder={
-            <Skeleton.Avatar active size='extra-small' className='shadow-sm' />
+            <Skeleton.Avatar size='extra-small' className='shadow-sm' />
           }
         />
         <div className='ml-1.5 mr-1'>
@@ -80,7 +79,6 @@ const SkeletonWrapper = ({
             active
             placeholder={
               <Skeleton.Title
-                active
                 style={{ width: isMobile ? 15 : width, height: 12 }}
               />
             }
@@ -98,7 +96,6 @@ const SkeletonWrapper = ({
         active
         placeholder={
           <Skeleton.Image
-            active
             className={`absolute inset-0 !rounded-full ${className}`}
             style={{ width: '100%', height: '100%' }}
           />
@@ -113,7 +110,7 @@ const SkeletonWrapper = ({
       <Skeleton
         loading={true}
         active
-        placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
+        placeholder={<Skeleton.Title style={{ width, height: 24 }} />}
       />
     );
   };
@@ -125,7 +122,7 @@ const SkeletonWrapper = ({
         <Skeleton
           loading={true}
           active
-          placeholder={<Skeleton.Title active style={{ width, height }} />}
+          placeholder={<Skeleton.Title style={{ width, height }} />}
         />
       </div>
     );
@@ -140,7 +137,6 @@ const SkeletonWrapper = ({
           active
           placeholder={
             <Skeleton.Title
-              active
               style={{ width, height, borderRadius: 9999 }}
             />
           }
@@ -164,7 +160,7 @@ const SkeletonWrapper = ({
               loading={true}
               active
               placeholder={
-                <Skeleton.Avatar active size='extra-small' shape='square' />
+                <Skeleton.Avatar size='extra-small' shape='square' />
               }
             />
           </div>
@@ -174,7 +170,6 @@ const SkeletonWrapper = ({
             active
             placeholder={
               <Skeleton.Title
-                active
                 style={{ width: width || 80, height: height || 14 }}
               />
             }
@@ -191,10 +186,7 @@ const SkeletonWrapper = ({
           loading={true}
           active
           placeholder={
-            <Skeleton.Title
-              active
-              style={{ width: width || 60, height: height || 12 }}
-            />
+            <Skeleton.Title style={{ width: width || 60, height: height || 12 }} />
           }
         />
       </div>
@@ -217,7 +209,6 @@ const SkeletonWrapper = ({
         active
         placeholder={
           <Skeleton.Avatar
-            active
             shape='square'
             style={{ width: ICON_SIZE, height: ICON_SIZE }}
           />
@@ -231,7 +222,6 @@ const SkeletonWrapper = ({
         active
         placeholder={
           <Skeleton.Title
-            active
             style={{ width: labelWidth, height: TEXT_HEIGHT }}
           />
         }
@@ -269,7 +259,6 @@ const SkeletonWrapper = ({
           active
           placeholder={
             <Skeleton.Avatar
-              active
               shape='square'
               style={{ width: ICON_SIZE, height: ICON_SIZE }}
             />
@@ -329,7 +318,6 @@ const SkeletonWrapper = ({
                     active
                     placeholder={
                       <Skeleton.Title
-                        active
                         style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
                       />
                     }
@@ -350,7 +338,6 @@ const SkeletonWrapper = ({
                     active
                     placeholder={
                       <Skeleton.Title
-                        active
                         style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
                       />
                     }

+ 377 - 0
web/src/components/model-deployments/DeploymentAccessGuard.jsx

@@ -0,0 +1,377 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React from 'react';
+import { Card, Button, Typography } from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+import { Settings, Server, AlertCircle, WifiOff } from 'lucide-react';
+
+const { Title, Text } = Typography;
+
+const DeploymentAccessGuard = ({
+  children,
+  loading,
+  isEnabled,
+  connectionLoading,
+  connectionOk,
+  connectionError,
+  onRetry,
+}) => {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+
+  const handleGoToSettings = () => {
+    navigate('/console/setting?tab=model-deployment');
+  };
+
+  if (loading) {
+    return (
+      <div className='mt-[60px] px-2'>
+        <Card loading={true} style={{ minHeight: '400px' }}>
+          <div style={{ textAlign: 'center', padding: '50px 0' }}>
+            <Text type="secondary">{t('加载设置中...')}</Text>
+          </div>
+        </Card>
+      </div>
+    );
+  }
+
+  if (!isEnabled) {
+    return (
+      <div 
+        className='mt-[60px] px-4' 
+        style={{
+          minHeight: 'calc(100vh - 60px)',
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center'
+        }}
+      >
+        <div 
+          style={{
+            maxWidth: '600px',
+            width: '100%',
+            textAlign: 'center',
+            padding: '0 20px'
+          }}
+        >
+          <Card
+            style={{
+              padding: '60px 40px',
+              borderRadius: '16px',
+              border: '1px solid var(--semi-color-border)',
+              boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
+              background: 'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)'
+            }}
+          >
+            {/* 图标区域 */}
+            <div style={{ marginBottom: '32px' }}>
+              <div style={{ 
+                display: 'inline-flex', 
+                alignItems: 'center',
+                justifyContent: 'center',
+                width: '120px',
+                height: '120px',
+                borderRadius: '50%',
+                background: 'linear-gradient(135deg, rgba(var(--semi-orange-4), 0.15) 0%, rgba(var(--semi-orange-5), 0.1) 100%)',
+                border: '3px solid rgba(var(--semi-orange-4), 0.3)',
+                marginBottom: '24px'
+              }}>
+                <AlertCircle size={56} color="var(--semi-color-warning)" />
+              </div>
+            </div>
+
+            {/* 标题区域 */}
+            <div style={{ marginBottom: '24px' }}>
+              <Title 
+                heading={2} 
+                style={{ 
+                  color: 'var(--semi-color-text-0)', 
+                  margin: '0 0 12px 0',
+                  fontSize: '28px',
+                  fontWeight: '700'
+                }}
+              >
+                {t('模型部署服务未启用')}
+              </Title>
+              <Text 
+                style={{ 
+                  fontSize: '18px', 
+                  lineHeight: '1.6',
+                  color: 'var(--semi-color-text-1)',
+                  display: 'block'
+                }}
+              >
+                {t('访问模型部署功能需要先启用 io.net 部署服务')}
+              </Text>
+            </div>
+
+            {/* 配置要求区域 */}
+            <div 
+              style={{ 
+                backgroundColor: 'var(--semi-color-bg-1)', 
+                padding: '24px', 
+                borderRadius: '12px',
+                border: '1px solid var(--semi-color-border)',
+                margin: '32px 0',
+                boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)'
+              }}
+            >
+              <div style={{ 
+                display: 'flex', 
+                alignItems: 'center', 
+                justifyContent: 'center',
+                gap: '12px', 
+                marginBottom: '16px' 
+              }}>
+                <div style={{
+                  display: 'flex',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  width: '32px',
+                  height: '32px',
+                  borderRadius: '8px',
+                  backgroundColor: 'rgba(var(--semi-blue-4), 0.15)'
+                }}>
+                  <Server size={20} color="var(--semi-color-primary)" />
+                </div>
+                <Text 
+                  strong 
+                  style={{ 
+                    fontSize: '16px', 
+                    color: 'var(--semi-color-text-0)' 
+                  }}
+                >
+                  {t('需要配置的项目')}
+                </Text>
+              </div>
+              
+              <div style={{ 
+                display: 'flex', 
+                flexDirection: 'column', 
+                gap: '12px',
+                alignItems: 'flex-start',
+                textAlign: 'left',
+                maxWidth: '320px',
+                margin: '0 auto'
+              }}>
+                <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
+                  <div style={{
+                    width: '6px',
+                    height: '6px',
+                    borderRadius: '50%',
+                    backgroundColor: 'var(--semi-color-primary)',
+                    flexShrink: 0
+                  }}></div>
+                  <Text style={{ fontSize: '15px', color: 'var(--semi-color-text-1)' }}>
+                    {t('启用 io.net 部署开关')}
+                  </Text>
+                </div>
+                <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
+                  <div style={{
+                    width: '6px',
+                    height: '6px',
+                    borderRadius: '50%',
+                    backgroundColor: 'var(--semi-color-primary)',
+                    flexShrink: 0
+                  }}></div>
+                  <Text style={{ fontSize: '15px', color: 'var(--semi-color-text-1)' }}>
+                    {t('配置有效的 io.net API Key')}
+                  </Text>
+                </div>
+              </div>
+            </div>
+
+            {/* 操作链接区域 */}
+            <div style={{ marginBottom: '20px' }}>
+              <div 
+                onClick={handleGoToSettings}
+                style={{ 
+                  display: 'inline-flex',
+                  alignItems: 'center',
+                  gap: '8px',
+                  cursor: 'pointer',
+                  padding: '12px 24px',
+                  borderRadius: '8px',
+                  fontSize: '16px',
+                  fontWeight: '500',
+                  color: 'var(--semi-color-primary)',
+                  background: 'var(--semi-color-fill-0)',
+                  border: '1px solid var(--semi-color-border)',
+                  transition: 'all 0.2s ease',
+                  textDecoration: 'none'
+                }}
+                onMouseEnter={(e) => {
+                  e.target.style.background = 'var(--semi-color-fill-1)';
+                  e.target.style.transform = 'translateY(-1px)';
+                  e.target.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
+                }}
+                onMouseLeave={(e) => {
+                  e.target.style.background = 'var(--semi-color-fill-0)';
+                  e.target.style.transform = 'translateY(0)';
+                  e.target.style.boxShadow = 'none';
+                }}
+              >
+                <Settings size={18} />
+                {t('前往设置页面')}
+              </div>
+            </div>
+
+            {/* 底部提示 */}
+            <Text 
+              type="tertiary" 
+              style={{ 
+                fontSize: '14px',
+                color: 'var(--semi-color-text-2)',
+                lineHeight: '1.5'
+              }}
+            >
+              {t('配置完成后刷新页面即可使用模型部署功能')}
+            </Text>
+          </Card>
+        </div>
+      </div>
+    );
+  }
+
+  if (connectionLoading || (connectionOk === null && !connectionError)) {
+    return (
+      <div className='mt-[60px] px-2'>
+        <Card loading={true} style={{ minHeight: '400px' }}>
+          <div style={{ textAlign: 'center', padding: '50px 0' }}>
+            <Text type="secondary">{t('Checking io.net connection...')}</Text>
+          </div>
+        </Card>
+      </div>
+    );
+  }
+
+  if (connectionOk === false) {
+    const isExpired = connectionError?.type === 'expired';
+    const title = isExpired
+      ? t('API key expired')
+      : t('io.net connection unavailable');
+    const description = isExpired
+      ? t('The current API key is expired. Please update it in settings.')
+      : t('Unable to connect to io.net with the current configuration.');
+    const detail = connectionError?.message || '';
+
+    return (
+      <div
+        className='mt-[60px] px-4'
+        style={{
+          minHeight: 'calc(100vh - 60px)',
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+        }}
+      >
+        <div
+          style={{
+            maxWidth: '600px',
+            width: '100%',
+            textAlign: 'center',
+            padding: '0 20px',
+          }}
+        >
+          <Card
+            style={{
+              padding: '60px 40px',
+              borderRadius: '16px',
+              border: '1px solid var(--semi-color-border)',
+              boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
+              background: 'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)',
+            }}
+          >
+            <div style={{ marginBottom: '32px' }}>
+              <div
+                style={{
+                  display: 'inline-flex',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  width: '120px',
+                  height: '120px',
+                  borderRadius: '50%',
+                  background: 'linear-gradient(135deg, rgba(var(--semi-red-4), 0.15) 0%, rgba(var(--semi-red-5), 0.1) 100%)',
+                  border: '3px solid rgba(var(--semi-red-4), 0.3)',
+                  marginBottom: '24px',
+                }}
+              >
+                <WifiOff size={56} color="var(--semi-color-danger)" />
+              </div>
+            </div>
+
+            <div style={{ marginBottom: '24px' }}>
+              <Title
+                heading={2}
+                style={{
+                  color: 'var(--semi-color-text-0)',
+                  margin: '0 0 12px 0',
+                  fontSize: '28px',
+                  fontWeight: '700',
+                }}
+              >
+                {title}
+              </Title>
+              <Text
+                style={{
+                  fontSize: '18px',
+                  lineHeight: '1.6',
+                  color: 'var(--semi-color-text-1)',
+                  display: 'block',
+                }}
+              >
+                {description}
+              </Text>
+              {detail ? (
+                <Text
+                  type="tertiary"
+                  style={{
+                    fontSize: '14px',
+                    lineHeight: '1.5',
+                    display: 'block',
+                    marginTop: '8px',
+                  }}
+                >
+                  {detail}
+                </Text>
+              ) : null}
+            </div>
+
+            <div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
+              <Button type="primary" icon={<Settings size={18} />} onClick={handleGoToSettings}>
+                {t('Go to settings')}
+              </Button>
+              {onRetry ? (
+                <Button type="tertiary" onClick={onRetry}>
+                  {t('Retry connection')}
+                </Button>
+              ) : null}
+            </div>
+          </Card>
+        </div>
+      </div>
+    );
+  }
+
+  return children;
+};
+
+export default DeploymentAccessGuard;

+ 85 - 0
web/src/components/settings/ModelDeploymentSetting.jsx

@@ -0,0 +1,85 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useEffect, useState } from 'react';
+import { Card, Spin } from '@douyinfe/semi-ui';
+import { API, showError, toBoolean } from '../../helpers';
+import { useTranslation } from 'react-i18next';
+import SettingModelDeployment from '../../pages/Setting/Model/SettingModelDeployment';
+
+const ModelDeploymentSetting = () => {
+  const { t } = useTranslation();
+  let [inputs, setInputs] = useState({
+    'model_deployment.ionet.api_key': '',
+    'model_deployment.ionet.enabled': false,
+  });
+
+  let [loading, setLoading] = useState(false);
+
+  const getOptions = async () => {
+    const res = await API.get('/api/option/');
+    const { success, message, data } = res.data;
+    if (success) {
+      let newInputs = {
+        'model_deployment.ionet.api_key': '',
+        'model_deployment.ionet.enabled': false,
+      };
+      
+      data.forEach((item) => {
+        if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
+          newInputs[item.key] = toBoolean(item.value);
+        } else {
+          newInputs[item.key] = item.value;
+        }
+      });
+
+      setInputs(newInputs);
+    } else {
+      showError(message);
+    }
+  };
+
+  async function onRefresh() {
+    try {
+      setLoading(true);
+      await getOptions();
+    } catch (error) {
+      showError('刷新失败');
+      console.error(error);
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  useEffect(() => {
+    onRefresh();
+  }, []);
+
+  return (
+    <>
+      <Spin spinning={loading} size='large'>
+        <Card style={{ marginTop: '10px' }}>
+          <SettingModelDeployment options={inputs} refresh={onRefresh} />
+        </Card>
+      </Spin>
+    </>
+  );
+};
+
+export default ModelDeploymentSetting;

+ 68 - 8
web/src/components/table/channels/ChannelsColumnDefs.jsx

@@ -47,7 +47,8 @@ import {
 import { FaRandom } from 'react-icons/fa';
 
 // Render functions
-const renderType = (type, channelInfo = undefined, t) => {
+const renderType = (type, record = {}, t) => {
+  const channelInfo = record?.channel_info;
   let type2label = new Map();
   for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
     type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
@@ -71,11 +72,65 @@ const renderType = (type, channelInfo = undefined, t) => {
       );
   }
 
-  return (
+  const typeTag = (
     <Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
       {type2label[type]?.label}
     </Tag>
   );
+
+  let ionetMeta = null;
+  if (record?.other_info) {
+    try {
+      const parsed = JSON.parse(record.other_info);
+      if (parsed && typeof parsed === 'object' && parsed.source === 'ionet') {
+        ionetMeta = parsed;
+      }
+    } catch (error) {
+      // ignore invalid metadata
+    }
+  }
+
+  if (!ionetMeta) {
+    return typeTag;
+  }
+
+  const handleNavigate = (event) => {
+    event?.stopPropagation?.();
+    if (!ionetMeta?.deployment_id) {
+      return;
+    }
+    const targetUrl = `/console/deployment?deployment_id=${ionetMeta.deployment_id}`;
+    window.open(targetUrl, '_blank', 'noopener');
+  };
+
+  return (
+    <Space spacing={6}>
+      {typeTag}
+      <Tooltip
+        content={
+          <div className='max-w-xs'>
+            <div className='text-xs text-gray-600'>{t('来源于 IO.NET 部署')}</div>
+            {ionetMeta?.deployment_id && (
+              <div className='text-xs text-gray-500 mt-1'>
+                {t('部署 ID')}: {ionetMeta.deployment_id}
+              </div>
+            )}
+          </div>
+        }
+      >
+        <span>
+          <Tag
+            color='purple'
+            type='light'
+            className='cursor-pointer'
+            onClick={handleNavigate}
+          >
+            IO.NET
+          </Tag>
+        </span>
+      </Tooltip>
+    </Space>
+  );
 };
 
 const renderTagType = (t) => {
@@ -231,6 +286,7 @@ export const getChannelsColumns = ({
   refresh,
   activePage,
   channels,
+  checkOllamaVersion,
   setShowMultiKeyManageModal,
   setCurrentMultiKeyChannel,
 }) => {
@@ -330,12 +386,7 @@ export const getChannelsColumns = ({
       dataIndex: 'type',
       render: (text, record, index) => {
         if (record.children === undefined) {
-          if (record.channel_info) {
-            if (record.channel_info.is_multi_key) {
-              return <>{renderType(text, record.channel_info, t)}</>;
-            }
-          }
-          return <>{renderType(text, undefined, t)}</>;
+          return <>{renderType(text, record, t)}</>;
         } else {
           return <>{renderTagType(t)}</>;
         }
@@ -569,6 +620,15 @@ export const getChannelsColumns = ({
             },
           ];
 
+          if (record.type === 4) {
+            moreMenuItems.unshift({
+              node: 'item',
+              name: t('测活'),
+              type: 'tertiary',
+              onClick: () => checkOllamaVersion(record),
+            });
+          }
+
           return (
             <Space wrap>
               <SplitButtonGroup

+ 3 - 0
web/src/components/table/channels/ChannelsTable.jsx

@@ -57,6 +57,7 @@ const ChannelsTable = (channelsData) => {
     setEditingTag,
     copySelectedChannel,
     refresh,
+    checkOllamaVersion,
     // Multi-key management
     setShowMultiKeyManageModal,
     setCurrentMultiKeyChannel,
@@ -82,6 +83,7 @@ const ChannelsTable = (channelsData) => {
       refresh,
       activePage,
       channels,
+      checkOllamaVersion,
       setShowMultiKeyManageModal,
       setCurrentMultiKeyChannel,
     });
@@ -103,6 +105,7 @@ const ChannelsTable = (channelsData) => {
     refresh,
     activePage,
     channels,
+    checkOllamaVersion,
     setShowMultiKeyManageModal,
     setCurrentMultiKeyChannel,
   ]);

+ 394 - 287
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -55,6 +55,7 @@ import {
   selectFilter,
 } from '../../../../helpers';
 import ModelSelectModal from './ModelSelectModal';
+import OllamaModelModal from './OllamaModelModal';
 import JSONEditor from '../../../common/ui/JSONEditor';
 import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
 import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
@@ -180,6 +181,7 @@ const EditChannelModal = (props) => {
   const [isModalOpenurl, setIsModalOpenurl] = useState(false);
   const [modelModalVisible, setModelModalVisible] = useState(false);
   const [fetchedModels, setFetchedModels] = useState([]);
+  const [ollamaModalVisible, setOllamaModalVisible] = useState(false);
   const formApiRef = useRef(null);
   const [vertexKeys, setVertexKeys] = useState([]);
   const [vertexFileList, setVertexFileList] = useState([]);
@@ -214,6 +216,8 @@ const EditChannelModal = (props) => {
       return [];
     }
   }, [inputs.model_mapping]);
+  const [isIonetChannel, setIsIonetChannel] = useState(false);
+  const [ionetMetadata, setIonetMetadata] = useState(null);
 
   // 密钥显示状态
   const [keyDisplayState, setKeyDisplayState] = useState({
@@ -224,6 +228,21 @@ const EditChannelModal = (props) => {
   // 专门的2FA验证状态(用于TwoFactorAuthModal)
   const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
   const [verifyCode, setVerifyCode] = useState('');
+
+  useEffect(() => {
+    if (!isEdit) {
+      setIsIonetChannel(false);
+      setIonetMetadata(null);
+    }
+  }, [isEdit]);
+
+  const handleOpenIonetDeployment = () => {
+    if (!ionetMetadata?.deployment_id) {
+      return;
+    }
+    const targetUrl = `/console/deployment?deployment_id=${ionetMetadata.deployment_id}`;
+    window.open(targetUrl, '_blank', 'noopener');
+  };
   const [verifyLoading, setVerifyLoading] = useState(false);
 
   // 表单块导航相关状态
@@ -404,7 +423,12 @@ const EditChannelModal = (props) => {
     handleInputChange('settings', settingsJson);
   };
 
+  const isIonetLocked = isIonetChannel && isEdit;
+
   const handleInputChange = (name, value) => {
+    if (isIonetChannel && isEdit && ['type', 'key', 'base_url'].includes(name)) {
+      return;
+    }
     if (formApiRef.current) {
       formApiRef.current.setValue(name, value);
     }
@@ -625,6 +649,25 @@ const EditChannelModal = (props) => {
         .map((model) => (model || '').trim())
         .filter(Boolean);
       initialModelMappingRef.current = data.model_mapping || '';
+
+      let parsedIonet = null;
+      if (data.other_info) {
+        try {
+          const maybeMeta = JSON.parse(data.other_info);
+          if (
+            maybeMeta &&
+            typeof maybeMeta === 'object' &&
+            maybeMeta.source === 'ionet'
+          ) {
+            parsedIonet = maybeMeta;
+          }
+        } catch (error) {
+          // ignore parse error
+        }
+      }
+      const managedByIonet = !!parsedIonet;
+      setIsIonetChannel(managedByIonet);
+      setIonetMetadata(parsedIonet);
       // console.log(data);
     } else {
       showError(message);
@@ -632,7 +675,8 @@ const EditChannelModal = (props) => {
     setLoading(false);
   };
 
-  const fetchUpstreamModelList = async (name) => {
+  const fetchUpstreamModelList = async (name, options = {}) => {
+    const silent = !!options.silent;
     // if (inputs['type'] !== 1) {
     //   showError(t('仅支持 OpenAI 接口格式'));
     //   return;
@@ -683,7 +727,9 @@ const EditChannelModal = (props) => {
     if (!err) {
       const uniqueModels = Array.from(new Set(models));
       setFetchedModels(uniqueModels);
-      setModelModalVisible(true);
+      if (!silent) {
+        setModelModalVisible(true);
+      }
     } else {
       showError(t('获取模型列表失败'));
     }
@@ -1626,20 +1672,44 @@ const EditChannelModal = (props) => {
                       </div>
                     </div>
 
-                    <Form.Select
-                      field='type'
-                      label={t('类型')}
-                      placeholder={t('请选择渠道类型')}
-                      rules={[{ required: true, message: t('请选择渠道类型') }]}
-                      optionList={channelOptionList}
-                      style={{ width: '100%' }}
-                      filter={selectFilter}
-                      autoClearSearchValue={false}
-                      searchPosition='dropdown'
-                      onSearch={(value) => setChannelSearchValue(value)}
-                      renderOptionItem={renderChannelOption}
-                      onChange={(value) => handleInputChange('type', value)}
-                    />
+                    {isIonetChannel && (
+                    <Banner
+                      type='info'
+                      closeIcon={null}
+                      className='mb-4 rounded-xl'
+                      description={t('此渠道由 IO.NET 自动同步,类型、密钥和 API 地址已锁定。')}
+                    >
+                      <Space>
+                        {ionetMetadata?.deployment_id && (
+                          <Button
+                            size='small'
+                            theme='light'
+                            type='primary'
+                            icon={<IconGlobe />}
+                            onClick={handleOpenIonetDeployment}
+                          >
+                            {t('查看关联部署')}
+                          </Button>
+                        )}
+                      </Space>
+                    </Banner>
+                  )}
+
+                  <Form.Select
+                    field='type'
+                    label={t('类型')}
+                    placeholder={t('请选择渠道类型')}
+                    rules={[{ required: true, message: t('请选择渠道类型') }]}
+                    optionList={channelOptionList}
+                    style={{ width: '100%' }}
+                    filter={selectFilter}
+                    autoClearSearchValue={false}
+                    searchPosition='dropdown'
+                    onSearch={(value) => setChannelSearchValue(value)}
+                    renderOptionItem={renderChannelOption}
+                    onChange={(value) => handleInputChange('type', value)}
+                    disabled={isIonetLocked}
+                  />
 
                     {inputs.type === 20 && (
                       <Form.Switch
@@ -1778,87 +1848,86 @@ const EditChannelModal = (props) => {
                           autosize
                           autoComplete='new-password'
                           onChange={(value) => handleInputChange('key', value)}
-                          extraText={
-                            <div className='flex items-center gap-2 flex-wrap'>
-                              {isEdit &&
-                                isMultiKeyChannel &&
-                                keyMode === 'append' && (
-                                  <Text type='warning' size='small'>
-                                    {t(
-                                      '追加模式:新密钥将添加到现有密钥列表的末尾',
-                                    )}
-                                  </Text>
-                                )}
-                              {isEdit && (
+                          disabled={isIonetLocked}
+                        extraText={
+                          <div className='flex items-center gap-2 flex-wrap'>
+                            {isEdit &&
+                              isMultiKeyChannel &&
+                              keyMode === 'append' && (
+                                <Text type='warning' size='small'>
+                                  {t(
+                                    '追加模式:新密钥将添加到现有密钥列表的末尾',
+                                  )}
+                                </Text>
+                              )}
+                            {isEdit && (
+                              <Button
+                                size='small'
+                                type='primary'
+                                theme='outline'
+                                onClick={handleShow2FAModal}
+                              >
+                                {t('查看密钥')}
+                              </Button>
+                            )}
+                            {batchExtra}
+                          </div>
+                        }
+                        showClear
+                      />
+                    )
+                  ) : (
+                    <>
+                      {inputs.type === 41 &&
+                      (inputs.vertex_key_type || 'json') === 'json' ? (
+                        <>
+                          {!batch && (
+                            <div className='flex items-center justify-between mb-3'>
+                              <Text className='text-sm font-medium'>
+                                {t('密钥输入方式')}
+                              </Text>
+                              <Space>
                                 <Button
                                   size='small'
-                                  type='primary'
-                                  theme='outline'
-                                  onClick={handleShow2FAModal}
+                                  type={
+                                    !useManualInput ? 'primary' : 'tertiary'
+                                  }
+                                  onClick={() => {
+                                    setUseManualInput(false);
+                                    // 切换到文件上传模式时清空手动输入的密钥
+                                    if (formApiRef.current) {
+                                      formApiRef.current.setValue('key', '');
+                                    }
+                                    handleInputChange('key', '');
+                                  }}
                                 >
-                                  {t('查看密钥')}
+                                  {t('文件上传')}
                                 </Button>
-                              )}
-                              {batchExtra}
-                            </div>
-                          }
-                          showClear
-                        />
-                      )
-                    ) : (
-                      <>
-                        {inputs.type === 41 &&
-                        (inputs.vertex_key_type || 'json') === 'json' ? (
-                          <>
-                            {!batch && (
-                              <div className='flex items-center justify-between mb-3'>
-                                <Text className='text-sm font-medium'>
-                                  {t('密钥输入方式')}
-                                </Text>
-                                <Space>
-                                  <Button
-                                    size='small'
-                                    type={
-                                      !useManualInput ? 'primary' : 'tertiary'
-                                    }
-                                    onClick={() => {
-                                      setUseManualInput(false);
-                                      // 切换到文件上传模式时清空手动输入的密钥
-                                      if (formApiRef.current) {
-                                        formApiRef.current.setValue('key', '');
-                                      }
-                                      handleInputChange('key', '');
-                                    }}
-                                  >
-                                    {t('文件上传')}
-                                  </Button>
-                                  <Button
-                                    size='small'
-                                    type={
-                                      useManualInput ? 'primary' : 'tertiary'
+                                <Button
+                                  size='small'
+                                  type={useManualInput ? 'primary' : 'tertiary'}
+                                  onClick={() => {
+                                    setUseManualInput(true);
+                                    // 切换到手动输入模式时清空文件上传相关状态
+                                    setVertexKeys([]);
+                                    setVertexFileList([]);
+                                    if (formApiRef.current) {
+                                      formApiRef.current.setValue(
+                                        'vertex_files',
+                                        [],
+                                      );
                                     }
-                                    onClick={() => {
-                                      setUseManualInput(true);
-                                      // 切换到手动输入模式时清空文件上传相关状态
-                                      setVertexKeys([]);
-                                      setVertexFileList([]);
-                                      if (formApiRef.current) {
-                                        formApiRef.current.setValue(
-                                          'vertex_files',
-                                          [],
-                                        );
-                                      }
-                                      setInputs((prev) => ({
-                                        ...prev,
-                                        vertex_files: [],
-                                      }));
-                                    }}
-                                  >
-                                    {t('手动输入')}
-                                  </Button>
-                                </Space>
-                              </div>
-                            )}
+                                    setInputs((prev) => ({
+                                      ...prev,
+                                      vertex_files: [],
+                                    }));
+                                  }}
+                                >
+                                  {t('手动输入')}
+                                </Button>
+                              </Space>
+                            </div>
+                          )}
 
                             {batch && (
                               <Banner
@@ -2189,84 +2258,86 @@ const EditChannelModal = (props) => {
                         />
                       )}
 
-                      {inputs.type === 3 && (
-                        <>
-                          <Banner
-                            type='warning'
-                            description={t(
-                              '2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的"."',
+                    {inputs.type === 3 && (
+                      <>
+                        <Banner
+                          type='warning'
+                          description={t(
+                            '2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的"."',
+                          )}
+                          className='!rounded-lg'
+                        />
+                        <div>
+                          <Form.Input
+                            field='base_url'
+                            label='AZURE_OPENAI_ENDPOINT'
+                            placeholder={t(
+                              '请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com',
                             )}
-                            className='!rounded-lg'
+                            onChange={(value) =>
+                              handleInputChange('base_url', value)
+                            }
+                            showClear
+                            disabled={isIonetLocked}
                           />
-                          <div>
-                            <Form.Input
-                              field='base_url'
-                              label='AZURE_OPENAI_ENDPOINT'
-                              placeholder={t(
-                                '请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com',
-                              )}
-                              onChange={(value) =>
-                                handleInputChange('base_url', value)
-                              }
-                              showClear
-                            />
-                          </div>
-                          <div>
-                            <Form.Input
-                              field='other'
-                              label={t('默认 API 版本')}
-                              placeholder={t(
-                                '请输入默认 API 版本,例如:2025-04-01-preview',
-                              )}
-                              onChange={(value) =>
-                                handleInputChange('other', value)
-                              }
-                              showClear
-                            />
-                          </div>
-                          <div>
-                            <Form.Input
-                              field='azure_responses_version'
-                              label={t(
-                                '默认 Responses API 版本,为空则使用上方版本',
-                              )}
-                              placeholder={t('例如:preview')}
-                              onChange={(value) =>
-                                handleChannelOtherSettingsChange(
-                                  'azure_responses_version',
-                                  value,
-                                )
-                              }
-                              showClear
-                            />
-                          </div>
-                        </>
-                      )}
+                        </div>
+                        <div>
+                          <Form.Input
+                            field='other'
+                            label={t('默认 API 版本')}
+                            placeholder={t(
+                              '请输入默认 API 版本,例如:2025-04-01-preview',
+                            )}
+                            onChange={(value) =>
+                              handleInputChange('other', value)
+                            }
+                            showClear
+                          />
+                        </div>
+                        <div>
+                          <Form.Input
+                            field='azure_responses_version'
+                            label={t(
+                              '默认 Responses API 版本,为空则使用上方版本',
+                            )}
+                            placeholder={t('例如:preview')}
+                            onChange={(value) =>
+                              handleChannelOtherSettingsChange(
+                                'azure_responses_version',
+                                value,
+                              )
+                            }
+                            showClear
+                          />
+                        </div>
+                      </>
+                    )}
 
-                      {inputs.type === 8 && (
-                        <>
-                          <Banner
-                            type='warning'
-                            description={t(
-                              '如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。',
+                    {inputs.type === 8 && (
+                      <>
+                        <Banner
+                          type='warning'
+                          description={t(
+                            '如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。',
+                          )}
+                          className='!rounded-lg'
+                        />
+                        <div>
+                          <Form.Input
+                            field='base_url'
+                            label={t('完整的 Base URL,支持变量{model}')}
+                            placeholder={t(
+                              '请输入完整的URL,例如:https://api.openai.com/v1/chat/completions',
                             )}
-                            className='!rounded-lg'
+                            onChange={(value) =>
+                              handleInputChange('base_url', value)
+                            }
+                            showClear
+                            disabled={isIonetLocked}
                           />
-                          <div>
-                            <Form.Input
-                              field='base_url'
-                              label={t('完整的 Base URL,支持变量{model}')}
-                              placeholder={t(
-                                '请输入完整的URL,例如:https://api.openai.com/v1/chat/completions',
-                              )}
-                              onChange={(value) =>
-                                handleInputChange('base_url', value)
-                              }
-                              showClear
-                            />
-                          </div>
-                        </>
-                      )}
+                        </div>
+                      </>
+                    )}
 
                       {inputs.type === 37 && (
                         <Banner
@@ -2294,76 +2365,77 @@ const EditChannelModal = (props) => {
                                 handleInputChange('base_url', value)
                               }
                               showClear
-                              extraText={t(
-                                '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写',
-                              )}
-                            />
-                          </div>
-                        )}
-
-                      {inputs.type === 22 && (
-                        <div>
-                          <Form.Input
-                            field='base_url'
-                            label={t('私有部署地址')}
-                            placeholder={t(
-                              '请输入私有部署地址,格式为:https://fastgpt.run/api/openapi',
+                              disabled={isIonetLocked}
+                            extraText={t(
+                              '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写',
                             )}
-                            onChange={(value) =>
-                              handleInputChange('base_url', value)
-                            }
-                            showClear
                           />
                         </div>
                       )}
 
-                      {inputs.type === 36 && (
-                        <div>
-                          <Form.Input
-                            field='base_url'
-                            label={t(
-                              '注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用',
-                            )}
-                            placeholder={t(
-                              '请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com',
-                            )}
-                            onChange={(value) =>
-                              handleInputChange('base_url', value)
-                            }
-                            showClear
-                          />
-                        </div>
+                    {inputs.type === 22 && (
+                      <div>
+                        <Form.Input
+                          field='base_url'
+                          label={t('私有部署地址')}
+                          placeholder={t(
+                            '请输入私有部署地址,格式为:https://fastgpt.run/api/openapi',
+                          )}
+                          onChange={(value) =>
+                            handleInputChange('base_url', value)
+                          }
+                          showClear
+                          disabled={isIonetLocked}
+                        />
+                      </div>
+                    )}
+
+                    {inputs.type === 36 && (
+                      <div>
+                        <Form.Input
+                          field='base_url'
+                          label={t(
+                            '注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用',
+                          )}
+                      placeholder={t(
+                        '请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com',
                       )}
+                      onChange={(value) =>
+                        handleInputChange('base_url', value)
+                      }
+                      showClear
+                      disabled={isIonetLocked}
+                    />
+                  </div>
+                )}
 
-                      {inputs.type === 45 && !doubaoApiEditUnlocked && (
-                        <div>
-                          <Form.Select
-                            field='base_url'
-                            label={t('API地址')}
-                            placeholder={t('请选择API地址')}
-                            onChange={(value) =>
+                {inputs.type === 45 && !doubaoApiEditUnlocked && (
+                    <div>
+                      <Form.Select
+                          field='base_url'
+                          label={t('API地址')}
+                          placeholder={t('请选择API地址')}
+                          onChange={(value) =>
                               handleInputChange('base_url', value)
-                            }
-                            optionList={[
-                              {
-                                value: 'https://ark.cn-beijing.volces.com',
-                                label: 'https://ark.cn-beijing.volces.com',
-                              },
-                              {
-                                value:
-                                  'https://ark.ap-southeast.bytepluses.com',
-                                label:
-                                  'https://ark.ap-southeast.bytepluses.com',
-                              },
-                                {
-                                    value: 'doubao-coding-plan',
+                          }
+                          optionList={[
+                            {
+                              value: 'https://ark.cn-beijing.volces.com',
+                              label: 'https://ark.cn-beijing.volces.com',
+                            },
+                            {
+                              value: 'https://ark.ap-southeast.bytepluses.com',
+                              label: 'https://ark.ap-southeast.bytepluses.com',
+                            },
+                          {
+                          value: 'doubao-coding-plan',
                                     label: 'Doubao Coding Plan',
                                 },
-                            ]}
-                            defaultValue='https://ark.cn-beijing.volces.com'
-                          />
-                        </div>
-                      )}
+                            ]}defaultValue='https://ark.cn-beijing.volces.com'
+                          disabled={isIonetLocked}
+                      />
+                    </div>
+                )}
                     </Card>
                   </div>
                 )}
@@ -2458,72 +2530,80 @@ const EditChannelModal = (props) => {
                               {t('获取模型列表')}
                             </Button>
                           )}
+                          {inputs.type === 4 && isEdit && (
                           <Button
                             size='small'
-                            type='warning'
-                            onClick={() => handleInputChange('models', [])}
-                          >
-                            {t('清除所有模型')}
-                          </Button>
-                          <Button
-                            size='small'
-                            type='tertiary'
-                            onClick={() => {
-                              if (inputs.models.length === 0) {
-                                showInfo(t('没有模型可以复制'));
-                                return;
-                              }
-                              try {
-                                copy(inputs.models.join(','));
-                                showSuccess(t('模型列表已复制到剪贴板'));
-                              } catch (error) {
-                                showError(t('复制失败'));
-                              }
-                            }}
+                            type='primary'
+                            theme='light'
+                            onClick={() => setOllamaModalVisible(true)}
                           >
-                            {t('复制所有模型')}
+                            {t('Ollama 模型管理')}
                           </Button>
-                          {modelGroups &&
-                            modelGroups.length > 0 &&
-                            modelGroups.map((group) => (
-                              <Button
-                                key={group.id}
-                                size='small'
-                                type='primary'
-                                onClick={() => {
-                                  let items = [];
-                                  try {
-                                    if (Array.isArray(group.items)) {
-                                      items = group.items;
-                                    } else if (
-                                      typeof group.items === 'string'
-                                    ) {
-                                      const parsed = JSON.parse(
-                                        group.items || '[]',
-                                      );
-                                      if (Array.isArray(parsed)) items = parsed;
-                                    }
-                                  } catch {}
-                                  const current =
-                                    formApiRef.current?.getValue('models') ||
-                                    inputs.models ||
-                                    [];
-                                  const merged = Array.from(
-                                    new Set(
-                                      [...current, ...items]
-                                        .map((m) => (m || '').trim())
-                                        .filter(Boolean),
-                                    ),
-                                  );
-                                  handleInputChange('models', merged);
-                                }}
-                              >
-                                {group.name}
-                              </Button>
-                            ))}
-                        </Space>
-                      }
-                    />
+                        )}
+                        <Button
+                          size='small'
+                          type='warning'
+                          onClick={() => handleInputChange('models', [])}
+                        >
+                          {t('清除所有模型')}
+                        </Button>
+                        <Button
+                          size='small'
+                          type='tertiary'
+                          onClick={() => {
+                            if (inputs.models.length === 0) {
+                              showInfo(t('没有模型可以复制'));
+                              return;
+                            }
+                            try {
+                              copy(inputs.models.join(','));
+                              showSuccess(t('模型列表已复制到剪贴板'));
+                            } catch (error) {
+                              showError(t('复制失败'));
+                            }
+                          }}
+                        >
+                          {t('复制所有模型')}
+                        </Button>
+                        {modelGroups &&
+                          modelGroups.length > 0 &&
+                          modelGroups.map((group) => (
+                            <Button
+                              key={group.id}
+                              size='small'
+                              type='primary'
+                              onClick={() => {
+                                let items = [];
+                                try {
+                                  if (Array.isArray(group.items)) {
+                                    items = group.items;
+                                  } else if (typeof group.items === 'string') {
+                                    const parsed = JSON.parse(
+                                      group.items || '[]',
+                                    );
+                                    if (Array.isArray(parsed)) items = parsed;
+                                  }
+                                } catch {}
+                                const current =
+                                  formApiRef.current?.getValue('models') ||
+                                  inputs.models ||
+                                  [];
+                                const merged = Array.from(
+                                  new Set(
+                                    [...current, ...items]
+                                      .map((m) => (m || '').trim())
+                                      .filter(Boolean),
+                                  ),
+                                );
+                                handleInputChange('models', merged);
+                              }}
+                            >
+                              {group.name}
+                            </Button>
+                          ))}
+                      </Space>
+                    }
+                  />
 
                     <Form.Input
                       field='custom_model'
@@ -3083,6 +3163,33 @@ const EditChannelModal = (props) => {
         }}
         onCancel={() => setModelModalVisible(false)}
       />
+
+      <OllamaModelModal
+        visible={ollamaModalVisible}
+        onCancel={() => setOllamaModalVisible(false)}
+        channelId={channelId}
+        channelInfo={inputs}
+        onModelsUpdate={(options = {}) => {
+          // 当模型更新后,重新获取模型列表以更新表单
+          fetchUpstreamModelList('models', { silent: !!options.silent });
+        }}
+        onApplyModels={({ mode, modelIds } = {}) => {
+          if (!Array.isArray(modelIds) || modelIds.length === 0) {
+            return;
+          }
+          const existingModels = Array.isArray(inputs.models)
+            ? inputs.models.map(String)
+            : [];
+          const incoming = modelIds.map(String);
+          const nextModels = Array.from(new Set([...existingModels, ...incoming]));
+
+          handleInputChange('models', nextModels);
+          if (formApiRef.current) {
+            formApiRef.current.setValue('models', nextModels);
+          }
+          showSuccess(t('模型列表已追加更新'));
+        }}
+      />
     </>
   );
 };

+ 16 - 3
web/src/components/table/channels/modals/ModelSelectModal.jsx

@@ -47,7 +47,20 @@ const ModelSelectModal = ({
   onCancel,
 }) => {
   const { t } = useTranslation();
-  const [checkedList, setCheckedList] = useState(selected);
+
+  const getModelName = (model) => {
+    if (!model) return '';
+    if (typeof model === 'string') return model;
+    if (typeof model === 'object' && model.model_name) return model.model_name;
+    return String(model ?? '');
+  };
+
+  const normalizedSelected = useMemo(
+    () => (selected || []).map(getModelName),
+    [selected],
+  );
+
+  const [checkedList, setCheckedList] = useState(normalizedSelected);
   const [keyword, setKeyword] = useState('');
   const [activeTab, setActiveTab] = useState('new');
 
@@ -105,9 +118,9 @@ const ModelSelectModal = ({
   // 同步外部选中值
   useEffect(() => {
     if (visible) {
-      setCheckedList(selected);
+      setCheckedList(normalizedSelected);
     }
-  }, [visible, selected]);
+  }, [visible, normalizedSelected]);
 
   // 当模型列表变化时,设置默认tab
   useEffect(() => {

+ 806 - 0
web/src/components/table/channels/modals/OllamaModelModal.jsx

@@ -0,0 +1,806 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal,
+  Button,
+  Typography,
+  Card,
+  List,
+  Space,
+  Input,
+  Spin,
+  Popconfirm,
+  Tag,
+  Avatar,
+  Empty,
+  Divider,
+  Row,
+  Col,
+  Progress,
+  Checkbox,
+  Radio,
+} from '@douyinfe/semi-ui';
+import {
+  IconClose,
+  IconDownload,
+  IconDelete,
+  IconRefresh,
+  IconSearch,
+  IconPlus,
+  IconServer,
+} from '@douyinfe/semi-icons';
+import {
+  API,
+  authHeader,
+  getUserIdFromLocalStorage,
+  showError,
+  showInfo,
+  showSuccess,
+} from '../../../../helpers';
+
+const { Text, Title } = Typography;
+
+const CHANNEL_TYPE_OLLAMA = 4;
+
+const parseMaybeJSON = (value) => {
+  if (!value) return null;
+  if (typeof value === 'object') return value;
+  if (typeof value === 'string') {
+    try {
+      return JSON.parse(value);
+    } catch (error) {
+      return null;
+    }
+  }
+  return null;
+};
+
+const resolveOllamaBaseUrl = (info) => {
+  if (!info) {
+    return '';
+  }
+
+  const direct = typeof info.base_url === 'string' ? info.base_url.trim() : '';
+  if (direct) {
+    return direct;
+  }
+
+  const alt =
+    typeof info.ollama_base_url === 'string'
+      ? info.ollama_base_url.trim()
+      : '';
+  if (alt) {
+    return alt;
+  }
+
+  const parsed = parseMaybeJSON(info.other_info);
+  if (parsed && typeof parsed === 'object') {
+    const candidate =
+      (typeof parsed.base_url === 'string' && parsed.base_url.trim()) ||
+      (typeof parsed.public_url === 'string' && parsed.public_url.trim()) ||
+      (typeof parsed.api_url === 'string' && parsed.api_url.trim());
+    if (candidate) {
+      return candidate;
+    }
+  }
+
+  return '';
+};
+
+const normalizeModels = (items) => {
+  if (!Array.isArray(items)) {
+    return [];
+  }
+
+  return items
+    .map((item) => {
+      if (!item) {
+        return null;
+      }
+
+      if (typeof item === 'string') {
+        return {
+          id: item,
+          owned_by: 'ollama',
+        };
+      }
+
+      if (typeof item === 'object') {
+        const candidateId = item.id || item.ID || item.name || item.model || item.Model;
+        if (!candidateId) {
+          return null;
+        }
+
+        const metadata = item.metadata || item.Metadata;
+        const normalized = {
+          ...item,
+          id: candidateId,
+          owned_by: item.owned_by || item.ownedBy || 'ollama',
+        };
+
+        if (typeof item.size === 'number' && !normalized.size) {
+          normalized.size = item.size;
+        }
+        if (metadata && typeof metadata === 'object') {
+          if (typeof metadata.size === 'number' && !normalized.size) {
+            normalized.size = metadata.size;
+          }
+          if (!normalized.digest && typeof metadata.digest === 'string') {
+            normalized.digest = metadata.digest;
+          }
+          if (!normalized.modified_at && typeof metadata.modified_at === 'string') {
+            normalized.modified_at = metadata.modified_at;
+          }
+          if (metadata.details && !normalized.details) {
+            normalized.details = metadata.details;
+          }
+        }
+
+        return normalized;
+      }
+
+      return null;
+    })
+    .filter(Boolean);
+};
+
+const OllamaModelModal = ({
+  visible,
+  onCancel,
+  channelId,
+  channelInfo,
+  onModelsUpdate,
+  onApplyModels,
+}) => {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(false);
+  const [models, setModels] = useState([]);
+  const [filteredModels, setFilteredModels] = useState([]);
+  const [searchValue, setSearchValue] = useState('');
+  const [pullModelName, setPullModelName] = useState('');
+  const [pullLoading, setPullLoading] = useState(false);
+  const [pullProgress, setPullProgress] = useState(null);
+  const [eventSource, setEventSource] = useState(null);
+  const [selectedModelIds, setSelectedModelIds] = useState([]);
+
+  const handleApplyAllModels = () => {
+    if (!onApplyModels || selectedModelIds.length === 0) {
+      return;
+    }
+    onApplyModels({ mode: 'append', modelIds: selectedModelIds });
+  };
+
+  const handleToggleModel = (modelId, checked) => {
+    if (!modelId) {
+      return;
+    }
+    setSelectedModelIds((prev) => {
+      if (checked) {
+        if (prev.includes(modelId)) {
+          return prev;
+        }
+        return [...prev, modelId];
+      }
+      return prev.filter((id) => id !== modelId);
+    });
+  };
+
+  const handleSelectAll = () => {
+    setSelectedModelIds(models.map((item) => item?.id).filter(Boolean));
+  };
+
+  const handleClearSelection = () => {
+    setSelectedModelIds([]);
+  };
+
+  // 获取模型列表
+  const fetchModels = async () => {
+    const channelType = Number(channelInfo?.type ?? CHANNEL_TYPE_OLLAMA);
+    const shouldTryLiveFetch = channelType === CHANNEL_TYPE_OLLAMA;
+    const resolvedBaseUrl = resolveOllamaBaseUrl(channelInfo);
+
+    setLoading(true);
+    let liveFetchSucceeded = false;
+    let fallbackSucceeded = false;
+    let lastError = '';
+    let nextModels = [];
+
+    try {
+      if (shouldTryLiveFetch && resolvedBaseUrl) {
+        try {
+          const payload = {
+            base_url: resolvedBaseUrl,
+            type: CHANNEL_TYPE_OLLAMA,
+            key: channelInfo?.key || '',
+          };
+
+          const res = await API.post('/api/channel/fetch_models', payload, {
+            skipErrorHandler: true,
+          });
+
+          if (res?.data?.success) {
+            nextModels = normalizeModels(res.data.data);
+            liveFetchSucceeded = true;
+          } else if (res?.data?.message) {
+            lastError = res.data.message;
+          }
+        } catch (error) {
+          const message = error?.response?.data?.message || error.message;
+          if (message) {
+            lastError = message;
+          }
+        }
+      } else if (shouldTryLiveFetch && !resolvedBaseUrl && !channelId) {
+        lastError = t('请先填写 Ollama API 地址');
+      }
+
+      if ((!liveFetchSucceeded || nextModels.length === 0) && channelId) {
+        try {
+          const res = await API.get(`/api/channel/fetch_models/${channelId}`, {
+            skipErrorHandler: true,
+          });
+
+          if (res?.data?.success) {
+            nextModels = normalizeModels(res.data.data);
+            fallbackSucceeded = true;
+            lastError = '';
+          } else if (res?.data?.message) {
+            lastError = res.data.message;
+          }
+        } catch (error) {
+          const message = error?.response?.data?.message || error.message;
+          if (message) {
+            lastError = message;
+          }
+        }
+      }
+
+      if (!liveFetchSucceeded && !fallbackSucceeded && lastError) {
+        showError(`${t('获取模型列表失败')}: ${lastError}`);
+      }
+
+      const normalized = nextModels;
+      setModels(normalized);
+      setFilteredModels(normalized);
+      setSelectedModelIds((prev) => {
+        if (!normalized || normalized.length === 0) {
+          return [];
+        }
+        if (!prev || prev.length === 0) {
+          return normalized.map((item) => item.id).filter(Boolean);
+        }
+        const available = prev.filter((id) =>
+          normalized.some((item) => item.id === id),
+        );
+        return available.length > 0
+          ? available
+          : normalized.map((item) => item.id).filter(Boolean);
+      });
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 拉取模型 (流式,支持进度)
+  const pullModel = async () => {
+    if (!pullModelName.trim()) {
+      showError(t('请输入模型名称'));
+      return;
+    }
+
+    setPullLoading(true);
+    setPullProgress({ status: 'starting', completed: 0, total: 0 });
+
+    let hasRefreshed = false;
+    const refreshModels = async () => {
+      if (hasRefreshed) return;
+      hasRefreshed = true;
+      await fetchModels();
+      if (onModelsUpdate) {
+        onModelsUpdate({ silent: true });
+      }
+    };
+
+    try {
+      // 关闭之前的连接
+      if (eventSource) {
+        eventSource.close();
+        setEventSource(null);
+      }
+
+      const controller = new AbortController();
+      const closable = {
+        close: () => controller.abort(),
+      };
+      setEventSource(closable);
+
+      // 使用 fetch 请求 SSE 流
+      const authHeaders = authHeader();
+      const userId = getUserIdFromLocalStorage();
+      const fetchHeaders = {
+        'Content-Type': 'application/json',
+        Accept: 'text/event-stream',
+        'New-API-User': String(userId),
+        ...authHeaders,
+      };
+
+      const response = await fetch('/api/channel/ollama/pull/stream', {
+        method: 'POST',
+        headers: fetchHeaders,
+        body: JSON.stringify({
+          channel_id: channelId,
+          model_name: pullModelName.trim(),
+        }),
+        signal: controller.signal,
+      });
+
+      if (!response.ok) {
+        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+      }
+
+      const reader = response.body.getReader();
+      const decoder = new TextDecoder();
+      let buffer = '';
+
+      // 读取 SSE 流
+      const processStream = async () => {
+        try {
+          while (true) {
+            const { done, value } = await reader.read();
+
+            if (done) break;
+
+            buffer += decoder.decode(value, { stream: true });
+            const lines = buffer.split('\n');
+            buffer = lines.pop() || '';
+
+            for (const line of lines) {
+              if (!line.startsWith('data: ')) {
+                continue;
+              }
+
+              try {
+                const eventData = line.substring(6);
+                if (eventData === '[DONE]') {
+                  setPullLoading(false);
+                  setPullProgress(null);
+                  setEventSource(null);
+                  return;
+                }
+
+                const data = JSON.parse(eventData);
+
+                if (data.status) {
+                  // 处理进度数据
+                  setPullProgress(data);
+                } else if (data.error) {
+                  // 处理错误
+                  showError(data.error);
+                  setPullProgress(null);
+                  setPullLoading(false);
+                  setEventSource(null);
+                  return;
+                } else if (data.message) {
+                  // 处理成功消息
+                  showSuccess(data.message);
+                  setPullModelName('');
+                  setPullProgress(null);
+                  setPullLoading(false);
+                  setEventSource(null);
+                  await fetchModels();
+                  if (onModelsUpdate) {
+                    onModelsUpdate({ silent: true });
+                  }
+                  await refreshModels();
+                  return;
+                }
+              } catch (e) {
+                console.error('Failed to parse SSE data:', e);
+              }
+            }
+          }
+          // 正常结束流
+          setPullLoading(false);
+          setPullProgress(null);
+          setEventSource(null);
+          await refreshModels();
+        } catch (error) {
+          if (error?.name === 'AbortError') {
+            setPullProgress(null);
+            setPullLoading(false);
+            setEventSource(null);
+            return;
+          }
+          console.error('Stream processing error:', error);
+          showError(t('数据传输中断'));
+          setPullProgress(null);
+          setPullLoading(false);
+          setEventSource(null);
+          await refreshModels();
+        }
+      };
+
+      await processStream();
+
+    } catch (error) {
+      if (error?.name !== 'AbortError') {
+        showError(t('模型拉取失败: {{error}}', { error: error.message }));
+      }
+      setPullLoading(false);
+      setPullProgress(null);
+      setEventSource(null);
+      await refreshModels();
+    }
+  };
+
+  // 删除模型
+  const deleteModel = async (modelName) => {
+    try {
+      const res = await API.delete('/api/channel/ollama/delete', {
+        data: {
+          channel_id: channelId,
+          model_name: modelName,
+        },
+      });
+      
+      if (res.data.success) {
+        showSuccess(t('模型删除成功'));
+        await fetchModels(); // 重新获取模型列表
+        if (onModelsUpdate) {
+          onModelsUpdate({ silent: true }); // 通知父组件更新
+        }
+      } else {
+        showError(res.data.message || t('模型删除失败'));
+      }
+    } catch (error) {
+      showError(t('模型删除失败: {{error}}', { error: error.message }));
+    }
+  };
+
+  // 搜索过滤
+  useEffect(() => {
+    if (!searchValue) {
+      setFilteredModels(models);
+    } else {
+      const filtered = models.filter(model =>
+        model.id.toLowerCase().includes(searchValue.toLowerCase())
+      );
+      setFilteredModels(filtered);
+    }
+  }, [models, searchValue]);
+
+  useEffect(() => {
+    if (!visible) {
+      setSelectedModelIds([]);
+      setPullModelName('');
+      setPullProgress(null);
+      setPullLoading(false);
+    }
+  }, [visible]);
+
+  // 组件加载时获取模型列表
+  useEffect(() => {
+    if (!visible) {
+      return;
+    }
+
+    if (channelId || Number(channelInfo?.type) === CHANNEL_TYPE_OLLAMA) {
+      fetchModels();
+    }
+  }, [
+    visible,
+    channelId,
+    channelInfo?.type,
+    channelInfo?.base_url,
+    channelInfo?.other_info,
+    channelInfo?.ollama_base_url,
+  ]);
+
+  // 组件卸载时清理 EventSource
+  useEffect(() => {
+    return () => {
+      if (eventSource) {
+        eventSource.close();
+      }
+    };
+  }, [eventSource]);
+
+  const formatModelSize = (size) => {
+    if (!size) return '-';
+    const gb = size / (1024 * 1024 * 1024);
+    return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(size / (1024 * 1024)).toFixed(0)} MB`;
+  };
+
+  return (
+    <Modal
+      title={
+        <div className='flex items-center'>
+          <Avatar
+            size='small'
+            color='blue'
+            className='mr-3 shadow-md'
+          >
+            <IconServer size={16} />
+          </Avatar>
+          <div>
+            <Title heading={4} className='m-0'>
+              {t('Ollama 模型管理')}
+            </Title>
+            <Text type='tertiary' size='small'>
+              {channelInfo?.name && `${channelInfo.name} - `}
+              {t('管理 Ollama 模型的拉取和删除')}
+            </Text>
+          </div>
+        </div>
+      }
+      visible={visible}
+      onCancel={onCancel}
+      width={800}
+      style={{ maxWidth: '95vw' }}
+      footer={
+        <div className='flex justify-end'>
+          <Button
+            theme='light'
+            type='primary'
+            onClick={onCancel}
+            icon={<IconClose />}
+          >
+            {t('关闭')}
+          </Button>
+        </div>
+      }
+    >
+      <div className='space-y-6'>
+        {/* 拉取新模型 */}
+        <Card className='!rounded-2xl shadow-sm border-0'>
+          <div className='flex items-center mb-4'>
+            <Avatar size='small' color='green' className='mr-2'>
+              <IconPlus size={16} />
+            </Avatar>
+            <Title heading={5} className='m-0'>
+              {t('拉取新模型')}
+            </Title>
+          </div>
+          
+          <Row gutter={12} align='middle'>
+            <Col span={16}>
+              <Input
+                placeholder={t('请输入模型名称,例如: llama3.2, qwen2.5:7b')}
+                value={pullModelName}
+                onChange={(value) => setPullModelName(value)}
+                onEnterPress={pullModel}
+                disabled={pullLoading}
+                showClear
+              />
+            </Col>
+            <Col span={8}>
+              <Button
+                theme='solid'
+                type='primary'
+                onClick={pullModel}
+                loading={pullLoading}
+                disabled={!pullModelName.trim()}
+                icon={<IconDownload />}
+                block
+              >
+                {pullLoading ? t('拉取中...') : t('拉取模型')}
+              </Button>
+            </Col>
+          </Row>
+          
+          {/* 进度条显示 */}
+          {pullProgress && (() => {
+            const completedBytes = Number(pullProgress.completed) || 0;
+            const totalBytes = Number(pullProgress.total) || 0;
+            const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
+            const safePercent = hasTotal
+              ? Math.min(
+                  100,
+                  Math.max(0, Math.round((completedBytes / totalBytes) * 100)),
+                )
+              : null;
+            const percentText = hasTotal && safePercent !== null
+              ? `${safePercent.toFixed(0)}%`
+              : pullProgress.status || t('处理中');
+
+            return (
+              <div className='mt-3 p-3 bg-gray-50 rounded-lg'>
+                <div className='flex items-center justify-between mb-2'>
+                  <Text strong>{t('拉取进度')}</Text>
+                  <Text type='tertiary' size='small'>{percentText}</Text>
+                </div>
+
+                {hasTotal && safePercent !== null ? (
+                  <div>
+                    <Progress
+                      percent={safePercent}
+                      showInfo={false}
+                      stroke='#1890ff'
+                      size='small'
+                    />
+                    <div className='flex justify-between mt-1'>
+                      <Text type='tertiary' size='small'>
+                        {(completedBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
+                      </Text>
+                      <Text type='tertiary' size='small'>
+                        {(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
+                      </Text>
+                    </div>
+                  </div>
+                ) : (
+                  <div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
+                    <Spin size='small' />
+                    <span>{t('准备中...')}</span>
+                  </div>
+                )}
+              </div>
+            );
+          })()}
+          
+          <Text type='tertiary' size='small' className='mt-2 block'>
+            {t('支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间')}
+          </Text>
+        </Card>
+
+        {/* 已有模型列表 */}
+        <Card className='!rounded-2xl shadow-sm border-0'>
+          <div className='flex items-center justify-between mb-4'>
+            <div className='flex items-center'>
+              <Avatar size='small' color='purple' className='mr-2'>
+                <IconServer size={16} />
+              </Avatar>
+              <Title heading={5} className='m-0'>
+                {t('已有模型')}
+                {models.length > 0 && (
+                  <Tag color='blue' className='ml-2'>
+                    {models.length}
+                  </Tag>
+                )}
+              </Title>
+            </div>
+            <Space wrap>
+              <Input
+                prefix={<IconSearch />}
+                placeholder={t('搜索模型...')}
+                value={searchValue}
+                onChange={(value) => setSearchValue(value)}
+                style={{ width: 200 }}
+                showClear
+              />
+              <Button
+                size='small'
+                theme='borderless'
+                onClick={handleSelectAll}
+                disabled={models.length === 0}
+              >
+                {t('全选')}
+              </Button>
+              <Button
+                size='small'
+                theme='borderless'
+                onClick={handleClearSelection}
+                disabled={selectedModelIds.length === 0}
+              >
+                {t('清空')}
+              </Button>
+              <Button
+                theme='solid'
+                type='primary'
+                icon={<IconPlus />}
+                onClick={handleApplyAllModels}
+                disabled={selectedModelIds.length === 0}
+                size='small'
+              >
+                {t('加入渠道')}
+              </Button>
+              <Button
+                theme='light'
+                type='primary'
+                onClick={fetchModels}
+                loading={loading}
+                icon={<IconRefresh />}
+                size='small'
+              >
+                {t('刷新')}
+              </Button>
+            </Space>
+          </div>
+
+          <Spin spinning={loading}>
+            {filteredModels.length === 0 ? (
+              <Empty
+                image={<IconServer size={60} />}
+                title={searchValue ? t('未找到匹配的模型') : t('暂无模型')}
+                description={
+                  searchValue 
+                    ? t('请尝试其他搜索关键词') 
+                    : t('您可以在上方拉取需要的模型')
+                }
+                style={{ padding: '40px 0' }}
+              />
+            ) : (
+              <List
+                dataSource={filteredModels}
+                split={false}
+                renderItem={(model, index) => (
+                  <List.Item
+                    key={model.id}
+                    className='hover:bg-gray-50 rounded-lg p-3 transition-colors'
+                  >
+                    <div className='flex items-center justify-between w-full'>
+                      <div className='flex items-center flex-1 min-w-0 gap-3'>
+                        <Checkbox
+                          checked={selectedModelIds.includes(model.id)}
+                          onChange={(checked) => handleToggleModel(model.id, checked)}
+                        />
+                        <Avatar
+                          size='small'
+                          color='blue'
+                          className='flex-shrink-0'
+                        >
+                          {model.id.charAt(0).toUpperCase()}
+                        </Avatar>
+                        <div className='flex-1 min-w-0'>
+                          <Text strong className='block truncate'>
+                            {model.id}
+                          </Text>
+                          <div className='flex items-center space-x-2 mt-1'>
+                            <Tag color='cyan' size='small'>
+                              {model.owned_by || 'ollama'}
+                            </Tag>
+                            {model.size && (
+                              <Text type='tertiary' size='small'>
+                                {formatModelSize(model.size)}
+                              </Text>
+                            )}
+                          </div>
+                        </div>
+                      </div>
+                    <div className='flex items-center space-x-2 ml-4'>
+                        <Popconfirm
+                          title={t('确认删除模型')}
+                          content={t('删除后无法恢复,确定要删除模型 "{{name}}" 吗?', { name: model.id })}
+                          onConfirm={() => deleteModel(model.id)}
+                          okText={t('确认')}
+                          cancelText={t('取消')}
+                        >
+                          <Button
+                            theme='borderless'
+                            type='danger'
+                            size='small'
+                            icon={<IconDelete />}
+                          />
+                        </Popconfirm>
+                      </div>
+                    </div>
+                  </List.Item>
+                )}
+              />
+            )}
+          </Spin>
+        </Card>
+      </div>
+    </Modal>
+  );
+};
+
+export default OllamaModelModal;

+ 109 - 0
web/src/components/table/model-deployments/DeploymentsActions.jsx

@@ -0,0 +1,109 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React from 'react';
+import { Button, Popconfirm } from '@douyinfe/semi-ui';
+import CompactModeToggle from '../../common/ui/CompactModeToggle';
+
+const DeploymentsActions = ({
+  selectedKeys,
+  setSelectedKeys,
+  setEditingDeployment,
+  setShowEdit,
+  batchDeleteDeployments,
+  compactMode,
+  setCompactMode,
+  showCreateModal,
+  setShowCreateModal,
+  t,
+}) => {
+  const hasSelected = selectedKeys.length > 0;
+
+  const handleAddDeployment = () => {
+    if (setShowCreateModal) {
+      setShowCreateModal(true);
+    } else {
+      // Fallback to old behavior if setShowCreateModal is not provided
+      setEditingDeployment({ id: undefined });
+      setShowEdit(true);
+    }
+  };
+
+  const handleBatchDelete = () => {
+    batchDeleteDeployments();
+  };
+
+  const handleDeselectAll = () => {
+    setSelectedKeys([]);
+  };
+
+
+  return (
+    <div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
+      <Button
+        type='primary'
+        className='flex-1 md:flex-initial'
+        onClick={handleAddDeployment}
+        size='small'
+      >
+        {t('新建容器')}
+      </Button>
+
+      {hasSelected && (
+        <>
+          <Popconfirm
+            title={t('确认删除')}
+            content={`${t('确定要删除选中的')} ${selectedKeys.length} ${t('个部署吗?此操作不可逆。')}`}
+            okText={t('删除')}
+            cancelText={t('取消')}
+            okType='danger'
+            onConfirm={handleBatchDelete}
+          >
+            <Button
+              type='danger'
+              className='flex-1 md:flex-initial'
+              disabled={selectedKeys.length === 0}
+              size='small'
+            >
+              {t('批量删除')} ({selectedKeys.length})
+            </Button>
+          </Popconfirm>
+
+          <Button
+            type='tertiary'
+            className='flex-1 md:flex-initial'
+            onClick={handleDeselectAll}
+            size='small'
+          >
+            {t('取消选择')}
+          </Button>
+        </>
+      )}
+
+      {/* Compact Mode */}
+      <CompactModeToggle
+        compactMode={compactMode}
+        setCompactMode={setCompactMode}
+        t={t}
+      />
+    </div>
+  );
+};
+
+export default DeploymentsActions;

+ 672 - 0
web/src/components/table/model-deployments/DeploymentsColumnDefs.jsx

@@ -0,0 +1,672 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React from 'react';
+import { 
+  Button,
+  Dropdown,
+  Tag,
+  Typography,
+} from '@douyinfe/semi-ui';
+import {
+  timestamp2string,
+  showSuccess,
+  showError,
+} from '../../../helpers';
+import { IconMore } from '@douyinfe/semi-icons';
+import {
+  FaPlay,
+  FaTrash,
+  FaServer,
+  FaMemory,
+  FaMicrochip,
+  FaCheckCircle,
+  FaSpinner,
+  FaClock,
+  FaExclamationCircle,
+  FaBan,
+  FaTerminal,
+  FaPlus,
+  FaCog,
+  FaInfoCircle,
+  FaLink,
+  FaStop,
+  FaHourglassHalf,
+  FaGlobe,
+} from 'react-icons/fa';
+import {t} from "i18next";
+
+const normalizeStatus = (status) =>
+  typeof status === 'string' ? status.trim().toLowerCase() : '';
+
+const STATUS_TAG_CONFIG = {
+  running: {
+    color: 'green',
+    label: t('运行中'),
+    icon: <FaPlay size={12} className='text-green-600' />,
+  },
+  deploying: {
+    color: 'blue',
+    label: t('部署中'),
+    icon: <FaSpinner size={12} className='text-blue-600' />,
+  },
+  pending: {
+    color: 'orange',
+    label: t('待部署'),
+    icon: <FaClock size={12} className='text-orange-600' />,
+  },
+  stopped: {
+    color: 'grey',
+    label: t('已停止'),
+    icon: <FaStop size={12} className='text-gray-500' />,
+  },
+  error: {
+    color: 'red',
+    label: t('错误'),
+    icon: <FaExclamationCircle size={12} className='text-red-500' />,
+  },
+  failed: {
+    color: 'red',
+    label: t('失败'),
+    icon: <FaExclamationCircle size={12} className='text-red-500' />,
+  },
+  destroyed: {
+    color: 'red',
+    label: t('已销毁'),
+    icon: <FaBan size={12} className='text-red-500' />,
+  },
+  completed: {
+    color: 'green',
+    label: t('已完成'),
+    icon: <FaCheckCircle size={12} className='text-green-600' />,
+  },
+  'deployment requested': {
+    color: 'blue',
+    label: t('部署请求中'),
+    icon: <FaSpinner size={12} className='text-blue-600' />,
+  },
+  'termination requested': {
+    color: 'orange',
+    label: t('终止请求中'),
+    icon: <FaClock size={12} className='text-orange-600' />,
+  },
+};
+
+const DEFAULT_STATUS_CONFIG = {
+  color: 'grey',
+  label: null,
+  icon: <FaInfoCircle size={12} className='text-gray-500' />,
+};
+
+const parsePercentValue = (value) => {
+  if (value === null || value === undefined) return null;
+  if (typeof value === 'string') {
+    const parsed = parseFloat(value.replace(/[^0-9.+-]/g, ''));
+    return Number.isFinite(parsed) ? parsed : null;
+  }
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? value : null;
+  }
+  return null;
+};
+
+const clampPercent = (value) => {
+  if (value === null || value === undefined) return null;
+  return Math.min(100, Math.max(0, Math.round(value)));
+};
+
+const formatRemainingMinutes = (minutes, t) => {
+  if (minutes === null || minutes === undefined) return null;
+  const numeric = Number(minutes);
+  if (!Number.isFinite(numeric)) return null;
+  const totalMinutes = Math.max(0, Math.round(numeric));
+  const days = Math.floor(totalMinutes / 1440);
+  const hours = Math.floor((totalMinutes % 1440) / 60);
+  const mins = totalMinutes % 60;
+  const parts = [];
+
+  if (days > 0) {
+    parts.push(`${days}${t('天')}`);
+  }
+  if (hours > 0) {
+    parts.push(`${hours}${t('小时')}`);
+  }
+  if (parts.length === 0 || mins > 0) {
+    parts.push(`${mins}${t('分钟')}`);
+  }
+
+  return parts.join(' ');
+};
+
+const getRemainingTheme = (percentRemaining) => {
+  if (percentRemaining === null) {
+    return {
+      iconColor: 'var(--semi-color-primary)',
+      tagColor: 'blue',
+      textColor: 'var(--semi-color-text-2)',
+    };
+  }
+
+  if (percentRemaining <= 10) {
+    return {
+      iconColor: '#ff5a5f',
+      tagColor: 'red',
+      textColor: '#ff5a5f',
+    };
+  }
+
+  if (percentRemaining <= 30) {
+    return {
+      iconColor: '#ffb400',
+      tagColor: 'orange',
+      textColor: '#ffb400',
+    };
+  }
+
+  return {
+    iconColor: '#2ecc71',
+    tagColor: 'green',
+    textColor: '#2ecc71',
+  };
+};
+
+const renderStatus = (status, t) => {
+  const normalizedStatus = normalizeStatus(status);
+  const config = STATUS_TAG_CONFIG[normalizedStatus] || DEFAULT_STATUS_CONFIG;
+  const statusText = typeof status === 'string' ? status : '';
+  const labelText = config.label ? t(config.label) : statusText || t('未知状态');
+
+  return (
+    <Tag
+      color={config.color}
+      shape='circle'
+      size='small'
+      prefixIcon={config.icon}
+    >
+      {labelText}
+    </Tag>
+  );
+};
+
+// Container Name Cell Component - to properly handle React hooks
+const ContainerNameCell = ({ text, record, t }) => {
+  const handleCopyId = () => {
+    navigator.clipboard.writeText(record.id);
+    showSuccess(t('ID已复制到剪贴板'));
+  };
+
+  return (
+    <div className="flex flex-col gap-1">
+      <Typography.Text strong className="text-base">
+        {text}
+      </Typography.Text>
+      <Typography.Text 
+        type="secondary" 
+        size="small" 
+        className="text-xs cursor-pointer hover:text-blue-600 transition-colors select-all"
+        onClick={handleCopyId}
+        title={t('点击复制ID')}
+      >
+        ID: {record.id}
+      </Typography.Text>
+    </div>
+  );
+};
+
+// Render resource configuration
+const renderResourceConfig = (resource, t) => {
+  if (!resource) return '-';
+  
+  const { cpu, memory, gpu } = resource;
+  
+  return (
+    <div className="flex flex-col gap-1">
+      {cpu && (
+        <div className="flex items-center gap-1 text-xs">
+          <FaMicrochip className="text-blue-500" />
+          <span>CPU: {cpu}</span>
+        </div>
+      )}
+      {memory && (
+        <div className="flex items-center gap-1 text-xs">
+          <FaMemory className="text-green-500" />
+          <span>内存: {memory}</span>
+        </div>
+      )}
+      {gpu && (
+        <div className="flex items-center gap-1 text-xs">
+          <FaServer className="text-purple-500" />
+          <span>GPU: {gpu}</span>
+        </div>
+      )}
+    </div>
+  );
+};
+
+// Render instance count with status indicator
+const renderInstanceCount = (count, record, t) => {
+  const normalizedStatus = normalizeStatus(record?.status);
+  const statusConfig = STATUS_TAG_CONFIG[normalizedStatus];
+  const countColor = statusConfig?.color ?? 'grey';
+
+  return (
+    <Tag color={countColor} size="small" shape='circle'>
+      {count || 0} {t('个实例')}
+    </Tag>
+  );
+};
+
+// Main function to get all deployment columns
+export const getDeploymentsColumns = ({
+  t,
+  COLUMN_KEYS,
+  startDeployment,
+  restartDeployment,
+  deleteDeployment,
+  setEditingDeployment,
+  setShowEdit,
+  refresh,
+  activePage,
+  deployments,
+  // New handlers for enhanced operations
+  onViewLogs,
+  onExtendDuration,
+  onViewDetails,
+  onUpdateConfig,
+  onSyncToChannel,
+}) => {
+  const columns = [
+    {
+      title: t('容器名称'),
+      dataIndex: 'container_name',
+      key: COLUMN_KEYS.container_name,
+      width: 300,
+      ellipsis: true,
+      render: (text, record) => (
+        <ContainerNameCell 
+          text={text} 
+          record={record} 
+          t={t}
+        />
+      ),
+    },
+    {
+      title: t('状态'),
+      dataIndex: 'status',
+      key: COLUMN_KEYS.status,
+      width: 140,
+      render: (status) => (
+        <div className="flex items-center gap-2">
+          {renderStatus(status, t)}
+        </div>
+      ),
+    },
+    {
+      title: t('服务商'),
+      dataIndex: 'provider',
+      key: COLUMN_KEYS.provider,
+      width: 140,
+      render: (provider) =>
+        provider ? (
+          <div
+            className="flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
+            style={{
+              borderColor: 'rgba(59, 130, 246, 0.4)',
+              backgroundColor: 'rgba(59, 130, 246, 0.08)',
+              color: '#2563eb',
+            }}
+          >
+            <FaGlobe className="text-[11px]" />
+            <span>{provider}</span>
+          </div>
+        ) : (
+          <Typography.Text type="tertiary" size="small" className="text-xs text-gray-500">
+            {t('暂无')}
+          </Typography.Text>
+        ),
+    },
+    {
+      title: t('剩余时间'),
+      dataIndex: 'time_remaining',
+      key: COLUMN_KEYS.time_remaining,
+      width: 140,
+      render: (text, record) => {
+        const normalizedStatus = normalizeStatus(record?.status);
+        const percentUsedRaw = parsePercentValue(record?.completed_percent);
+        const percentUsed = clampPercent(percentUsedRaw);
+        const percentRemaining =
+          percentUsed === null ? null : clampPercent(100 - percentUsed);
+        const theme = getRemainingTheme(percentRemaining);
+        const statusDisplayMap = {
+          completed: t('已完成'),
+          destroyed: t('已销毁'),
+          failed: t('失败'),
+          error: t('失败'),
+          stopped: t('已停止'),
+          pending: t('待部署'),
+          deploying: t('部署中'),
+          'deployment requested': t('部署请求中'),
+          'termination requested': t('终止中'),
+        };
+        const statusOverride = statusDisplayMap[normalizedStatus];
+        const baseTimeDisplay =
+          text && String(text).trim() !== '' ? text : t('计算中');
+        const timeDisplay = baseTimeDisplay;
+        const humanReadable = formatRemainingMinutes(
+          record.compute_minutes_remaining,
+          t,
+        );
+        const showProgress = !statusOverride && normalizedStatus === 'running';
+        const showExtraInfo = Boolean(humanReadable || percentUsed !== null);
+        const showRemainingMeta =
+          record.compute_minutes_remaining !== undefined &&
+          record.compute_minutes_remaining !== null &&
+          percentRemaining !== null;
+
+        return (
+          <div className="flex flex-col gap-1 leading-tight text-xs">
+            <div className="flex items-center gap-1.5">
+              <FaHourglassHalf
+                className="text-sm"
+                style={{ color: theme.iconColor }}
+              />
+              <Typography.Text className="text-sm font-medium text-[var(--semi-color-text-0)]">
+                {timeDisplay}
+              </Typography.Text>
+              {showProgress && percentRemaining !== null ? (
+                <Tag size="small" color={theme.tagColor}>
+                  {percentRemaining}%
+                </Tag>
+              ) : statusOverride ? (
+                <Tag size="small" color="grey">
+                  {statusOverride}
+                </Tag>
+              ) : null}
+            </div>
+            {showExtraInfo && (
+              <div className="flex items-center gap-3 text-[var(--semi-color-text-2)]">
+                {humanReadable && (
+                  <span className="flex items-center gap-1">
+                    <FaClock className="text-[11px]" />
+                    {t('约')} {humanReadable}
+                  </span>
+                )}
+                {percentUsed !== null && (
+                  <span className="flex items-center gap-1">
+                    <FaCheckCircle className="text-[11px]" />
+                    {t('已用')} {percentUsed}%
+                  </span>
+                )}
+              </div>
+            )}
+            {showProgress && showRemainingMeta && (
+              <div className="text-[10px]" style={{ color: theme.textColor }}>
+                {t('剩余')} {record.compute_minutes_remaining} {t('分钟')}
+              </div>
+            )}
+          </div>
+        );
+      },
+    },
+    {
+      title: t('硬件配置'),
+      dataIndex: 'hardware_info',
+      key: COLUMN_KEYS.hardware_info,
+      width: 220,
+      ellipsis: true,
+      render: (text, record) => (
+        <div className="flex items-center gap-2">
+          <div className="flex items-center gap-1 px-2 py-1 bg-green-50 border border-green-200 rounded-md">
+            <FaServer className="text-green-600 text-xs" />
+            <span className="text-xs font-medium text-green-700">
+              {record.hardware_name}
+            </span>
+          </div>
+          <span className="text-xs text-gray-500 font-medium">x{record.hardware_quantity}</span>
+        </div>
+      ),
+    },
+    {
+      title: t('创建时间'),
+      dataIndex: 'created_at',
+      key: COLUMN_KEYS.created_at,
+      width: 150,
+      render: (text) => (
+        <span className="text-sm text-gray-600">{timestamp2string(text)}</span>
+      ),
+    },
+    {
+      title: t('操作'),
+      key: COLUMN_KEYS.actions,
+      fixed: 'right',
+      width: 120,
+      render: (_, record) => {
+        const { status, id } = record;
+        const normalizedStatus = normalizeStatus(status);
+        const isEnded = normalizedStatus === 'completed' || normalizedStatus === 'destroyed';
+
+        const handleDelete = () => {
+          // Use enhanced confirmation dialog
+          onUpdateConfig?.(record, 'delete');
+        };
+
+        // Get primary action based on status
+        const getPrimaryAction = () => {
+          switch (normalizedStatus) {
+            case 'running':
+              return {
+                icon: <FaInfoCircle className="text-xs" />,
+                text: t('查看详情'),
+                onClick: () => onViewDetails?.(record),
+                type: 'secondary',
+                theme: 'borderless',
+              };
+            case 'failed':
+            case 'error':
+              return {
+                icon: <FaPlay className="text-xs" />,
+                text: t('重试'),
+                onClick: () => startDeployment(id),
+                type: 'primary',
+                theme: 'solid',
+              };
+            case 'stopped':
+              return {
+                icon: <FaPlay className="text-xs" />,
+                text: t('启动'),
+                onClick: () => startDeployment(id),
+                type: 'primary',
+                theme: 'solid',
+              };
+            case 'deployment requested':
+            case 'deploying':
+              return {
+                icon: <FaClock className="text-xs" />,
+                text: t('部署中'),
+                onClick: () => {},
+                type: 'secondary',
+                theme: 'light',
+                disabled: true,
+              };
+            case 'pending':
+              return {
+                icon: <FaClock className="text-xs" />,
+                text: t('待部署'),
+                onClick: () => {},
+                type: 'secondary',
+                theme: 'light',
+                disabled: true,
+              };
+            case 'termination requested':
+              return {
+                icon: <FaClock className="text-xs" />,
+                text: t('终止中'),
+                onClick: () => {},
+                type: 'secondary',
+                theme: 'light',
+                disabled: true,
+              };
+            case 'completed':
+            case 'destroyed':
+            default:
+              return {
+                icon: <FaInfoCircle className="text-xs" />,
+                text: t('已结束'),
+                onClick: () => {},
+                type: 'tertiary',
+                theme: 'borderless',
+                disabled: true,
+              };
+          }
+        };
+
+        const primaryAction = getPrimaryAction();
+        const primaryTheme = primaryAction.theme || 'solid';
+        const primaryType = primaryAction.type || 'primary';
+
+        if (isEnded) {
+          return (
+            <div className="flex w-full items-center justify-start gap-1 pr-2">
+              <Button
+                size="small"
+                type="tertiary"
+                theme="borderless"
+                onClick={() => onViewDetails?.(record)}
+                icon={<FaInfoCircle className="text-xs" />}
+              >
+                {t('查看详情')}
+              </Button>
+            </div>
+          );
+        }
+
+        // All actions dropdown with enhanced operations
+        const dropdownItems = [
+          <Dropdown.Item key="details" onClick={() => onViewDetails?.(record)} icon={<FaInfoCircle />}>
+            {t('查看详情')}
+          </Dropdown.Item>,
+        ];
+
+        if (!isEnded) {
+          dropdownItems.push(
+            <Dropdown.Item key="logs" onClick={() => onViewLogs?.(record)} icon={<FaTerminal />}>
+              {t('查看日志')}
+            </Dropdown.Item>,
+          );
+        }
+
+        const managementItems = [];
+        if (normalizedStatus === 'running') {
+          if (onSyncToChannel) {
+            managementItems.push(
+              <Dropdown.Item key="sync-channel" onClick={() => onSyncToChannel(record)} icon={<FaLink />}>
+                {t('同步到渠道')}
+              </Dropdown.Item>,
+            );
+          }
+        }
+        if (normalizedStatus === 'failed' || normalizedStatus === 'error') {
+          managementItems.push(
+            <Dropdown.Item key="retry" onClick={() => startDeployment(id)} icon={<FaPlay />}>
+              {t('重试')}
+            </Dropdown.Item>,
+          );
+        }
+        if (normalizedStatus === 'stopped') {
+          managementItems.push(
+            <Dropdown.Item key="start" onClick={() => startDeployment(id)} icon={<FaPlay />}>
+              {t('启动')}
+            </Dropdown.Item>,
+          );
+        }
+
+        if (managementItems.length > 0) {
+          dropdownItems.push(<Dropdown.Divider key="management-divider" />);
+          dropdownItems.push(...managementItems);
+        }
+
+        const configItems = [];
+        if (!isEnded && (normalizedStatus === 'running' || normalizedStatus === 'deployment requested')) {
+          configItems.push(
+            <Dropdown.Item key="extend" onClick={() => onExtendDuration?.(record)} icon={<FaPlus />}>
+              {t('延长时长')}
+            </Dropdown.Item>,
+          );
+        }
+        // if (!isEnded && normalizedStatus === 'running') {
+        //   configItems.push(
+        //     <Dropdown.Item key="update-config" onClick={() => onUpdateConfig?.(record)} icon={<FaCog />}>
+        //       {t('更新配置')}
+        //     </Dropdown.Item>,
+        //   );
+        // }
+
+        if (configItems.length > 0) {
+          dropdownItems.push(<Dropdown.Divider key="config-divider" />);
+          dropdownItems.push(...configItems);
+        }
+        if (!isEnded) {
+          dropdownItems.push(<Dropdown.Divider key="danger-divider" />);
+          dropdownItems.push(
+            <Dropdown.Item key="delete" type="danger" onClick={handleDelete} icon={<FaTrash />}>
+              {t('销毁容器')}
+            </Dropdown.Item>,
+          );
+        }
+
+        const allActions = <Dropdown.Menu>{dropdownItems}</Dropdown.Menu>;
+        const hasDropdown = dropdownItems.length > 0;
+
+        return (
+          <div className="flex w-full items-center justify-start gap-1 pr-2">
+            <Button
+              size="small"
+              theme={primaryTheme}
+              type={primaryType}
+              icon={primaryAction.icon}
+              onClick={primaryAction.onClick}
+              className="px-2 text-xs"
+              disabled={primaryAction.disabled}
+            >
+              {primaryAction.text}
+            </Button>
+            
+            {hasDropdown && (
+              <Dropdown
+                trigger="click"
+                position="bottomRight"
+                render={allActions}
+              >
+                <Button
+                  size="small"
+                  theme="light"
+                  type="tertiary"
+                  icon={<IconMore />}
+                  className="px-1"
+                />
+              </Dropdown>
+            )}
+          </div>
+        );
+      },
+    },
+  ];
+
+  return columns;
+};

+ 130 - 0
web/src/components/table/model-deployments/DeploymentsFilters.jsx

@@ -0,0 +1,130 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useRef } from 'react';
+import { Form, Button } from '@douyinfe/semi-ui';
+import { IconSearch, IconRefresh } from '@douyinfe/semi-icons';
+
+const DeploymentsFilters = ({
+  formInitValues,
+  setFormApi,
+  searchDeployments,
+  loading,
+  searching,
+  setShowColumnSelector,
+  t,
+}) => {
+  const formApiRef = useRef(null);
+
+  const handleSubmit = (values) => {
+    searchDeployments(values);
+  };
+
+  const handleReset = () => {
+    if (!formApiRef.current) return;
+    formApiRef.current.reset();
+    setTimeout(() => {
+      formApiRef.current.submitForm();
+    }, 0);
+  };
+
+  const statusOptions = [
+    { label: t('全部状态'), value: '' },
+    { label: t('运行中'), value: 'running' },
+    { label: t('已完成'), value: 'completed' },
+    { label: t('失败'), value: 'failed' },
+    { label: t('部署请求中'), value: 'deployment requested' },
+    { label: t('终止请求中'), value: 'termination requested' },
+    { label: t('已销毁'), value: 'destroyed' },
+  ];
+
+  return (
+    <Form
+      layout='horizontal'
+      onSubmit={handleSubmit}
+      initValues={formInitValues}
+      getFormApi={(formApi) => {
+        setFormApi(formApi);
+        formApiRef.current = formApi;
+      }}
+      className='w-full md:w-auto order-1 md:order-2'
+    >
+      <div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto'>
+        <div className='w-full md:w-64'>
+          <Form.Input
+            field='searchKeyword'
+            placeholder={t('搜索部署名称')}
+            prefix={<IconSearch />}
+            showClear
+            size='small'
+            pure
+          />
+        </div>
+
+        <div className='w-full md:w-48'>
+          <Form.Select
+            field='searchStatus'
+            placeholder={t('选择状态')}
+            optionList={statusOptions}
+            className='w-full'
+            showClear
+            size='small'
+            pure
+          />
+        </div>
+
+        <div className='flex gap-2 w-full md:w-auto'>
+          <Button
+            htmlType='submit'
+            type='tertiary'
+            icon={<IconSearch />}
+            loading={searching}
+            disabled={loading}
+            size='small'
+            className='flex-1 md:flex-initial md:w-auto'
+          >
+            {t('查询')}
+          </Button>
+
+          <Button
+            type='tertiary'
+            icon={<IconRefresh />}
+            onClick={handleReset}
+            disabled={loading || searching}
+            size='small'
+            className='flex-1 md:flex-initial md:w-auto'
+          >
+            {t('重置')}
+          </Button>
+
+          <Button
+            type='tertiary'
+            onClick={() => setShowColumnSelector(true)}
+            size='small'
+            className='flex-1 md:flex-initial md:w-auto'
+          >
+            {t('列设置')}
+          </Button>
+        </div>
+      </div>
+    </Form>
+  );
+};
+
+export default DeploymentsFilters;

+ 247 - 0
web/src/components/table/model-deployments/DeploymentsTable.jsx

@@ -0,0 +1,247 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useMemo, useState } from 'react';
+import { Empty } from '@douyinfe/semi-ui';
+import CardTable from '../../common/ui/CardTable';
+import {
+  IllustrationNoResult,
+  IllustrationNoResultDark,
+} from '@douyinfe/semi-illustrations';
+import { getDeploymentsColumns } from './DeploymentsColumnDefs';
+
+// Import all the new modals
+import ViewLogsModal from './modals/ViewLogsModal';
+import ExtendDurationModal from './modals/ExtendDurationModal';
+import ViewDetailsModal from './modals/ViewDetailsModal';
+import UpdateConfigModal from './modals/UpdateConfigModal';
+import ConfirmationDialog from './modals/ConfirmationDialog';
+
+const DeploymentsTable = (deploymentsData) => {
+  const {
+    deployments,
+    loading,
+    searching,
+    activePage,
+    pageSize,
+    deploymentCount,
+    compactMode,
+    visibleColumns,
+    setSelectedKeys,
+    handlePageChange,
+    handlePageSizeChange,
+    handleRow,
+    t,
+    COLUMN_KEYS,
+    // Column functions and data
+    startDeployment,
+    restartDeployment,
+    deleteDeployment,
+    syncDeploymentToChannel,
+    setEditingDeployment,
+    setShowEdit,
+    refresh,
+  } = deploymentsData;
+
+  // Modal states
+  const [selectedDeployment, setSelectedDeployment] = useState(null);
+  const [showLogsModal, setShowLogsModal] = useState(false);
+  const [showExtendModal, setShowExtendModal] = useState(false);
+  const [showDetailsModal, setShowDetailsModal] = useState(false);
+  const [showConfigModal, setShowConfigModal] = useState(false);
+  const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+  const [confirmOperation, setConfirmOperation] = useState('delete');
+
+  // Enhanced modal handlers
+  const handleViewLogs = (deployment) => {
+    setSelectedDeployment(deployment);
+    setShowLogsModal(true);
+  };
+
+  const handleExtendDuration = (deployment) => {
+    setSelectedDeployment(deployment);
+    setShowExtendModal(true);
+  };
+
+  const handleViewDetails = (deployment) => {
+    setSelectedDeployment(deployment);
+    setShowDetailsModal(true);
+  };
+
+  const handleUpdateConfig = (deployment, operation = 'update') => {
+    setSelectedDeployment(deployment);
+    if (operation === 'delete' || operation === 'destroy') {
+      setConfirmOperation(operation);
+      setShowConfirmDialog(true);
+    } else {
+      setShowConfigModal(true);
+    }
+  };
+
+  const handleConfirmAction = () => {
+    if (selectedDeployment && confirmOperation === 'delete') {
+      deleteDeployment(selectedDeployment.id);
+    }
+    setShowConfirmDialog(false);
+    setSelectedDeployment(null);
+  };
+
+  const handleModalSuccess = (updatedDeployment) => {
+    // Refresh the deployments list
+    refresh?.();
+  };
+
+  // Get all columns
+  const allColumns = useMemo(() => {
+    return getDeploymentsColumns({
+      t,
+      COLUMN_KEYS,
+      startDeployment,
+      restartDeployment,
+      deleteDeployment,
+      setEditingDeployment,
+      setShowEdit,
+      refresh,
+      activePage,
+      deployments,
+      // Enhanced handlers
+      onViewLogs: handleViewLogs,
+      onExtendDuration: handleExtendDuration,
+      onViewDetails: handleViewDetails,
+      onUpdateConfig: handleUpdateConfig,
+      onSyncToChannel: syncDeploymentToChannel,
+    });
+  }, [
+    t,
+    COLUMN_KEYS,
+    startDeployment,
+    restartDeployment,
+    deleteDeployment,
+    syncDeploymentToChannel,
+    setEditingDeployment,
+    setShowEdit,
+    refresh,
+    activePage,
+    deployments,
+  ]);
+
+  // Filter columns based on visibility settings
+  const getVisibleColumns = () => {
+    return allColumns.filter((column) => visibleColumns[column.key]);
+  };
+
+  const visibleColumnsList = useMemo(() => {
+    return getVisibleColumns();
+  }, [visibleColumns, allColumns]);
+
+  const tableColumns = useMemo(() => {
+    if (compactMode) {
+      // In compact mode, remove fixed columns and adjust widths
+      return visibleColumnsList.map(({ fixed, width, ...rest }) => ({
+        ...rest,
+        width: width ? Math.max(width * 0.8, 80) : undefined, // Reduce width by 20% but keep minimum
+      }));
+    }
+    return visibleColumnsList;
+  }, [compactMode, visibleColumnsList]);
+
+  return (
+    <>
+      <CardTable
+        columns={tableColumns}
+        dataSource={deployments}
+        scroll={compactMode ? { x: 800 } : { x: 1200 }}
+        pagination={{
+          currentPage: activePage,
+          pageSize: pageSize,
+          total: deploymentCount,
+          pageSizeOpts: [10, 20, 50, 100],
+          showSizeChanger: true,
+          onPageSizeChange: handlePageSizeChange,
+          onPageChange: handlePageChange,
+        }}
+        hidePagination={true}
+        expandAllRows={false}
+        onRow={handleRow}
+        rowSelection={{
+          onChange: (selectedRowKeys, selectedRows) => {
+            setSelectedKeys(selectedRows);
+          },
+        }}
+        empty={
+          <Empty
+            image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+            darkModeImage={
+              <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
+            }
+            description={t('搜索无结果')}
+            style={{ padding: 30 }}
+          />
+        }
+        className='rounded-xl overflow-hidden'
+        size='middle'
+        loading={loading || searching}
+      />
+
+      {/* Enhanced Modals */}
+      <ViewLogsModal
+        visible={showLogsModal}
+        onCancel={() => setShowLogsModal(false)}
+        deployment={selectedDeployment}
+        t={t}
+      />
+
+      <ExtendDurationModal
+        visible={showExtendModal}
+        onCancel={() => setShowExtendModal(false)}
+        deployment={selectedDeployment}
+        onSuccess={handleModalSuccess}
+        t={t}
+      />
+
+      <ViewDetailsModal
+        visible={showDetailsModal}
+        onCancel={() => setShowDetailsModal(false)}
+        deployment={selectedDeployment}
+        t={t}
+      />
+
+      <UpdateConfigModal
+        visible={showConfigModal}
+        onCancel={() => setShowConfigModal(false)}
+        deployment={selectedDeployment}
+        onSuccess={handleModalSuccess}
+        t={t}
+      />
+
+      <ConfirmationDialog
+        visible={showConfirmDialog}
+        onCancel={() => setShowConfirmDialog(false)}
+        onConfirm={handleConfirmAction}
+        title={t('确认操作')}
+        type="danger"
+        deployment={selectedDeployment}
+        operation={confirmOperation}
+        t={t}
+      />
+    </>
+  );
+};
+
+export default DeploymentsTable;

+ 147 - 0
web/src/components/table/model-deployments/index.jsx

@@ -0,0 +1,147 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState } from 'react';
+import CardPro from '../../common/ui/CardPro';
+import DeploymentsTable from './DeploymentsTable';
+import DeploymentsActions from './DeploymentsActions';
+import DeploymentsFilters from './DeploymentsFilters';
+import EditDeploymentModal from './modals/EditDeploymentModal';
+import CreateDeploymentModal from './modals/CreateDeploymentModal';
+import ColumnSelectorModal from './modals/ColumnSelectorModal';
+import { useDeploymentsData } from '../../../hooks/model-deployments/useDeploymentsData';
+import { useIsMobile } from '../../../hooks/common/useIsMobile';
+import { createCardProPagination } from '../../../helpers/utils';
+
+const DeploymentsPage = () => {
+  const deploymentsData = useDeploymentsData();
+  const isMobile = useIsMobile();
+  
+  // Create deployment modal state
+  const [showCreateModal, setShowCreateModal] = useState(false);
+
+  const {
+    // Edit state
+    showEdit,
+    editingDeployment,
+    closeEdit,
+    refresh,
+
+    // Actions state
+    selectedKeys,
+    setSelectedKeys,
+    setEditingDeployment,
+    setShowEdit,
+    batchDeleteDeployments,
+
+    // Filters state
+    formInitValues,
+    setFormApi,
+    searchDeployments,
+    loading,
+    searching,
+
+    // Column visibility
+    showColumnSelector,
+    setShowColumnSelector,
+    visibleColumns,
+    setVisibleColumns,
+    COLUMN_KEYS,
+
+    // Description state
+    compactMode,
+    setCompactMode,
+
+    // Translation
+    t,
+  } = deploymentsData;
+
+  return (
+    <>
+      {/* Modals */}
+      <EditDeploymentModal
+        refresh={refresh}
+        editingDeployment={editingDeployment}
+        visible={showEdit}
+        handleClose={closeEdit}
+      />
+      
+      <CreateDeploymentModal
+        visible={showCreateModal}
+        onCancel={() => setShowCreateModal(false)}
+        onSuccess={refresh}
+        t={t}
+      />
+
+      <ColumnSelectorModal
+        visible={showColumnSelector}
+        onCancel={() => setShowColumnSelector(false)}
+        visibleColumns={visibleColumns}
+        onVisibleColumnsChange={setVisibleColumns}
+        columnKeys={COLUMN_KEYS}
+        t={t}
+      />
+
+      {/* Main Content */}
+      <CardPro
+        type='type3'
+        actionsArea={
+          <div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
+            <DeploymentsActions
+              selectedKeys={selectedKeys}
+              setSelectedKeys={setSelectedKeys}
+              setEditingDeployment={setEditingDeployment}
+              setShowEdit={setShowEdit}
+              batchDeleteDeployments={batchDeleteDeployments}
+              compactMode={compactMode}
+              setCompactMode={setCompactMode}
+              showCreateModal={showCreateModal}
+              setShowCreateModal={setShowCreateModal}
+              setShowColumnSelector={setShowColumnSelector}
+              t={t}
+            />
+            <DeploymentsFilters
+              formInitValues={formInitValues}
+              setFormApi={setFormApi}
+              searchDeployments={searchDeployments}
+              loading={loading}
+              searching={searching}
+              setShowColumnSelector={setShowColumnSelector}
+              t={t}
+            />
+          </div>
+        }
+        paginationArea={createCardProPagination({
+          currentPage: deploymentsData.activePage,
+          pageSize: deploymentsData.pageSize,
+          total: deploymentsData.deploymentCount,
+          onPageChange: deploymentsData.handlePageChange,
+          onPageSizeChange: deploymentsData.handlePageSizeChange,
+          isMobile: isMobile,
+          t: deploymentsData.t,
+        })}
+        t={deploymentsData.t}
+      >
+        <DeploymentsTable {...deploymentsData} />
+      </CardPro>
+    </>
+  );
+};
+
+export default DeploymentsPage;

+ 127 - 0
web/src/components/table/model-deployments/modals/ColumnSelectorModal.jsx

@@ -0,0 +1,127 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useMemo } from 'react';
+import { Modal, Button, Checkbox } from '@douyinfe/semi-ui';
+
+const ColumnSelectorModal = ({
+  visible,
+  onCancel,
+  visibleColumns,
+  onVisibleColumnsChange,
+  columnKeys,
+  t,
+}) => {
+  const columnOptions = useMemo(
+    () => [
+      { key: columnKeys.container_name, label: t('容器名称'), required: true },
+      { key: columnKeys.status, label: t('状态') },
+      { key: columnKeys.time_remaining, label: t('剩余时间') },
+      { key: columnKeys.hardware_info, label: t('硬件配置') },
+      { key: columnKeys.created_at, label: t('创建时间') },
+      { key: columnKeys.actions, label: t('操作'), required: true },
+    ],
+    [columnKeys, t],
+  );
+
+  const handleColumnVisibilityChange = (key, checked) => {
+    const column = columnOptions.find((option) => option.key === key);
+    if (column?.required) return;
+    onVisibleColumnsChange({
+      ...visibleColumns,
+      [key]: checked,
+    });
+  };
+
+  const handleSelectAll = (checked) => {
+    const updated = { ...visibleColumns };
+    columnOptions.forEach(({ key, required }) => {
+      updated[key] = required ? true : checked;
+    });
+    onVisibleColumnsChange(updated);
+  };
+
+  const handleReset = () => {
+    const defaults = columnOptions.reduce((acc, { key }) => {
+      acc[key] = true;
+      return acc;
+    }, {});
+    onVisibleColumnsChange({
+      ...visibleColumns,
+      ...defaults,
+    });
+  };
+
+  const allSelected = columnOptions.every(
+    ({ key, required }) => required || visibleColumns[key],
+  );
+  const indeterminate =
+    columnOptions.some(
+      ({ key, required }) => !required && visibleColumns[key],
+    ) && !allSelected;
+
+  const handleConfirm = () => onCancel();
+
+  return (
+    <Modal
+      title={t('列设置')}
+      visible={visible}
+      onCancel={onCancel}
+      footer={
+        <div className='flex justify-end gap-2'>
+          <Button onClick={handleReset}>{t('重置')}</Button>
+          <Button onClick={onCancel}>{t('取消')}</Button>
+          <Button type='primary' onClick={handleConfirm}>
+            {t('确定')}
+          </Button>
+        </div>
+      }
+    >
+      <div style={{ marginBottom: 20 }}>
+        <Checkbox
+          checked={allSelected}
+          indeterminate={indeterminate}
+          onChange={(e) => handleSelectAll(e.target.checked)}
+        >
+          {t('全选')}
+        </Checkbox>
+      </div>
+      <div
+        className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
+        style={{ border: '1px solid var(--semi-color-border)' }}
+      >
+        {columnOptions.map(({ key, label, required }) => (
+          <div key={key} className='w-1/2 mb-4 pr-2'>
+            <Checkbox
+              checked={!!visibleColumns[key]}
+              disabled={required}
+              onChange={(e) =>
+                handleColumnVisibilityChange(key, e.target.checked)
+              }
+            >
+              {label}
+            </Checkbox>
+          </div>
+        ))}
+      </div>
+    </Modal>
+  );
+};
+
+export default ColumnSelectorModal;

+ 99 - 0
web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx

@@ -0,0 +1,99 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState, useEffect } from 'react';
+import { Modal, Typography, Input } from '@douyinfe/semi-ui';
+
+const { Text } = Typography;
+
+const ConfirmationDialog = ({
+  visible,
+  onCancel,
+  onConfirm,
+  title,
+  type = 'danger',
+  deployment,
+  t,
+  loading = false
+}) => {
+  const [confirmText, setConfirmText] = useState('');
+
+  useEffect(() => {
+    if (!visible) {
+      setConfirmText('');
+    }
+  }, [visible]);
+
+  const requiredText = deployment?.container_name || deployment?.id || '';
+  const isConfirmed = Boolean(requiredText) && confirmText === requiredText;
+
+  const handleCancel = () => {
+    setConfirmText('');
+    onCancel();
+  };
+
+  const handleConfirm = () => {
+    if (isConfirmed) {
+      onConfirm();
+      handleCancel();
+    }
+  };
+
+  return (
+    <Modal
+      title={title}
+      visible={visible}
+      onCancel={handleCancel}
+      onOk={handleConfirm}
+      okText={t('确认')}
+      cancelText={t('取消')}
+      okButtonProps={{
+        disabled: !isConfirmed,
+        type: type === 'danger' ? 'danger' : 'primary',
+        loading
+      }}
+      width={480}
+    >
+      <div className="space-y-4">
+        <Text type="danger" strong>
+          {t('此操作具有风险,请确认要继续执行')}。
+        </Text>
+        <Text>
+          {t('请输入部署名称以完成二次确认')}:
+          <Text code className="ml-1">
+            {requiredText || t('未知部署')}
+          </Text>
+        </Text>
+        <Input
+          value={confirmText}
+          onChange={setConfirmText}
+          placeholder={t('再次输入部署名称')}
+          autoFocus
+        />
+        {!isConfirmed && confirmText && (
+          <Text type="danger" size="small">
+            {t('部署名称不匹配,请检查后重新输入')}
+          </Text>
+        )}
+      </div>
+    </Modal>
+  );
+};
+
+export default ConfirmationDialog;

+ 1462 - 0
web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx

@@ -0,0 +1,1462 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState, useEffect, useMemo, useRef } from 'react';
+import {
+  Modal,
+  Form,
+  Input,
+  Select,
+  InputNumber,
+  Switch,
+  Collapse,
+  Card,
+  Divider,
+  Button,
+  Typography,
+  Space,
+  Spin,
+  Tag,
+  Row,
+  Col,
+  Tooltip,
+  Radio,
+} from '@douyinfe/semi-ui';
+import { IconPlus, IconMinus, IconHelpCircle, IconCopy } from '@douyinfe/semi-icons';
+import { API } from '../../../../helpers';
+import { showError, showSuccess, copy } from '../../../../helpers';
+
+const { Text, Title } = Typography;
+const { Option } = Select;
+const RadioGroup = Radio.Group;
+
+const BUILTIN_IMAGE = 'ollama/ollama:latest';
+const DEFAULT_TRAFFIC_PORT = 11434;
+
+const generateRandomKey = () => {
+  try {
+    if (typeof crypto !== 'undefined' && crypto.randomUUID) {
+      return `ionet-${crypto.randomUUID().replace(/-/g, '')}`;
+    }
+  } catch (error) {
+    // ignore
+  }
+  return `ionet-${Math.random().toString(36).slice(2)}${Math.random()
+    .toString(36)
+    .slice(2)}`;
+};
+
+const CreateDeploymentModal = ({ visible, onCancel, onSuccess, t }) => {
+  const [formApi, setFormApi] = useState(null);
+  const [loading, setLoading] = useState(false);
+  const [submitting, setSubmitting] = useState(false);
+
+  // Resource data states
+  const [hardwareTypes, setHardwareTypes] = useState([]);
+  const [hardwareTotalAvailable, setHardwareTotalAvailable] = useState(null);
+  const [locations, setLocations] = useState([]);
+  const [locationTotalAvailable, setLocationTotalAvailable] = useState(null);
+  const [availableReplicas, setAvailableReplicas] = useState([]);
+  const [priceEstimation, setPriceEstimation] = useState(null);
+
+  // UI states
+  const [loadingHardware, setLoadingHardware] = useState(false);
+  const [loadingLocations, setLoadingLocations] = useState(false);
+  const [loadingReplicas, setLoadingReplicas] = useState(false);
+  const [loadingPrice, setLoadingPrice] = useState(false);
+  const [showAdvanced, setShowAdvanced] = useState(false);
+  const [envVariables, setEnvVariables] = useState([{ key: '', value: '' }]);
+  const [secretEnvVariables, setSecretEnvVariables] = useState([{ key: '', value: '' }]);
+  const [entrypoint, setEntrypoint] = useState(['']);
+  const [args, setArgs] = useState(['']);
+  const [imageMode, setImageMode] = useState('builtin');
+  const [autoOllamaKey, setAutoOllamaKey] = useState('');
+  const customSecretEnvRef = useRef(null);
+  const customEnvRef = useRef(null);
+  const customImageRef = useRef('');
+  const customTrafficPortRef = useRef(null);
+  const prevImageModeRef = useRef('builtin');
+  const basicSectionRef = useRef(null);
+  const priceSectionRef = useRef(null);
+  const advancedSectionRef = useRef(null);
+  const locationRequestIdRef = useRef(0);
+  const replicaRequestIdRef = useRef(0);
+  const [formDefaults, setFormDefaults] = useState({
+    resource_private_name: '',
+    image_url: BUILTIN_IMAGE,
+    gpus_per_container: 1,
+    replica_count: 1,
+    duration_hours: 1,
+    traffic_port: DEFAULT_TRAFFIC_PORT,
+    location_ids: [],
+  });
+  const [formKey, setFormKey] = useState(0);
+  const [priceCurrency, setPriceCurrency] = useState('usdc');
+  const normalizeCurrencyValue = (value) => {
+    if (typeof value === 'string') return value.toLowerCase();
+    if (value && typeof value === 'object') {
+      if (typeof value.value === 'string') return value.value.toLowerCase();
+      if (typeof value.target?.value === 'string') {
+        return value.target.value.toLowerCase();
+      }
+    }
+    return 'usdc';
+  };
+
+  const handleCurrencyChange = (value) => {
+    const normalized = normalizeCurrencyValue(value);
+    setPriceCurrency(normalized);
+  };
+
+  const hardwareLabelMap = useMemo(() => {
+    const map = {};
+    hardwareTypes.forEach((hardware) => {
+      const displayName = hardware.brand_name
+        ? `${hardware.brand_name} ${hardware.name}`.trim()
+        : hardware.name;
+      map[hardware.id] = displayName;
+    });
+    return map;
+  }, [hardwareTypes]);
+
+  const locationLabelMap = useMemo(() => {
+    const map = {};
+    locations.forEach((location) => {
+      map[location.id] = location.name;
+    });
+    return map;
+  }, [locations]);
+
+  // Form values for price calculation
+  const [selectedHardwareId, setSelectedHardwareId] = useState(null);
+  const [selectedLocationIds, setSelectedLocationIds] = useState([]);
+  const [gpusPerContainer, setGpusPerContainer] = useState(1);
+  const [durationHours, setDurationHours] = useState(1);
+  const [replicaCount, setReplicaCount] = useState(1);
+
+  // Load initial data when modal opens
+  useEffect(() => {
+    if (visible) {
+      loadHardwareTypes();
+      resetFormState();
+    }
+  }, [visible]);
+
+  // Load available replicas when hardware or locations change
+  useEffect(() => {
+    if (!visible) {
+      return;
+    }
+    if (selectedHardwareId && gpusPerContainer > 0) {
+      loadAvailableReplicas(selectedHardwareId, gpusPerContainer);
+    }
+  }, [selectedHardwareId, gpusPerContainer, visible]);
+
+  // Calculate price when relevant parameters change
+  useEffect(() => {
+    if (!visible) {
+      return;
+    }
+    if (
+      selectedHardwareId &&
+      selectedLocationIds.length > 0 &&
+      gpusPerContainer > 0 &&
+      durationHours > 0 &&
+      replicaCount > 0
+    ) {
+      calculatePrice();
+    } else {
+      setPriceEstimation(null);
+    }
+  }, [
+    selectedHardwareId,
+    selectedLocationIds,
+    gpusPerContainer,
+    durationHours,
+    replicaCount,
+    priceCurrency,
+    visible,
+  ]);
+
+  useEffect(() => {
+    if (!visible) {
+      return;
+    }
+    const prevMode = prevImageModeRef.current;
+    if (prevMode === imageMode) {
+      return;
+    }
+
+    if (imageMode === 'builtin') {
+      if (prevMode === 'custom') {
+        if (formApi) {
+          customImageRef.current = formApi.getValue('image_url') || customImageRef.current;
+          customTrafficPortRef.current = formApi.getValue('traffic_port') ?? customTrafficPortRef.current;
+        }
+        customSecretEnvRef.current = secretEnvVariables.map((item) => ({ ...item }));
+        customEnvRef.current = envVariables.map((item) => ({ ...item }));
+      }
+      const newKey = generateRandomKey();
+      setAutoOllamaKey(newKey);
+      setSecretEnvVariables([{ key: 'OLLAMA_API_KEY', value: newKey }]);
+      setEnvVariables([{ key: '', value: '' }]);
+      if (formApi) {
+        formApi.setValue('image_url', BUILTIN_IMAGE);
+        formApi.setValue('traffic_port', DEFAULT_TRAFFIC_PORT);
+      }
+    } else {
+      const restoredSecrets =
+        customSecretEnvRef.current && customSecretEnvRef.current.length > 0
+          ? customSecretEnvRef.current.map((item) => ({ ...item }))
+          : [{ key: '', value: '' }];
+      const restoredEnv =
+        customEnvRef.current && customEnvRef.current.length > 0
+          ? customEnvRef.current.map((item) => ({ ...item }))
+          : [{ key: '', value: '' }];
+      setSecretEnvVariables(restoredSecrets);
+      setEnvVariables(restoredEnv);
+      if (formApi) {
+        const restoredImage = customImageRef.current || '';
+        formApi.setValue('image_url', restoredImage);
+        if (customTrafficPortRef.current) {
+          formApi.setValue('traffic_port', customTrafficPortRef.current);
+        }
+      }
+    }
+
+    prevImageModeRef.current = imageMode;
+  }, [imageMode, visible, secretEnvVariables, envVariables, formApi]);
+
+  useEffect(() => {
+    if (!visible || !formApi) {
+      return;
+    }
+    if (imageMode === 'builtin') {
+      formApi.setValue('image_url', BUILTIN_IMAGE);
+    }
+  }, [formApi, imageMode, visible]);
+
+  useEffect(() => {
+    if (!formApi) {
+      return;
+    }
+    if (selectedHardwareId !== null && selectedHardwareId !== undefined) {
+      formApi.setValue('hardware_id', selectedHardwareId);
+    }
+  }, [formApi, selectedHardwareId]);
+
+  useEffect(() => {
+    if (!formApi) {
+      return;
+    }
+    formApi.setValue('location_ids', selectedLocationIds);
+  }, [formApi, selectedLocationIds]);
+
+  useEffect(() => {
+    if (!visible) {
+      return;
+    }
+    if (selectedHardwareId) {
+      loadLocations(selectedHardwareId);
+    } else {
+      setLocations([]);
+      setSelectedLocationIds([]);
+      setAvailableReplicas([]);
+      setLocationTotalAvailable(null);
+      setLoadingLocations(false);
+      setLoadingReplicas(false);
+      locationRequestIdRef.current = 0;
+      replicaRequestIdRef.current = 0;
+      if (formApi) {
+        formApi.setValue('location_ids', []);
+      }
+    }
+  }, [selectedHardwareId, visible, formApi]);
+
+  const resetFormState = () => {
+    const randomName = `deployment-${Math.random().toString(36).slice(2, 8)}`;
+    const generatedKey = generateRandomKey();
+
+    setSelectedHardwareId(null);
+    setSelectedLocationIds([]);
+    setGpusPerContainer(1);
+    setDurationHours(1);
+    setReplicaCount(1);
+    setPriceEstimation(null);
+    setAvailableReplicas([]);
+    setLocations([]);
+    setLocationTotalAvailable(null);
+    setHardwareTotalAvailable(null);
+    setEnvVariables([{ key: '', value: '' }]);
+    setSecretEnvVariables([{ key: 'OLLAMA_API_KEY', value: generatedKey }]);
+    setEntrypoint(['']);
+    setArgs(['']);
+    setShowAdvanced(false);
+    setImageMode('builtin');
+    setAutoOllamaKey(generatedKey);
+    customSecretEnvRef.current = null;
+    customEnvRef.current = null;
+    customImageRef.current = '';
+    customTrafficPortRef.current = DEFAULT_TRAFFIC_PORT;
+    prevImageModeRef.current = 'builtin';
+    setFormDefaults({
+      resource_private_name: randomName,
+      image_url: BUILTIN_IMAGE,
+      gpus_per_container: 1,
+      replica_count: 1,
+      duration_hours: 1,
+      traffic_port: DEFAULT_TRAFFIC_PORT,
+      location_ids: [],
+    });
+    setFormKey((prev) => prev + 1);
+    setPriceCurrency('usdc');
+  };
+
+  const arraysEqual = (a = [], b = []) =>
+    a.length === b.length && a.every((value, index) => value === b[index]);
+
+  const loadHardwareTypes = async () => {
+    try {
+      setLoadingHardware(true);
+      const response = await API.get('/api/deployments/hardware-types');
+      if (response.data.success) {
+        const { hardware_types: hardwareList = [], total_available } = response.data.data || {};
+
+        const normalizedHardware = hardwareList.map((hardware) => {
+          const availableCountValue = Number(hardware.available_count);
+          const availableCount = Number.isNaN(availableCountValue) ? 0 : availableCountValue;
+          const availableBool =
+            typeof hardware.available === 'boolean'
+              ? hardware.available
+              : availableCount > 0;
+
+          return {
+            ...hardware,
+            available: availableBool,
+            available_count: availableCount,
+          };
+        });
+
+        const providedTotal = Number(total_available);
+        const fallbackTotal = normalizedHardware.reduce(
+          (acc, item) => acc + (Number.isNaN(item.available_count) ? 0 : item.available_count),
+          0,
+        );
+        const hasProvidedTotal =
+          total_available !== undefined &&
+          total_available !== null &&
+          total_available !== '' &&
+          !Number.isNaN(providedTotal);
+
+        setHardwareTypes(normalizedHardware);
+        setHardwareTotalAvailable(
+          hasProvidedTotal ? providedTotal : fallbackTotal,
+        );
+      } else {
+        showError(t('获取硬件类型失败: ') + response.data.message);
+      }
+    } catch (error) {
+      showError(t('获取硬件类型失败: ') + error.message);
+    } finally {
+      setLoadingHardware(false);
+    }
+  };
+
+  const loadLocations = async (hardwareId) => {
+    if (!hardwareId) {
+      setLocations([]);
+      setLocationTotalAvailable(null);
+      return;
+    }
+
+    const requestId = Date.now();
+    locationRequestIdRef.current = requestId;
+    setLoadingLocations(true);
+    setLocations([]);
+    setLocationTotalAvailable(null);
+
+    try {
+      const response = await API.get('/api/deployments/locations', {
+        params: { hardware_id: hardwareId },
+      });
+
+      if (locationRequestIdRef.current !== requestId) {
+        return;
+      }
+
+      if (response.data.success) {
+        const { locations: locationsList = [], total } =
+          response.data.data || {};
+
+        const normalizedLocations = locationsList.map((location) => {
+          const iso2 = (location.iso2 || '').toString().toUpperCase();
+          const availableValue = Number(location.available);
+          const available = Number.isNaN(availableValue) ? 0 : availableValue;
+
+          return {
+            ...location,
+            iso2,
+            available,
+          };
+        });
+
+        const providedTotal = Number(total);
+        const fallbackTotal = normalizedLocations.reduce(
+          (acc, item) =>
+            acc + (Number.isNaN(item.available) ? 0 : item.available),
+          0,
+        );
+        const hasProvidedTotal =
+          total !== undefined &&
+          total !== null &&
+          total !== '' &&
+          !Number.isNaN(providedTotal);
+
+        setLocations(normalizedLocations);
+        setLocationTotalAvailable(
+          hasProvidedTotal ? providedTotal : fallbackTotal,
+        );
+      } else {
+        showError(t('获取部署位置失败: ') + response.data.message);
+        setLocations([]);
+        setLocationTotalAvailable(null);
+      }
+    } catch (error) {
+      if (locationRequestIdRef.current === requestId) {
+        showError(t('获取部署位置失败: ') + error.message);
+        setLocations([]);
+        setLocationTotalAvailable(null);
+      }
+    } finally {
+      if (locationRequestIdRef.current === requestId) {
+        setLoadingLocations(false);
+      }
+    }
+  };
+
+  const loadAvailableReplicas = async (hardwareId, gpuCount) => {
+    if (!hardwareId || !gpuCount) {
+      setAvailableReplicas([]);
+      setLocationTotalAvailable(null);
+      setLoadingReplicas(false);
+      return;
+    }
+
+    const requestId = Date.now();
+    replicaRequestIdRef.current = requestId;
+    setLoadingReplicas(true);
+    setAvailableReplicas([]);
+
+    try {
+      const response = await API.get(
+        `/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`,
+      );
+
+      if (replicaRequestIdRef.current !== requestId) {
+        return;
+      }
+
+      if (response.data.success) {
+        const replicasList = response.data.data?.replicas || [];
+        const filteredReplicas = replicasList.filter(
+          (replica) => (replica.available_count || 0) > 0,
+        );
+        setAvailableReplicas(filteredReplicas);
+        const totalAvailableForHardware = filteredReplicas.reduce(
+          (total, replica) => total + (replica.available_count || 0),
+          0,
+        );
+        setLocationTotalAvailable(totalAvailableForHardware);
+      } else {
+        showError(t('获取可用资源失败: ') + response.data.message);
+        setAvailableReplicas([]);
+        setLocationTotalAvailable(null);
+      }
+    } catch (error) {
+      if (replicaRequestIdRef.current === requestId) {
+        console.error('Load available replicas error:', error);
+        setAvailableReplicas([]);
+        setLocationTotalAvailable(null);
+      }
+    } finally {
+      if (replicaRequestIdRef.current === requestId) {
+        setLoadingReplicas(false);
+      }
+    }
+  };
+
+  const calculatePrice = async () => {
+    try {
+      setLoadingPrice(true);
+      const requestData = {
+        location_ids: selectedLocationIds,
+        hardware_id: selectedHardwareId,
+        gpus_per_container: gpusPerContainer,
+        duration_hours: durationHours,
+        replica_count: replicaCount,
+        currency: priceCurrency?.toLowerCase?.() || priceCurrency,
+        duration_type: 'hour',
+        duration_qty: durationHours,
+        hardware_qty: gpusPerContainer,
+      };
+
+      const response = await API.post('/api/deployments/price-estimation', requestData);
+      if (response.data.success) {
+        setPriceEstimation(response.data.data);
+      } else {
+        showError(t('价格计算失败: ') + response.data.message);
+        setPriceEstimation(null);
+      }
+    } catch (error) {
+      console.error('Price calculation error:', error);
+      setPriceEstimation(null);
+    } finally {
+      setLoadingPrice(false);
+    }
+  };
+
+  const handleSubmit = async (values) => {
+    try {
+      setSubmitting(true);
+
+      // Prepare environment variables
+      const envVars = {};
+      envVariables.forEach(env => {
+        if (env.key && env.value) {
+          envVars[env.key] = env.value;
+        }
+      });
+
+      const secretEnvVars = {};
+      secretEnvVariables.forEach(env => {
+        if (env.key && env.value) {
+          secretEnvVars[env.key] = env.value;
+        }
+      });
+
+      if (imageMode === 'builtin') {
+        if (!secretEnvVars.OLLAMA_API_KEY) {
+          const ensuredKey = autoOllamaKey || generateRandomKey();
+          secretEnvVars.OLLAMA_API_KEY = ensuredKey;
+          setAutoOllamaKey(ensuredKey);
+        }
+      }
+
+      // Prepare entrypoint and args
+      const cleanEntrypoint = entrypoint.filter(item => item.trim() !== '');
+      const cleanArgs = args.filter(item => item.trim() !== '');
+
+      const resolvedImage = imageMode === 'builtin' ? BUILTIN_IMAGE : values.image_url;
+      const resolvedTrafficPort =
+        values.traffic_port || (imageMode === 'builtin' ? DEFAULT_TRAFFIC_PORT : undefined);
+
+      const requestData = {
+        resource_private_name: values.resource_private_name,
+        duration_hours: values.duration_hours,
+        gpus_per_container: values.gpus_per_container,
+        hardware_id: values.hardware_id,
+        location_ids: values.location_ids,
+        container_config: {
+          replica_count: values.replica_count,
+          env_variables: envVars,
+          secret_env_variables: secretEnvVars,
+          entrypoint: cleanEntrypoint.length > 0 ? cleanEntrypoint : undefined,
+          args: cleanArgs.length > 0 ? cleanArgs : undefined,
+          traffic_port: resolvedTrafficPort,
+        },
+        registry_config: {
+          image_url: resolvedImage,
+          registry_username: values.registry_username || undefined,
+          registry_secret: values.registry_secret || undefined,
+        },
+      };
+
+      const response = await API.post('/api/deployments', requestData);
+      
+      if (response.data.success) {
+        showSuccess(t('容器创建成功'));
+        onSuccess?.(response.data.data);
+        onCancel();
+      } else {
+        showError(t('容器创建失败: ') + response.data.message);
+      }
+    } catch (error) {
+      showError(t('容器创建失败: ') + error.message);
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  const handleAddEnvVariable = (type) => {
+    if (type === 'env') {
+      setEnvVariables([...envVariables, { key: '', value: '' }]);
+    } else {
+      setSecretEnvVariables([...secretEnvVariables, { key: '', value: '' }]);
+    }
+  };
+
+  const handleRemoveEnvVariable = (index, type) => {
+    if (type === 'env') {
+      const newEnvVars = envVariables.filter((_, i) => i !== index);
+      setEnvVariables(newEnvVars.length > 0 ? newEnvVars : [{ key: '', value: '' }]);
+    } else {
+      const newSecretEnvVars = secretEnvVariables.filter((_, i) => i !== index);
+      setSecretEnvVariables(newSecretEnvVars.length > 0 ? newSecretEnvVars : [{ key: '', value: '' }]);
+    }
+  };
+
+  const handleEnvVariableChange = (index, field, value, type) => {
+    if (type === 'env') {
+      const newEnvVars = [...envVariables];
+      newEnvVars[index][field] = value;
+      setEnvVariables(newEnvVars);
+    } else {
+      const newSecretEnvVars = [...secretEnvVariables];
+      newSecretEnvVars[index][field] = value;
+      setSecretEnvVariables(newSecretEnvVars);
+    }
+  };
+
+  const handleArrayFieldChange = (index, value, type) => {
+    if (type === 'entrypoint') {
+      const newEntrypoint = [...entrypoint];
+      newEntrypoint[index] = value;
+      setEntrypoint(newEntrypoint);
+    } else {
+      const newArgs = [...args];
+      newArgs[index] = value;
+      setArgs(newArgs);
+    }
+  };
+
+  const handleAddArrayField = (type) => {
+    if (type === 'entrypoint') {
+      setEntrypoint([...entrypoint, '']);
+    } else {
+      setArgs([...args, '']);
+    }
+  };
+
+  const handleRemoveArrayField = (index, type) => {
+    if (type === 'entrypoint') {
+      const newEntrypoint = entrypoint.filter((_, i) => i !== index);
+      setEntrypoint(newEntrypoint.length > 0 ? newEntrypoint : ['']);
+    } else {
+      const newArgs = args.filter((_, i) => i !== index);
+      setArgs(newArgs.length > 0 ? newArgs : ['']);
+    }
+  };
+
+  useEffect(() => {
+    if (!visible) {
+      return;
+    }
+
+    if (!selectedHardwareId) {
+      if (selectedLocationIds.length > 0) {
+        setSelectedLocationIds([]);
+        if (formApi) {
+          formApi.setValue('location_ids', []);
+        }
+      }
+      return;
+    }
+
+    const validLocationIds =
+      availableReplicas.length > 0
+        ? availableReplicas.map((item) => item.location_id)
+        : locations.map((location) => location.id);
+
+    if (validLocationIds.length === 0) {
+      if (selectedLocationIds.length > 0) {
+        setSelectedLocationIds([]);
+        if (formApi) {
+          formApi.setValue('location_ids', []);
+        }
+      }
+      return;
+    }
+
+    if (selectedLocationIds.length === 0) {
+      return;
+    }
+
+    const filteredSelection = selectedLocationIds.filter((id) =>
+      validLocationIds.includes(id),
+    );
+
+    if (!arraysEqual(selectedLocationIds, filteredSelection)) {
+      setSelectedLocationIds(filteredSelection);
+      if (formApi) {
+        formApi.setValue('location_ids', filteredSelection);
+      }
+    }
+  }, [
+    availableReplicas,
+    locations,
+    selectedHardwareId,
+    selectedLocationIds,
+    visible,
+    formApi,
+  ]);
+
+  const maxAvailableReplicas = useMemo(() => {
+    if (!selectedLocationIds.length) return 0;
+
+    if (availableReplicas.length > 0) {
+      return availableReplicas
+        .filter((replica) => selectedLocationIds.includes(replica.location_id))
+        .reduce((total, replica) => total + (replica.available_count || 0), 0);
+    }
+
+    return locations
+      .filter((location) => selectedLocationIds.includes(location.id))
+      .reduce((total, location) => {
+        const availableValue = Number(location.available);
+        return total + (Number.isNaN(availableValue) ? 0 : availableValue);
+      }, 0);
+  }, [availableReplicas, selectedLocationIds, locations]);
+
+  const isPriceReady = useMemo(
+    () =>
+      selectedHardwareId &&
+      selectedLocationIds.length > 0 &&
+      gpusPerContainer > 0 &&
+      durationHours > 0 &&
+      replicaCount > 0,
+    [
+      selectedHardwareId,
+      selectedLocationIds,
+      gpusPerContainer,
+      durationHours,
+      replicaCount,
+    ],
+  );
+
+  const currencyLabel = (priceEstimation?.currency || priceCurrency || '').toUpperCase();
+  const selectedHardwareLabel = selectedHardwareId
+    ? hardwareLabelMap[selectedHardwareId]
+    : '';
+  const selectedLocationNames = selectedLocationIds
+    .map((id) => locationLabelMap[id])
+    .filter(Boolean);
+  const totalGpuHours =
+    Number(gpusPerContainer || 0) *
+    Number(replicaCount || 0) *
+    Number(durationHours || 0);
+  const priceSummaryItems = [
+    {
+      key: 'hardware',
+      label: t('硬件类型'),
+      value: selectedHardwareLabel || '--',
+    },
+    {
+      key: 'locations',
+      label: t('部署位置'),
+      value: selectedLocationNames.length ? selectedLocationNames.join('、') : '--',
+    },
+    {
+      key: 'replicas',
+      label: t('副本数量'),
+      value: (replicaCount ?? 0).toString(),
+    },
+    {
+      key: 'gpus',
+      label: t('每容器GPU数量'),
+      value: (gpusPerContainer ?? 0).toString(),
+    },
+    {
+      key: 'duration',
+      label: t('运行时长(小时)'),
+      value: durationHours ? durationHours.toString() : '0',
+    },
+    {
+      key: 'gpu-hours',
+      label: t('总 GPU 小时'),
+      value: totalGpuHours > 0 ? totalGpuHours.toLocaleString() : '0',
+    },
+  ];
+
+  const scrollToSection = (ref) => {
+    if (ref?.current && typeof ref.current.scrollIntoView === 'function') {
+      ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    }
+  };
+
+  const priceUnavailableContent = (
+    <div style={{ marginTop: 12 }}>
+      {loadingPrice ? (
+        <Space spacing={8} align="center">
+          <Spin size="small" />
+          <Text size="small" type="tertiary">
+            {t('价格计算中...')}
+          </Text>
+        </Space>
+      ) : (
+        <Text size="small" type="tertiary">
+          {isPriceReady
+            ? t('价格暂时不可用,请稍后重试')
+            : t('完成硬件类型、部署位置、副本数量等配置后,将自动计算价格')}
+        </Text>
+      )}
+    </div>
+  );
+
+  useEffect(() => {
+    if (!visible || !formApi) {
+      return;
+    }
+    if (maxAvailableReplicas > 0 && replicaCount > maxAvailableReplicas) {
+      setReplicaCount(maxAvailableReplicas);
+      formApi.setValue('replica_count', maxAvailableReplicas);
+    }
+  }, [maxAvailableReplicas, replicaCount, visible, formApi]);
+
+  return (
+    <Modal
+      title={t('新建容器部署')}
+      visible={visible}
+      onCancel={onCancel}
+      onOk={() => formApi?.submitForm()}
+      okText={t('创建')}
+      cancelText={t('取消')}
+      width={800}
+      confirmLoading={submitting}
+      style={{ top: 20 }}
+    >
+      <Form
+        key={formKey}
+        initValues={formDefaults}
+        getFormApi={setFormApi}
+        onSubmit={handleSubmit}
+        style={{ maxHeight: '70vh', overflowY: 'auto' }}
+        labelPosition="top"
+      >
+        <Space
+          wrap
+          spacing={8}
+          style={{ justifyContent: 'flex-end', width: '100%', marginBottom: 8 }}
+        >
+          <Button
+            size="small"
+            theme="borderless"
+            type="tertiary"
+            onClick={() => scrollToSection(basicSectionRef)}
+          >
+            {t('部署配置')}
+          </Button>
+          <Button
+            size="small"
+            theme="borderless"
+            type="tertiary"
+            onClick={() => scrollToSection(priceSectionRef)}
+          >
+            {t('价格预估')}
+          </Button>
+          <Button
+            size="small"
+            theme="borderless"
+            type="tertiary"
+            onClick={() => scrollToSection(advancedSectionRef)}
+          >
+            {t('高级配置')}
+          </Button>
+        </Space>
+
+        <div ref={basicSectionRef}>
+          <Card className="mb-4">
+            <Title heading={6}>{t('部署配置')}</Title>
+            
+            <Form.Input
+              field="resource_private_name"
+              label={t('容器名称')}
+              placeholder={t('请输入容器名称')}
+              rules={[{ required: true, message: t('请输入容器名称') }]}
+            />
+
+            <div className="mt-2">
+              <Text strong>{t('镜像选择')}</Text>
+              <div style={{ marginTop: 8 }}>
+                <RadioGroup
+                  type="button"
+                  value={imageMode}
+                  onChange={(value) => setImageMode(value?.target?.value ?? value)}
+                >
+                  <Radio value="builtin">{t('内置 Ollama 镜像')}</Radio>
+                  <Radio value="custom">{t('自定义镜像')}</Radio>
+                </RadioGroup>
+              </div>
+            </div>
+
+            <Form.Input
+              field="image_url"
+              label={t('镜像地址')}
+              placeholder={t('例如:nginx:latest')}
+              rules={[{ required: true, message: t('请输入镜像地址') }]}
+              disabled={imageMode === 'builtin'}
+              onChange={(value) => {
+                if (imageMode === 'custom') {
+                  customImageRef.current = value;
+                }
+              }}
+            />
+
+            {imageMode === 'builtin' && (
+              <Space align="center" spacing={8} className="mt-2">
+                <Text size="small" type="tertiary">
+                  {t('系统已为该部署准备 Ollama 镜像与随机 API Key')}
+                </Text>
+                <Input
+                  readOnly
+                  value={autoOllamaKey}
+                  size="small"
+                  style={{ width: 220 }}
+                />
+                <Button
+                  icon={<IconCopy />}
+                  size="small"
+                  theme="borderless"
+                  onClick={async () => {
+                    if (!autoOllamaKey) {
+                      return;
+                    }
+                    const copied = await copy(autoOllamaKey);
+                    if (copied) {
+                      showSuccess(t('已复制自动生成的 API Key'));
+                    } else {
+                      showError(t('复制失败,请手动选择文本复制'));
+                    }
+                  }}
+                >
+                  {t('复制')}
+                </Button>
+              </Space>
+            )}
+
+            <Row gutter={16}>
+              <Col xs={24} md={12}>
+                <Form.Select
+                  field="hardware_id"
+                  label={t('硬件类型')}
+                  placeholder={t('选择硬件类型')}
+                  loading={loadingHardware}
+                  rules={[{ required: true, message: t('请选择硬件类型') }]}
+                  onChange={(value) => {
+                    setSelectedHardwareId(value);
+                    setSelectedLocationIds([]);
+                    if (formApi) {
+                      formApi.setValue('location_ids', []);
+                    }
+                  }}
+                  style={{ width: '100%' }}
+                  dropdownStyle={{ maxHeight: 360, overflowY: 'auto' }}
+                  renderSelectedItem={(optionNode) =>
+                    optionNode
+                      ? hardwareLabelMap[optionNode?.value] ||
+                        optionNode?.label ||
+                        optionNode?.value ||
+                        ''
+                      : ''
+                  }
+                >
+                  {hardwareTypes.map((hardware) => {
+                    const displayName = hardware.brand_name
+                      ? `${hardware.brand_name} ${hardware.name}`.trim()
+                      : hardware.name;
+                    const availableCount =
+                      typeof hardware.available_count === 'number'
+                        ? hardware.available_count
+                        : 0;
+                    const hasAvailability = availableCount > 0;
+
+                    return (
+                      <Option key={hardware.id} value={hardware.id}>
+                        <div className="flex flex-col gap-1">
+                          <Text strong>{displayName}</Text>
+                          <div className="flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]">
+                            <span>
+                              {t('最大GPU数')}: {hardware.max_gpus}
+                            </span>
+                            <Tag color={hasAvailability ? 'green' : 'red'} size="small">
+                              {t('可用数量')}: {availableCount}
+                            </Tag>
+                          </div>
+                        </div>
+                      </Option>
+                    );
+                  })}
+                </Form.Select>
+              </Col>
+              <Col xs={24} md={12}>
+                <Form.InputNumber
+                  field="gpus_per_container"
+                  label={t('每容器GPU数量')}
+                  placeholder={1}
+                  min={1}
+                  max={selectedHardwareId ? hardwareTypes.find((h) => h.id === selectedHardwareId)?.max_gpus : 8}
+                  step={1}
+                  innerButtons
+                  rules={[{ required: true, message: t('请输入GPU数量') }]}
+                  onChange={(value) => setGpusPerContainer(value)}
+                  style={{ width: '100%' }}
+                />
+              </Col>
+            </Row>
+
+            {typeof hardwareTotalAvailable === 'number' && (
+              <Text size="small" type="tertiary">
+                {t('全部硬件总可用资源')}: {hardwareTotalAvailable}
+              </Text>
+            )}
+
+            <Form.Select
+              field="location_ids"
+              label={
+                <Space>
+                  {t('部署位置')}
+                  {loadingReplicas && <Spin size="small" />}
+                </Space>
+              }
+              placeholder={
+                !selectedHardwareId
+                  ? t('请先选择硬件类型')
+                  : loadingLocations || loadingReplicas
+                    ? t('正在加载可用部署位置...')
+                    : t('选择部署位置(可多选)')
+              }
+              multiple
+              loading={loadingLocations || loadingReplicas}
+              disabled={!selectedHardwareId || loadingLocations || loadingReplicas}
+              rules={[{ required: true, message: t('请选择至少一个部署位置') }]}
+              onChange={(value) => setSelectedLocationIds(value)}
+              style={{ width: '100%' }}
+              dropdownStyle={{ maxHeight: 360, overflowY: 'auto' }}
+              renderSelectedItem={(optionNode) => ({
+                isRenderInTag: true,
+                content:
+                  !optionNode
+                    ? ''
+                    : loadingLocations || loadingReplicas
+                      ? t('部署位置加载中...')
+                      : locationLabelMap[optionNode?.value] ||
+                        optionNode?.label ||
+                        optionNode?.value ||
+                        '',
+              })}
+            >
+              {locations.map((location) => {
+                const replicaEntry = availableReplicas.find(
+                  (r) => r.location_id === location.id,
+                );
+                const hasReplicaData = availableReplicas.length > 0;
+                const availableCount = hasReplicaData
+                  ? replicaEntry?.available_count ?? 0
+                  : (() => {
+                      const numeric = Number(location.available);
+                      return Number.isNaN(numeric) ? 0 : numeric;
+                    })();
+                const locationLabel =
+                  location.region ||
+                  location.country ||
+                  (location.iso2 ? location.iso2.toUpperCase() : '') ||
+                  location.code ||
+                  '';
+                const disableOption = hasReplicaData
+                  ? availableCount === 0
+                  : typeof location.available === 'number'
+                    ? location.available === 0
+                    : false;
+
+                return (
+                  <Option
+                    key={location.id}
+                    value={location.id}
+                    disabled={disableOption}
+                  >
+                    <div className="flex flex-col gap-1">
+                      <div className="flex items-center gap-2">
+                        <Text strong>{location.name}</Text>
+                        {locationLabel && (
+                          <Tag color="blue" size="small">
+                            {locationLabel}
+                          </Tag>
+                        )}
+                      </div>
+                      <Text
+                        size="small"
+                        type={availableCount > 0 ? 'success' : 'danger'}
+                      >
+                        {t('可用数量')}: {availableCount}
+                      </Text>
+                    </div>
+                  </Option>
+                );
+              })}
+            </Form.Select>
+
+            {typeof locationTotalAvailable === 'number' && (
+              <Text size="small" type="tertiary">
+                {t('全部地区总可用资源')}: {locationTotalAvailable}
+              </Text>
+            )}
+
+          <Row gutter={16}>
+            <Col xs={24} md={8}>
+              <Form.InputNumber
+                field="replica_count"
+                label={t('副本数量')}
+                  placeholder={1}
+                  min={1}
+                  max={maxAvailableReplicas || 100}
+                  rules={[{ required: true, message: t('请输入副本数量') }]}
+                  onChange={(value) => setReplicaCount(value)}
+                  style={{ width: '100%' }}
+                />
+                {maxAvailableReplicas > 0 && (
+                  <Text size="small" type="tertiary">
+                    {t('最大可用')}: {maxAvailableReplicas}
+                  </Text>
+                )}
+              </Col>
+              <Col xs={24} md={8}>
+                <Form.InputNumber
+                  field="duration_hours"
+                  label={t('运行时长(小时)')}
+                  placeholder={1}
+                  min={1}
+                  max={8760} // 1 year
+                  rules={[{ required: true, message: t('请输入运行时长') }]}
+                  onChange={(value) => setDurationHours(value)}
+                  style={{ width: '100%' }}
+                />
+              </Col>
+              <Col xs={24} md={8}>
+                <Form.InputNumber
+                  field="traffic_port"
+                  label={
+                    <Space>
+                      {t('流量端口')}
+                      <Tooltip content={t('容器对外服务的端口号,可选')}>
+                        <IconHelpCircle />
+                      </Tooltip>
+                    </Space>
+                  }
+                  placeholder={DEFAULT_TRAFFIC_PORT}
+                  min={1}
+                  max={65535}
+                  style={{ width: '100%' }}
+                  disabled={imageMode === 'builtin'}
+              />
+            </Col>
+          </Row>
+
+          <div ref={advancedSectionRef}>
+            <Collapse className="mt-4">
+              <Collapse.Panel header={t('高级配置')} itemKey="advanced">
+                <Card>
+                  <Title heading={6}>{t('镜像仓库配置')}</Title>
+                  <Row gutter={16}>
+                    <Col span={12}>
+                      <Form.Input
+                        field="registry_username"
+                        label={t('镜像仓库用户名')}
+                        placeholder={t('私有镜像仓库的用户名')}
+                      />
+                    </Col>
+                    <Col span={12}>
+                      <Form.Input
+                        field="registry_secret"
+                        label={t('镜像仓库密码')}
+                        type="password"
+                        placeholder={t('私有镜像仓库的密码')}
+                      />
+                    </Col>
+                  </Row>
+                </Card>
+
+                <Divider />
+
+                <Card>
+                  <Title heading={6}>{t('容器启动配置')}</Title>
+
+                  <div style={{ marginBottom: 16 }}>
+                    <Text strong>{t('启动命令 (Entrypoint)')}</Text>
+                    {entrypoint.map((cmd, index) => (
+                      <div key={index} style={{ display: 'flex', marginTop: 8 }}>
+                        <Input
+                          value={cmd}
+                          placeholder={t('例如:/bin/bash')}
+                          onChange={(value) => handleArrayFieldChange(index, value, 'entrypoint')}
+                          style={{ flex: 1, marginRight: 8 }}
+                        />
+                        <Button
+                          icon={<IconMinus />}
+                          onClick={() => handleRemoveArrayField(index, 'entrypoint')}
+                          disabled={entrypoint.length === 1}
+                        />
+                      </div>
+                    ))}
+                    <Button
+                      icon={<IconPlus />}
+                      onClick={() => handleAddArrayField('entrypoint')}
+                      style={{ marginTop: 8 }}
+                    >
+                      {t('添加启动命令')}
+                    </Button>
+                  </div>
+
+                  <div style={{ marginBottom: 16 }}>
+                    <Text strong>{t('启动参数 (Args)')}</Text>
+                    {args.map((arg, index) => (
+                      <div key={index} style={{ display: 'flex', marginTop: 8 }}>
+                        <Input
+                          value={arg}
+                          placeholder={t('例如:-c')}
+                          onChange={(value) => handleArrayFieldChange(index, value, 'args')}
+                          style={{ flex: 1, marginRight: 8 }}
+                        />
+                        <Button
+                          icon={<IconMinus />}
+                          onClick={() => handleRemoveArrayField(index, 'args')}
+                          disabled={args.length === 1}
+                        />
+                      </div>
+                    ))}
+                    <Button
+                      icon={<IconPlus />}
+                      onClick={() => handleAddArrayField('args')}
+                      style={{ marginTop: 8 }}
+                    >
+                      {t('添加启动参数')}
+                    </Button>
+                  </div>
+                </Card>
+
+                <Divider />
+
+                <Card>
+                  <Title heading={6}>{t('环境变量')}</Title>
+
+                  <div style={{ marginBottom: 16 }}>
+                    <Text strong>{t('普通环境变量')}</Text>
+                    {envVariables.map((env, index) => (
+                      <Row key={index} gutter={8} style={{ marginTop: 8 }}>
+                        <Col span={10}>
+                          <Input
+                            placeholder={t('变量名')}
+                            value={env.key}
+                            onChange={(value) => handleEnvVariableChange(index, 'key', value, 'env')}
+                          />
+                        </Col>
+                        <Col span={10}>
+                          <Input
+                            placeholder={t('变量值')}
+                            value={env.value}
+                            onChange={(value) => handleEnvVariableChange(index, 'value', value, 'env')}
+                          />
+                        </Col>
+                        <Col span={4}>
+                          <Button
+                            icon={<IconMinus />}
+                            onClick={() => handleRemoveEnvVariable(index, 'env')}
+                            disabled={envVariables.length === 1}
+                          />
+                        </Col>
+                      </Row>
+                    ))}
+                    <Button
+                      icon={<IconPlus />}
+                      onClick={() => handleAddEnvVariable('env')}
+                      style={{ marginTop: 8 }}
+                    >
+                      {t('添加环境变量')}
+                    </Button>
+                  </div>
+
+                  <div>
+                    <Text strong>{t('密钥环境变量')}</Text>
+                    {secretEnvVariables.map((env, index) => {
+                      const isAutoSecret =
+                        imageMode === 'builtin' && env.key === 'OLLAMA_API_KEY';
+                      return (
+                        <Row key={index} gutter={8} style={{ marginTop: 8 }}>
+                          <Col span={10}>
+                            <Input
+                              placeholder={t('变量名')}
+                              value={env.key}
+                              onChange={(value) => handleEnvVariableChange(index, 'key', value, 'secret')}
+                              disabled={isAutoSecret}
+                            />
+                          </Col>
+                          <Col span={10}>
+                            <Input
+                              placeholder={t('变量值')}
+                              type="password"
+                              value={env.value}
+                              onChange={(value) => handleEnvVariableChange(index, 'value', value, 'secret')}
+                              disabled={isAutoSecret}
+                            />
+                          </Col>
+                          <Col span={4}>
+                            <Button
+                              icon={<IconMinus />}
+                              onClick={() => handleRemoveEnvVariable(index, 'secret')}
+                              disabled={secretEnvVariables.length === 1 || isAutoSecret}
+                            />
+                          </Col>
+                        </Row>
+                      );
+                    })}
+                    <Button
+                      icon={<IconPlus />}
+                      onClick={() => handleAddEnvVariable('secret')}
+                      style={{ marginTop: 8 }}
+                    >
+                      {t('添加密钥环境变量')}
+                    </Button>
+                  </div>
+                </Card>
+              </Collapse.Panel>
+            </Collapse>
+          </div>
+        </Card>
+        </div>
+
+        <div ref={priceSectionRef}>
+          <Card className="mb-4">
+              <div className="flex flex-wrap items-center justify-between gap-3">
+                <Title heading={6} style={{ margin: 0 }}>
+                  {t('价格预估')}
+                </Title>
+                <Space align="center" spacing={12} className="flex flex-wrap">
+                  <Text type="secondary" size="small">
+                    {t('计价币种')}
+                  </Text>
+                  <RadioGroup
+                    type="button"
+                    value={priceCurrency}
+                    onChange={handleCurrencyChange}
+                  >
+                    <Radio value="usdc">USDC</Radio>
+                    <Radio value="iocoin">IOCOIN</Radio>
+                  </RadioGroup>
+                  <Tag size="small" color="blue">
+                    {currencyLabel}
+                  </Tag>
+                </Space>
+              </div>
+
+              {priceEstimation ? (
+                <div className="mt-4 flex w-full flex-col gap-4">
+                  <div className="grid w-full gap-4 md:grid-cols-2 lg:grid-cols-3">
+                    <div
+                      className="flex flex-col gap-1 rounded-md px-4 py-3"
+                      style={{
+                        border: '1px solid var(--semi-color-border)',
+                        backgroundColor: 'var(--semi-color-fill-0)',
+                      }}
+                    >
+                      <Text size="small" type="tertiary">
+                        {t('预估总费用')}
+                      </Text>
+                      <div
+                        style={{
+                          fontSize: 24,
+                          fontWeight: 600,
+                          color: 'var(--semi-color-text-0)',
+                        }}
+                      >
+                        {typeof priceEstimation.estimated_cost === 'number'
+                          ? `${priceEstimation.estimated_cost.toFixed(4)} ${currencyLabel}`
+                          : '--'}
+                      </div>
+                    </div>
+                    <div
+                      className="flex flex-col gap-1 rounded-md px-4 py-3"
+                      style={{
+                        border: '1px solid var(--semi-color-border)',
+                        backgroundColor: 'var(--semi-color-fill-0)',
+                      }}
+                    >
+                      <Text size="small" type="tertiary">
+                        {t('小时费率')}
+                      </Text>
+                      <Text strong>
+                        {typeof priceEstimation.price_breakdown?.hourly_rate === 'number'
+                          ? `${priceEstimation.price_breakdown.hourly_rate.toFixed(4)} ${currencyLabel}/h`
+                          : '--'}
+                      </Text>
+                    </div>
+                    <div
+                      className="flex flex-col gap-1 rounded-md px-4 py-3"
+                      style={{
+                        border: '1px solid var(--semi-color-border)',
+                        backgroundColor: 'var(--semi-color-fill-0)',
+                      }}
+                    >
+                      <Text size="small" type="tertiary">
+                        {t('计算成本')}
+                      </Text>
+                      <Text strong>
+                        {typeof priceEstimation.price_breakdown?.compute_cost === 'number'
+                          ? `${priceEstimation.price_breakdown.compute_cost.toFixed(4)} ${currencyLabel}`
+                          : '--'}
+                      </Text>
+                    </div>
+                  </div>
+
+                  <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
+                    {priceSummaryItems.map((item) => (
+                      <div
+                        key={item.key}
+                        className="flex items-center justify-between gap-3 rounded-md px-3 py-2"
+                        style={{
+                          border: '1px solid var(--semi-color-border)',
+                          backgroundColor: 'var(--semi-color-fill-0)',
+                        }}
+                      >
+                        <Text size="small" type="tertiary">
+                          {item.label}
+                        </Text>
+                        <Text strong>{item.value}</Text>
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              ) : (
+                priceUnavailableContent
+              )}
+
+              {priceEstimation && loadingPrice && (
+                <Space align="center" spacing={8} style={{ marginTop: 12 }}>
+                  <Spin size="small" />
+                  <Text size="small" type="tertiary">
+                    {t('价格重新计算中...')}
+                  </Text>
+                </Space>
+              )}
+          </Card>
+        </div>
+
+      </Form>
+    </Modal>
+  );
+};
+
+export default CreateDeploymentModal;

+ 241 - 0
web/src/components/table/model-deployments/modals/EditDeploymentModal.jsx

@@ -0,0 +1,241 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+  SideSheet,
+  Form,
+  Button,
+  Space,
+  Spin,
+  Typography,
+  Card,
+  InputNumber,
+  Select,
+  Input,
+  Row,
+  Col,
+  Divider,
+  Tag,
+} from '@douyinfe/semi-ui';
+import { Save, X, Server } from 'lucide-react';
+import { API, showError, showSuccess } from '../../../../helpers';
+import { useTranslation } from 'react-i18next';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile';
+
+const { Text, Title } = Typography;
+
+const EditDeploymentModal = ({
+  refresh,
+  editingDeployment,
+  visible,
+  handleClose,
+}) => {
+  const { t } = useTranslation();
+  const isMobile = useIsMobile();
+  const [loading, setLoading] = useState(false);
+  const [models, setModels] = useState([]);
+  const [loadingModels, setLoadingModels] = useState(false);
+  const formRef = useRef();
+
+  const isEdit = Boolean(editingDeployment?.id);
+  const title = t('重命名部署');
+
+  // Resource configuration options
+  const cpuOptions = [
+    { label: '0.5 Core', value: '0.5' },
+    { label: '1 Core', value: '1' },
+    { label: '2 Cores', value: '2' },
+    { label: '4 Cores', value: '4' },
+    { label: '8 Cores', value: '8' },
+  ];
+
+  const memoryOptions = [
+    { label: '1GB', value: '1Gi' },
+    { label: '2GB', value: '2Gi' },
+    { label: '4GB', value: '4Gi' },
+    { label: '8GB', value: '8Gi' },
+    { label: '16GB', value: '16Gi' },
+    { label: '32GB', value: '32Gi' },
+  ];
+
+  const gpuOptions = [
+    { label: t('无GPU'), value: '' },
+    { label: '1 GPU', value: '1' },
+    { label: '2 GPUs', value: '2' },
+    { label: '4 GPUs', value: '4' },
+  ];
+
+  // Load available models
+  const loadModels = async () => {
+    setLoadingModels(true);
+    try {
+      const res = await API.get('/api/models/?page_size=1000');
+      if (res.data.success) {
+        const items = res.data.data.items || res.data.data || [];
+        const modelOptions = items.map((model) => ({
+          label: `${model.model_name} (${model.vendor?.name || 'Unknown'})`,
+          value: model.model_name,
+          model_id: model.id,
+        }));
+        setModels(modelOptions);
+      }
+    } catch (error) {
+      console.error('Failed to load models:', error);
+      showError(t('加载模型列表失败'));
+    }
+    setLoadingModels(false);
+  };
+
+  // Form submission
+  const handleSubmit = async (values) => {
+    if (!isEdit || !editingDeployment?.id) {
+      showError(t('无效的部署信息'));
+      return;
+    }
+
+    setLoading(true);
+    try {
+      // Only handle name update for now
+      const res = await API.put(
+        `/api/deployments/${editingDeployment.id}/name`,
+        {
+          name: values.deployment_name,
+        },
+      );
+
+      if (res.data.success) {
+        showSuccess(t('部署名称更新成功'));
+        handleClose();
+        refresh();
+      } else {
+        showError(res.data.message || t('更新失败'));
+      }
+    } catch (error) {
+      console.error('Submit error:', error);
+      showError(t('更新失败,请检查输入信息'));
+    }
+    setLoading(false);
+  };
+
+  // Load models when modal opens
+  useEffect(() => {
+    if (visible) {
+      loadModels();
+    }
+  }, [visible]);
+
+  // Set form values when editing
+  useEffect(() => {
+    if (formRef.current && editingDeployment && visible && isEdit) {
+      formRef.current.setValues({
+        deployment_name: editingDeployment.deployment_name || '',
+      });
+    }
+  }, [editingDeployment, visible, isEdit]);
+
+  return (
+    <SideSheet
+      title={
+        <div className='flex items-center gap-2'>
+          <Server size={20} />
+          <span>{title}</span>
+        </div>
+      }
+      visible={visible}
+      onCancel={handleClose}
+      width={isMobile ? '100%' : 600}
+      bodyStyle={{ padding: 0 }}
+      maskClosable={false}
+      closeOnEsc={true}
+    >
+      <div className='p-6 h-full overflow-auto'>
+        <Spin spinning={loading} style={{ width: '100%' }}>
+          <Form
+            ref={formRef}
+            onSubmit={handleSubmit}
+            labelPosition='top'
+            style={{ width: '100%' }}
+          >
+            <Card>
+              <Title heading={5} style={{ marginBottom: 16 }}>
+                {t('修改部署名称')}
+              </Title>
+
+              <Row gutter={16}>
+                <Col span={24}>
+                  <Form.Input
+                    field='deployment_name'
+                    label={t('部署名称')}
+                    placeholder={t('请输入新的部署名称')}
+                    rules={[
+                      { required: true, message: t('请输入部署名称') },
+                      {
+                        pattern: /^[a-zA-Z0-9-_\u4e00-\u9fa5]+$/,
+                        message: t(
+                          '部署名称只能包含字母、数字、横线、下划线和中文',
+                        ),
+                      },
+                    ]}
+                  />
+                </Col>
+              </Row>
+
+              {isEdit && (
+                <div className='mt-4 p-3 bg-gray-50 rounded'>
+                  <Text type='secondary'>{t('部署ID')}: </Text>
+                  <Text code>{editingDeployment.id}</Text>
+                  <br />
+                  <Text type='secondary'>{t('当前状态')}: </Text>
+                  <Tag
+                    color={
+                      editingDeployment.status === 'running' ? 'green' : 'grey'
+                    }
+                  >
+                    {editingDeployment.status}
+                  </Tag>
+                </div>
+              )}
+            </Card>
+          </Form>
+        </Spin>
+      </div>
+
+      <div className='p-4 border-t border-gray-200 bg-gray-50 flex justify-end'>
+        <Space>
+          <Button theme='outline' onClick={handleClose} disabled={loading}>
+            <X size={16} className='mr-1' />
+            {t('取消')}
+          </Button>
+          <Button
+            theme='solid'
+            type='primary'
+            loading={loading}
+            onClick={() => formRef.current?.submitForm()}
+          >
+            <Save size={16} className='mr-1' />
+            {isEdit ? t('更新') : t('创建')}
+          </Button>
+        </Space>
+      </div>
+    </SideSheet>
+  );
+};
+
+export default EditDeploymentModal;

+ 548 - 0
web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx

@@ -0,0 +1,548 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useEffect, useRef, useState } from 'react';
+import {
+  Modal,
+  Form,
+  InputNumber,
+  Typography,
+  Card,
+  Space,
+  Divider,
+  Button,
+  Tag,
+  Banner,
+  Spin,
+} from '@douyinfe/semi-ui';
+import {
+  FaClock,
+  FaCalculator,
+  FaInfoCircle,
+  FaExclamationTriangle,
+} from 'react-icons/fa';
+import { API, showError, showSuccess } from '../../../../helpers';
+
+const { Text } = Typography;
+
+const ExtendDurationModal = ({
+  visible,
+  onCancel,
+  deployment,
+  onSuccess,
+  t,
+}) => {
+  const formRef = useRef(null);
+  const [loading, setLoading] = useState(false);
+  const [durationHours, setDurationHours] = useState(1);
+  const [costLoading, setCostLoading] = useState(false);
+  const [priceEstimation, setPriceEstimation] = useState(null);
+  const [priceError, setPriceError] = useState(null);
+  const [detailsLoading, setDetailsLoading] = useState(false);
+  const [deploymentDetails, setDeploymentDetails] = useState(null);
+  const costRequestIdRef = useRef(0);
+
+  const resetState = () => {
+    costRequestIdRef.current += 1;
+    setDurationHours(1);
+    setPriceEstimation(null);
+    setPriceError(null);
+    setDeploymentDetails(null);
+    setCostLoading(false);
+  };
+
+  const fetchDeploymentDetails = async (deploymentId) => {
+    setDetailsLoading(true);
+    try {
+      const response = await API.get(`/api/deployments/${deploymentId}`);
+      if (response.data.success) {
+        const details = response.data.data;
+        setDeploymentDetails(details);
+        setPriceError(null);
+        return details;
+      }
+
+      const message = response.data.message || '';
+      const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
+      showError(errorMessage);
+      setDeploymentDetails(null);
+      setPriceEstimation(null);
+      setPriceError(errorMessage);
+      return null;
+    } catch (error) {
+      const message = error?.response?.data?.message || error.message || '';
+      const errorMessage = t('获取详情失败') + (message ? `: ${message}` : '');
+      showError(errorMessage);
+      setDeploymentDetails(null);
+      setPriceEstimation(null);
+      setPriceError(errorMessage);
+      return null;
+    } finally {
+      setDetailsLoading(false);
+    }
+  };
+
+  const calculatePrice = async (hours, details) => {
+    if (!visible || !details) {
+      return;
+    }
+
+    const sanitizedHours = Number.isFinite(hours) ? Math.round(hours) : 0;
+    if (sanitizedHours <= 0) {
+      setPriceEstimation(null);
+      setPriceError(null);
+      return;
+    }
+
+    const hardwareId = Number(details?.hardware_id) || 0;
+    const totalGPUs = Number(details?.total_gpus) || 0;
+    const totalContainers = Number(details?.total_containers) || 0;
+    const baseGpusPerContainer = Number(details?.gpus_per_container) || 0;
+    const resolvedGpusPerContainer =
+      baseGpusPerContainer > 0
+        ? baseGpusPerContainer
+        : totalContainers > 0 && totalGPUs > 0
+          ? Math.max(1, Math.round(totalGPUs / totalContainers))
+          : 0;
+    const resolvedReplicaCount =
+      totalContainers > 0
+        ? totalContainers
+        : resolvedGpusPerContainer > 0 && totalGPUs > 0
+          ? Math.max(1, Math.round(totalGPUs / resolvedGpusPerContainer))
+          : 0;
+    const locationIds = Array.isArray(details?.locations)
+      ? details.locations
+          .map((location) =>
+            Number(
+              location?.id ??
+                location?.location_id ??
+                location?.locationId,
+            ),
+          )
+          .filter((id) => Number.isInteger(id) && id > 0)
+      : [];
+
+    if (
+      hardwareId <= 0 ||
+      resolvedGpusPerContainer <= 0 ||
+      resolvedReplicaCount <= 0 ||
+      locationIds.length === 0
+    ) {
+      setPriceEstimation(null);
+      setPriceError(t('价格计算失败'));
+      return;
+    }
+
+    const requestId = Date.now();
+    costRequestIdRef.current = requestId;
+    setCostLoading(true);
+    setPriceError(null);
+
+    const payload = {
+      location_ids: locationIds,
+      hardware_id: hardwareId,
+      gpus_per_container: resolvedGpusPerContainer,
+      duration_hours: sanitizedHours,
+      replica_count: resolvedReplicaCount,
+      currency: 'usdc',
+      duration_type: 'hour',
+      duration_qty: sanitizedHours,
+      hardware_qty: resolvedGpusPerContainer,
+    };
+
+    try {
+      const response = await API.post(
+        '/api/deployments/price-estimation',
+        payload,
+      );
+
+      if (costRequestIdRef.current !== requestId) {
+        return;
+      }
+
+      if (response.data.success) {
+        setPriceEstimation(response.data.data);
+      } else {
+        const message = response.data.message || '';
+        setPriceEstimation(null);
+        setPriceError(
+          t('价格计算失败') + (message ? `: ${message}` : ''),
+        );
+      }
+    } catch (error) {
+      if (costRequestIdRef.current !== requestId) {
+        return;
+      }
+
+      const message = error?.response?.data?.message || error.message || '';
+      setPriceEstimation(null);
+      setPriceError(
+        t('价格计算失败') + (message ? `: ${message}` : ''),
+      );
+    } finally {
+      if (costRequestIdRef.current === requestId) {
+        setCostLoading(false);
+      }
+    }
+  };
+
+  useEffect(() => {
+    if (visible && deployment?.id) {
+      resetState();
+      if (formRef.current) {
+        formRef.current.setValue('duration_hours', 1);
+      }
+      fetchDeploymentDetails(deployment.id);
+    }
+    if (!visible) {
+      resetState();
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [visible, deployment?.id]);
+
+  useEffect(() => {
+    if (!visible) {
+      return;
+    }
+    if (!deploymentDetails) {
+      return;
+    }
+    calculatePrice(durationHours, deploymentDetails);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [durationHours, deploymentDetails, visible]);
+
+  const handleExtend = async () => {
+    try {
+      if (formRef.current) {
+        await formRef.current.validate();
+      }
+      setLoading(true);
+
+      const response = await API.post(
+        `/api/deployments/${deployment.id}/extend`,
+        {
+          duration_hours: Math.round(durationHours),
+        },
+      );
+
+      if (response.data.success) {
+        showSuccess(t('容器时长延长成功'));
+        onSuccess?.(response.data.data);
+        handleCancel();
+      }
+    } catch (error) {
+      showError(
+        t('延长时长失败') +
+          ': ' +
+          (error?.response?.data?.message || error.message),
+      );
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleCancel = () => {
+    if (formRef.current) {
+      formRef.current.reset();
+    }
+    resetState();
+    onCancel();
+  };
+
+  const currentRemainingTime = deployment?.time_remaining || '0分钟';
+  const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
+
+  const priceData = priceEstimation || {};
+  const breakdown =
+    priceData.price_breakdown || priceData.PriceBreakdown || {};
+  const currencyLabel = (
+    priceData.currency || priceData.Currency || 'USDC'
+  )
+    .toString()
+    .toUpperCase();
+
+  const estimatedTotalCost =
+    typeof priceData.estimated_cost === 'number'
+      ? priceData.estimated_cost
+      : typeof priceData.EstimatedCost === 'number'
+        ? priceData.EstimatedCost
+        : typeof breakdown.total_cost === 'number'
+          ? breakdown.total_cost
+          : breakdown.TotalCost;
+  const hourlyRate =
+    typeof breakdown.hourly_rate === 'number'
+      ? breakdown.hourly_rate
+      : breakdown.HourlyRate;
+  const computeCost =
+    typeof breakdown.compute_cost === 'number'
+      ? breakdown.compute_cost
+      : breakdown.ComputeCost;
+
+  const resolvedHardwareName =
+    deploymentDetails?.hardware_name || deployment?.hardware_name || '--';
+  const gpuCount =
+    deploymentDetails?.total_gpus || deployment?.hardware_quantity || 0;
+  const containers = deploymentDetails?.total_containers || 0;
+
+  return (
+    <Modal
+      title={
+        <div className='flex items-center gap-2'>
+          <FaClock className='text-blue-500' />
+          <span>{t('延长容器时长')}</span>
+        </div>
+      }
+      visible={visible}
+      onCancel={handleCancel}
+      onOk={handleExtend}
+      okText={t('确认延长')}
+      cancelText={t('取消')}
+      confirmLoading={loading}
+      okButtonProps={{
+        disabled:
+          !deployment?.id || detailsLoading || !durationHours || durationHours < 1,
+      }}
+      width={600}
+      className='extend-duration-modal'
+    >
+      <div className='space-y-4'>
+        <Card className='border-0 bg-gray-50'>
+          <div className='flex items-center justify-between'>
+            <div>
+              <Text strong className='text-base'>
+                {deployment?.container_name || deployment?.deployment_name}
+              </Text>
+              <div className='mt-1'>
+                <Text type='secondary' size='small'>
+                  ID: {deployment?.id}
+                </Text>
+              </div>
+            </div>
+            <div className='text-right'>
+              <div className='flex items-center gap-2 mb-1'>
+                <Tag color='blue' size='small'>
+                  {resolvedHardwareName}
+                  {gpuCount ? ` x${gpuCount}` : ''}
+                </Tag>
+              </div>
+              <Text size='small' type='secondary'>
+                {t('当前剩余')}: <Text strong>{currentRemainingTime}</Text>
+              </Text>
+            </div>
+          </div>
+        </Card>
+
+        <Banner
+          type='warning'
+          icon={<FaExclamationTriangle />}
+          title={t('重要提醒')}
+          description={
+            <div className='space-y-2'>
+              <p>
+                {t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
+              </p>
+              <p>
+                {t('延长操作一旦确认无法撤销,费用将立即扣除。')}
+              </p>
+            </div>
+          }
+        />
+
+        <Form
+          getFormApi={(api) => (formRef.current = api)}
+          layout='vertical'
+          onValueChange={(values) => {
+            if (values.duration_hours !== undefined) {
+              const numericValue = Number(values.duration_hours);
+              setDurationHours(Number.isFinite(numericValue) ? numericValue : 0);
+            }
+          }}
+        >
+          <Form.InputNumber
+            field='duration_hours'
+            label={t('延长时长(小时)')}
+            placeholder={t('请输入要延长的小时数')}
+            min={1}
+            max={720}
+            step={1}
+            initValue={1}
+            style={{ width: '100%' }}
+            suffix={t('小时')}
+            rules={[
+              { required: true, message: t('请输入延长时长') },
+              {
+                type: 'number',
+                min: 1,
+                message: t('延长时长至少为1小时'),
+              },
+              {
+                type: 'number',
+                max: 720,
+                message: t('延长时长不能超过720小时(30天)'),
+              },
+            ]}
+          />
+        </Form>
+
+        <div className='space-y-2'>
+          <Text size='small' type='secondary'>
+            {t('快速选择')}:
+          </Text>
+          <Space wrap>
+            {[1, 2, 6, 12, 24, 48, 72, 168].map((hours) => (
+              <Button
+                key={hours}
+                size='small'
+                theme={durationHours === hours ? 'solid' : 'borderless'}
+                type={durationHours === hours ? 'primary' : 'secondary'}
+                onClick={() => {
+                  setDurationHours(hours);
+                  if (formRef.current) {
+                    formRef.current.setValue('duration_hours', hours);
+                  }
+                }}
+              >
+                {hours < 24
+                  ? `${hours}${t('小时')}`
+                  : `${hours / 24}${t('天')}`}
+              </Button>
+            ))}
+          </Space>
+        </div>
+
+        <Divider />
+
+        <Card
+          title={
+            <div className='flex items-center gap-2'>
+              <FaCalculator className='text-green-500' />
+              <span>{t('费用预估')}</span>
+            </div>
+          }
+          className='border border-green-200'
+        >
+          {priceEstimation ? (
+            <div className='space-y-3'>
+              <div className='flex items-center justify-between'>
+                <Text>{t('延长时长')}:</Text>
+                <Text strong>
+                  {Math.round(durationHours)} {t('小时')}
+                </Text>
+              </div>
+
+              <div className='flex items-center justify-between'>
+                <Text>{t('硬件配置')}:</Text>
+                <Text strong>
+                  {resolvedHardwareName}
+                  {gpuCount ? ` x${gpuCount}` : ''}
+                </Text>
+              </div>
+
+              {containers ? (
+                <div className='flex items-center justify-between'>
+                  <Text>{t('容器数量')}:</Text>
+                  <Text strong>{containers}</Text>
+                </div>
+              ) : null}
+
+              <div className='flex items-center justify-between'>
+                <Text>{t('单GPU小时费率')}:</Text>
+                <Text strong>
+                  {typeof hourlyRate === 'number'
+                    ? `${hourlyRate.toFixed(4)} ${currencyLabel}`
+                    : '--'}
+                </Text>
+              </div>
+
+              {typeof computeCost === 'number' && (
+                <div className='flex items-center justify-between'>
+                  <Text>{t('计算成本')}:</Text>
+                  <Text strong>
+                    {computeCost.toFixed(4)} {currencyLabel}
+                  </Text>
+                </div>
+              )}
+
+              <Divider margin='12px' />
+
+              <div className='flex items-center justify-between'>
+                <Text strong className='text-lg'>
+                  {t('预估总费用')}:
+                </Text>
+                <Text strong className='text-lg text-green-600'>
+                  {typeof estimatedTotalCost === 'number'
+                    ? `${estimatedTotalCost.toFixed(4)} ${currencyLabel}`
+                    : '--'}
+                </Text>
+              </div>
+
+              <div className='bg-blue-50 p-3 rounded-lg'>
+                <div className='flex items-start gap-2'>
+                  <FaInfoCircle className='text-blue-500 mt-0.5' />
+                  <div>
+                    <Text size='small' type='secondary'>
+                      {t('延长后总时长')}: <Text strong>{newTotalTime}</Text>
+                    </Text>
+                    <br />
+                    <Text size='small' type='secondary'>
+                      {t('预估费用仅供参考,实际费用可能略有差异')}
+                    </Text>
+                  </div>
+                </div>
+              </div>
+            </div>
+          ) : (
+            <div className='text-center text-gray-500 py-4'>
+              {costLoading ? (
+                <Space align='center' className='justify-center'>
+                  <Spin size='small' />
+                  <Text type='secondary'>{t('计算费用中...')}</Text>
+                </Space>
+              ) : priceError ? (
+                <Text type='danger'>{priceError}</Text>
+              ) : deploymentDetails ? (
+                <Text type='secondary'>{t('请输入延长时长')}</Text>
+              ) : (
+                <Text type='secondary'>{t('加载详情中...')}</Text>
+              )}
+            </div>
+          )}
+        </Card>
+
+        <div className='bg-red-50 border border-red-200 rounded-lg p-3'>
+          <div className='flex items-start gap-2'>
+            <FaExclamationTriangle className='text-red-500 mt-0.5' />
+            <div>
+              <Text strong className='text-red-700'>
+                {t('确认延长容器时长')}
+              </Text>
+              <div className='mt-1'>
+                <Text size='small' className='text-red-600'>
+                  {t('点击"确认延长"后将立即扣除费用并延长容器运行时间')}
+                </Text>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </Modal>
+  );
+};
+
+export default ExtendDurationModal;

+ 475 - 0
web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx

@@ -0,0 +1,475 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+  Modal,
+  Form,
+  Input,
+  InputNumber,
+  Typography,
+  Card,
+  Space,
+  Divider,
+  Button,
+  Banner,
+  Tag,
+  Collapse,
+  TextArea,
+  Switch,
+} from '@douyinfe/semi-ui';
+import { 
+  FaCog, 
+  FaDocker,
+  FaKey,
+  FaTerminal,
+  FaNetworkWired,
+  FaExclamationTriangle,
+  FaPlus,
+  FaMinus
+} from 'react-icons/fa';
+import { API, showError, showSuccess } from '../../../../helpers';
+
+const { Text, Title } = Typography;
+
+const UpdateConfigModal = ({ 
+  visible, 
+  onCancel, 
+  deployment, 
+  onSuccess,
+  t 
+}) => {
+  const formRef = useRef(null);
+  const [loading, setLoading] = useState(false);
+  const [envVars, setEnvVars] = useState([]);
+  const [secretEnvVars, setSecretEnvVars] = useState([]);
+
+  // Initialize form data when modal opens
+  useEffect(() => {
+    if (visible && deployment) {
+      // Set initial form values based on deployment data
+      const initialValues = {
+        image_url: deployment.container_config?.image_url || '',
+        traffic_port: deployment.container_config?.traffic_port || null,
+        entrypoint: deployment.container_config?.entrypoint?.join(' ') || '',
+        registry_username: '',
+        registry_secret: '',
+        command: '',
+      };
+      
+      if (formRef.current) {
+        formRef.current.setValues(initialValues);
+      }
+      
+      // Initialize environment variables
+      const envVarsList = deployment.container_config?.env_variables 
+        ? Object.entries(deployment.container_config.env_variables).map(([key, value]) => ({
+            key, value: String(value)
+          }))
+        : [];
+      
+      setEnvVars(envVarsList);
+      setSecretEnvVars([]);
+    }
+  }, [visible, deployment]);
+
+  const handleUpdate = async () => {
+    try {
+      const formValues = formRef.current ? await formRef.current.validate() : {};
+      setLoading(true);
+
+      // Prepare the update payload
+      const payload = {};
+      
+      if (formValues.image_url) payload.image_url = formValues.image_url;
+      if (formValues.traffic_port) payload.traffic_port = formValues.traffic_port;
+      if (formValues.registry_username) payload.registry_username = formValues.registry_username;
+      if (formValues.registry_secret) payload.registry_secret = formValues.registry_secret;
+      if (formValues.command) payload.command = formValues.command;
+      
+      // Process entrypoint
+      if (formValues.entrypoint) {
+        payload.entrypoint = formValues.entrypoint.split(' ').filter(cmd => cmd.trim());
+      }
+      
+      // Process environment variables
+      if (envVars.length > 0) {
+        payload.env_variables = envVars.reduce((acc, env) => {
+          if (env.key && env.value !== undefined) {
+            acc[env.key] = env.value;
+          }
+          return acc;
+        }, {});
+      }
+      
+      // Process secret environment variables
+      if (secretEnvVars.length > 0) {
+        payload.secret_env_variables = secretEnvVars.reduce((acc, env) => {
+          if (env.key && env.value !== undefined) {
+            acc[env.key] = env.value;
+          }
+          return acc;
+        }, {});
+      }
+
+      const response = await API.put(`/api/deployments/${deployment.id}`, payload);
+
+      if (response.data.success) {
+        showSuccess(t('容器配置更新成功'));
+        onSuccess?.(response.data.data);
+        handleCancel();
+      }
+    } catch (error) {
+      showError(t('更新配置失败') + ': ' + (error.response?.data?.message || error.message));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleCancel = () => {
+    if (formRef.current) {
+      formRef.current.reset();
+    }
+    setEnvVars([]);
+    setSecretEnvVars([]);
+    onCancel();
+  };
+
+  const addEnvVar = () => {
+    setEnvVars([...envVars, { key: '', value: '' }]);
+  };
+
+  const removeEnvVar = (index) => {
+    const newEnvVars = envVars.filter((_, i) => i !== index);
+    setEnvVars(newEnvVars);
+  };
+
+  const updateEnvVar = (index, field, value) => {
+    const newEnvVars = [...envVars];
+    newEnvVars[index][field] = value;
+    setEnvVars(newEnvVars);
+  };
+
+  const addSecretEnvVar = () => {
+    setSecretEnvVars([...secretEnvVars, { key: '', value: '' }]);
+  };
+
+  const removeSecretEnvVar = (index) => {
+    const newSecretEnvVars = secretEnvVars.filter((_, i) => i !== index);
+    setSecretEnvVars(newSecretEnvVars);
+  };
+
+  const updateSecretEnvVar = (index, field, value) => {
+    const newSecretEnvVars = [...secretEnvVars];
+    newSecretEnvVars[index][field] = value;
+    setSecretEnvVars(newSecretEnvVars);
+  };
+
+  return (
+    <Modal
+      title={
+        <div className="flex items-center gap-2">
+          <FaCog className="text-blue-500" />
+          <span>{t('更新容器配置')}</span>
+        </div>
+      }
+      visible={visible}
+      onCancel={handleCancel}
+      onOk={handleUpdate}
+      okText={t('更新配置')}
+      cancelText={t('取消')}
+      confirmLoading={loading}
+      width={700}
+      className="update-config-modal"
+    >
+      <div className="space-y-4 max-h-[600px] overflow-y-auto">
+        {/* Container Info */}
+        <Card className="border-0 bg-gray-50">
+          <div className="flex items-center justify-between">
+            <div>
+              <Text strong className="text-base">
+                {deployment?.container_name}
+              </Text>
+              <div className="mt-1">
+                <Text type="secondary" size="small">
+                  ID: {deployment?.id}
+                </Text>
+              </div>
+            </div>
+            <Tag color="blue">{deployment?.status}</Tag>
+          </div>
+        </Card>
+
+        {/* Warning Banner */}
+        <Banner
+          type="warning"
+          icon={<FaExclamationTriangle />}
+          title={t('重要提醒')}
+          description={
+            <div className="space-y-2">
+              <p>{t('更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。')}</p>
+              <p>{t('某些配置更改可能需要几分钟才能生效。')}</p>
+            </div>
+          }
+        />
+
+        <Form
+          getFormApi={(api) => (formRef.current = api)}
+          layout="vertical"
+        >
+          <Collapse defaultActiveKey={['docker']}>
+            {/* Docker Configuration */}
+            <Collapse.Panel 
+              header={
+                <div className="flex items-center gap-2">
+                  <FaDocker className="text-blue-600" />
+                  <span>{t('Docker 配置')}</span>
+                </div>
+              }
+              itemKey="docker"
+            >
+              <div className="space-y-4">
+                <Form.Input
+                  field="image_url"
+                  label={t('镜像地址')}
+                  placeholder={t('例如: nginx:latest')}
+                  rules={[
+                    { 
+                      type: 'string',
+                      message: t('请输入有效的镜像地址') 
+                    }
+                  ]}
+                />
+
+                <Form.Input
+                  field="registry_username"
+                  label={t('镜像仓库用户名')}
+                  placeholder={t('如果镜像为私有,请填写用户名')}
+                />
+
+                <Form.Input
+                  field="registry_secret"
+                  label={t('镜像仓库密码')}
+                  mode="password"
+                  placeholder={t('如果镜像为私有,请填写密码或Token')}
+                />
+              </div>
+            </Collapse.Panel>
+
+            {/* Network Configuration */}
+            <Collapse.Panel 
+              header={
+                <div className="flex items-center gap-2">
+                  <FaNetworkWired className="text-green-600" />
+                  <span>{t('网络配置')}</span>
+                </div>
+              }
+              itemKey="network"
+            >
+              <Form.InputNumber
+                field="traffic_port"
+                label={t('流量端口')}
+                placeholder={t('容器对外暴露的端口')}
+                min={1}
+                max={65535}
+                style={{ width: '100%' }}
+                rules={[
+                  { 
+                    type: 'number',
+                    min: 1,
+                    max: 65535,
+                    message: t('端口号必须在1-65535之间') 
+                  }
+                ]}
+              />
+            </Collapse.Panel>
+
+            {/* Startup Configuration */}
+            <Collapse.Panel 
+              header={
+                <div className="flex items-center gap-2">
+                  <FaTerminal className="text-purple-600" />
+                  <span>{t('启动配置')}</span>
+                </div>
+              }
+              itemKey="startup"
+            >
+              <div className="space-y-4">
+                <Form.Input
+                  field="entrypoint"
+                  label={t('启动命令 (Entrypoint)')}
+                  placeholder={t('例如: /bin/bash -c "python app.py"')}
+                  helpText={t('多个命令用空格分隔')}
+                />
+
+                <Form.Input
+                  field="command"
+                  label={t('运行命令 (Command)')}
+                  placeholder={t('容器启动后执行的命令')}
+                />
+              </div>
+            </Collapse.Panel>
+
+            {/* Environment Variables */}
+            <Collapse.Panel 
+              header={
+                <div className="flex items-center gap-2">
+                  <FaKey className="text-orange-600" />
+                  <span>{t('环境变量')}</span>
+                  <Tag size="small">{envVars.length}</Tag>
+                </div>
+              }
+              itemKey="env"
+            >
+              <div className="space-y-4">
+                {/* Regular Environment Variables */}
+                <div>
+                  <div className="flex items-center justify-between mb-3">
+                    <Text strong>{t('普通环境变量')}</Text>
+                    <Button
+                      size="small"
+                      icon={<FaPlus />}
+                      onClick={addEnvVar}
+                      theme="borderless"
+                      type="primary"
+                    >
+                      {t('添加')}
+                    </Button>
+                  </div>
+                  
+                  {envVars.map((envVar, index) => (
+                    <div key={index} className="flex items-end gap-2 mb-2">
+                      <Input
+                        placeholder={t('变量名')}
+                        value={envVar.key}
+                        onChange={(value) => updateEnvVar(index, 'key', value)}
+                        style={{ flex: 1 }}
+                      />
+                      <Text>=</Text>
+                      <Input
+                        placeholder={t('变量值')}
+                        value={envVar.value}
+                        onChange={(value) => updateEnvVar(index, 'value', value)}
+                        style={{ flex: 2 }}
+                      />
+                      <Button
+                        size="small"
+                        icon={<FaMinus />}
+                        onClick={() => removeEnvVar(index)}
+                        theme="borderless"
+                        type="danger"
+                      />
+                    </div>
+                  ))}
+                  
+                  {envVars.length === 0 && (
+                    <div className="text-center text-gray-500 py-4 border-2 border-dashed border-gray-300 rounded-lg">
+                      <Text type="secondary">{t('暂无环境变量')}</Text>
+                    </div>
+                  )}
+                </div>
+
+                <Divider />
+
+                {/* Secret Environment Variables */}
+                <div>
+                  <div className="flex items-center justify-between mb-3">
+                    <div className="flex items-center gap-2">
+                      <Text strong>{t('机密环境变量')}</Text>
+                      <Tag size="small" type="danger">
+                        {t('加密存储')}
+                      </Tag>
+                    </div>
+                    <Button
+                      size="small"
+                      icon={<FaPlus />}
+                      onClick={addSecretEnvVar}
+                      theme="borderless"
+                      type="danger"
+                    >
+                      {t('添加')}
+                    </Button>
+                  </div>
+                  
+                  {secretEnvVars.map((envVar, index) => (
+                    <div key={index} className="flex items-end gap-2 mb-2">
+                      <Input
+                        placeholder={t('变量名')}
+                        value={envVar.key}
+                        onChange={(value) => updateSecretEnvVar(index, 'key', value)}
+                        style={{ flex: 1 }}
+                      />
+                      <Text>=</Text>
+                      <Input
+                        mode="password"
+                        placeholder={t('变量值')}
+                        value={envVar.value}
+                        onChange={(value) => updateSecretEnvVar(index, 'value', value)}
+                        style={{ flex: 2 }}
+                      />
+                      <Button
+                        size="small"
+                        icon={<FaMinus />}
+                        onClick={() => removeSecretEnvVar(index)}
+                        theme="borderless"
+                        type="danger"
+                      />
+                    </div>
+                  ))}
+                  
+                  {secretEnvVars.length === 0 && (
+                    <div className="text-center text-gray-500 py-4 border-2 border-dashed border-red-200 rounded-lg bg-red-50">
+                      <Text type="secondary">{t('暂无机密环境变量')}</Text>
+                    </div>
+                  )}
+                  
+                  <Banner
+                    type="info"
+                    title={t('机密环境变量说明')}
+                    description={t('机密环境变量将被加密存储,适用于存储密码、API密钥等敏感信息。')}
+                    size="small"
+                  />
+                </div>
+              </div>
+            </Collapse.Panel>
+          </Collapse>
+        </Form>
+
+        {/* Final Warning */}
+        <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
+          <div className="flex items-start gap-2">
+            <FaExclamationTriangle className="text-yellow-600 mt-0.5" />
+            <div>
+              <Text strong className="text-yellow-800">
+                {t('配置更新确认')}
+              </Text>
+              <div className="mt-1">
+                <Text size="small" className="text-yellow-700">
+                  {t('更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。')}
+                </Text>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </Modal>
+  );
+};
+
+export default UpdateConfigModal;

+ 517 - 0
web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx

@@ -0,0 +1,517 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState, useEffect } from 'react';
+import {
+  Modal,
+  Typography,
+  Card,
+  Tag,
+  Progress,
+  Descriptions,
+  Spin,
+  Empty,
+  Button,
+  Badge,
+  Tooltip,
+} from '@douyinfe/semi-ui';
+import { 
+  FaInfoCircle, 
+  FaServer,
+  FaClock,
+  FaMapMarkerAlt,
+  FaDocker,
+  FaMoneyBillWave,
+  FaChartLine,
+  FaCopy,
+  FaLink,
+} from 'react-icons/fa';
+import { IconRefresh } from '@douyinfe/semi-icons';
+import { API, showError, showSuccess, timestamp2string } from '../../../../helpers';
+
+const { Text, Title } = Typography;
+
+const ViewDetailsModal = ({ 
+  visible, 
+  onCancel, 
+  deployment, 
+  t 
+}) => {
+  const [details, setDetails] = useState(null);
+  const [loading, setLoading] = useState(false);
+  const [containers, setContainers] = useState([]);
+  const [containersLoading, setContainersLoading] = useState(false);
+
+  const fetchDetails = async () => {
+    if (!deployment?.id) return;
+    
+    setLoading(true);
+    try {
+      const response = await API.get(`/api/deployments/${deployment.id}`);
+      if (response.data.success) {
+        setDetails(response.data.data);
+      }
+    } catch (error) {
+      showError(t('获取详情失败') + ': ' + (error.response?.data?.message || error.message));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const fetchContainers = async () => {
+    if (!deployment?.id) return;
+
+    setContainersLoading(true);
+    try {
+      const response = await API.get(`/api/deployments/${deployment.id}/containers`);
+      if (response.data.success) {
+        setContainers(response.data.data?.containers || []);
+      }
+    } catch (error) {
+      showError(t('获取容器信息失败') + ': ' + (error.response?.data?.message || error.message));
+    } finally {
+      setContainersLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (visible && deployment?.id) {
+      fetchDetails();
+      fetchContainers();
+    } else if (!visible) {
+      setDetails(null);
+      setContainers([]);
+    }
+  }, [visible, deployment?.id]);
+
+  const handleCopyId = () => {
+    navigator.clipboard.writeText(deployment?.id);
+    showSuccess(t('ID已复制到剪贴板'));
+  };
+
+  const handleRefresh = () => {
+    fetchDetails();
+    fetchContainers();
+  };
+
+  const getStatusConfig = (status) => {
+    const statusConfig = {
+      'running': { color: 'green', text: '运行中', icon: '🟢' },
+      'completed': { color: 'green', text: '已完成', icon: '✅' },
+      'deployment requested': { color: 'blue', text: '部署请求中', icon: '🔄' },
+      'termination requested': { color: 'orange', text: '终止请求中', icon: '⏸️' },
+      'destroyed': { color: 'red', text: '已销毁', icon: '🔴' },
+      'failed': { color: 'red', text: '失败', icon: '❌' }
+    };
+    return statusConfig[status] || { color: 'grey', text: status, icon: '❓' };
+  };
+
+  const statusConfig = getStatusConfig(deployment?.status);
+
+  return (
+    <Modal
+      title={
+        <div className="flex items-center gap-2">
+          <FaInfoCircle className="text-blue-500" />
+          <span>{t('容器详情')}</span>
+        </div>
+      }
+      visible={visible}
+      onCancel={onCancel}
+      footer={
+        <div className="flex justify-between">
+          <Button 
+            icon={<IconRefresh />} 
+            onClick={handleRefresh}
+            loading={loading || containersLoading}
+            theme="borderless"
+          >
+            {t('刷新')}
+          </Button>
+          <Button onClick={onCancel}>
+            {t('关闭')}
+          </Button>
+        </div>
+      }
+      width={800}
+      className="deployment-details-modal"
+    >
+      {loading && !details ? (
+        <div className="flex items-center justify-center py-12">
+          <Spin size="large" tip={t('加载详情中...')} />
+        </div>
+      ) : details ? (
+        <div className="space-y-4 max-h-[600px] overflow-y-auto">
+          {/* Basic Info */}
+          <Card 
+            title={
+              <div className="flex items-center gap-2">
+                <FaServer className="text-blue-500" />
+                <span>{t('基本信息')}</span>
+              </div>
+            }
+            className="border-0 shadow-sm"
+          >
+            <Descriptions data={[
+              {
+                key: t('容器名称'),
+                value: (
+                  <div className="flex items-center gap-2">
+                    <Text strong className="text-base">
+                      {details.deployment_name || details.id}
+                    </Text>
+                    <Button
+                      size="small"
+                      theme="borderless"
+                      icon={<FaCopy />}
+                      onClick={handleCopyId}
+                      className="opacity-70 hover:opacity-100"
+                    />
+                  </div>
+                )
+              },
+              {
+                key: t('容器ID'),
+                value: (
+                  <Text type="secondary" className="font-mono text-sm">
+                    {details.id}
+                  </Text>
+                )
+              },
+              {
+                key: t('状态'),
+                value: (
+                  <div className="flex items-center gap-2">
+                    <span>{statusConfig.icon}</span>
+                    <Tag color={statusConfig.color}>
+                      {t(statusConfig.text)}
+                    </Tag>
+                  </div>
+                )
+              },
+              {
+                key: t('创建时间'),
+                value: timestamp2string(details.created_at)
+              }
+            ]} />
+          </Card>
+
+          {/* Hardware & Performance */}
+          <Card 
+            title={
+              <div className="flex items-center gap-2">
+                <FaChartLine className="text-green-500" />
+                <span>{t('硬件与性能')}</span>
+              </div>
+            }
+            className="border-0 shadow-sm"
+          >
+            <div className="space-y-4">
+              <Descriptions data={[
+                {
+                  key: t('硬件类型'),
+                  value: (
+                    <div className="flex items-center gap-2">
+                      <Tag color="blue">{details.brand_name}</Tag>
+                      <Text strong>{details.hardware_name}</Text>
+                    </div>
+                  )
+                },
+                {
+                  key: t('GPU数量'),
+                  value: (
+                    <div className="flex items-center gap-2">
+                      <Badge count={details.total_gpus} theme="solid" type="primary">
+                        <FaServer className="text-purple-500" />
+                      </Badge>
+                      <Text>{t('总计')} {details.total_gpus} {t('个GPU')}</Text>
+                    </div>
+                  )
+                },
+                {
+                  key: t('容器配置'),
+                  value: (
+                    <div className="space-y-1">
+                      <div>{t('每容器GPU数')}: {details.gpus_per_container}</div>
+                      <div>{t('容器总数')}: {details.total_containers}</div>
+                    </div>
+                  )
+                }
+              ]} />
+
+              {/* Progress Bar */}
+              <div className="space-y-2">
+                <div className="flex items-center justify-between">
+                  <Text strong>{t('完成进度')}</Text>
+                  <Text>{details.completed_percent}%</Text>
+                </div>
+                <Progress
+                  percent={details.completed_percent}
+                  status={details.completed_percent === 100 ? 'success' : 'normal'}
+                  strokeWidth={8}
+                  showInfo={false}
+                />
+                <div className="flex justify-between text-xs text-gray-500">
+                  <span>{t('已服务')}: {details.compute_minutes_served} {t('分钟')}</span>
+                  <span>{t('剩余')}: {details.compute_minutes_remaining} {t('分钟')}</span>
+                </div>
+              </div>
+            </div>
+          </Card>
+
+          {/* Container Configuration */}
+          {details.container_config && (
+            <Card 
+              title={
+                <div className="flex items-center gap-2">
+                  <FaDocker className="text-blue-600" />
+                  <span>{t('容器配置')}</span>
+                </div>
+              }
+              className="border-0 shadow-sm"
+            >
+              <div className="space-y-3">
+                <Descriptions data={[
+                  {
+                    key: t('镜像地址'),
+                    value: (
+                      <Text className="font-mono text-sm break-all">
+                        {details.container_config.image_url || 'N/A'}
+                      </Text>
+                    )
+                  },
+                  {
+                    key: t('流量端口'),
+                    value: details.container_config.traffic_port || 'N/A'
+                  },
+                  {
+                    key: t('启动命令'),
+                    value: (
+                      <Text className="font-mono text-sm">
+                        {details.container_config.entrypoint ? 
+                          details.container_config.entrypoint.join(' ') : 'N/A'
+                        }
+                      </Text>
+                    )
+                  }
+                ]} />
+
+                {/* Environment Variables */}
+                {details.container_config.env_variables && 
+                 Object.keys(details.container_config.env_variables).length > 0 && (
+                  <div className="mt-4">
+                    <Text strong className="block mb-2">{t('环境变量')}:</Text>
+                    <div className="bg-gray-50 p-3 rounded-lg max-h-32 overflow-y-auto">
+                      {Object.entries(details.container_config.env_variables).map(([key, value]) => (
+                        <div key={key} className="flex gap-2 text-sm font-mono mb-1">
+                          <span className="text-blue-600 font-medium">{key}=</span>
+                          <span className="text-gray-700 break-all">{String(value)}</span>
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                )}
+              </div>
+            </Card>
+          )}
+
+          {/* Containers List */}
+          <Card
+            title={
+              <div className="flex items-center gap-2">
+                <FaServer className="text-indigo-500" />
+                <span>{t('容器实例')}</span>
+              </div>
+            }
+            className="border-0 shadow-sm"
+          >
+            {containersLoading ? (
+              <div className="flex items-center justify-center py-6">
+                <Spin tip={t('加载容器信息中...')} />
+              </div>
+            ) : containers.length === 0 ? (
+              <Empty description={t('暂无容器信息')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
+            ) : (
+              <div className="space-y-3">
+                {containers.map((ctr) => (
+                  <Card
+                    key={ctr.container_id}
+                    className="bg-gray-50 border border-gray-100"
+                    bodyStyle={{ padding: '12px 16px' }}
+                  >
+                    <div className="flex flex-wrap items-center justify-between gap-3">
+                      <div className="flex flex-col gap-1">
+                        <Text strong className="font-mono text-sm">
+                          {ctr.container_id}
+                        </Text>
+                        <Text size="small" type="secondary">
+                          {t('设备')} {ctr.device_id || '--'} · {t('状态')} {ctr.status || '--'}
+                        </Text>
+                        <Text size="small" type="secondary">
+                          {t('创建时间')}: {ctr.created_at ? timestamp2string(ctr.created_at) : '--'}
+                        </Text>
+                      </div>
+                      <div className="flex flex-col items-end gap-2">
+                        <Tag color="blue" size="small">
+                          {t('GPU/容器')}: {ctr.gpus_per_container ?? '--'}
+                        </Tag>
+                        {ctr.public_url && (
+                          <Tooltip content={ctr.public_url}>
+                            <Button
+                              icon={<FaLink />}
+                              size="small"
+                              theme="light"
+                              onClick={() => window.open(ctr.public_url, '_blank', 'noopener,noreferrer')}
+                            >
+                              {t('访问容器')}
+                            </Button>
+                          </Tooltip>
+                        )}
+                      </div>
+                    </div>
+
+                    {ctr.events && ctr.events.length > 0 && (
+                      <div className="mt-3 bg-white rounded-md border border-gray-100 p-3">
+                        <Text size="small" type="secondary" className="block mb-2">
+                          {t('最近事件')}
+                        </Text>
+                        <div className="space-y-2 max-h-32 overflow-y-auto">
+                          {ctr.events.map((event, index) => (
+                            <div key={`${ctr.container_id}-${event.time}-${index}`} className="flex gap-3 text-xs font-mono">
+                              <span className="text-gray-500 min-w-[140px]">
+                                {event.time ? timestamp2string(event.time) : '--'}
+                              </span>
+                              <span className="text-gray-700 break-all flex-1">
+                                {event.message || '--'}
+                              </span>
+                            </div>
+                          ))}
+                        </div>
+                      </div>
+                    )}
+                  </Card>
+                ))}
+              </div>
+            )}
+          </Card>
+
+          {/* Location Information */}
+          {details.locations && details.locations.length > 0 && (
+            <Card 
+              title={
+                <div className="flex items-center gap-2">
+                  <FaMapMarkerAlt className="text-orange-500" />
+                  <span>{t('部署位置')}</span>
+                </div>
+              }
+              className="border-0 shadow-sm"
+            >
+              <div className="flex flex-wrap gap-2">
+                {details.locations.map((location) => (
+                  <Tag key={location.id} color="orange" size="large">
+                    <div className="flex items-center gap-1">
+                      <span>🌍</span>
+                      <span>{location.name} ({location.iso2})</span>
+                    </div>
+                  </Tag>
+                ))}
+              </div>
+            </Card>
+          )}
+
+          {/* Cost Information */}
+          <Card 
+            title={
+              <div className="flex items-center gap-2">
+                <FaMoneyBillWave className="text-green-500" />
+                <span>{t('费用信息')}</span>
+              </div>
+            }
+            className="border-0 shadow-sm"
+          >
+            <div className="space-y-3">
+              <div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
+                <Text>{t('已支付金额')}</Text>
+                <Text strong className="text-lg text-green-600">
+                  ${details.amount_paid ? details.amount_paid.toFixed(2) : '0.00'} USDC
+                </Text>
+              </div>
+              
+              <div className="grid grid-cols-2 gap-4 text-sm">
+                <div className="flex justify-between">
+                  <Text type="secondary">{t('计费开始')}:</Text>
+                  <Text>{details.started_at ? timestamp2string(details.started_at) : 'N/A'}</Text>
+                </div>
+                <div className="flex justify-between">
+                  <Text type="secondary">{t('预计结束')}:</Text>
+                  <Text>{details.finished_at ? timestamp2string(details.finished_at) : 'N/A'}</Text>
+                </div>
+              </div>
+            </div>
+          </Card>
+
+          {/* Time Information */}
+          <Card 
+            title={
+              <div className="flex items-center gap-2">
+                <FaClock className="text-purple-500" />
+                <span>{t('时间信息')}</span>
+              </div>
+            }
+            className="border-0 shadow-sm"
+          >
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+              <div className="space-y-2">
+                <div className="flex items-center justify-between">
+                  <Text type="secondary">{t('已运行时间')}:</Text>
+                  <Text strong>
+                    {Math.floor(details.compute_minutes_served / 60)}h {details.compute_minutes_served % 60}m
+                  </Text>
+                </div>
+                <div className="flex items-center justify-between">
+                  <Text type="secondary">{t('剩余时间')}:</Text>
+                  <Text strong className="text-orange-600">
+                    {Math.floor(details.compute_minutes_remaining / 60)}h {details.compute_minutes_remaining % 60}m
+                  </Text>
+                </div>
+              </div>
+              <div className="space-y-2">
+                <div className="flex items-center justify-between">
+                  <Text type="secondary">{t('创建时间')}:</Text>
+                  <Text>{timestamp2string(details.created_at)}</Text>
+                </div>
+                <div className="flex items-center justify-between">
+                  <Text type="secondary">{t('最后更新')}:</Text>
+                  <Text>{timestamp2string(details.updated_at)}</Text>
+                </div>
+              </div>
+            </div>
+          </Card>
+        </div>
+      ) : (
+        <Empty 
+          image={Empty.PRESENTED_IMAGE_SIMPLE}
+          description={t('无法获取容器详情')}
+        />
+      )}
+    </Modal>
+  );
+};
+
+export default ViewDetailsModal;

+ 660 - 0
web/src/components/table/model-deployments/modals/ViewLogsModal.jsx

@@ -0,0 +1,660 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+  Modal,
+  Button,
+  Typography,
+  Select,
+  Input,
+  Space,
+  Spin,
+  Card,
+  Tag,
+  Empty,
+  Switch,
+  Divider,
+  Tooltip,
+  Radio,
+} from '@douyinfe/semi-ui';
+import {
+  FaCopy,
+  FaSearch,
+  FaClock,
+  FaTerminal,
+  FaServer,
+  FaInfoCircle,
+  FaLink,
+} from 'react-icons/fa';
+import { IconRefresh, IconDownload } from '@douyinfe/semi-icons';
+import { API, showError, showSuccess, copy, timestamp2string } from '../../../../helpers';
+
+const { Text } = Typography;
+
+const ALL_CONTAINERS = '__all__';
+
+const ViewLogsModal = ({ 
+  visible, 
+  onCancel, 
+  deployment, 
+  t 
+}) => {
+  const [logLines, setLogLines] = useState([]);
+  const [loading, setLoading] = useState(false);
+  const [autoRefresh, setAutoRefresh] = useState(false);
+  const [searchTerm, setSearchTerm] = useState('');
+  const [following, setFollowing] = useState(false);
+  const [containers, setContainers] = useState([]);
+  const [containersLoading, setContainersLoading] = useState(false);
+  const [selectedContainerId, setSelectedContainerId] = useState(ALL_CONTAINERS);
+  const [containerDetails, setContainerDetails] = useState(null);
+  const [containerDetailsLoading, setContainerDetailsLoading] = useState(false);
+  const [streamFilter, setStreamFilter] = useState('stdout');
+  const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
+  
+  const logContainerRef = useRef(null);
+  const autoRefreshRef = useRef(null);
+
+  // Auto scroll to bottom when new logs arrive
+  const scrollToBottom = () => {
+    if (logContainerRef.current) {
+      logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
+    }
+  };
+
+  const resolveStreamValue = (value) => {
+    if (typeof value === 'string') {
+      return value;
+    }
+    if (value && typeof value.value === 'string') {
+      return value.value;
+    }
+    if (value && value.target && typeof value.target.value === 'string') {
+      return value.target.value;
+    }
+    return '';
+  };
+
+  const handleStreamChange = (value) => {
+    const next = resolveStreamValue(value) || 'stdout';
+    setStreamFilter(next);
+  };
+
+  const fetchLogs = async (containerIdOverride = undefined) => {
+    if (!deployment?.id) return;
+
+    const containerId = typeof containerIdOverride === 'string' ? containerIdOverride : selectedContainerId;
+
+    if (!containerId || containerId === ALL_CONTAINERS) {
+      setLogLines([]);
+      setLastUpdatedAt(null);
+      setLoading(false);
+      return;
+    }
+
+    setLoading(true);
+    try {
+      const params = new URLSearchParams();
+      params.append('container_id', containerId);
+
+      const streamValue = resolveStreamValue(streamFilter) || 'stdout';
+      if (streamValue && streamValue !== 'all') {
+        params.append('stream', streamValue);
+      }
+      if (following) params.append('follow', 'true');
+
+      const response = await API.get(`/api/deployments/${deployment.id}/logs?${params}`);
+
+      if (response.data.success) {
+        const rawContent = typeof response.data.data === 'string' ? response.data.data : '';
+        const normalized = rawContent.replace(/\r\n?/g, '\n');
+        const lines = normalized ? normalized.split('\n') : [];
+
+        setLogLines(lines);
+        setLastUpdatedAt(new Date());
+
+        setTimeout(scrollToBottom, 100);
+      }
+    } catch (error) {
+      showError(t('获取日志失败') + ': ' + (error.response?.data?.message || error.message));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const fetchContainers = async () => {
+    if (!deployment?.id) return;
+
+    setContainersLoading(true);
+    try {
+      const response = await API.get(`/api/deployments/${deployment.id}/containers`);
+
+      if (response.data.success) {
+        const list = response.data.data?.containers || [];
+        setContainers(list);
+
+        setSelectedContainerId((current) => {
+          if (current !== ALL_CONTAINERS && list.some(item => item.container_id === current)) {
+            return current;
+          }
+
+          return list.length > 0 ? list[0].container_id : ALL_CONTAINERS;
+        });
+
+        if (list.length === 0) {
+          setContainerDetails(null);
+        }
+      }
+    } catch (error) {
+      showError(t('获取容器列表失败') + ': ' + (error.response?.data?.message || error.message));
+    } finally {
+      setContainersLoading(false);
+    }
+  };
+
+  const fetchContainerDetails = async (containerId) => {
+    if (!deployment?.id || !containerId || containerId === ALL_CONTAINERS) {
+      setContainerDetails(null);
+      return;
+    }
+
+    setContainerDetailsLoading(true);
+    try {
+      const response = await API.get(`/api/deployments/${deployment.id}/containers/${containerId}`);
+
+      if (response.data.success) {
+        setContainerDetails(response.data.data || null);
+      }
+    } catch (error) {
+      showError(t('获取容器详情失败') + ': ' + (error.response?.data?.message || error.message));
+    } finally {
+      setContainerDetailsLoading(false);
+    }
+  };
+
+  const handleContainerChange = (value) => {
+    const newValue = value || ALL_CONTAINERS;
+    setSelectedContainerId(newValue);
+    setLogLines([]);
+    setLastUpdatedAt(null);
+  };
+
+  const refreshContainerDetails = () => {
+    if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
+      fetchContainerDetails(selectedContainerId);
+    }
+  };
+
+  const renderContainerStatusTag = (status) => {
+    if (!status) {
+      return (
+        <Tag color="grey" size="small">
+          {t('未知状态')}
+        </Tag>
+      );
+    }
+
+    const normalized = typeof status === 'string' ? status.trim().toLowerCase() : '';
+    const statusMap = {
+      running: { color: 'green', label: '运行中' },
+      pending: { color: 'orange', label: '准备中' },
+      deployed: { color: 'blue', label: '已部署' },
+      failed: { color: 'red', label: '失败' },
+      destroyed: { color: 'red', label: '已销毁' },
+      stopping: { color: 'orange', label: '停止中' },
+      terminated: { color: 'grey', label: '已终止' },
+    };
+
+    const config = statusMap[normalized] || { color: 'grey', label: status };
+
+    return (
+      <Tag color={config.color} size="small">
+        {t(config.label)}
+      </Tag>
+    );
+  };
+
+  const currentContainer = selectedContainerId !== ALL_CONTAINERS
+    ? containers.find((ctr) => ctr.container_id === selectedContainerId)
+    : null;
+
+  const refreshLogs = () => {
+    if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
+      fetchContainerDetails(selectedContainerId);
+    }
+    fetchLogs();
+  };
+
+  const downloadLogs = () => {
+    const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
+    if (sourceLogs.length === 0) {
+      showError(t('暂无日志可下载'));
+      return;
+    }
+    const logText = sourceLogs.join('\n');
+
+    const blob = new Blob([logText], { type: 'text/plain' });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    const safeContainerId = selectedContainerId && selectedContainerId !== ALL_CONTAINERS
+      ? selectedContainerId.replace(/[^a-zA-Z0-9_-]/g, '-')
+      : '';
+    const fileName = safeContainerId
+      ? `deployment-${deployment.id}-container-${safeContainerId}-logs.txt`
+      : `deployment-${deployment.id}-logs.txt`;
+    a.download = fileName;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    URL.revokeObjectURL(url);
+    
+    showSuccess(t('日志已下载'));
+  };
+
+  const copyAllLogs = async () => {
+    const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
+    if (sourceLogs.length === 0) {
+      showError(t('暂无日志可复制'));
+      return;
+    }
+    const logText = sourceLogs.join('\n');
+
+    const copied = await copy(logText);
+    if (copied) {
+      showSuccess(t('日志已复制到剪贴板'));
+    } else {
+      showError(t('复制失败,请手动选择文本复制'));
+    }
+  };
+
+  // Auto refresh functionality
+  useEffect(() => {
+    if (autoRefresh && visible) {
+      autoRefreshRef.current = setInterval(() => {
+        fetchLogs();
+      }, 5000);
+    } else {
+      if (autoRefreshRef.current) {
+        clearInterval(autoRefreshRef.current);
+        autoRefreshRef.current = null;
+      }
+    }
+
+    return () => {
+      if (autoRefreshRef.current) {
+        clearInterval(autoRefreshRef.current);
+      }
+    };
+  }, [autoRefresh, visible, selectedContainerId, streamFilter, following]);
+
+  useEffect(() => {
+    if (visible && deployment?.id) {
+      fetchContainers();
+    } else if (!visible) {
+      setContainers([]);
+      setSelectedContainerId(ALL_CONTAINERS);
+      setContainerDetails(null);
+      setStreamFilter('stdout');
+      setLogLines([]);
+      setLastUpdatedAt(null);
+    }
+  }, [visible, deployment?.id]);
+
+  useEffect(() => {
+    if (visible) {
+      setStreamFilter('stdout');
+    }
+  }, [selectedContainerId, visible]);
+
+  useEffect(() => {
+    if (visible && deployment?.id) {
+      fetchContainerDetails(selectedContainerId);
+    }
+  }, [visible, deployment?.id, selectedContainerId]);
+
+  // Initial load and cleanup
+  useEffect(() => {
+    if (visible && deployment?.id) {
+      fetchLogs();
+    }
+
+    return () => {
+      if (autoRefreshRef.current) {
+        clearInterval(autoRefreshRef.current);
+      }
+    };
+  }, [visible, deployment?.id, streamFilter, selectedContainerId, following]);
+
+  // Filter logs based on search term
+  const filteredLogs = logLines
+    .map((line) => line ?? '')
+    .filter((line) =>
+      !searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()),
+    );
+
+  const renderLogEntry = (line, index) => (
+    <div
+      key={`${index}-${line.slice(0, 20)}`}
+      className="py-1 px-3 hover:bg-gray-50 font-mono text-sm border-b border-gray-100 whitespace-pre-wrap break-words"
+    >
+      {line}
+    </div>
+  );
+
+  return (
+    <Modal
+      title={
+        <div className="flex items-center gap-2">
+          <FaTerminal className="text-blue-500" />
+          <span>{t('容器日志')}</span>
+          <Text type="secondary" size="small">
+            - {deployment?.container_name || deployment?.id}
+          </Text>
+        </div>
+      }
+      visible={visible}
+      onCancel={onCancel}
+      footer={null}
+      width={1000}
+      height={700}
+      className="logs-modal"
+      style={{ top: 20 }}
+    >
+      <div className="flex flex-col h-full max-h-[600px]">
+        {/* Controls */}
+        <Card className="mb-4 border-0 shadow-sm">
+          <div className="flex items-center justify-between flex-wrap gap-3">
+            <Space wrap>
+              <Select
+                prefix={<FaServer />}
+                placeholder={t('选择容器')}
+                value={selectedContainerId}
+                onChange={handleContainerChange}
+                style={{ width: 240 }}
+                size="small"
+                loading={containersLoading}
+                dropdownStyle={{ maxHeight: 320, overflowY: 'auto' }}
+              >
+                <Select.Option value={ALL_CONTAINERS}>
+                  {t('全部容器')}
+                </Select.Option>
+                {containers.map((ctr) => (
+                  <Select.Option key={ctr.container_id} value={ctr.container_id}>
+                    <div className="flex flex-col">
+                      <span className="font-mono text-xs">{ctr.container_id}</span>
+                      <span className="text-xs text-gray-500">
+                        {ctr.brand_name || 'IO.NET'}
+                        {ctr.hardware ? ` · ${ctr.hardware}` : ''}
+                      </span>
+                    </div>
+                  </Select.Option>
+                ))}
+              </Select>
+
+              <Input
+                prefix={<FaSearch />}
+                placeholder={t('搜索日志内容')}
+                value={searchTerm}
+                onChange={setSearchTerm}
+                style={{ width: 200 }}
+                size="small"
+              />
+              
+              <Space align="center" className="ml-2">
+                <Text size="small" type="secondary">
+                  {t('日志流')}
+                </Text>
+                <Radio.Group
+                  type="button"
+                  size="small"
+                  value={streamFilter}
+                  onChange={handleStreamChange}
+                >
+                  <Radio value="stdout">STDOUT</Radio>
+                  <Radio value="stderr">STDERR</Radio>
+                </Radio.Group>
+              </Space>
+
+              <div className="flex items-center gap-2">
+                <Switch
+                  checked={autoRefresh}
+                  onChange={setAutoRefresh}
+                  size="small"
+                />
+                <Text size="small">{t('自动刷新')}</Text>
+              </div>
+
+              <div className="flex items-center gap-2">
+                <Switch
+                  checked={following}
+                  onChange={setFollowing}
+                  size="small"
+                />
+                <Text size="small">{t('跟随日志')}</Text>
+              </div>
+            </Space>
+
+            <Space>
+              <Tooltip content={t('刷新日志')}>
+                <Button 
+                  icon={<IconRefresh />} 
+                  onClick={refreshLogs}
+                  loading={loading}
+                  size="small"
+                  theme="borderless"
+                />
+              </Tooltip>
+              
+              <Tooltip content={t('复制日志')}>
+                <Button 
+                  icon={<FaCopy />} 
+                  onClick={copyAllLogs}
+                  size="small"
+                  theme="borderless"
+                  disabled={logLines.length === 0}
+                />
+              </Tooltip>
+              
+              <Tooltip content={t('下载日志')}>
+                <Button 
+                  icon={<IconDownload />} 
+                  onClick={downloadLogs}
+                  size="small"
+                  theme="borderless"
+                  disabled={logLines.length === 0}
+                />
+              </Tooltip>
+            </Space>
+          </div>
+          
+          {/* Status Info */}
+          <Divider margin="12px" />
+          <div className="flex items-center justify-between">
+            <Space size="large">
+              <Text size="small" type="secondary">
+                {t('共 {{count}} 条日志', { count: logLines.length })}
+              </Text>
+              {searchTerm && (
+                <Text size="small" type="secondary">
+                  {t('(筛选后显示 {{count}} 条)', { count: filteredLogs.length })}
+                </Text>
+              )}
+              {autoRefresh && (
+                <Tag color="green" size="small">
+                  <FaClock className="mr-1" />
+                  {t('自动刷新中')}
+                </Tag>
+              )}
+            </Space>
+            
+            <Text size="small" type="secondary">
+              {t('状态')}: {deployment?.status || 'unknown'}
+            </Text>
+          </div>
+
+          {selectedContainerId !== ALL_CONTAINERS && (
+            <>
+              <Divider margin="12px" />
+              <div className="flex flex-col gap-3">
+                <div className="flex items-center justify-between flex-wrap gap-2">
+                  <Space>
+                    <Tag color="blue" size="small">
+                      {t('容器')}
+                    </Tag>
+                    <Text className="font-mono text-xs">
+                      {selectedContainerId}
+                    </Text>
+                    {renderContainerStatusTag(containerDetails?.status || currentContainer?.status)}
+                  </Space>
+
+                  <Space>
+                    {containerDetails?.public_url && (
+                      <Tooltip content={containerDetails.public_url}>
+                        <Button
+                          icon={<FaLink />}
+                          size="small"
+                          theme="borderless"
+                          onClick={() => window.open(containerDetails.public_url, '_blank')}
+                        />
+                      </Tooltip>
+                    )}
+                    <Tooltip content={t('刷新容器信息')}>
+                      <Button
+                        icon={<IconRefresh />}
+                        onClick={refreshContainerDetails}
+                        size="small"
+                        theme="borderless"
+                        loading={containerDetailsLoading}
+                      />
+                    </Tooltip>
+                  </Space>
+                </div>
+
+                {containerDetailsLoading ? (
+                  <div className="flex items-center justify-center py-6">
+                    <Spin tip={t('加载容器详情中...')} />
+                  </div>
+                ) : containerDetails ? (
+                  <div className="grid gap-4 md:grid-cols-2 text-sm">
+                    <div className="flex items-center gap-2">
+                      <FaInfoCircle className="text-blue-500" />
+                      <Text type="secondary">{t('硬件')}</Text>
+                      <Text>
+                        {containerDetails?.brand_name || currentContainer?.brand_name || t('未知品牌')}
+                        {(containerDetails?.hardware || currentContainer?.hardware) ? ` · ${containerDetails?.hardware || currentContainer?.hardware}` : ''}
+                      </Text>
+                    </div>
+                    <div className="flex items-center gap-2">
+                      <FaServer className="text-purple-500" />
+                      <Text type="secondary">{t('GPU/容器')}</Text>
+                      <Text>{containerDetails?.gpus_per_container ?? currentContainer?.gpus_per_container ?? 0}</Text>
+                    </div>
+                    <div className="flex items-center gap-2">
+                      <FaClock className="text-orange-500" />
+                      <Text type="secondary">{t('创建时间')}</Text>
+                      <Text>
+                        {containerDetails?.created_at
+                          ? timestamp2string(containerDetails.created_at)
+                          : currentContainer?.created_at
+                            ? timestamp2string(currentContainer.created_at)
+                            : t('未知')}
+                      </Text>
+                    </div>
+                    <div className="flex items-center gap-2">
+                      <FaInfoCircle className="text-green-500" />
+                      <Text type="secondary">{t('运行时长')}</Text>
+                      <Text>{containerDetails?.uptime_percent ?? currentContainer?.uptime_percent ?? 0}%</Text>
+                    </div>
+                  </div>
+                ) : (
+                  <Text size="small" type="secondary">
+                    {t('暂无容器详情')}
+                  </Text>
+                )}
+
+                {containerDetails?.events && containerDetails.events.length > 0 && (
+                  <div className="bg-gray-50 rounded-lg p-3">
+                    <Text size="small" type="secondary">
+                      {t('最近事件')}
+                    </Text>
+                    <div className="mt-2 space-y-2 max-h-32 overflow-y-auto">
+                      {containerDetails.events.slice(0, 5).map((event, index) => (
+                        <div key={`${event.time}-${index}`} className="flex gap-3 text-xs font-mono">
+                          <span className="text-gray-500">
+                            {event.time ? timestamp2string(event.time) : '--'}
+                          </span>
+                          <span className="text-gray-700 break-all flex-1">
+                            {event.message}
+                          </span>
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                )}
+              </div>
+            </>
+          )}
+        </Card>
+
+        {/* Log Content */}
+        <div className="flex-1 flex flex-col border rounded-lg bg-gray-50 overflow-hidden">
+          <div 
+            ref={logContainerRef}
+            className="flex-1 overflow-y-auto bg-white"
+            style={{ maxHeight: '400px' }}
+          >
+            {loading && logLines.length === 0 ? (
+              <div className="flex items-center justify-center p-8">
+                <Spin tip={t('加载日志中...')} />
+              </div>
+            ) : filteredLogs.length === 0 ? (
+              <Empty
+                image={Empty.PRESENTED_IMAGE_SIMPLE}
+                description={
+                  searchTerm ? t('没有匹配的日志条目') : t('暂无日志')
+                }
+                style={{ padding: '60px 20px' }}
+              />
+            ) : (
+              <div>
+                {filteredLogs.map((log, index) => renderLogEntry(log, index))}
+              </div>
+            )}
+          </div>
+          
+          {/* Footer status */}
+          {logLines.length > 0 && (
+            <div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-t text-xs text-gray-500">
+              <span>
+                {following ? t('正在跟随最新日志') : t('日志已加载')}
+              </span>
+              <span>
+                {t('最后更新')}: {lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'}
+              </span>
+            </div>
+          )}
+        </div>
+      </div>
+    </Modal>
+  );
+};
+
+export default ViewLogsModal;

+ 3 - 0
web/src/helpers/render.jsx

@@ -73,6 +73,7 @@ import {
   Settings,
   CircleUser,
   Package,
+  Server,
 } from 'lucide-react';
 
 // 获取侧边栏Lucide图标组件
@@ -114,6 +115,8 @@ export function getLucideIcon(key, selected = false) {
       return <User {...commonProps} color={iconColor} />;
     case 'models':
       return <Package {...commonProps} color={iconColor} />;
+    case 'deployment':
+      return <Server {...commonProps} color={iconColor} />;
     case 'setting':
       return <Settings {...commonProps} color={iconColor} />;
     default:

+ 63 - 1
web/src/hooks/channels/useChannelsData.jsx

@@ -35,7 +35,7 @@ import {
 } from '../../constants';
 import { useIsMobile } from '../common/useIsMobile';
 import { useTableCompactMode } from '../common/useTableCompactMode';
-import { Modal } from '@douyinfe/semi-ui';
+import { Modal, Button } from '@douyinfe/semi-ui';
 
 export const useChannelsData = () => {
   const { t } = useTranslation();
@@ -775,6 +775,67 @@ export const useChannelsData = () => {
     }
   };
 
+  const checkOllamaVersion = async (record) => {
+    try {
+      const res = await API.get(`/api/channel/ollama/version/${record.id}`);
+      const { success, message, data } = res.data;
+
+      if (success) {
+        const version = data?.version || '-';
+        const infoMessage = t('当前 Ollama 版本为 ${version}').replace(
+          '${version}',
+          version,
+        );
+
+        const handleCopyVersion = async () => {
+          if (!version || version === '-') {
+            showInfo(t('暂无可复制的版本信息'));
+            return;
+          }
+
+          const copied = await copy(version);
+          if (copied) {
+            showSuccess(t('已复制版本号'));
+          } else {
+            showError(t('复制失败,请手动复制'));
+          }
+        };
+
+        Modal.info({
+          title: t('Ollama 版本信息'),
+          content: infoMessage,
+          centered: true,
+          footer: (
+            <div className='flex justify-end gap-2'>
+              <Button type='tertiary' onClick={handleCopyVersion}>
+                {t('复制版本号')}
+              </Button>
+              <Button
+                type='primary'
+                theme='solid'
+                onClick={() => Modal.destroyAll()}
+              >
+                {t('关闭')}
+              </Button>
+            </div>
+          ),
+          hasCancel: false,
+          hasOk: false,
+          closable: true,
+          maskClosable: true,
+        });
+      } else {
+        showError(message || t('获取 Ollama 版本失败'));
+      }
+    } catch (error) {
+      const errMsg =
+        error?.response?.data?.message ||
+        error?.message ||
+        t('获取 Ollama 版本失败');
+      showError(errMsg);
+    }
+  };
+
   // Test channel - 单个模型测试,参考旧版实现
   const testChannel = async (record, model, endpointType = '') => {
     const testKey = `${record.id}-${model}`;
@@ -1132,6 +1193,7 @@ export const useChannelsData = () => {
     updateAllChannelsBalance,
     updateChannelBalance,
     fixChannelsAbilities,
+    checkOllamaVersion,
     testChannel,
     batchTestModels,
     handleCloseModal,

+ 1 - 0
web/src/hooks/common/useSidebar.js

@@ -61,6 +61,7 @@ export const useSidebar = () => {
       enabled: true,
       channel: true,
       models: true,
+      deployment: true,
       redemption: true,
       user: true,
       setting: true,

+ 266 - 0
web/src/hooks/model-deployments/useDeploymentResources.js

@@ -0,0 +1,266 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import { useState, useCallback } from 'react';
+import { API } from '../../helpers';
+import { showError } from '../../helpers';
+
+export const useDeploymentResources = () => {
+  const [hardwareTypes, setHardwareTypes] = useState([]);
+  const [hardwareTotalAvailable, setHardwareTotalAvailable] = useState(0);
+  const [locations, setLocations] = useState([]);
+  const [locationsTotalAvailable, setLocationsTotalAvailable] = useState(0);
+  const [availableReplicas, setAvailableReplicas] = useState([]);
+  const [priceEstimation, setPriceEstimation] = useState(null);
+
+  const [loadingHardware, setLoadingHardware] = useState(false);
+  const [loadingLocations, setLoadingLocations] = useState(false);
+  const [loadingReplicas, setLoadingReplicas] = useState(false);
+  const [loadingPrice, setLoadingPrice] = useState(false);
+
+  const fetchHardwareTypes = useCallback(async () => {
+    try {
+      setLoadingHardware(true);
+      const response = await API.get('/api/deployments/hardware-types');
+      if (response.data.success) {
+        const { hardware_types: hardwareList = [], total_available } = response.data.data || {};
+        const normalizedHardware = hardwareList.map((hardware) => {
+          const availableCountValue = Number(hardware.available_count);
+          const availableCount = Number.isNaN(availableCountValue) ? 0 : availableCountValue;
+          const availableBool =
+            typeof hardware.available === 'boolean'
+              ? hardware.available
+              : availableCount > 0;
+
+          return {
+            ...hardware,
+            available: availableBool,
+            available_count: availableCount,
+          };
+        });
+
+        const providedTotal = Number(total_available);
+        const fallbackTotal = normalizedHardware.reduce(
+          (acc, item) => acc + (Number.isNaN(item.available_count) ? 0 : item.available_count),
+          0,
+        );
+        const hasProvidedTotal =
+          total_available !== undefined &&
+          total_available !== null &&
+          total_available !== '' &&
+          !Number.isNaN(providedTotal);
+
+        setHardwareTypes(normalizedHardware);
+        setHardwareTotalAvailable(
+          hasProvidedTotal ? providedTotal : fallbackTotal,
+        );
+        return normalizedHardware;
+      } else {
+        showError('获取硬件类型失败: ' + response.data.message);
+        setHardwareTotalAvailable(0);
+        return [];
+      }
+    } catch (error) {
+      showError('获取硬件类型失败: ' + error.message);
+      setHardwareTotalAvailable(0);
+      return [];
+    } finally {
+      setLoadingHardware(false);
+    }
+  }, []);
+
+  const fetchLocations = useCallback(async () => {
+    try {
+      setLoadingLocations(true);
+      const response = await API.get('/api/deployments/locations');
+      if (response.data.success) {
+        const { locations: locationsList = [], total } = response.data.data || {};
+        const normalizedLocations = locationsList.map((location) => {
+          const iso2 = (location.iso2 || '').toString().toUpperCase();
+          const availableValue = Number(location.available);
+          const available = Number.isNaN(availableValue) ? 0 : availableValue;
+
+          return {
+            ...location,
+            iso2,
+            available,
+          };
+        });
+        const providedTotal = Number(total);
+        const fallbackTotal = normalizedLocations.reduce(
+          (acc, item) => acc + (Number.isNaN(item.available) ? 0 : item.available),
+          0,
+        );
+        const hasProvidedTotal =
+          total !== undefined &&
+          total !== null &&
+          total !== '' &&
+          !Number.isNaN(providedTotal);
+
+        setLocations(normalizedLocations);
+        setLocationsTotalAvailable(
+          hasProvidedTotal ? providedTotal : fallbackTotal,
+        );
+        return normalizedLocations;
+      } else {
+        showError('获取部署位置失败: ' + response.data.message);
+        setLocationsTotalAvailable(0);
+        return [];
+      }
+    } catch (error) {
+      showError('获取部署位置失败: ' + error.message);
+      setLocationsTotalAvailable(0);
+      return [];
+    } finally {
+      setLoadingLocations(false);
+    }
+  }, []);
+
+  const fetchAvailableReplicas = useCallback(async (hardwareId, gpuCount = 1) => {
+    if (!hardwareId) {
+      setAvailableReplicas([]);
+      return [];
+    }
+
+    try {
+      setLoadingReplicas(true);
+      const response = await API.get(
+        `/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`
+      );
+      if (response.data.success) {
+        const replicas = response.data.data.replicas || [];
+        setAvailableReplicas(replicas);
+        return replicas;
+      } else {
+        showError('获取可用资源失败: ' + response.data.message);
+        setAvailableReplicas([]);
+        return [];
+      }
+    } catch (error) {
+      console.error('Load available replicas error:', error);
+      setAvailableReplicas([]);
+      return [];
+    } finally {
+      setLoadingReplicas(false);
+    }
+  }, []);
+
+  const calculatePrice = useCallback(async (params) => {
+    const {
+      locationIds,
+      hardwareId,
+      gpusPerContainer,
+      durationHours,
+      replicaCount
+    } = params;
+
+    if (!locationIds?.length || !hardwareId || !gpusPerContainer || !durationHours || !replicaCount) {
+      setPriceEstimation(null);
+      return null;
+    }
+
+    try {
+      setLoadingPrice(true);
+      const requestData = {
+        location_ids: locationIds,
+        hardware_id: hardwareId,
+        gpus_per_container: gpusPerContainer,
+        duration_hours: durationHours,
+        replica_count: replicaCount,
+      };
+
+      const response = await API.post('/api/deployments/price-estimation', requestData);
+      if (response.data.success) {
+        const estimation = response.data.data;
+        setPriceEstimation(estimation);
+        return estimation;
+      } else {
+        showError('价格计算失败: ' + response.data.message);
+        setPriceEstimation(null);
+        return null;
+      }
+    } catch (error) {
+      console.error('Price calculation error:', error);
+      setPriceEstimation(null);
+      return null;
+    } finally {
+      setLoadingPrice(false);
+    }
+  }, []);
+
+  const checkClusterNameAvailability = useCallback(async (name) => {
+    if (!name?.trim()) return false;
+
+    try {
+      const response = await API.get(`/api/deployments/check-name?name=${encodeURIComponent(name.trim())}`);
+      if (response.data.success) {
+        return response.data.data.available;
+      } else {
+        showError('检查名称可用性失败: ' + response.data.message);
+        return false;
+      }
+    } catch (error) {
+      console.error('Check cluster name availability error:', error);
+      return false;
+    }
+  }, []);
+
+  const createDeployment = useCallback(async (deploymentData) => {
+    try {
+      const response = await API.post('/api/deployments', deploymentData);
+      if (response.data.success) {
+        return response.data.data;
+      } else {
+        throw new Error(response.data.message || '创建部署失败');
+      }
+    } catch (error) {
+      throw error;
+    }
+  }, []);
+
+  return {
+    // Data
+    hardwareTypes,
+    hardwareTotalAvailable,
+    locations,
+    locationsTotalAvailable,
+    availableReplicas,
+    priceEstimation,
+
+    // Loading states
+    loadingHardware,
+    loadingLocations,
+    loadingReplicas,
+    loadingPrice,
+
+    // Functions
+    fetchHardwareTypes,
+    fetchLocations,
+    fetchAvailableReplicas,
+    calculatePrice,
+    checkClusterNameAvailability,
+    createDeployment,
+
+    // Clear functions
+    clearPriceEstimation: () => setPriceEstimation(null),
+    clearAvailableReplicas: () => setAvailableReplicas([]),
+  };
+};
+
+export default useDeploymentResources;

+ 507 - 0
web/src/hooks/model-deployments/useDeploymentsData.jsx

@@ -0,0 +1,507 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import { useState, useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { API, showError, showSuccess } from '../../helpers';
+import { ITEMS_PER_PAGE } from '../../constants';
+import { useTableCompactMode } from '../common/useTableCompactMode';
+
+export const useDeploymentsData = () => {
+  const { t } = useTranslation();
+  const [compactMode, setCompactMode] = useTableCompactMode('deployments');
+
+  // State management
+  const [deployments, setDeployments] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
+  const [searching, setSearching] = useState(false);
+  const [deploymentCount, setDeploymentCount] = useState(0);
+
+  // Modal states
+  const [showEdit, setShowEdit] = useState(false);
+  const [editingDeployment, setEditingDeployment] = useState({
+    id: undefined,
+  });
+
+  // Row selection
+  const [selectedKeys, setSelectedKeys] = useState([]);
+  const rowSelection = {
+    getCheckboxProps: (record) => ({
+      name: record.deployment_name,
+    }),
+    selectedRowKeys: selectedKeys.map((deployment) => deployment.id),
+    onChange: (selectedRowKeys, selectedRows) => {
+      setSelectedKeys(selectedRows);
+    },
+  };
+
+  // Form initial values
+  const formInitValues = {
+    searchKeyword: '',
+    searchStatus: '',
+  };
+
+  // ---------- helpers ----------
+  // Safely extract array items from API payload
+  const extractItems = (payload) => {
+    const items = payload?.items || payload || [];
+    return Array.isArray(items) ? items : [];
+  };
+
+  // Form API reference
+  const [formApi, setFormApi] = useState(null);
+
+  // Get form values helper function
+  const getFormValues = () => formApi?.getValues() || formInitValues;
+
+  // Close edit modal
+  const closeEdit = () => {
+    setShowEdit(false);
+    setTimeout(() => {
+      setEditingDeployment({ id: undefined });
+    }, 500);
+  };
+
+  // Set deployment format with key field
+  const setDeploymentFormat = (deployments) => {
+    for (let i = 0; i < deployments.length; i++) {
+      deployments[i].key = deployments[i].id;
+    }
+    setDeployments(deployments);
+  };
+
+  // Status tabs
+  const [activeStatusKey, setActiveStatusKey] = useState('all');
+  const [statusCounts, setStatusCounts] = useState({});
+
+  // Column visibility
+  const COLUMN_KEYS = useMemo(
+    () => ({
+      id: 'id',
+      status: 'status',
+      provider: 'provider',
+      container_name: 'container_name',
+      time_remaining: 'time_remaining',
+      hardware_info: 'hardware_info',
+      created_at: 'created_at',
+      actions: 'actions',
+      // Legacy keys for compatibility
+      deployment_name: 'deployment_name',
+      model_name: 'model_name',
+      instance_count: 'instance_count',
+      resource_config: 'resource_config',
+      updated_at: 'updated_at',
+    }),
+    [],
+  );
+
+  const ensureRequiredColumns = (columns = {}) => {
+    const normalized = {
+      ...columns,
+      [COLUMN_KEYS.container_name]: true,
+      [COLUMN_KEYS.actions]: true,
+    };
+
+    if (normalized[COLUMN_KEYS.provider] === undefined) {
+      normalized[COLUMN_KEYS.provider] = true;
+    }
+
+    return normalized;
+  };
+
+  const [visibleColumns, setVisibleColumnsState] = useState(() => {
+    const saved = localStorage.getItem('deployments_visible_columns');
+    if (saved) {
+      try {
+        const parsed = JSON.parse(saved);
+        return ensureRequiredColumns(parsed);
+      } catch (e) {
+        console.error('Failed to parse saved column visibility:', e);
+      }
+    }
+    return ensureRequiredColumns({
+      [COLUMN_KEYS.container_name]: true,
+      [COLUMN_KEYS.status]: true,
+      [COLUMN_KEYS.provider]: true,
+      [COLUMN_KEYS.time_remaining]: true,
+      [COLUMN_KEYS.hardware_info]: true,
+      [COLUMN_KEYS.created_at]: true,
+      [COLUMN_KEYS.actions]: true,
+      // Legacy columns (hidden by default)
+      [COLUMN_KEYS.deployment_name]: false,
+      [COLUMN_KEYS.model_name]: false,
+      [COLUMN_KEYS.instance_count]: false,
+      [COLUMN_KEYS.resource_config]: false,
+      [COLUMN_KEYS.updated_at]: false,
+    });
+  });
+
+  // Column selector modal
+  const [showColumnSelector, setShowColumnSelector] = useState(false);
+
+  // Save column visibility to localStorage
+  const saveColumnVisibility = (newVisibleColumns) => {
+    const normalized = ensureRequiredColumns(newVisibleColumns);
+    localStorage.setItem('deployments_visible_columns', JSON.stringify(normalized));
+    setVisibleColumnsState(normalized);
+  };
+
+  // Load deployments data
+  const loadDeployments = async (
+    page = 1,
+    size = pageSize,
+    statusKey = activeStatusKey,
+  ) => {
+    setLoading(true);
+    try {
+      let url = `/api/deployments/?p=${page}&page_size=${size}`;
+      if (statusKey && statusKey !== 'all') {
+        url = `/api/deployments/search?status=${statusKey}&p=${page}&page_size=${size}`;
+      }
+
+      const res = await API.get(url);
+      const { success, message, data } = res.data;
+      if (success) {
+        const newPageData = extractItems(data);
+        setActivePage(data.page || page);
+        setDeploymentCount(data.total || newPageData.length);
+        setDeploymentFormat(newPageData);
+
+        if (data.status_counts) {
+          const sumAll = Object.values(data.status_counts).reduce(
+            (acc, v) => acc + v,
+            0,
+          );
+          setStatusCounts({ ...data.status_counts, all: sumAll });
+        }
+      } else {
+        showError(message);
+        setDeployments([]);
+      }
+    } catch (error) {
+      console.error(error);
+      showError(t('获取部署列表失败'));
+      setDeployments([]);
+    }
+    setLoading(false);
+  };
+
+  // Search deployments
+  const searchDeployments = async (searchTerms) => {
+    setSearching(true);
+    try {
+      const { searchKeyword, searchStatus } = searchTerms;
+      const params = new URLSearchParams({
+        p: '1',
+        page_size: pageSize.toString(),
+      });
+
+      if (searchKeyword?.trim()) {
+        params.append('keyword', searchKeyword.trim());
+      }
+      if (searchStatus && searchStatus !== 'all') {
+        params.append('status', searchStatus);
+      }
+
+      const res = await API.get(`/api/deployments/search?${params}`);
+      const { success, message, data } = res.data;
+      
+      if (success) {
+        const items = extractItems(data);
+        setActivePage(1);
+        setDeploymentCount(data.total || items.length);
+        setDeploymentFormat(items);
+      } else {
+        showError(message);
+        setDeployments([]);
+      }
+    } catch (error) {
+      console.error('Search error:', error);
+      showError(t('搜索失败'));
+      setDeployments([]);
+    }
+    setSearching(false);
+  };
+
+  // Refresh data
+  const refresh = async (page = activePage) => {
+    await loadDeployments(page, pageSize);
+  };
+
+  // Handle page change
+  const handlePageChange = (page) => {
+    setActivePage(page);
+    if (!searching) {
+      loadDeployments(page, pageSize);
+    }
+  };
+
+  // Handle page size change
+  const handlePageSizeChange = (size) => {
+    setPageSize(size);
+    setActivePage(1);
+    if (!searching) {
+      loadDeployments(1, size);
+    }
+  };
+
+  // Handle tab change
+  const handleTabChange = (statusKey) => {
+    setActiveStatusKey(statusKey);
+    setActivePage(1);
+    loadDeployments(1, pageSize, statusKey);
+  };
+
+  // Deployment operations
+  const startDeployment = async (deploymentId) => {
+    try {
+      const res = await API.post(`/api/deployments/${deploymentId}/start`);
+      if (res.data.success) {
+        showSuccess(t('部署启动成功'));
+        await refresh();
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      console.error(error);
+      showError(t('启动部署失败'));
+    }
+  };
+
+  const restartDeployment = async (deploymentId) => {
+    try {
+      const res = await API.post(`/api/deployments/${deploymentId}/restart`);
+      if (res.data.success) {
+        showSuccess(t('部署重启成功'));
+        await refresh();
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      console.error(error);
+      showError(t('重启部署失败'));
+    }
+  };
+
+  const deleteDeployment = async (deploymentId) => {
+    try {
+      const res = await API.delete(`/api/deployments/${deploymentId}`);
+      if (res.data.success) {
+        showSuccess(t('部署删除成功'));
+        await refresh();
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      console.error(error);
+      showError(t('删除部署失败'));
+    }
+  };
+
+  const syncDeploymentToChannel = async (deployment) => {
+    if (!deployment?.id) {
+      showError(t('同步渠道失败:缺少部署信息'));
+      return;
+    }
+
+    try {
+      const containersResp = await API.get(`/api/deployments/${deployment.id}/containers`);
+      if (!containersResp.data?.success) {
+        showError(containersResp.data?.message || t('获取容器信息失败'));
+        return;
+      }
+
+      const containers = containersResp.data?.data?.containers || [];
+      const activeContainer = containers.find((ctr) => ctr?.public_url);
+
+      if (!activeContainer?.public_url) {
+        showError(t('未找到可用的容器访问地址'));
+        return;
+      }
+
+      const rawUrl = String(activeContainer.public_url).trim();
+      const baseUrl = rawUrl.replace(/\/+$/, '');
+      if (!baseUrl) {
+        showError(t('容器访问地址无效'));
+        return;
+      }
+
+      const baseName = deployment.container_name || deployment.deployment_name || deployment.name || deployment.id;
+      const safeName = String(baseName || 'ionet').slice(0, 60);
+      const channelName = `[IO.NET] ${safeName}`;
+
+      let randomKey;
+      try {
+        randomKey = (typeof crypto !== 'undefined' && crypto.randomUUID)
+          ? `ionet-${crypto.randomUUID().replace(/-/g, '')}`
+          : null;
+      } catch (err) {
+        randomKey = null;
+      }
+      if (!randomKey) {
+        randomKey = `ionet-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
+      }
+
+      const otherInfo = {
+        source: 'ionet',
+        deployment_id: deployment.id,
+        deployment_name: safeName,
+        container_id: activeContainer.container_id || null,
+        public_url: baseUrl,
+      };
+
+      const payload = {
+        mode: 'single',
+        channel: {
+          name: channelName,
+          type: 4,
+          key: randomKey,
+          base_url: baseUrl,
+          group: 'default',
+          tag: 'ionet',
+          remark: `[IO.NET] Auto-synced from deployment ${deployment.id}`,
+          other_info: JSON.stringify(otherInfo),
+        },
+      };
+
+      const createResp = await API.post('/api/channel/', payload);
+      if (createResp.data?.success) {
+        showSuccess(t('已同步到渠道'));
+      } else {
+        showError(createResp.data?.message || t('同步渠道失败'));
+      }
+    } catch (error) {
+      console.error(error);
+      showError(t('同步渠道失败'));
+    }
+  };
+
+  const updateDeploymentName = async (deploymentId, newName) => {
+    try {
+      const res = await API.put(`/api/deployments/${deploymentId}/name`, { name: newName });
+      if (res.data.success) {
+        showSuccess(t('部署名称更新成功'));
+        await refresh();
+        return true;
+      } else {
+        showError(res.data.message);
+        return false;
+      }
+    } catch (error) {
+      console.error(error);
+      showError(t('更新部署名称失败'));
+      return false;
+    }
+  };
+
+  // Batch operations
+  const batchDeleteDeployments = async () => {
+    if (selectedKeys.length === 0) return;
+    
+    try {
+      const ids = selectedKeys.map(deployment => deployment.id);
+      const res = await API.post('/api/deployments/batch_delete', { ids });
+      if (res.data.success) {
+        showSuccess(t('批量删除成功'));
+        setSelectedKeys([]);
+        await refresh();
+      } else {
+        showError(res.data.message);
+      }
+    } catch (error) {
+      console.error(error);
+      showError(t('批量删除失败'));
+    }
+  };
+
+  // Table row click handler
+  const handleRow = (record) => ({
+    onClick: () => {
+      // Handle row click if needed
+    },
+  });
+
+  // Initial load
+  useEffect(() => {
+    loadDeployments();
+  }, []);
+
+  return {
+    // Data
+    deployments,
+    loading,
+    searching,
+    activePage,
+    pageSize,
+    deploymentCount,
+    statusCounts,
+    activeStatusKey,
+    compactMode,
+    setCompactMode,
+
+    // Selection
+    selectedKeys,
+    setSelectedKeys,
+    rowSelection,
+
+    // Modals
+    showEdit,
+    setShowEdit,
+    editingDeployment,
+    setEditingDeployment,
+    closeEdit,
+
+    // Column visibility
+    visibleColumns,
+    setVisibleColumns: saveColumnVisibility,
+    showColumnSelector,
+    setShowColumnSelector,
+    COLUMN_KEYS,
+
+    // Form
+    formInitValues,
+    formApi,
+    setFormApi,
+    getFormValues,
+
+    // Operations
+    loadDeployments,
+    searchDeployments,
+    refresh,
+    handlePageChange,
+    handlePageSizeChange,
+    handleTabChange,
+    handleRow,
+
+    // Deployment operations
+    startDeployment,
+    restartDeployment,
+    deleteDeployment,
+    updateDeploymentName,
+    syncDeploymentToChannel,
+
+    // Batch operations
+    batchDeleteDeployments,
+
+    // Translation
+    t,
+  };
+};

+ 249 - 0
web/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx

@@ -0,0 +1,249 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import { useState } from 'react';
+import { API, showError, showSuccess } from '../../helpers';
+
+export const useEnhancedDeploymentActions = (t) => {
+  const [loading, setLoading] = useState({});
+
+  // Set loading state for specific operation
+  const setOperationLoading = (operation, deploymentId, isLoading) => {
+    setLoading(prev => ({
+      ...prev,
+      [`${operation}_${deploymentId}`]: isLoading
+    }));
+  };
+
+  // Get loading state for specific operation
+  const isOperationLoading = (operation, deploymentId) => {
+    return loading[`${operation}_${deploymentId}`] || false;
+  };
+
+  // Extend deployment duration
+  const extendDeployment = async (deploymentId, durationHours) => {
+    const operationKey = `extend_${deploymentId}`;
+    try {
+      setOperationLoading('extend', deploymentId, true);
+      
+      const response = await API.post(`/api/deployments/${deploymentId}/extend`, {
+        duration_hours: durationHours
+      });
+
+      if (response.data.success) {
+        showSuccess(t('容器时长延长成功'));
+        return response.data.data;
+      }
+    } catch (error) {
+      showError(t('延长时长失败') + ': ' + (error.response?.data?.message || error.message));
+      throw error;
+    } finally {
+      setOperationLoading('extend', deploymentId, false);
+    }
+  };
+
+  // Get deployment details
+  const getDeploymentDetails = async (deploymentId) => {
+    try {
+      setOperationLoading('details', deploymentId, true);
+      
+      const response = await API.get(`/api/deployments/${deploymentId}`);
+      
+      if (response.data.success) {
+        return response.data.data;
+      }
+    } catch (error) {
+      showError(t('获取详情失败') + ': ' + (error.response?.data?.message || error.message));
+      throw error;
+    } finally {
+      setOperationLoading('details', deploymentId, false);
+    }
+  };
+
+  // Get deployment logs
+  const getDeploymentLogs = async (deploymentId, options = {}) => {
+    try {
+      setOperationLoading('logs', deploymentId, true);
+      
+      const params = new URLSearchParams();
+      
+      if (options.containerId) params.append('container_id', options.containerId);
+      if (options.level) params.append('level', options.level);
+      if (options.limit) params.append('limit', options.limit.toString());
+      if (options.cursor) params.append('cursor', options.cursor);
+      if (options.follow) params.append('follow', 'true');
+      if (options.startTime) params.append('start_time', options.startTime);
+      if (options.endTime) params.append('end_time', options.endTime);
+      
+      const response = await API.get(`/api/deployments/${deploymentId}/logs?${params}`);
+      
+      if (response.data.success) {
+        return response.data.data;
+      }
+    } catch (error) {
+      showError(t('获取日志失败') + ': ' + (error.response?.data?.message || error.message));
+      throw error;
+    } finally {
+      setOperationLoading('logs', deploymentId, false);
+    }
+  };
+
+  // Update deployment configuration
+  const updateDeploymentConfig = async (deploymentId, config) => {
+    try {
+      setOperationLoading('config', deploymentId, true);
+      
+      const response = await API.put(`/api/deployments/${deploymentId}`, config);
+      
+      if (response.data.success) {
+        showSuccess(t('容器配置更新成功'));
+        return response.data.data;
+      }
+    } catch (error) {
+      showError(t('更新配置失败') + ': ' + (error.response?.data?.message || error.message));
+      throw error;
+    } finally {
+      setOperationLoading('config', deploymentId, false);
+    }
+  };
+
+  // Delete (destroy) deployment
+  const deleteDeployment = async (deploymentId) => {
+    try {
+      setOperationLoading('delete', deploymentId, true);
+      
+      const response = await API.delete(`/api/deployments/${deploymentId}`);
+      
+      if (response.data.success) {
+        showSuccess(t('容器销毁请求已提交'));
+        return response.data.data;
+      }
+    } catch (error) {
+      showError(t('销毁容器失败') + ': ' + (error.response?.data?.message || error.message));
+      throw error;
+    } finally {
+      setOperationLoading('delete', deploymentId, false);
+    }
+  };
+
+  // Update deployment name
+  const updateDeploymentName = async (deploymentId, newName) => {
+    try {
+      setOperationLoading('rename', deploymentId, true);
+      
+      const response = await API.put(`/api/deployments/${deploymentId}/name`, {
+        name: newName
+      });
+      
+      if (response.data.success) {
+        showSuccess(t('容器名称更新成功'));
+        return response.data.data;
+      }
+    } catch (error) {
+      showError(t('更新名称失败') + ': ' + (error.response?.data?.message || error.message));
+      throw error;
+    } finally {
+      setOperationLoading('rename', deploymentId, false);
+    }
+  };
+
+  // Batch operations
+  const batchDelete = async (deploymentIds) => {
+    try {
+      setOperationLoading('batch_delete', 'all', true);
+      
+      const results = await Promise.allSettled(
+        deploymentIds.map(id => deleteDeployment(id))
+      );
+      
+      const successful = results.filter(r => r.status === 'fulfilled').length;
+      const failed = results.filter(r => r.status === 'rejected').length;
+      
+      if (successful > 0) {
+        showSuccess(t('批量操作完成: {{success}}个成功, {{failed}}个失败', { 
+          success: successful, 
+          failed: failed 
+        }));
+      }
+      
+      return { successful, failed };
+    } catch (error) {
+      showError(t('批量操作失败') + ': ' + error.message);
+      throw error;
+    } finally {
+      setOperationLoading('batch_delete', 'all', false);
+    }
+  };
+
+  // Export logs
+  const exportLogs = async (deploymentId, options = {}) => {
+    try {
+      setOperationLoading('export_logs', deploymentId, true);
+      
+      const logs = await getDeploymentLogs(deploymentId, {
+        ...options,
+        limit: 10000 // Get more logs for export
+      });
+      
+      if (logs && logs.logs) {
+        const logText = logs.logs.map(log => 
+          `[${new Date(log.timestamp).toISOString()}] [${log.level}] ${log.source ? `[${log.source}] ` : ''}${log.message}`
+        ).join('\n');
+        
+        const blob = new Blob([logText], { type: 'text/plain' });
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        a.download = `deployment-${deploymentId}-logs-${new Date().toISOString().split('T')[0]}.txt`;
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        URL.revokeObjectURL(url);
+        
+        showSuccess(t('日志导出成功'));
+      }
+    } catch (error) {
+      showError(t('导出日志失败') + ': ' + error.message);
+      throw error;
+    } finally {
+      setOperationLoading('export_logs', deploymentId, false);
+    }
+  };
+
+  return {
+    // Actions
+    extendDeployment,
+    getDeploymentDetails,
+    getDeploymentLogs,
+    updateDeploymentConfig,
+    deleteDeployment,
+    updateDeploymentName,
+    batchDelete,
+    exportLogs,
+    
+    // Loading states
+    isOperationLoading,
+    loading,
+    
+    // Utility
+    setOperationLoading
+  };
+};
+
+export default useEnhancedDeploymentActions;

+ 143 - 0
web/src/hooks/model-deployments/useModelDeploymentSettings.js

@@ -0,0 +1,143 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import { useCallback, useEffect, useState } from 'react';
+import { API, toBoolean } from '../../helpers';
+
+export const useModelDeploymentSettings = () => {
+  const [loading, setLoading] = useState(true);
+  const [settings, setSettings] = useState({
+    'model_deployment.ionet.enabled': false,
+    'model_deployment.ionet.api_key': '',
+  });
+  const [connectionState, setConnectionState] = useState({
+    loading: false,
+    ok: null,
+    error: null,
+  });
+
+  const getSettings = async () => {
+    try {
+      setLoading(true);
+      const res = await API.get('/api/option/');
+      const { success, data } = res.data;
+      
+      if (success) {
+        const newSettings = {
+          'model_deployment.ionet.enabled': false,
+          'model_deployment.ionet.api_key': '',
+        };
+        
+        data.forEach((item) => {
+          if (item.key.endsWith('enabled')) {
+            newSettings[item.key] = toBoolean(item.value);
+          } else if (newSettings.hasOwnProperty(item.key)) {
+            newSettings[item.key] = item.value || '';
+          }
+        });
+        
+        setSettings(newSettings);
+      }
+    } catch (error) {
+      console.error('Failed to get model deployment settings:', error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    getSettings();
+  }, []);
+
+  const apiKey = settings['model_deployment.ionet.api_key'];
+  const isIoNetEnabled = settings['model_deployment.ionet.enabled'] && 
+                        apiKey && 
+                        apiKey.trim() !== '';
+
+  const buildConnectionError = (rawMessage, fallbackMessage = 'Connection failed') => {
+    const message = (rawMessage || fallbackMessage).trim();
+    const normalized = message.toLowerCase();
+    if (normalized.includes('expired') || normalized.includes('expire')) {
+      return { type: 'expired', message };
+    }
+    if (normalized.includes('invalid') || normalized.includes('unauthorized') || normalized.includes('api key')) {
+      return { type: 'invalid', message };
+    }
+    if (normalized.includes('network') || normalized.includes('timeout')) {
+      return { type: 'network', message };
+    }
+    return { type: 'unknown', message };
+  };
+
+  const testConnection = useCallback(async (apiKey) => {
+    const key = (apiKey || '').trim();
+    if (key === '') {
+      setConnectionState({ loading: false, ok: null, error: null });
+      return;
+    }
+
+    setConnectionState({ loading: true, ok: null, error: null });
+    try {
+      const response = await API.post(
+        '/api/deployments/test-connection',
+        { api_key: key },
+        { skipErrorHandler: true },
+      );
+
+      if (response?.data?.success) {
+        setConnectionState({ loading: false, ok: true, error: null });
+        return;
+      }
+
+      const message = response?.data?.message || 'Connection failed';
+      setConnectionState({ loading: false, ok: false, error: buildConnectionError(message) });
+    } catch (error) {
+      if (error?.code === 'ERR_NETWORK') {
+        setConnectionState({
+          loading: false,
+          ok: false,
+          error: { type: 'network', message: 'Network connection failed' },
+        });
+        return;
+      }
+      const rawMessage = error?.response?.data?.message || error?.message || 'Unknown error';
+      setConnectionState({ loading: false, ok: false, error: buildConnectionError(rawMessage, 'Connection failed') });
+    }
+  }, []);
+
+  useEffect(() => {
+    if (!loading && isIoNetEnabled) {
+      testConnection(apiKey);
+      return;
+    }
+    setConnectionState({ loading: false, ok: null, error: null });
+  }, [loading, isIoNetEnabled, apiKey, testConnection]);
+
+  return {
+    loading,
+    settings,
+    apiKey,
+    isIoNetEnabled,
+    refresh: getSettings,
+    connectionLoading: connectionState.loading,
+    connectionOk: connectionState.ok,
+    connectionError: connectionState.error,
+    testConnection,
+  };
+};

+ 52 - 0
web/src/pages/ModelDeployment/index.jsx

@@ -0,0 +1,52 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React from 'react';
+import DeploymentsTable from '../../components/table/model-deployments';
+import DeploymentAccessGuard from '../../components/model-deployments/DeploymentAccessGuard';
+import { useModelDeploymentSettings } from '../../hooks/model-deployments/useModelDeploymentSettings';
+
+const ModelDeploymentPage = () => {
+  const {
+    loading,
+    isIoNetEnabled,
+    connectionLoading,
+    connectionOk,
+    connectionError,
+    apiKey,
+    testConnection,
+  } = useModelDeploymentSettings();
+
+  return (
+    <DeploymentAccessGuard
+      loading={loading}
+      isEnabled={isIoNetEnabled}
+      connectionLoading={connectionLoading}
+      connectionOk={connectionOk}
+      connectionError={connectionError}
+      onRetry={() => testConnection(apiKey)}
+    >
+      <div className='mt-[60px] px-2'>
+        <DeploymentsTable />
+      </div>
+    </DeploymentAccessGuard>
+  );
+};
+
+export default ModelDeploymentPage;

+ 334 - 0
web/src/pages/Setting/Model/SettingModelDeployment.jsx

@@ -0,0 +1,334 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+
+import React, { useEffect, useState, useRef } from 'react';
+import { Button, Col, Form, Row, Spin, Card, Typography } from '@douyinfe/semi-ui';
+import {
+  compareObjects,
+  API,
+  showError,
+  showSuccess,
+  showWarning,
+} from '../../../helpers';
+import { useTranslation } from 'react-i18next';
+import { Server, Cloud, Zap, ArrowUpRight } from 'lucide-react';
+
+const { Text } = Typography;
+
+export default function SettingModelDeployment(props) {
+  const { t } = useTranslation();
+
+  const [loading, setLoading] = useState(false);
+  const [inputs, setInputs] = useState({
+    'model_deployment.ionet.api_key': '',
+    'model_deployment.ionet.enabled': false,
+  });
+  const refForm = useRef();
+  const [inputsRow, setInputsRow] = useState({
+    'model_deployment.ionet.api_key': '',
+    'model_deployment.ionet.enabled': false,
+  });
+  const [testing, setTesting] = useState(false);
+
+  const testApiKey = async () => {
+    const apiKey = inputs['model_deployment.ionet.api_key'];
+    if (!apiKey || apiKey.trim() === '') {
+      showError(t('请先填写 API Key'));
+      return;
+    }
+
+    const getLocalizedMessage = (message) => {
+      switch (message) {
+        case 'invalid request payload':
+          return t('请求参数无效');
+        case 'api_key is required':
+          return t('请先填写 API Key');
+        case 'failed to validate api key':
+          return t('API Key 验证失败');
+        default:
+          return message;
+      }
+    };
+
+    setTesting(true);
+    try {
+      const response = await API.post(
+        '/api/deployments/test-connection',
+        {
+          api_key: apiKey.trim(),
+        },
+        {
+          skipErrorHandler: true,
+        },
+      );
+
+      if (response?.data?.success) {
+        showSuccess(t('API Key 验证成功!连接到 io.net 服务正常'));
+      } else {
+        const rawMessage = response?.data?.message;
+        const localizedMessage = rawMessage
+          ? getLocalizedMessage(rawMessage)
+          : t('API Key 验证失败');
+        showError(localizedMessage);
+      }
+    } catch (error) {
+      console.error('io.net API test error:', error);
+
+      if (error?.code === 'ERR_NETWORK') {
+        showError(t('网络连接失败,请检查网络设置或稍后重试'));
+      } else {
+        const rawMessage =
+          error?.response?.data?.message ||
+          error?.message ||
+          '';
+        const localizedMessage = rawMessage
+          ? getLocalizedMessage(rawMessage)
+          : t('未知错误');
+        showError(t('测试失败:') + localizedMessage);
+      }
+    } finally {
+      setTesting(false);
+    }
+  };
+
+  function onSubmit() {
+    // 前置校验:如果启用了 io.net 但没有填写 API Key
+    if (inputs['model_deployment.ionet.enabled'] && 
+        (!inputs['model_deployment.ionet.api_key'] || inputs['model_deployment.ionet.api_key'].trim() === '')) {
+      return showError(t('启用 io.net 部署时必须填写 API Key'));
+    }
+
+    const updateArray = compareObjects(inputs, inputsRow);
+    if (!updateArray.length) return showWarning(t('你似乎并没有修改什么'));
+    
+    const requestQueue = updateArray.map((item) => {
+      let value = String(inputs[item.key]);
+      return API.put('/api/option/', {
+        key: item.key,
+        value,
+      });
+    });
+    
+    setLoading(true);
+    Promise.all(requestQueue)
+      .then((res) => {
+        if (requestQueue.length === 1) {
+          if (res.includes(undefined)) return;
+        } else if (requestQueue.length > 1) {
+          if (res.includes(undefined))
+            return showError(t('部分保存失败,请重试'));
+        }
+        showSuccess(t('保存成功'));
+        // 更新 inputsRow 以反映已保存的状态
+        setInputsRow(structuredClone(inputs));
+        props.refresh();
+      })
+      .catch(() => {
+        showError(t('保存失败,请重试'));
+      })
+      .finally(() => {
+        setLoading(false);
+      });
+  }
+
+  useEffect(() => {
+    if (props.options) {
+      const defaultInputs = {
+        'model_deployment.ionet.api_key': '',
+        'model_deployment.ionet.enabled': false,
+      };
+      
+      const currentInputs = {};
+      for (let key in defaultInputs) {
+        if (props.options.hasOwnProperty(key)) {
+          currentInputs[key] = props.options[key];
+        } else {
+          currentInputs[key] = defaultInputs[key];
+        }
+      }
+      
+      setInputs(currentInputs);
+      setInputsRow(structuredClone(currentInputs));
+      refForm.current?.setValues(currentInputs);
+    }
+  }, [props.options]);
+
+  return (
+    <>
+      <Spin spinning={loading}>
+        <Form
+          values={inputs}
+          getFormApi={(formAPI) => (refForm.current = formAPI)}
+          style={{ marginBottom: 15 }}
+        >
+          <Form.Section 
+            text={
+              <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+                <span>{t('模型部署设置')}</span>
+              </div>
+            }
+          >
+            {/*<Text */}
+            {/*  type="secondary" */}
+            {/*  size="small"*/}
+            {/*  style={{ */}
+            {/*    display: 'block', */}
+            {/*    marginBottom: '20px',*/}
+            {/*    color: 'var(--semi-color-text-2)'*/}
+            {/*  }}*/}
+            {/*>*/}
+            {/*  {t('配置模型部署服务提供商的API密钥和启用状态')}*/}
+            {/*</Text>*/}
+
+            <Card
+              title={
+                <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+                  <Cloud size={18} />
+                  <span>io.net</span>
+                </div>
+              }
+              bodyStyle={{ padding: '20px' }}
+              style={{ marginBottom: '16px' }}
+            >
+              <Row gutter={24}>
+                <Col xs={24} lg={14}>
+                  <div
+                    style={{
+                      display: 'flex',
+                      flexDirection: 'column',
+                      gap: '16px',
+                    }}
+                  >
+                    <Form.Switch
+                      label={t('启用 io.net 部署')}
+                      field={'model_deployment.ionet.enabled'}
+                      onChange={(value) =>
+                        setInputs({
+                          ...inputs,
+                          'model_deployment.ionet.enabled': value,
+                        })
+                      }
+                      extraText={t('启用后可接入 io.net GPU 资源')}
+                    />
+                    <Form.Input
+                      label={t('API Key')}
+                      field={'model_deployment.ionet.api_key'}
+                      placeholder={t('请输入 io.net API Key')}
+                      onChange={(value) =>
+                        setInputs({
+                          ...inputs,
+                          'model_deployment.ionet.api_key': value,
+                        })
+                      }
+                      disabled={!inputs['model_deployment.ionet.enabled']}
+                      extraText={t('请使用 Project 为 io.cloud 的密钥')}
+                      mode="password"
+                    />
+                    <div style={{ display: 'flex', gap: '12px' }}>
+                      <Button
+                        type="outline"
+                        size="small"
+                        icon={<Zap size={16} />}
+                        onClick={testApiKey}
+                        loading={testing}
+                        disabled={
+                          !inputs['model_deployment.ionet.enabled'] ||
+                          !inputs['model_deployment.ionet.api_key'] ||
+                          inputs['model_deployment.ionet.api_key'].trim() === ''
+                        }
+                        style={{
+                          height: '32px',
+                          fontSize: '13px',
+                          borderRadius: '6px',
+                          fontWeight: '500',
+                          borderColor: testing
+                            ? 'var(--semi-color-primary)'
+                            : 'var(--semi-color-border)',
+                          color: testing
+                            ? 'var(--semi-color-primary)'
+                            : 'var(--semi-color-text-0)',
+                        }}
+                      >
+                        {testing ? t('连接测试中...') : t('测试连接')}
+                      </Button>
+                    </div>
+                  </div>
+                </Col>
+                <Col xs={24} lg={10}>
+                  <div
+                    style={{
+                      background: 'var(--semi-color-fill-0)',
+                      padding: '16px',
+                      borderRadius: '8px',
+                      border: '1px solid var(--semi-color-border)',
+                      height: '100%',
+                      display: 'flex',
+                      flexDirection: 'column',
+                      gap: '12px',
+                      justifyContent: 'space-between',
+                    }}
+                  >
+                    <div>
+                      <Text strong style={{ display: 'block', marginBottom: '8px' }}>
+                        {t('获取 io.net API Key')}
+                      </Text>
+                      <ul
+                        style={{
+                          margin: 0,
+                          paddingLeft: '18px',
+                          display: 'flex',
+                          flexDirection: 'column',
+                          gap: '6px',
+                          color: 'var(--semi-color-text-2)',
+                          fontSize: '13px',
+                          lineHeight: 1.6,
+                        }}
+                      >
+                        <li>{t('访问 io.net 控制台的 API Keys 页面')}</li>
+                        <li>{t('创建或选择密钥时,将 Project 设置为 io.cloud')}</li>
+                        <li>{t('复制生成的密钥并粘贴到此处')}</li>
+                      </ul>
+                    </div>
+                    <Button
+                      icon={<ArrowUpRight size={16} />}
+                      type="primary"
+                      theme="solid"
+                      style={{ width: '100%' }}
+                      onClick={() =>
+                        window.open('https://ai.io.net/ai/api-keys', '_blank')
+                      }
+                    >
+                      {t('前往 io.net API Keys')}
+                    </Button>
+                  </div>
+                </Col>
+              </Row>
+            </Card>
+
+            <Row>
+              <Button size='default' type="primary" onClick={onSubmit}>
+                {t('保存设置')}
+              </Button>
+            </Row>
+          </Form.Section>
+        </Form>
+      </Spin>
+    </>
+  );
+}

+ 4 - 0
web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx

@@ -62,6 +62,7 @@ export default function SettingsSidebarModulesAdmin(props) {
       enabled: true,
       channel: true,
       models: true,
+      deployment: true,
       redemption: true,
       user: true,
       setting: true,
@@ -121,6 +122,7 @@ export default function SettingsSidebarModulesAdmin(props) {
         enabled: true,
         channel: true,
         models: true,
+        deployment: true,
         redemption: true,
         user: true,
         setting: true,
@@ -188,6 +190,7 @@ export default function SettingsSidebarModulesAdmin(props) {
             enabled: true,
             channel: true,
             models: true,
+            deployment: true,
             redemption: true,
             user: true,
             setting: true,
@@ -249,6 +252,7 @@ export default function SettingsSidebarModulesAdmin(props) {
       modules: [
         { key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
         { key: 'models', title: t('模型管理'), description: t('AI模型配置') },
+        { key: 'deployment', title: t('模型部署'), description: t('模型部署管理') },
         {
           key: 'redemption',
           title: t('兑换码管理'),

+ 1 - 0
web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx

@@ -104,6 +104,7 @@ export default function SettingsSidebarModulesUser() {
         enabled: true,
         channel: isSidebarModuleAllowed('admin', 'channel'),
         models: isSidebarModuleAllowed('admin', 'models'),
+        deployment: isSidebarModuleAllowed('admin', 'deployment'),
         redemption: isSidebarModuleAllowed('admin', 'redemption'),
         user: isSidebarModuleAllowed('admin', 'user'),
         setting: isSidebarModuleAllowed('admin', 'setting'),

+ 12 - 0
web/src/pages/Setting/index.jsx

@@ -32,6 +32,7 @@ import {
   MessageSquare,
   Palette,
   CreditCard,
+  Server,
 } from 'lucide-react';
 
 import SystemSetting from '../../components/settings/SystemSetting';
@@ -45,6 +46,7 @@ import RatioSetting from '../../components/settings/RatioSetting';
 import ChatsSetting from '../../components/settings/ChatsSetting';
 import DrawingSetting from '../../components/settings/DrawingSetting';
 import PaymentSetting from '../../components/settings/PaymentSetting';
+import ModelDeploymentSetting from '../../components/settings/ModelDeploymentSetting';
 
 const Setting = () => {
   const { t } = useTranslation();
@@ -134,6 +136,16 @@ const Setting = () => {
       content: <ModelSetting />,
       itemKey: 'models',
     });
+    panes.push({
+      tab: (
+        <span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
+          <Server size={18} />
+          {t('模型部署设置')}
+        </span>
+      ),
+      content: <ModelDeploymentSetting />,
+      itemKey: 'model-deployment',
+    });
     panes.push({
       tab: (
         <span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>