diff --git a/.dockerignore b/.dockerignore index 781a7b550..53d001932 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,5 @@ Makefile docs .eslintcache -.gocache \ No newline at end of file +.gocache +/web/node_modules \ No newline at end of file diff --git a/.gitignore b/.gitignore index 133f59090..67ce02704 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ web/bun.lock electron/node_modules electron/dist data/ -.gomodcache/ \ No newline at end of file +.gomodcache/ +.gocache-temp +.gopath diff --git a/controller/channel.go b/controller/channel.go index b2db2b777..ea2f47680 100644 --- a/controller/channel.go +++ b/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, + }, + }) +} diff --git a/controller/deployment.go b/controller/deployment.go new file mode 100644 index 000000000..7530b4edf --- /dev/null +++ b/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) +} diff --git a/docs/ionet-client.md b/docs/ionet-client.md new file mode 100644 index 000000000..a4d40b171 --- /dev/null +++ b/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"} + diff --git a/go.mod b/go.mod index 4b5d63e49..f4f133973 100644 --- a/go.mod +++ b/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 diff --git a/model/main.go b/model/main.go index 04842f13f..8dcedad0b 100644 --- a/model/main.go +++ b/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)) diff --git a/pkg/ionet/client.go b/pkg/ionet/client.go new file mode 100644 index 000000000..e53947570 --- /dev/null +++ b/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 "" +} diff --git a/pkg/ionet/container.go b/pkg/ionet/container.go new file mode 100644 index 000000000..805a3b162 --- /dev/null +++ b/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 +} diff --git a/pkg/ionet/deployment.go b/pkg/ionet/deployment.go new file mode 100644 index 000000000..36597399b --- /dev/null +++ b/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 +} diff --git a/pkg/ionet/hardware.go b/pkg/ionet/hardware.go new file mode 100644 index 000000000..54ccdb886 --- /dev/null +++ b/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 +} diff --git a/pkg/ionet/jsonutil.go b/pkg/ionet/jsonutil.go new file mode 100644 index 000000000..0b3219cfe --- /dev/null +++ b/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 +} diff --git a/pkg/ionet/types.go b/pkg/ionet/types.go new file mode 100644 index 000000000..7912f360d --- /dev/null +++ b/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"` +} diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go index 2434a4cbc..07aeb17a7 100644 --- a/relay/channel/ollama/dto.go +++ b/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"` +} diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go index 9c05b1357..795e9c975 100644 --- a/relay/channel/ollama/relay-ollama.go +++ b/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 +} diff --git a/router/api-router.go b/router/api-router.go index fd204e7e6..e8266ef3f 100644 --- a/router/api-router.go +++ b/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) + } } } diff --git a/web/src/App.jsx b/web/src/App.jsx index b0f281c45..995c64499 100644 --- a/web/src/App.jsx +++ b/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() { } /> + + + + } + /> {} }) => { to: '/console/models', className: isAdmin() ? '' : 'tableHiddle', }, + { + text: t('模型部署'), + itemKey: 'deployment', + to: '/deployment', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('兑换码管理'), itemKey: 'redemption', diff --git a/web/src/components/layout/components/SkeletonWrapper.jsx b/web/src/components/layout/components/SkeletonWrapper.jsx index 7fbd588ca..eec4be9a7 100644 --- a/web/src/components/layout/components/SkeletonWrapper.jsx +++ b/web/src/components/layout/components/SkeletonWrapper.jsx @@ -52,7 +52,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -71,7 +70,7 @@ const SkeletonWrapper = ({ loading={true} active placeholder={ - + } />
@@ -80,7 +79,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -98,7 +96,6 @@ const SkeletonWrapper = ({ active placeholder={ @@ -113,7 +110,7 @@ const SkeletonWrapper = ({ } + placeholder={} /> ); }; @@ -125,7 +122,7 @@ const SkeletonWrapper = ({ } + placeholder={} />
); @@ -140,7 +137,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -164,7 +160,7 @@ const SkeletonWrapper = ({ loading={true} active placeholder={ - + } /> @@ -174,7 +170,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -191,10 +186,7 @@ const SkeletonWrapper = ({ loading={true} active placeholder={ - + } /> @@ -217,7 +209,6 @@ const SkeletonWrapper = ({ active placeholder={ @@ -231,7 +222,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -269,7 +259,6 @@ const SkeletonWrapper = ({ active placeholder={ @@ -329,7 +318,6 @@ const SkeletonWrapper = ({ active placeholder={ } @@ -350,7 +338,6 @@ const SkeletonWrapper = ({ active placeholder={ } diff --git a/web/src/components/model-deployments/DeploymentAccessGuard.jsx b/web/src/components/model-deployments/DeploymentAccessGuard.jsx new file mode 100644 index 000000000..f771fa1c5 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( +
+ +
+ {t('加载设置中...')} +
+
+
+ ); + } + + if (!isEnabled) { + return ( +
+
+ + {/* 图标区域 */} +
+
+ +
+
+ + {/* 标题区域 */} +
+ + {t('模型部署服务未启用')} + + + {t('访问模型部署功能需要先启用 io.net 部署服务')} + +
+ + {/* 配置要求区域 */} +
+
+
+ +
+ + {t('需要配置的项目')} + +
+ +
+
+
+ + {t('启用 io.net 部署开关')} + +
+
+
+ + {t('配置有效的 io.net API Key')} + +
+
+
+ + {/* 操作链接区域 */} +
+
{ + 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'; + }} + > + + {t('前往设置页面')} +
+
+ + {/* 底部提示 */} + + {t('配置完成后刷新页面即可使用模型部署功能')} + +
+
+
+ ); + } + + if (connectionLoading || (connectionOk === null && !connectionError)) { + return ( +
+ +
+ {t('Checking io.net connection...')} +
+
+
+ ); + } + + 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 ( +
+
+ +
+
+ +
+
+ +
+ + {title} + + + {description} + + {detail ? ( + + {detail} + + ) : null} +
+ +
+ + {onRetry ? ( + + ) : null} +
+
+
+
+ ); + } + + return children; +}; + +export default DeploymentAccessGuard; diff --git a/web/src/components/settings/ModelDeploymentSetting.jsx b/web/src/components/settings/ModelDeploymentSetting.jsx new file mode 100644 index 000000000..941f640a4 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + <> + + + + + + + ); +}; + +export default ModelDeploymentSetting; \ No newline at end of file diff --git a/web/src/components/table/channels/ChannelsColumnDefs.jsx b/web/src/components/table/channels/ChannelsColumnDefs.jsx index 643f3ffe6..2c9f7498b 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.jsx +++ b/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 = ( {type2label[type]?.label} ); + + 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 ( + + {typeTag} + +
{t('来源于 IO.NET 部署')}
+ {ionetMeta?.deployment_id && ( +
+ {t('部署 ID')}: {ionetMeta.deployment_id} +
+ )} + + } + > + + + IO.NET + + +
+
+ ); }; 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 ( { 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, ]); diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index e5cf66434..9e07a2bd1 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/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) => { - setChannelSearchValue(value)} - renderOptionItem={renderChannelOption} - onChange={(value) => handleInputChange('type', value)} - /> + {isIonetChannel && ( + + + {ionetMetadata?.deployment_id && ( + + )} + + + )} + + setChannelSearchValue(value)} + renderOptionItem={renderChannelOption} + onChange={(value) => handleInputChange('type', value)} + disabled={isIonetLocked} + /> {inputs.type === 20 && ( { autosize autoComplete='new-password' onChange={(value) => handleInputChange('key', value)} - extraText={ -
- {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - - )} - {isEdit && ( + disabled={isIonetLocked} + extraText={ +
+ {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + + )} + {batchExtra} +
+ } + showClear + /> + ) + ) : ( + <> + {inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + <> + {!batch && ( +
+ + {t('密钥输入方式')} + + - )} - {batchExtra} + +
- } - showClear - /> - ) - ) : ( - <> - {inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - <> - {!batch && ( -
- - {t('密钥输入方式')} - - - - - -
- )} + )} {batch && ( { /> )} - {inputs.type === 3 && ( - <> - + +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} /> -
- - handleInputChange('base_url', value) - } - showClear - /> -
-
- - handleInputChange('other', value) - } - showClear - /> -
-
- - handleChannelOtherSettingsChange( - 'azure_responses_version', - value, - ) - } - showClear - /> -
- - )} +
+
+ + handleInputChange('other', value) + } + showClear + /> +
+
+ + handleChannelOtherSettingsChange( + 'azure_responses_version', + value, + ) + } + showClear + /> +
+ + )} - {inputs.type === 8 && ( - <> - + +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} /> -
- - handleInputChange('base_url', value) - } - showClear - /> -
- - )} +
+ + )} {inputs.type === 37 && ( { handleInputChange('base_url', value) } showClear - extraText={t( - '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', - )} - /> -
- )} - - {inputs.type === 22 && ( -
- - handleInputChange('base_url', value) - } - showClear />
)} - {inputs.type === 36 && ( -
- - handleInputChange('base_url', value) - } - showClear - /> -
- )} + {inputs.type === 22 && ( +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ )} - {inputs.type === 45 && !doubaoApiEditUnlocked && ( -
- + {inputs.type === 36 && ( +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ )} + + {inputs.type === 45 && !doubaoApiEditUnlocked && ( +
+ 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' - /> -
- )} + ]}defaultValue='https://ark.cn-beijing.volces.com' + disabled={isIonetLocked} + /> +
+ )} )} @@ -2458,72 +2530,80 @@ const EditChannelModal = (props) => { {t('获取模型列表')} )} + {inputs.type === 4 && isEdit && ( - - {modelGroups && - modelGroups.length > 0 && - modelGroups.map((group) => ( - - ))} -
- } - /> + )} + + + {modelGroups && + modelGroups.length > 0 && + modelGroups.map((group) => ( + + ))} + + } + /> { }} onCancel={() => setModelModalVisible(false)} /> + + 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('模型列表已追加更新')); + }} + /> ); }; diff --git a/web/src/components/table/channels/modals/ModelSelectModal.jsx b/web/src/components/table/channels/modals/ModelSelectModal.jsx index 21ac768c6..eda7f80b5 100644 --- a/web/src/components/table/channels/modals/ModelSelectModal.jsx +++ b/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(() => { diff --git a/web/src/components/table/channels/modals/OllamaModelModal.jsx b/web/src/components/table/channels/modals/OllamaModelModal.jsx new file mode 100644 index 000000000..8b1dfcce1 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + + + + +
+ + {t('Ollama 模型管理')} + + + {channelInfo?.name && `${channelInfo.name} - `} + {t('管理 Ollama 模型的拉取和删除')} + +
+ + } + visible={visible} + onCancel={onCancel} + width={800} + style={{ maxWidth: '95vw' }} + footer={ +
+ +
+ } + > +
+ {/* 拉取新模型 */} + +
+ + + + + {t('拉取新模型')} + +
+ + + + setPullModelName(value)} + onEnterPress={pullModel} + disabled={pullLoading} + showClear + /> + + + + + + + {/* 进度条显示 */} + {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 ( +
+
+ {t('拉取进度')} + {percentText} +
+ + {hasTotal && safePercent !== null ? ( +
+ +
+ + {(completedBytes / (1024 * 1024 * 1024)).toFixed(2)} GB + + + {(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB + +
+
+ ) : ( +
+ + {t('准备中...')} +
+ )} +
+ ); + })()} + + + {t('支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间')} + +
+ + {/* 已有模型列表 */} + +
+
+ + + + + {t('已有模型')} + {models.length > 0 && ( + <Tag color='blue' className='ml-2'> + {models.length} + </Tag> + )} + +
+ + } + placeholder={t('搜索模型...')} + value={searchValue} + onChange={(value) => setSearchValue(value)} + style={{ width: 200 }} + showClear + /> + + + + + +
+ + + {filteredModels.length === 0 ? ( + } + title={searchValue ? t('未找到匹配的模型') : t('暂无模型')} + description={ + searchValue + ? t('请尝试其他搜索关键词') + : t('您可以在上方拉取需要的模型') + } + style={{ padding: '40px 0' }} + /> + ) : ( + ( + +
+
+ handleToggleModel(model.id, checked)} + /> + + {model.id.charAt(0).toUpperCase()} + +
+ + {model.id} + +
+ + {model.owned_by || 'ollama'} + + {model.size && ( + + {formatModelSize(model.size)} + + )} +
+
+
+
+ deleteModel(model.id)} + okText={t('确认')} + cancelText={t('取消')} + > +
+
+
+ )} + /> + )} +
+
+
+
+ ); +}; + +export default OllamaModelModal; diff --git a/web/src/components/table/model-deployments/DeploymentsActions.jsx b/web/src/components/table/model-deployments/DeploymentsActions.jsx new file mode 100644 index 000000000..f89ed4ace --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( +
+ + + {hasSelected && ( + <> + + + + + + + )} + + {/* Compact Mode */} + +
+ ); +}; + +export default DeploymentsActions; diff --git a/web/src/components/table/model-deployments/DeploymentsColumnDefs.jsx b/web/src/components/table/model-deployments/DeploymentsColumnDefs.jsx new file mode 100644 index 000000000..965ca7be7 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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: , + }, + deploying: { + color: 'blue', + label: t('部署中'), + icon: , + }, + pending: { + color: 'orange', + label: t('待部署'), + icon: , + }, + stopped: { + color: 'grey', + label: t('已停止'), + icon: , + }, + error: { + color: 'red', + label: t('错误'), + icon: , + }, + failed: { + color: 'red', + label: t('失败'), + icon: , + }, + destroyed: { + color: 'red', + label: t('已销毁'), + icon: , + }, + completed: { + color: 'green', + label: t('已完成'), + icon: , + }, + 'deployment requested': { + color: 'blue', + label: t('部署请求中'), + icon: , + }, + 'termination requested': { + color: 'orange', + label: t('终止请求中'), + icon: , + }, +}; + +const DEFAULT_STATUS_CONFIG = { + color: 'grey', + label: null, + icon: , +}; + +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 ( + + {labelText} + + ); +}; + +// 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 ( +
+ + {text} + + + ID: {record.id} + +
+ ); +}; + +// Render resource configuration +const renderResourceConfig = (resource, t) => { + if (!resource) return '-'; + + const { cpu, memory, gpu } = resource; + + return ( +
+ {cpu && ( +
+ + CPU: {cpu} +
+ )} + {memory && ( +
+ + 内存: {memory} +
+ )} + {gpu && ( +
+ + GPU: {gpu} +
+ )} +
+ ); +}; + +// 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 ( + + {count || 0} {t('个实例')} + + ); +}; + +// 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) => ( + + ), + }, + { + title: t('状态'), + dataIndex: 'status', + key: COLUMN_KEYS.status, + width: 140, + render: (status) => ( +
+ {renderStatus(status, t)} +
+ ), + }, + { + title: t('服务商'), + dataIndex: 'provider', + key: COLUMN_KEYS.provider, + width: 140, + render: (provider) => + provider ? ( +
+ + {provider} +
+ ) : ( + + {t('暂无')} + + ), + }, + { + 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 ( +
+
+ + + {timeDisplay} + + {showProgress && percentRemaining !== null ? ( + + {percentRemaining}% + + ) : statusOverride ? ( + + {statusOverride} + + ) : null} +
+ {showExtraInfo && ( +
+ {humanReadable && ( + + + {t('约')} {humanReadable} + + )} + {percentUsed !== null && ( + + + {t('已用')} {percentUsed}% + + )} +
+ )} + {showProgress && showRemainingMeta && ( +
+ {t('剩余')} {record.compute_minutes_remaining} {t('分钟')} +
+ )} +
+ ); + }, + }, + { + title: t('硬件配置'), + dataIndex: 'hardware_info', + key: COLUMN_KEYS.hardware_info, + width: 220, + ellipsis: true, + render: (text, record) => ( +
+
+ + + {record.hardware_name} + +
+ x{record.hardware_quantity} +
+ ), + }, + { + title: t('创建时间'), + dataIndex: 'created_at', + key: COLUMN_KEYS.created_at, + width: 150, + render: (text) => ( + {timestamp2string(text)} + ), + }, + { + 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: , + text: t('查看详情'), + onClick: () => onViewDetails?.(record), + type: 'secondary', + theme: 'borderless', + }; + case 'failed': + case 'error': + return { + icon: , + text: t('重试'), + onClick: () => startDeployment(id), + type: 'primary', + theme: 'solid', + }; + case 'stopped': + return { + icon: , + text: t('启动'), + onClick: () => startDeployment(id), + type: 'primary', + theme: 'solid', + }; + case 'deployment requested': + case 'deploying': + return { + icon: , + text: t('部署中'), + onClick: () => {}, + type: 'secondary', + theme: 'light', + disabled: true, + }; + case 'pending': + return { + icon: , + text: t('待部署'), + onClick: () => {}, + type: 'secondary', + theme: 'light', + disabled: true, + }; + case 'termination requested': + return { + icon: , + text: t('终止中'), + onClick: () => {}, + type: 'secondary', + theme: 'light', + disabled: true, + }; + case 'completed': + case 'destroyed': + default: + return { + icon: , + 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 ( +
+ +
+ ); + } + + // All actions dropdown with enhanced operations + const dropdownItems = [ + onViewDetails?.(record)} icon={}> + {t('查看详情')} + , + ]; + + if (!isEnded) { + dropdownItems.push( + onViewLogs?.(record)} icon={}> + {t('查看日志')} + , + ); + } + + const managementItems = []; + if (normalizedStatus === 'running') { + if (onSyncToChannel) { + managementItems.push( + onSyncToChannel(record)} icon={}> + {t('同步到渠道')} + , + ); + } + } + if (normalizedStatus === 'failed' || normalizedStatus === 'error') { + managementItems.push( + startDeployment(id)} icon={}> + {t('重试')} + , + ); + } + if (normalizedStatus === 'stopped') { + managementItems.push( + startDeployment(id)} icon={}> + {t('启动')} + , + ); + } + + if (managementItems.length > 0) { + dropdownItems.push(); + dropdownItems.push(...managementItems); + } + + const configItems = []; + if (!isEnded && (normalizedStatus === 'running' || normalizedStatus === 'deployment requested')) { + configItems.push( + onExtendDuration?.(record)} icon={}> + {t('延长时长')} + , + ); + } + // if (!isEnded && normalizedStatus === 'running') { + // configItems.push( + // onUpdateConfig?.(record)} icon={}> + // {t('更新配置')} + // , + // ); + // } + + if (configItems.length > 0) { + dropdownItems.push(); + dropdownItems.push(...configItems); + } + if (!isEnded) { + dropdownItems.push(); + dropdownItems.push( + }> + {t('销毁容器')} + , + ); + } + + const allActions = {dropdownItems}; + const hasDropdown = dropdownItems.length > 0; + + return ( +
+ + + {hasDropdown && ( + +
+ ); + }, + }, + ]; + + return columns; +}; diff --git a/web/src/components/table/model-deployments/DeploymentsFilters.jsx b/web/src/components/table/model-deployments/DeploymentsFilters.jsx new file mode 100644 index 000000000..b268f6832 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( +
{ + setFormApi(formApi); + formApiRef.current = formApi; + }} + className='w-full md:w-auto order-1 md:order-2' + > +
+
+ } + showClear + size='small' + pure + /> +
+ +
+ +
+ +
+ + + + + +
+
+
+ ); +}; + +export default DeploymentsFilters; diff --git a/web/src/components/table/model-deployments/DeploymentsTable.jsx b/web/src/components/table/model-deployments/DeploymentsTable.jsx new file mode 100644 index 000000000..3c5687e61 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + <> + { + setSelectedKeys(selectedRows); + }, + }} + empty={ + } + darkModeImage={ + + } + description={t('搜索无结果')} + style={{ padding: 30 }} + /> + } + className='rounded-xl overflow-hidden' + size='middle' + loading={loading || searching} + /> + + {/* Enhanced Modals */} + setShowLogsModal(false)} + deployment={selectedDeployment} + t={t} + /> + + setShowExtendModal(false)} + deployment={selectedDeployment} + onSuccess={handleModalSuccess} + t={t} + /> + + setShowDetailsModal(false)} + deployment={selectedDeployment} + t={t} + /> + + setShowConfigModal(false)} + deployment={selectedDeployment} + onSuccess={handleModalSuccess} + t={t} + /> + + setShowConfirmDialog(false)} + onConfirm={handleConfirmAction} + title={t('确认操作')} + type="danger" + deployment={selectedDeployment} + operation={confirmOperation} + t={t} + /> + + ); +}; + +export default DeploymentsTable; diff --git a/web/src/components/table/model-deployments/index.jsx b/web/src/components/table/model-deployments/index.jsx new file mode 100644 index 000000000..e3332027e --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 */} + + + setShowCreateModal(false)} + onSuccess={refresh} + t={t} + /> + + setShowColumnSelector(false)} + visibleColumns={visibleColumns} + onVisibleColumnsChange={setVisibleColumns} + columnKeys={COLUMN_KEYS} + t={t} + /> + + {/* Main Content */} + + + + + } + 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} + > + + + + ); +}; + +export default DeploymentsPage; diff --git a/web/src/components/table/model-deployments/modals/ColumnSelectorModal.jsx b/web/src/components/table/model-deployments/modals/ColumnSelectorModal.jsx new file mode 100644 index 000000000..3589ec491 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + + + + + + } + > +
+ handleSelectAll(e.target.checked)} + > + {t('全选')} + +
+
+ {columnOptions.map(({ key, label, required }) => ( +
+ + handleColumnVisibilityChange(key, e.target.checked) + } + > + {label} + +
+ ))} +
+
+ ); +}; + +export default ColumnSelectorModal; diff --git a/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx b/web/src/components/table/model-deployments/modals/ConfirmationDialog.jsx new file mode 100644 index 000000000..f462292a3 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + +
+ + {t('此操作具有风险,请确认要继续执行')}。 + + + {t('请输入部署名称以完成二次确认')}: + + {requiredText || t('未知部署')} + + + + {!isConfirmed && confirmText && ( + + {t('部署名称不匹配,请检查后重新输入')} + + )} +
+
+ ); +}; + +export default ConfirmationDialog; diff --git a/web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx b/web/src/components/table/model-deployments/modals/CreateDeploymentModal.jsx new file mode 100644 index 000000000..e32f29524 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 = ( +
+ {loadingPrice ? ( + + + + {t('价格计算中...')} + + + ) : ( + + {isPriceReady + ? t('价格暂时不可用,请稍后重试') + : t('完成硬件类型、部署位置、副本数量等配置后,将自动计算价格')} + + )} +
+ ); + + useEffect(() => { + if (!visible || !formApi) { + return; + } + if (maxAvailableReplicas > 0 && replicaCount > maxAvailableReplicas) { + setReplicaCount(maxAvailableReplicas); + formApi.setValue('replica_count', maxAvailableReplicas); + } + }, [maxAvailableReplicas, replicaCount, visible, formApi]); + + return ( + formApi?.submitForm()} + okText={t('创建')} + cancelText={t('取消')} + width={800} + confirmLoading={submitting} + style={{ top: 20 }} + > +
+ + + + + + +
+ + {t('部署配置')} + + + +
+ {t('镜像选择')} +
+ setImageMode(value?.target?.value ?? value)} + > + {t('内置 Ollama 镜像')} + {t('自定义镜像')} + +
+
+ + { + if (imageMode === 'custom') { + customImageRef.current = value; + } + }} + /> + + {imageMode === 'builtin' && ( + + + {t('系统已为该部署准备 Ollama 镜像与随机 API Key')} + + + + + )} + + + + { + 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 ( + + ); + })} + + + + h.id === selectedHardwareId)?.max_gpus : 8} + step={1} + innerButtons + rules={[{ required: true, message: t('请输入GPU数量') }]} + onChange={(value) => setGpusPerContainer(value)} + style={{ width: '100%' }} + /> + + + + {typeof hardwareTotalAvailable === 'number' && ( + + {t('全部硬件总可用资源')}: {hardwareTotalAvailable} + + )} + + + {t('部署位置')} + {loadingReplicas && } + + } + 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 ( + + ); + })} + + + {typeof locationTotalAvailable === 'number' && ( + + {t('全部地区总可用资源')}: {locationTotalAvailable} + + )} + + + + setReplicaCount(value)} + style={{ width: '100%' }} + /> + {maxAvailableReplicas > 0 && ( + + {t('最大可用')}: {maxAvailableReplicas} + + )} + + + setDurationHours(value)} + style={{ width: '100%' }} + /> + + + + {t('流量端口')} + + + + + } + placeholder={DEFAULT_TRAFFIC_PORT} + min={1} + max={65535} + style={{ width: '100%' }} + disabled={imageMode === 'builtin'} + /> + + + +
+ + + + {t('镜像仓库配置')} + + + + + + + + + + + + + + {t('容器启动配置')} + +
+ {t('启动命令 (Entrypoint)')} + {entrypoint.map((cmd, index) => ( +
+ handleArrayFieldChange(index, value, 'entrypoint')} + style={{ flex: 1, marginRight: 8 }} + /> +
+ ))} + +
+ +
+ {t('启动参数 (Args)')} + {args.map((arg, index) => ( +
+ handleArrayFieldChange(index, value, 'args')} + style={{ flex: 1, marginRight: 8 }} + /> +
+ ))} + +
+
+ + + + + {t('环境变量')} + +
+ {t('普通环境变量')} + {envVariables.map((env, index) => ( + + + handleEnvVariableChange(index, 'key', value, 'env')} + /> + + + handleEnvVariableChange(index, 'value', value, 'env')} + /> + + + +
+ +
+ {t('密钥环境变量')} + {secretEnvVariables.map((env, index) => { + const isAutoSecret = + imageMode === 'builtin' && env.key === 'OLLAMA_API_KEY'; + return ( + + + handleEnvVariableChange(index, 'key', value, 'secret')} + disabled={isAutoSecret} + /> + + + handleEnvVariableChange(index, 'value', value, 'secret')} + disabled={isAutoSecret} + /> + + + +
+
+
+
+
+
+
+ +
+ +
+ + {t('价格预估')} + + + + {t('计价币种')} + + + USDC + IOCOIN + + + {currencyLabel} + + +
+ + {priceEstimation ? ( +
+
+
+ + {t('预估总费用')} + +
+ {typeof priceEstimation.estimated_cost === 'number' + ? `${priceEstimation.estimated_cost.toFixed(4)} ${currencyLabel}` + : '--'} +
+
+
+ + {t('小时费率')} + + + {typeof priceEstimation.price_breakdown?.hourly_rate === 'number' + ? `${priceEstimation.price_breakdown.hourly_rate.toFixed(4)} ${currencyLabel}/h` + : '--'} + +
+
+ + {t('计算成本')} + + + {typeof priceEstimation.price_breakdown?.compute_cost === 'number' + ? `${priceEstimation.price_breakdown.compute_cost.toFixed(4)} ${currencyLabel}` + : '--'} + +
+
+ +
+ {priceSummaryItems.map((item) => ( +
+ + {item.label} + + {item.value} +
+ ))} +
+
+ ) : ( + priceUnavailableContent + )} + + {priceEstimation && loadingPrice && ( + + + + {t('价格重新计算中...')} + + + )} +
+
+ +
+
+ ); +}; + +export default CreateDeploymentModal; diff --git a/web/src/components/table/model-deployments/modals/EditDeploymentModal.jsx b/web/src/components/table/model-deployments/modals/EditDeploymentModal.jsx new file mode 100644 index 000000000..4d95b91ac --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + + + {title} + + } + visible={visible} + onCancel={handleClose} + width={isMobile ? '100%' : 600} + bodyStyle={{ padding: 0 }} + maskClosable={false} + closeOnEsc={true} + > +
+ +
+ + + {t('修改部署名称')} + + + + + + + + + {isEdit && ( +
+ {t('部署ID')}: + {editingDeployment.id} +
+ {t('当前状态')}: + + {editingDeployment.status} + +
+ )} +
+
+
+
+ +
+ + + + +
+
+ ); +}; + +export default EditDeploymentModal; diff --git a/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx b/web/src/components/table/model-deployments/modals/ExtendDurationModal.jsx new file mode 100644 index 000000000..3b357bc94 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + + + {t('延长容器时长')} + + } + 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' + > +
+ +
+
+ + {deployment?.container_name || deployment?.deployment_name} + +
+ + ID: {deployment?.id} + +
+
+
+
+ + {resolvedHardwareName} + {gpuCount ? ` x${gpuCount}` : ''} + +
+ + {t('当前剩余')}: {currentRemainingTime} + +
+
+
+ + } + title={t('重要提醒')} + description={ +
+

+ {t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')} +

+

+ {t('延长操作一旦确认无法撤销,费用将立即扣除。')} +

+
+ } + /> + +
(formRef.current = api)} + layout='vertical' + onValueChange={(values) => { + if (values.duration_hours !== undefined) { + const numericValue = Number(values.duration_hours); + setDurationHours(Number.isFinite(numericValue) ? numericValue : 0); + } + }} + > + + + +
+ + {t('快速选择')}: + + + {[1, 2, 6, 12, 24, 48, 72, 168].map((hours) => ( + + ))} + +
+ + + + + + {t('费用预估')} +
+ } + className='border border-green-200' + > + {priceEstimation ? ( +
+
+ {t('延长时长')}: + + {Math.round(durationHours)} {t('小时')} + +
+ +
+ {t('硬件配置')}: + + {resolvedHardwareName} + {gpuCount ? ` x${gpuCount}` : ''} + +
+ + {containers ? ( +
+ {t('容器数量')}: + {containers} +
+ ) : null} + +
+ {t('单GPU小时费率')}: + + {typeof hourlyRate === 'number' + ? `${hourlyRate.toFixed(4)} ${currencyLabel}` + : '--'} + +
+ + {typeof computeCost === 'number' && ( +
+ {t('计算成本')}: + + {computeCost.toFixed(4)} {currencyLabel} + +
+ )} + + + +
+ + {t('预估总费用')}: + + + {typeof estimatedTotalCost === 'number' + ? `${estimatedTotalCost.toFixed(4)} ${currencyLabel}` + : '--'} + +
+ +
+
+ +
+ + {t('延长后总时长')}: {newTotalTime} + +
+ + {t('预估费用仅供参考,实际费用可能略有差异')} + +
+
+
+
+ ) : ( +
+ {costLoading ? ( + + + {t('计算费用中...')} + + ) : priceError ? ( + {priceError} + ) : deploymentDetails ? ( + {t('请输入延长时长')} + ) : ( + {t('加载详情中...')} + )} +
+ )} + + +
+
+ +
+ + {t('确认延长容器时长')} + +
+ + {t('点击"确认延长"后将立即扣除费用并延长容器运行时间')} + +
+
+
+
+ +
+ ); +}; + +export default ExtendDurationModal; diff --git a/web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx b/web/src/components/table/model-deployments/modals/UpdateConfigModal.jsx new file mode 100644 index 000000000..3b21b8b68 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + + + {t('更新容器配置')} + + } + visible={visible} + onCancel={handleCancel} + onOk={handleUpdate} + okText={t('更新配置')} + cancelText={t('取消')} + confirmLoading={loading} + width={700} + className="update-config-modal" + > +
+ {/* Container Info */} + +
+
+ + {deployment?.container_name} + +
+ + ID: {deployment?.id} + +
+
+ {deployment?.status} +
+
+ + {/* Warning Banner */} + } + title={t('重要提醒')} + description={ +
+

{t('更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。')}

+

{t('某些配置更改可能需要几分钟才能生效。')}

+
+ } + /> + +
(formRef.current = api)} + layout="vertical" + > + + {/* Docker Configuration */} + + + {t('Docker 配置')} +
+ } + itemKey="docker" + > +
+ + + + + +
+ + + {/* Network Configuration */} + + + {t('网络配置')} + + } + itemKey="network" + > + + + + {/* Startup Configuration */} + + + {t('启动配置')} + + } + itemKey="startup" + > +
+ + + +
+
+ + {/* Environment Variables */} + + + {t('环境变量')} + {envVars.length} + + } + itemKey="env" + > +
+ {/* Regular Environment Variables */} +
+
+ {t('普通环境变量')} + +
+ + {envVars.map((envVar, index) => ( +
+ updateEnvVar(index, 'key', value)} + style={{ flex: 1 }} + /> + = + updateEnvVar(index, 'value', value)} + style={{ flex: 2 }} + /> +
+ ))} + + {envVars.length === 0 && ( +
+ {t('暂无环境变量')} +
+ )} +
+ + + + {/* Secret Environment Variables */} +
+
+
+ {t('机密环境变量')} + + {t('加密存储')} + +
+ +
+ + {secretEnvVars.map((envVar, index) => ( +
+ updateSecretEnvVar(index, 'key', value)} + style={{ flex: 1 }} + /> + = + updateSecretEnvVar(index, 'value', value)} + style={{ flex: 2 }} + /> +
+ ))} + + {secretEnvVars.length === 0 && ( +
+ {t('暂无机密环境变量')} +
+ )} + + +
+
+
+ + + + {/* Final Warning */} +
+
+ +
+ + {t('配置更新确认')} + +
+ + {t('更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。')} + +
+
+
+
+ +
+ ); +}; + +export default UpdateConfigModal; \ No newline at end of file diff --git a/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx b/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx new file mode 100644 index 000000000..7967e96e5 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + + + {t('容器详情')} + + } + visible={visible} + onCancel={onCancel} + footer={ +
+ + +
+ } + width={800} + className="deployment-details-modal" + > + {loading && !details ? ( +
+ +
+ ) : details ? ( +
+ {/* Basic Info */} + + + {t('基本信息')} +
+ } + className="border-0 shadow-sm" + > + + + {details.deployment_name || details.id} + + + + )} + + + + {ctr.events && ctr.events.length > 0 && ( +
+ + {t('最近事件')} + +
+ {ctr.events.map((event, index) => ( +
+ + {event.time ? timestamp2string(event.time) : '--'} + + + {event.message || '--'} + +
+ ))} +
+
+ )} + + ))} + + )} + + + {/* Location Information */} + {details.locations && details.locations.length > 0 && ( + + + {t('部署位置')} + + } + className="border-0 shadow-sm" + > +
+ {details.locations.map((location) => ( + +
+ 🌍 + {location.name} ({location.iso2}) +
+
+ ))} +
+
+ )} + + {/* Cost Information */} + + + {t('费用信息')} + + } + className="border-0 shadow-sm" + > +
+
+ {t('已支付金额')} + + ${details.amount_paid ? details.amount_paid.toFixed(2) : '0.00'} USDC + +
+ +
+
+ {t('计费开始')}: + {details.started_at ? timestamp2string(details.started_at) : 'N/A'} +
+
+ {t('预计结束')}: + {details.finished_at ? timestamp2string(details.finished_at) : 'N/A'} +
+
+
+
+ + {/* Time Information */} + + + {t('时间信息')} + + } + className="border-0 shadow-sm" + > +
+
+
+ {t('已运行时间')}: + + {Math.floor(details.compute_minutes_served / 60)}h {details.compute_minutes_served % 60}m + +
+
+ {t('剩余时间')}: + + {Math.floor(details.compute_minutes_remaining / 60)}h {details.compute_minutes_remaining % 60}m + +
+
+
+
+ {t('创建时间')}: + {timestamp2string(details.created_at)} +
+
+ {t('最后更新')}: + {timestamp2string(details.updated_at)} +
+
+
+
+ + ) : ( + + )} +
+ ); +}; + +export default ViewDetailsModal; diff --git a/web/src/components/table/model-deployments/modals/ViewLogsModal.jsx b/web/src/components/table/model-deployments/modals/ViewLogsModal.jsx new file mode 100644 index 000000000..18eb5535b --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + + {t('未知状态')} + + ); + } + + 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 ( + + {t(config.label)} + + ); + }; + + 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) => ( +
+ {line} +
+ ); + + return ( + + + {t('容器日志')} + + - {deployment?.container_name || deployment?.id} + + + } + visible={visible} + onCancel={onCancel} + footer={null} + width={1000} + height={700} + className="logs-modal" + style={{ top: 20 }} + > +
+ {/* Controls */} + +
+ + + + } + placeholder={t('搜索日志内容')} + value={searchTerm} + onChange={setSearchTerm} + style={{ width: 200 }} + size="small" + /> + + + + {t('日志流')} + + + STDOUT + STDERR + + + +
+ + {t('自动刷新')} +
+ +
+ + {t('跟随日志')} +
+
+ + + +
+ + {/* Status Info */} + +
+ + + {t('共 {{count}} 条日志', { count: logLines.length })} + + {searchTerm && ( + + {t('(筛选后显示 {{count}} 条)', { count: filteredLogs.length })} + + )} + {autoRefresh && ( + + + {t('自动刷新中')} + + )} + + + + {t('状态')}: {deployment?.status || 'unknown'} + +
+ + {selectedContainerId !== ALL_CONTAINERS && ( + <> + +
+
+ + + {t('容器')} + + + {selectedContainerId} + + {renderContainerStatusTag(containerDetails?.status || currentContainer?.status)} + + + + {containerDetails?.public_url && ( + +
+ + {containerDetailsLoading ? ( +
+ +
+ ) : containerDetails ? ( +
+
+ + {t('硬件')} + + {containerDetails?.brand_name || currentContainer?.brand_name || t('未知品牌')} + {(containerDetails?.hardware || currentContainer?.hardware) ? ` · ${containerDetails?.hardware || currentContainer?.hardware}` : ''} + +
+
+ + {t('GPU/容器')} + {containerDetails?.gpus_per_container ?? currentContainer?.gpus_per_container ?? 0} +
+
+ + {t('创建时间')} + + {containerDetails?.created_at + ? timestamp2string(containerDetails.created_at) + : currentContainer?.created_at + ? timestamp2string(currentContainer.created_at) + : t('未知')} + +
+
+ + {t('运行时长')} + {containerDetails?.uptime_percent ?? currentContainer?.uptime_percent ?? 0}% +
+
+ ) : ( + + {t('暂无容器详情')} + + )} + + {containerDetails?.events && containerDetails.events.length > 0 && ( +
+ + {t('最近事件')} + +
+ {containerDetails.events.slice(0, 5).map((event, index) => ( +
+ + {event.time ? timestamp2string(event.time) : '--'} + + + {event.message} + +
+ ))} +
+
+ )} +
+ + )} +
+ + {/* Log Content */} +
+
+ {loading && logLines.length === 0 ? ( +
+ +
+ ) : filteredLogs.length === 0 ? ( + + ) : ( +
+ {filteredLogs.map((log, index) => renderLogEntry(log, index))} +
+ )} +
+ + {/* Footer status */} + {logLines.length > 0 && ( +
+ + {following ? t('正在跟随最新日志') : t('日志已加载')} + + + {t('最后更新')}: {lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'} + +
+ )} +
+
+
+ ); +}; + +export default ViewLogsModal; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 450c5799b..4be021866 100644 --- a/web/src/helpers/render.jsx +++ b/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 ; case 'models': return ; + case 'deployment': + return ; case 'setting': return ; default: diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index f3df1bcca..415a34a5d 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/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: ( +
+ + +
+ ), + 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, diff --git a/web/src/hooks/common/useSidebar.js b/web/src/hooks/common/useSidebar.js index 76d74ac34..34f8f4aa0 100644 --- a/web/src/hooks/common/useSidebar.js +++ b/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, diff --git a/web/src/hooks/model-deployments/useDeploymentResources.js b/web/src/hooks/model-deployments/useDeploymentResources.js new file mode 100644 index 000000000..277277719 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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; diff --git a/web/src/hooks/model-deployments/useDeploymentsData.jsx b/web/src/hooks/model-deployments/useDeploymentsData.jsx new file mode 100644 index 000000000..f5c49ad61 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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, + }; +}; diff --git a/web/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx b/web/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx new file mode 100644 index 000000000..215430ef2 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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; \ No newline at end of file diff --git a/web/src/hooks/model-deployments/useModelDeploymentSettings.js b/web/src/hooks/model-deployments/useModelDeploymentSettings.js new file mode 100644 index 000000000..fee0f3be6 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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, + }; +}; diff --git a/web/src/pages/ModelDeployment/index.jsx b/web/src/pages/ModelDeployment/index.jsx new file mode 100644 index 000000000..e45da6cf2 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + testConnection(apiKey)} + > +
+ +
+
+ ); +}; + +export default ModelDeploymentPage; diff --git a/web/src/pages/Setting/Model/SettingModelDeployment.jsx b/web/src/pages/Setting/Model/SettingModelDeployment.jsx new file mode 100644 index 000000000..28bc1db55 --- /dev/null +++ b/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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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 ( + <> + +
(refForm.current = formAPI)} + style={{ marginBottom: 15 }} + > + + {t('模型部署设置')} + + } + > + {/**/} + {/* {t('配置模型部署服务提供商的API密钥和启用状态')}*/} + {/**/} + + + + io.net + + } + bodyStyle={{ padding: '20px' }} + style={{ marginBottom: '16px' }} + > + + +
+ + setInputs({ + ...inputs, + 'model_deployment.ionet.enabled': value, + }) + } + extraText={t('启用后可接入 io.net GPU 资源')} + /> + + setInputs({ + ...inputs, + 'model_deployment.ionet.api_key': value, + }) + } + disabled={!inputs['model_deployment.ionet.enabled']} + extraText={t('请使用 Project 为 io.cloud 的密钥')} + mode="password" + /> +
+ +
+
+ + +
+
+ + {t('获取 io.net API Key')} + +
    +
  • {t('访问 io.net 控制台的 API Keys 页面')}
  • +
  • {t('创建或选择密钥时,将 Project 设置为 io.cloud')}
  • +
  • {t('复制生成的密钥并粘贴到此处')}
  • +
+
+ +
+ +
+
+ + + + +
+
+
+ + ); +} diff --git a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx b/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx index a46893b81..5f351c105 100644 --- a/web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx +++ b/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('兑换码管理'), diff --git a/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx b/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx index 939e76cfc..3ea18521f 100644 --- a/web/src/pages/Setting/Personal/SettingsSidebarModulesUser.jsx +++ b/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'), diff --git a/web/src/pages/Setting/index.jsx b/web/src/pages/Setting/index.jsx index 1dc4fd828..058b355e9 100644 --- a/web/src/pages/Setting/index.jsx +++ b/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: , itemKey: 'models', }); + panes.push({ + tab: ( + + + {t('模型部署设置')} + + ), + content: , + itemKey: 'model-deployment', + }); panes.push({ tab: (