mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-29 22:09:25 +00:00
feat: ionet integrate (#2105)
* wip ionet integrate * wip ionet integrate * wip ionet integrate * ollama wip * wip * feat: ionet integration & ollama manage * fix merge conflict * wip * fix: test conn cors * wip * fix ionet * fix ionet * wip * fix model select * refactor: Remove `pkg/ionet` test files and update related Go source and web UI model deployment components. * feat: Enhance model deployment UI with styling improvements, updated text, and a new description component. * Revert "feat: Enhance model deployment UI with styling improvements, updated text, and a new description component." This reverts commit 8b75cb5bf0d1a534b339df8c033be9a6c7df7964.
This commit is contained in:
@@ -6,4 +6,5 @@
|
||||
Makefile
|
||||
docs
|
||||
.eslintcache
|
||||
.gocache
|
||||
.gocache
|
||||
/web/node_modules
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,4 +23,6 @@ web/bun.lock
|
||||
electron/node_modules
|
||||
electron/dist
|
||||
data/
|
||||
.gomodcache/
|
||||
.gomodcache/
|
||||
.gocache-temp
|
||||
.gopath
|
||||
|
||||
@@ -11,16 +11,18 @@ import (
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/relay/channel/ollama"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type OpenAIModel struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Permission []struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
@@ -207,6 +209,57 @@ func FetchUpstreamModels(c *gin.Context) {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
|
||||
// 对于 Ollama 渠道,使用特殊处理
|
||||
if channel.Type == constant.ChannelTypeOllama {
|
||||
key := strings.Split(channel.Key, "\n")[0]
|
||||
models, err := ollama.FetchOllamaModels(baseURL, key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result := OpenAIModelsResponse{
|
||||
Data: make([]OpenAIModel, 0, len(models)),
|
||||
}
|
||||
|
||||
for _, modelInfo := range models {
|
||||
metadata := map[string]any{}
|
||||
if modelInfo.Size > 0 {
|
||||
metadata["size"] = modelInfo.Size
|
||||
}
|
||||
if modelInfo.Digest != "" {
|
||||
metadata["digest"] = modelInfo.Digest
|
||||
}
|
||||
if modelInfo.ModifiedAt != "" {
|
||||
metadata["modified_at"] = modelInfo.ModifiedAt
|
||||
}
|
||||
details := modelInfo.Details
|
||||
if details.ParentModel != "" || details.Format != "" || details.Family != "" || len(details.Families) > 0 || details.ParameterSize != "" || details.QuantizationLevel != "" {
|
||||
metadata["details"] = modelInfo.Details
|
||||
}
|
||||
if len(metadata) == 0 {
|
||||
metadata = nil
|
||||
}
|
||||
|
||||
result.Data = append(result.Data, OpenAIModel{
|
||||
ID: modelInfo.Name,
|
||||
Object: "model",
|
||||
Created: 0,
|
||||
OwnedBy: "ollama",
|
||||
Metadata: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": result.Data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var url string
|
||||
switch channel.Type {
|
||||
case constant.ChannelTypeGemini:
|
||||
@@ -975,6 +1028,32 @@ func FetchModels(c *gin.Context) {
|
||||
baseURL = constant.ChannelBaseURLs[req.Type]
|
||||
}
|
||||
|
||||
// remove line breaks and extra spaces.
|
||||
key := strings.TrimSpace(req.Key)
|
||||
key = strings.Split(key, "\n")[0]
|
||||
|
||||
if req.Type == constant.ChannelTypeOllama {
|
||||
models, err := ollama.FetchOllamaModels(baseURL, key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(models))
|
||||
for _, modelInfo := range models {
|
||||
names = append(names, modelInfo.Name)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": names,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
url := fmt.Sprintf("%s/v1/models", baseURL)
|
||||
|
||||
@@ -987,10 +1066,6 @@ func FetchModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// remove line breaks and extra spaces.
|
||||
key := strings.TrimSpace(req.Key)
|
||||
// If the key contains a line break, only take the first part.
|
||||
key = strings.Split(key, "\n")[0]
|
||||
request.Header.Set("Authorization", "Bearer "+key)
|
||||
|
||||
response, err := client.Do(request)
|
||||
@@ -1640,3 +1715,262 @@ func ManageMultiKeys(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// OllamaPullModel 拉取 Ollama 模型
|
||||
func OllamaPullModel(c *gin.Context) {
|
||||
var req struct {
|
||||
ChannelID int `json:"channel_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request parameters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.ChannelID == 0 || req.ModelName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Channel ID and model name are required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取渠道信息
|
||||
channel, err := model.GetChannelById(req.ChannelID, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"message": "Channel not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是 Ollama 渠道
|
||||
if channel.Type != constant.ChannelTypeOllama {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "This operation is only supported for Ollama channels",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := constant.ChannelBaseURLs[channel.Type]
|
||||
if channel.GetBaseURL() != "" {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
|
||||
key := strings.Split(channel.Key, "\n")[0]
|
||||
err = ollama.PullOllamaModel(baseURL, key, req.ModelName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("Failed to pull model: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("Model %s pulled successfully", req.ModelName),
|
||||
})
|
||||
}
|
||||
|
||||
// OllamaPullModelStream 流式拉取 Ollama 模型
|
||||
func OllamaPullModelStream(c *gin.Context) {
|
||||
var req struct {
|
||||
ChannelID int `json:"channel_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request parameters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.ChannelID == 0 || req.ModelName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Channel ID and model name are required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取渠道信息
|
||||
channel, err := model.GetChannelById(req.ChannelID, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"message": "Channel not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是 Ollama 渠道
|
||||
if channel.Type != constant.ChannelTypeOllama {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "This operation is only supported for Ollama channels",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := constant.ChannelBaseURLs[channel.Type]
|
||||
if channel.GetBaseURL() != "" {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
|
||||
// 设置 SSE 头部
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
key := strings.Split(channel.Key, "\n")[0]
|
||||
|
||||
// 创建进度回调函数
|
||||
progressCallback := func(progress ollama.OllamaPullResponse) {
|
||||
data, _ := json.Marshal(progress)
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", string(data))
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// 执行拉取
|
||||
err = ollama.PullOllamaModelStream(baseURL, key, req.ModelName, progressCallback)
|
||||
|
||||
if err != nil {
|
||||
errorData, _ := json.Marshal(gin.H{
|
||||
"error": err.Error(),
|
||||
})
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", string(errorData))
|
||||
} else {
|
||||
successData, _ := json.Marshal(gin.H{
|
||||
"message": fmt.Sprintf("Model %s pulled successfully", req.ModelName),
|
||||
})
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", string(successData))
|
||||
}
|
||||
|
||||
// 发送结束标志
|
||||
fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
// OllamaDeleteModel 删除 Ollama 模型
|
||||
func OllamaDeleteModel(c *gin.Context) {
|
||||
var req struct {
|
||||
ChannelID int `json:"channel_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid request parameters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.ChannelID == 0 || req.ModelName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Channel ID and model name are required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取渠道信息
|
||||
channel, err := model.GetChannelById(req.ChannelID, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"message": "Channel not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否是 Ollama 渠道
|
||||
if channel.Type != constant.ChannelTypeOllama {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "This operation is only supported for Ollama channels",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := constant.ChannelBaseURLs[channel.Type]
|
||||
if channel.GetBaseURL() != "" {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
|
||||
key := strings.Split(channel.Key, "\n")[0]
|
||||
err = ollama.DeleteOllamaModel(baseURL, key, req.ModelName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("Failed to delete model: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("Model %s deleted successfully", req.ModelName),
|
||||
})
|
||||
}
|
||||
|
||||
// OllamaVersion 获取 Ollama 服务版本信息
|
||||
func OllamaVersion(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "Invalid channel id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
channel, err := model.GetChannelById(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"success": false,
|
||||
"message": "Channel not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if channel.Type != constant.ChannelTypeOllama {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "This operation is only supported for Ollama channels",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := constant.ChannelBaseURLs[channel.Type]
|
||||
if channel.GetBaseURL() != "" {
|
||||
baseURL = channel.GetBaseURL()
|
||||
}
|
||||
|
||||
key := strings.Split(channel.Key, "\n")[0]
|
||||
version, err := ollama.FetchOllamaVersion(baseURL, key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("获取Ollama版本失败: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"version": version,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
781
controller/deployment.go
Normal file
781
controller/deployment.go
Normal file
@@ -0,0 +1,781 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/pkg/ionet"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func getIoAPIKey(c *gin.Context) (string, bool) {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
enabled := common.OptionMap["model_deployment.ionet.enabled"] == "true"
|
||||
apiKey := common.OptionMap["model_deployment.ionet.api_key"]
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
if !enabled || strings.TrimSpace(apiKey) == "" {
|
||||
common.ApiErrorMsg(c, "io.net model deployment is not enabled or api key missing")
|
||||
return "", false
|
||||
}
|
||||
return apiKey, true
|
||||
}
|
||||
|
||||
func getIoClient(c *gin.Context) (*ionet.Client, bool) {
|
||||
apiKey, ok := getIoAPIKey(c)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return ionet.NewClient(apiKey), true
|
||||
}
|
||||
|
||||
func getIoEnterpriseClient(c *gin.Context) (*ionet.Client, bool) {
|
||||
apiKey, ok := getIoAPIKey(c)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return ionet.NewEnterpriseClient(apiKey), true
|
||||
}
|
||||
|
||||
func TestIoNetConnection(c *gin.Context) {
|
||||
var req struct {
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "invalid request payload")
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(req.APIKey)
|
||||
if apiKey == "" {
|
||||
common.ApiErrorMsg(c, "api_key is required")
|
||||
return
|
||||
}
|
||||
|
||||
client := ionet.NewEnterpriseClient(apiKey)
|
||||
result, err := client.GetMaxGPUsPerContainer()
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*ionet.APIError); ok {
|
||||
message := strings.TrimSpace(apiErr.Message)
|
||||
if message == "" {
|
||||
message = "failed to validate api key"
|
||||
}
|
||||
common.ApiErrorMsg(c, message)
|
||||
return
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
totalHardware := 0
|
||||
totalAvailable := 0
|
||||
if result != nil {
|
||||
totalHardware = len(result.Hardware)
|
||||
totalAvailable = result.Total
|
||||
if totalAvailable == 0 {
|
||||
for _, hw := range result.Hardware {
|
||||
totalAvailable += hw.Available
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"hardware_count": totalHardware,
|
||||
"total_available": totalAvailable,
|
||||
})
|
||||
}
|
||||
|
||||
func requireDeploymentID(c *gin.Context) (string, bool) {
|
||||
deploymentID := strings.TrimSpace(c.Param("id"))
|
||||
if deploymentID == "" {
|
||||
common.ApiErrorMsg(c, "deployment ID is required")
|
||||
return "", false
|
||||
}
|
||||
return deploymentID, true
|
||||
}
|
||||
|
||||
func requireContainerID(c *gin.Context) (string, bool) {
|
||||
containerID := strings.TrimSpace(c.Param("container_id"))
|
||||
if containerID == "" {
|
||||
common.ApiErrorMsg(c, "container ID is required")
|
||||
return "", false
|
||||
}
|
||||
return containerID, true
|
||||
}
|
||||
|
||||
func mapIoNetDeployment(d ionet.Deployment) map[string]interface{} {
|
||||
var created int64
|
||||
if d.CreatedAt.IsZero() {
|
||||
created = time.Now().Unix()
|
||||
} else {
|
||||
created = d.CreatedAt.Unix()
|
||||
}
|
||||
|
||||
timeRemainingHours := d.ComputeMinutesRemaining / 60
|
||||
timeRemainingMins := d.ComputeMinutesRemaining % 60
|
||||
var timeRemaining string
|
||||
if timeRemainingHours > 0 {
|
||||
timeRemaining = fmt.Sprintf("%d hour %d minutes", timeRemainingHours, timeRemainingMins)
|
||||
} else if timeRemainingMins > 0 {
|
||||
timeRemaining = fmt.Sprintf("%d minutes", timeRemainingMins)
|
||||
} else {
|
||||
timeRemaining = "completed"
|
||||
}
|
||||
|
||||
hardwareInfo := fmt.Sprintf("%s %s x%d", d.BrandName, d.HardwareName, d.HardwareQuantity)
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": d.ID,
|
||||
"deployment_name": d.Name,
|
||||
"container_name": d.Name,
|
||||
"status": strings.ToLower(d.Status),
|
||||
"type": "Container",
|
||||
"time_remaining": timeRemaining,
|
||||
"time_remaining_minutes": d.ComputeMinutesRemaining,
|
||||
"hardware_info": hardwareInfo,
|
||||
"hardware_name": d.HardwareName,
|
||||
"brand_name": d.BrandName,
|
||||
"hardware_quantity": d.HardwareQuantity,
|
||||
"completed_percent": d.CompletedPercent,
|
||||
"compute_minutes_served": d.ComputeMinutesServed,
|
||||
"compute_minutes_remaining": d.ComputeMinutesRemaining,
|
||||
"created_at": created,
|
||||
"updated_at": created,
|
||||
"model_name": "",
|
||||
"model_version": "",
|
||||
"instance_count": d.HardwareQuantity,
|
||||
"resource_config": map[string]interface{}{
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"gpu": strconv.Itoa(d.HardwareQuantity),
|
||||
},
|
||||
"description": "",
|
||||
"provider": "io.net",
|
||||
}
|
||||
}
|
||||
|
||||
func computeStatusCounts(total int, deployments []ionet.Deployment) map[string]int64 {
|
||||
counts := map[string]int64{
|
||||
"all": int64(total),
|
||||
}
|
||||
|
||||
for _, status := range []string{"running", "completed", "failed", "deployment requested", "termination requested", "destroyed"} {
|
||||
counts[status] = 0
|
||||
}
|
||||
|
||||
for _, d := range deployments {
|
||||
status := strings.ToLower(strings.TrimSpace(d.Status))
|
||||
counts[status] = counts[status] + 1
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
func GetAllDeployments(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Query("status")
|
||||
opts := &ionet.ListDeploymentsOptions{
|
||||
Status: strings.ToLower(strings.TrimSpace(status)),
|
||||
Page: pageInfo.GetPage(),
|
||||
PageSize: pageInfo.GetPageSize(),
|
||||
SortBy: "created_at",
|
||||
SortOrder: "desc",
|
||||
}
|
||||
|
||||
dl, err := client.ListDeployments(opts)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(dl.Deployments))
|
||||
for _, d := range dl.Deployments {
|
||||
items = append(items, mapIoNetDeployment(d))
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"page": pageInfo.GetPage(),
|
||||
"page_size": pageInfo.GetPageSize(),
|
||||
"total": dl.Total,
|
||||
"items": items,
|
||||
"status_counts": computeStatusCounts(dl.Total, dl.Deployments),
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func SearchDeployments(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
|
||||
dl, err := client.ListDeployments(&ionet.ListDeploymentsOptions{
|
||||
Status: status,
|
||||
Page: pageInfo.GetPage(),
|
||||
PageSize: pageInfo.GetPageSize(),
|
||||
SortBy: "created_at",
|
||||
SortOrder: "desc",
|
||||
})
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]ionet.Deployment, 0, len(dl.Deployments))
|
||||
if keyword == "" {
|
||||
filtered = dl.Deployments
|
||||
} else {
|
||||
kw := strings.ToLower(keyword)
|
||||
for _, d := range dl.Deployments {
|
||||
if strings.Contains(strings.ToLower(d.Name), kw) {
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(filtered))
|
||||
for _, d := range filtered {
|
||||
items = append(items, mapIoNetDeployment(d))
|
||||
}
|
||||
|
||||
total := dl.Total
|
||||
if keyword != "" {
|
||||
total = len(filtered)
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"page": pageInfo.GetPage(),
|
||||
"page_size": pageInfo.GetPageSize(),
|
||||
"total": total,
|
||||
"items": items,
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func GetDeployment(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deploymentID, ok := requireDeploymentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
details, err := client.GetDeployment(deploymentID)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"id": details.ID,
|
||||
"deployment_name": details.ID,
|
||||
"model_name": "",
|
||||
"model_version": "",
|
||||
"status": strings.ToLower(details.Status),
|
||||
"instance_count": details.TotalContainers,
|
||||
"hardware_id": details.HardwareID,
|
||||
"resource_config": map[string]interface{}{
|
||||
"cpu": "",
|
||||
"memory": "",
|
||||
"gpu": strconv.Itoa(details.TotalGPUs),
|
||||
},
|
||||
"created_at": details.CreatedAt.Unix(),
|
||||
"updated_at": details.CreatedAt.Unix(),
|
||||
"description": "",
|
||||
"amount_paid": details.AmountPaid,
|
||||
"completed_percent": details.CompletedPercent,
|
||||
"gpus_per_container": details.GPUsPerContainer,
|
||||
"total_gpus": details.TotalGPUs,
|
||||
"total_containers": details.TotalContainers,
|
||||
"hardware_name": details.HardwareName,
|
||||
"brand_name": details.BrandName,
|
||||
"compute_minutes_served": details.ComputeMinutesServed,
|
||||
"compute_minutes_remaining": details.ComputeMinutesRemaining,
|
||||
"locations": details.Locations,
|
||||
"container_config": details.ContainerConfig,
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func UpdateDeploymentName(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deploymentID, ok := requireDeploymentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
updateReq := &ionet.UpdateClusterNameRequest{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
}
|
||||
|
||||
if updateReq.Name == "" {
|
||||
common.ApiErrorMsg(c, "deployment name cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
available, err := client.CheckClusterNameAvailability(updateReq.Name)
|
||||
if err != nil {
|
||||
common.ApiError(c, fmt.Errorf("failed to check name availability: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if !available {
|
||||
common.ApiErrorMsg(c, "deployment name is not available, please choose a different name")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.UpdateClusterName(deploymentID, updateReq)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"status": resp.Status,
|
||||
"message": resp.Message,
|
||||
"id": deploymentID,
|
||||
"name": updateReq.Name,
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func UpdateDeployment(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deploymentID, ok := requireDeploymentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req ionet.UpdateDeploymentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.UpdateDeployment(deploymentID, &req)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"status": resp.Status,
|
||||
"deployment_id": resp.DeploymentID,
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func ExtendDeployment(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deploymentID, ok := requireDeploymentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req ionet.ExtendDurationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
details, err := client.ExtendDeployment(deploymentID, &req)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data := mapIoNetDeployment(ionet.Deployment{
|
||||
ID: details.ID,
|
||||
Status: details.Status,
|
||||
Name: deploymentID,
|
||||
CompletedPercent: float64(details.CompletedPercent),
|
||||
HardwareQuantity: details.TotalGPUs,
|
||||
BrandName: details.BrandName,
|
||||
HardwareName: details.HardwareName,
|
||||
ComputeMinutesServed: details.ComputeMinutesServed,
|
||||
ComputeMinutesRemaining: details.ComputeMinutesRemaining,
|
||||
CreatedAt: details.CreatedAt,
|
||||
})
|
||||
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func DeleteDeployment(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deploymentID, ok := requireDeploymentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.DeleteDeployment(deploymentID)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"status": resp.Status,
|
||||
"deployment_id": resp.DeploymentID,
|
||||
"message": "Deployment termination requested successfully",
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func CreateDeployment(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req ionet.DeploymentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.DeployContainer(&req)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"deployment_id": resp.DeploymentID,
|
||||
"status": resp.Status,
|
||||
"message": "Deployment created successfully",
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func GetHardwareTypes(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
hardwareTypes, totalAvailable, err := client.ListHardwareTypes()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"hardware_types": hardwareTypes,
|
||||
"total": len(hardwareTypes),
|
||||
"total_available": totalAvailable,
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func GetLocations(c *gin.Context) {
|
||||
client, ok := getIoClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
locationsResp, err := client.ListLocations()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
total := locationsResp.Total
|
||||
if total == 0 {
|
||||
total = len(locationsResp.Locations)
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"locations": locationsResp.Locations,
|
||||
"total": total,
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func GetAvailableReplicas(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
hardwareIDStr := c.Query("hardware_id")
|
||||
gpuCountStr := c.Query("gpu_count")
|
||||
|
||||
if hardwareIDStr == "" {
|
||||
common.ApiErrorMsg(c, "hardware_id parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
hardwareID, err := strconv.Atoi(hardwareIDStr)
|
||||
if err != nil || hardwareID <= 0 {
|
||||
common.ApiErrorMsg(c, "invalid hardware_id parameter")
|
||||
return
|
||||
}
|
||||
|
||||
gpuCount := 1
|
||||
if gpuCountStr != "" {
|
||||
if parsed, err := strconv.Atoi(gpuCountStr); err == nil && parsed > 0 {
|
||||
gpuCount = parsed
|
||||
}
|
||||
}
|
||||
|
||||
replicas, err := client.GetAvailableReplicas(hardwareID, gpuCount)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, replicas)
|
||||
}
|
||||
|
||||
func GetPriceEstimation(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req ionet.PriceEstimationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
priceResp, err := client.GetPriceEstimation(&req)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, priceResp)
|
||||
}
|
||||
|
||||
func CheckClusterNameAvailability(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
clusterName := strings.TrimSpace(c.Query("name"))
|
||||
if clusterName == "" {
|
||||
common.ApiErrorMsg(c, "name parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
available, err := client.CheckClusterNameAvailability(clusterName)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"available": available,
|
||||
"name": clusterName,
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
|
||||
func GetDeploymentLogs(c *gin.Context) {
|
||||
client, ok := getIoClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deploymentID, ok := requireDeploymentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
containerID := c.Query("container_id")
|
||||
if containerID == "" {
|
||||
common.ApiErrorMsg(c, "container_id parameter is required")
|
||||
return
|
||||
}
|
||||
level := c.Query("level")
|
||||
stream := c.Query("stream")
|
||||
cursor := c.Query("cursor")
|
||||
limitStr := c.Query("limit")
|
||||
follow := c.Query("follow") == "true"
|
||||
|
||||
var limit int = 100
|
||||
if limitStr != "" {
|
||||
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
||||
limit = parsedLimit
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
opts := &ionet.GetLogsOptions{
|
||||
Level: level,
|
||||
Stream: stream,
|
||||
Limit: limit,
|
||||
Cursor: cursor,
|
||||
Follow: follow,
|
||||
}
|
||||
|
||||
if startTime := c.Query("start_time"); startTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startTime); err == nil {
|
||||
opts.StartTime = &t
|
||||
}
|
||||
}
|
||||
if endTime := c.Query("end_time"); endTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, endTime); err == nil {
|
||||
opts.EndTime = &t
|
||||
}
|
||||
}
|
||||
|
||||
rawLogs, err := client.GetContainerLogsRaw(deploymentID, containerID, opts)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, rawLogs)
|
||||
}
|
||||
|
||||
func ListDeploymentContainers(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deploymentID, ok := requireDeploymentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
containers, err := client.ListContainers(deploymentID)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, 0)
|
||||
if containers != nil {
|
||||
items = make([]map[string]interface{}, 0, len(containers.Workers))
|
||||
for _, ctr := range containers.Workers {
|
||||
events := make([]map[string]interface{}, 0, len(ctr.ContainerEvents))
|
||||
for _, event := range ctr.ContainerEvents {
|
||||
events = append(events, map[string]interface{}{
|
||||
"time": event.Time.Unix(),
|
||||
"message": event.Message,
|
||||
})
|
||||
}
|
||||
|
||||
items = append(items, map[string]interface{}{
|
||||
"container_id": ctr.ContainerID,
|
||||
"device_id": ctr.DeviceID,
|
||||
"status": strings.ToLower(strings.TrimSpace(ctr.Status)),
|
||||
"hardware": ctr.Hardware,
|
||||
"brand_name": ctr.BrandName,
|
||||
"created_at": ctr.CreatedAt.Unix(),
|
||||
"uptime_percent": ctr.UptimePercent,
|
||||
"gpus_per_container": ctr.GPUsPerContainer,
|
||||
"public_url": ctr.PublicURL,
|
||||
"events": events,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"total": 0,
|
||||
"containers": items,
|
||||
}
|
||||
if containers != nil {
|
||||
response["total"] = containers.Total
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, response)
|
||||
}
|
||||
|
||||
func GetContainerDetails(c *gin.Context) {
|
||||
client, ok := getIoEnterpriseClient(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deploymentID, ok := requireDeploymentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
containerID, ok := requireContainerID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
details, err := client.GetContainerDetails(deploymentID, containerID)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if details == nil {
|
||||
common.ApiErrorMsg(c, "container details not found")
|
||||
return
|
||||
}
|
||||
|
||||
events := make([]map[string]interface{}, 0, len(details.ContainerEvents))
|
||||
for _, event := range details.ContainerEvents {
|
||||
events = append(events, map[string]interface{}{
|
||||
"time": event.Time.Unix(),
|
||||
"message": event.Message,
|
||||
})
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"deployment_id": deploymentID,
|
||||
"container_id": details.ContainerID,
|
||||
"device_id": details.DeviceID,
|
||||
"status": strings.ToLower(strings.TrimSpace(details.Status)),
|
||||
"hardware": details.Hardware,
|
||||
"brand_name": details.BrandName,
|
||||
"created_at": details.CreatedAt.Unix(),
|
||||
"uptime_percent": details.UptimePercent,
|
||||
"gpus_per_container": details.GPUsPerContainer,
|
||||
"public_url": details.PublicURL,
|
||||
"events": events,
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
7
docs/ionet-client.md
Normal file
7
docs/ionet-client.md
Normal file
@@ -0,0 +1,7 @@
|
||||
Request URL
|
||||
https://api.io.solutions/v1/io-cloud/clusters/654fc0a9-0d4a-4db4-9b95-3f56189348a2/update-name
|
||||
Request Method
|
||||
PUT
|
||||
|
||||
{"status":"succeeded","message":"Cluster name updated successfully"}
|
||||
|
||||
4
go.mod
4
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
|
||||
|
||||
@@ -248,26 +248,26 @@ func InitLogDB() (err error) {
|
||||
}
|
||||
|
||||
func migrateDB() error {
|
||||
err := DB.AutoMigrate(
|
||||
&Channel{},
|
||||
&Token{},
|
||||
&User{},
|
||||
&PasskeyCredential{},
|
||||
err := DB.AutoMigrate(
|
||||
&Channel{},
|
||||
&Token{},
|
||||
&User{},
|
||||
&PasskeyCredential{},
|
||||
&Option{},
|
||||
&Redemption{},
|
||||
&Ability{},
|
||||
&Log{},
|
||||
&Midjourney{},
|
||||
&TopUp{},
|
||||
&QuotaData{},
|
||||
&Task{},
|
||||
&Model{},
|
||||
&Vendor{},
|
||||
&PrefillGroup{},
|
||||
&Setup{},
|
||||
&TwoFA{},
|
||||
&TwoFABackupCode{},
|
||||
)
|
||||
&Redemption{},
|
||||
&Ability{},
|
||||
&Log{},
|
||||
&Midjourney{},
|
||||
&TopUp{},
|
||||
&QuotaData{},
|
||||
&Task{},
|
||||
&Model{},
|
||||
&Vendor{},
|
||||
&PrefillGroup{},
|
||||
&Setup{},
|
||||
&TwoFA{},
|
||||
&TwoFABackupCode{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -278,29 +278,29 @@ func migrateDBFast() error {
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
migrations := []struct {
|
||||
model interface{}
|
||||
name string
|
||||
}{
|
||||
{&Channel{}, "Channel"},
|
||||
{&Token{}, "Token"},
|
||||
{&User{}, "User"},
|
||||
{&PasskeyCredential{}, "PasskeyCredential"},
|
||||
migrations := []struct {
|
||||
model interface{}
|
||||
name string
|
||||
}{
|
||||
{&Channel{}, "Channel"},
|
||||
{&Token{}, "Token"},
|
||||
{&User{}, "User"},
|
||||
{&PasskeyCredential{}, "PasskeyCredential"},
|
||||
{&Option{}, "Option"},
|
||||
{&Redemption{}, "Redemption"},
|
||||
{&Ability{}, "Ability"},
|
||||
{&Log{}, "Log"},
|
||||
{&Midjourney{}, "Midjourney"},
|
||||
{&TopUp{}, "TopUp"},
|
||||
{&QuotaData{}, "QuotaData"},
|
||||
{&Task{}, "Task"},
|
||||
{&Model{}, "Model"},
|
||||
{&Vendor{}, "Vendor"},
|
||||
{&PrefillGroup{}, "PrefillGroup"},
|
||||
{&Setup{}, "Setup"},
|
||||
{&TwoFA{}, "TwoFA"},
|
||||
{&TwoFABackupCode{}, "TwoFABackupCode"},
|
||||
}
|
||||
{&Redemption{}, "Redemption"},
|
||||
{&Ability{}, "Ability"},
|
||||
{&Log{}, "Log"},
|
||||
{&Midjourney{}, "Midjourney"},
|
||||
{&TopUp{}, "TopUp"},
|
||||
{&QuotaData{}, "QuotaData"},
|
||||
{&Task{}, "Task"},
|
||||
{&Model{}, "Model"},
|
||||
{&Vendor{}, "Vendor"},
|
||||
{&PrefillGroup{}, "PrefillGroup"},
|
||||
{&Setup{}, "Setup"},
|
||||
{&TwoFA{}, "TwoFA"},
|
||||
{&TwoFABackupCode{}, "TwoFABackupCode"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
errChan := make(chan error, len(migrations))
|
||||
|
||||
|
||||
219
pkg/ionet/client.go
Normal file
219
pkg/ionet/client.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package ionet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultEnterpriseBaseURL = "https://api.io.solutions/enterprise/v1/io-cloud/caas"
|
||||
DefaultBaseURL = "https://api.io.solutions/v1/io-cloud/caas"
|
||||
DefaultTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// DefaultHTTPClient is the default HTTP client implementation
|
||||
type DefaultHTTPClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewDefaultHTTPClient creates a new default HTTP client
|
||||
func NewDefaultHTTPClient(timeout time.Duration) *DefaultHTTPClient {
|
||||
return &DefaultHTTPClient{
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Do executes an HTTP request
|
||||
func (c *DefaultHTTPClient) Do(req *HTTPRequest) (*HTTPResponse, error) {
|
||||
httpReq, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(req.Body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
for key, value := range req.Headers {
|
||||
httpReq.Header.Set(key, value)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
var body bytes.Buffer
|
||||
_, err = body.ReadFrom(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Convert headers
|
||||
headers := make(map[string]string)
|
||||
for key, values := range resp.Header {
|
||||
if len(values) > 0 {
|
||||
headers[key] = values[0]
|
||||
}
|
||||
}
|
||||
|
||||
return &HTTPResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
Headers: headers,
|
||||
Body: body.Bytes(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewEnterpriseClient creates a new IO.NET API client targeting the enterprise API base URL.
|
||||
func NewEnterpriseClient(apiKey string) *Client {
|
||||
return NewClientWithConfig(apiKey, DefaultEnterpriseBaseURL, nil)
|
||||
}
|
||||
|
||||
// NewClient creates a new IO.NET API client targeting the public API base URL.
|
||||
func NewClient(apiKey string) *Client {
|
||||
return NewClientWithConfig(apiKey, DefaultBaseURL, nil)
|
||||
}
|
||||
|
||||
// NewClientWithConfig creates a new IO.NET API client with custom configuration
|
||||
func NewClientWithConfig(apiKey, baseURL string, httpClient HTTPClient) *Client {
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
if httpClient == nil {
|
||||
httpClient = NewDefaultHTTPClient(DefaultTimeout)
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
HTTPClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// makeRequest performs an HTTP request and handles common response processing
|
||||
func (c *Client) makeRequest(method, endpoint string, body interface{}) (*HTTPResponse, error) {
|
||||
var reqBody []byte
|
||||
var err error
|
||||
|
||||
if body != nil {
|
||||
reqBody, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"X-API-KEY": c.APIKey,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
req := &HTTPRequest{
|
||||
Method: method,
|
||||
URL: c.BaseURL + endpoint,
|
||||
Headers: headers,
|
||||
Body: reqBody,
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
// Handle API errors
|
||||
if resp.StatusCode >= 400 {
|
||||
var apiErr APIError
|
||||
if len(resp.Body) > 0 {
|
||||
// Try to parse the actual error format: {"detail": "message"}
|
||||
var errorResp struct {
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &errorResp); err == nil && errorResp.Detail != "" {
|
||||
apiErr = APIError{
|
||||
Code: resp.StatusCode,
|
||||
Message: errorResp.Detail,
|
||||
}
|
||||
} else {
|
||||
// Fallback: use raw body as details
|
||||
apiErr = APIError{
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("API request failed with status %d", resp.StatusCode),
|
||||
Details: string(resp.Body),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
apiErr = APIError{
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("API request failed with status %d", resp.StatusCode),
|
||||
}
|
||||
}
|
||||
return nil, &apiErr
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// buildQueryParams builds query parameters for GET requests
|
||||
func buildQueryParams(params map[string]interface{}) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
for key, value := range params {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if v != "" {
|
||||
values.Add(key, v)
|
||||
}
|
||||
case int:
|
||||
if v != 0 {
|
||||
values.Add(key, strconv.Itoa(v))
|
||||
}
|
||||
case int64:
|
||||
if v != 0 {
|
||||
values.Add(key, strconv.FormatInt(v, 10))
|
||||
}
|
||||
case float64:
|
||||
if v != 0 {
|
||||
values.Add(key, strconv.FormatFloat(v, 'f', -1, 64))
|
||||
}
|
||||
case bool:
|
||||
values.Add(key, strconv.FormatBool(v))
|
||||
case time.Time:
|
||||
if !v.IsZero() {
|
||||
values.Add(key, v.Format(time.RFC3339))
|
||||
}
|
||||
case *time.Time:
|
||||
if v != nil && !v.IsZero() {
|
||||
values.Add(key, v.Format(time.RFC3339))
|
||||
}
|
||||
case []int:
|
||||
if len(v) > 0 {
|
||||
if encoded, err := json.Marshal(v); err == nil {
|
||||
values.Add(key, string(encoded))
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
if len(v) > 0 {
|
||||
if encoded, err := json.Marshal(v); err == nil {
|
||||
values.Add(key, string(encoded))
|
||||
}
|
||||
}
|
||||
default:
|
||||
values.Add(key, fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
if len(values) > 0 {
|
||||
return "?" + values.Encode()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
302
pkg/ionet/container.go
Normal file
302
pkg/ionet/container.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package ionet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// ListContainers retrieves all containers for a specific deployment
|
||||
func (c *Client) ListContainers(deploymentID string) (*ContainerList, error) {
|
||||
if deploymentID == "" {
|
||||
return nil, fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s/containers", deploymentID)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
var containerList ContainerList
|
||||
if err := decodeDataWithFlexibleTimes(resp.Body, &containerList); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse containers list: %w", err)
|
||||
}
|
||||
|
||||
return &containerList, nil
|
||||
}
|
||||
|
||||
// GetContainerDetails retrieves detailed information about a specific container
|
||||
func (c *Client) GetContainerDetails(deploymentID, containerID string) (*Container, error) {
|
||||
if deploymentID == "" {
|
||||
return nil, fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
if containerID == "" {
|
||||
return nil, fmt.Errorf("container ID cannot be empty")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s/container/%s", deploymentID, containerID)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get container details: %w", err)
|
||||
}
|
||||
|
||||
// API response format not documented, assuming direct format
|
||||
var container Container
|
||||
if err := decodeWithFlexibleTimes(resp.Body, &container); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse container details: %w", err)
|
||||
}
|
||||
|
||||
return &container, nil
|
||||
}
|
||||
|
||||
// GetContainerJobs retrieves containers jobs for a specific container (similar to containers endpoint)
|
||||
func (c *Client) GetContainerJobs(deploymentID, containerID string) (*ContainerList, error) {
|
||||
if deploymentID == "" {
|
||||
return nil, fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
if containerID == "" {
|
||||
return nil, fmt.Errorf("container ID cannot be empty")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s/containers-jobs/%s", deploymentID, containerID)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get container jobs: %w", err)
|
||||
}
|
||||
|
||||
var containerList ContainerList
|
||||
if err := decodeDataWithFlexibleTimes(resp.Body, &containerList); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse container jobs: %w", err)
|
||||
}
|
||||
|
||||
return &containerList, nil
|
||||
}
|
||||
|
||||
// buildLogEndpoint constructs the request path for fetching logs
|
||||
func buildLogEndpoint(deploymentID, containerID string, opts *GetLogsOptions) (string, error) {
|
||||
if deploymentID == "" {
|
||||
return "", fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
if containerID == "" {
|
||||
return "", fmt.Errorf("container ID cannot be empty")
|
||||
}
|
||||
|
||||
params := make(map[string]interface{})
|
||||
|
||||
if opts != nil {
|
||||
if opts.Level != "" {
|
||||
params["level"] = opts.Level
|
||||
}
|
||||
if opts.Stream != "" {
|
||||
params["stream"] = opts.Stream
|
||||
}
|
||||
if opts.Limit > 0 {
|
||||
params["limit"] = opts.Limit
|
||||
}
|
||||
if opts.Cursor != "" {
|
||||
params["cursor"] = opts.Cursor
|
||||
}
|
||||
if opts.Follow {
|
||||
params["follow"] = true
|
||||
}
|
||||
|
||||
if opts.StartTime != nil {
|
||||
params["start_time"] = opts.StartTime
|
||||
}
|
||||
if opts.EndTime != nil {
|
||||
params["end_time"] = opts.EndTime
|
||||
}
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s/log/%s", deploymentID, containerID)
|
||||
endpoint += buildQueryParams(params)
|
||||
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
// GetContainerLogs retrieves logs for containers in a deployment and normalizes them
|
||||
func (c *Client) GetContainerLogs(deploymentID, containerID string, opts *GetLogsOptions) (*ContainerLogs, error) {
|
||||
raw, err := c.GetContainerLogsRaw(deploymentID, containerID, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logs := &ContainerLogs{
|
||||
ContainerID: containerID,
|
||||
}
|
||||
|
||||
if raw == "" {
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
normalized := strings.ReplaceAll(raw, "\r\n", "\n")
|
||||
lines := strings.Split(normalized, "\n")
|
||||
logs.Logs = lo.FilterMap(lines, func(line string, _ int) (LogEntry, bool) {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
return LogEntry{}, false
|
||||
}
|
||||
return LogEntry{Message: line}, true
|
||||
})
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// GetContainerLogsRaw retrieves the raw text logs for a specific container
|
||||
func (c *Client) GetContainerLogsRaw(deploymentID, containerID string, opts *GetLogsOptions) (string, error) {
|
||||
endpoint, err := buildLogEndpoint(deploymentID, containerID, opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get container logs: %w", err)
|
||||
}
|
||||
|
||||
return string(resp.Body), nil
|
||||
}
|
||||
|
||||
// StreamContainerLogs streams real-time logs for a specific container
|
||||
// This method uses a callback function to handle incoming log entries
|
||||
func (c *Client) StreamContainerLogs(deploymentID, containerID string, opts *GetLogsOptions, callback func(*LogEntry) error) error {
|
||||
if deploymentID == "" {
|
||||
return fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
if containerID == "" {
|
||||
return fmt.Errorf("container ID cannot be empty")
|
||||
}
|
||||
if callback == nil {
|
||||
return fmt.Errorf("callback function cannot be nil")
|
||||
}
|
||||
|
||||
// Set follow to true for streaming
|
||||
if opts == nil {
|
||||
opts = &GetLogsOptions{}
|
||||
}
|
||||
opts.Follow = true
|
||||
|
||||
endpoint, err := buildLogEndpoint(deploymentID, containerID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Note: This is a simplified implementation. In a real scenario, you might want to use
|
||||
// Server-Sent Events (SSE) or WebSocket for streaming logs
|
||||
for {
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stream container logs: %w", err)
|
||||
}
|
||||
|
||||
var logs ContainerLogs
|
||||
if err := decodeWithFlexibleTimes(resp.Body, &logs); err != nil {
|
||||
return fmt.Errorf("failed to parse container logs: %w", err)
|
||||
}
|
||||
|
||||
// Call the callback for each log entry
|
||||
for _, logEntry := range logs.Logs {
|
||||
if err := callback(&logEntry); err != nil {
|
||||
return fmt.Errorf("callback error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no more logs or we have a cursor, continue polling
|
||||
if !logs.HasMore && logs.NextCursor == "" {
|
||||
break
|
||||
}
|
||||
|
||||
// Update cursor for next request
|
||||
if logs.NextCursor != "" {
|
||||
opts.Cursor = logs.NextCursor
|
||||
endpoint, err = buildLogEndpoint(deploymentID, containerID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit before next poll to avoid overwhelming the API
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestartContainer restarts a specific container (if supported by the API)
|
||||
func (c *Client) RestartContainer(deploymentID, containerID string) error {
|
||||
if deploymentID == "" {
|
||||
return fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
if containerID == "" {
|
||||
return fmt.Errorf("container ID cannot be empty")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s/container/%s/restart", deploymentID, containerID)
|
||||
|
||||
_, err := c.makeRequest("POST", endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart container: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopContainer stops a specific container (if supported by the API)
|
||||
func (c *Client) StopContainer(deploymentID, containerID string) error {
|
||||
if deploymentID == "" {
|
||||
return fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
if containerID == "" {
|
||||
return fmt.Errorf("container ID cannot be empty")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s/container/%s/stop", deploymentID, containerID)
|
||||
|
||||
_, err := c.makeRequest("POST", endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop container: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteInContainer executes a command in a specific container (if supported by the API)
|
||||
func (c *Client) ExecuteInContainer(deploymentID, containerID string, command []string) (string, error) {
|
||||
if deploymentID == "" {
|
||||
return "", fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
if containerID == "" {
|
||||
return "", fmt.Errorf("container ID cannot be empty")
|
||||
}
|
||||
if len(command) == 0 {
|
||||
return "", fmt.Errorf("command cannot be empty")
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"command": command,
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s/container/%s/exec", deploymentID, containerID)
|
||||
|
||||
resp, err := c.makeRequest("POST", endpoint, reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute command in container: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse execution result: %w", err)
|
||||
}
|
||||
|
||||
if output, ok := result["output"].(string); ok {
|
||||
return output, nil
|
||||
}
|
||||
|
||||
return string(resp.Body), nil
|
||||
}
|
||||
377
pkg/ionet/deployment.go
Normal file
377
pkg/ionet/deployment.go
Normal file
@@ -0,0 +1,377 @@
|
||||
package ionet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// DeployContainer deploys a new container with the specified configuration
|
||||
func (c *Client) DeployContainer(req *DeploymentRequest) (*DeploymentResponse, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("deployment request cannot be nil")
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.ResourcePrivateName == "" {
|
||||
return nil, fmt.Errorf("resource_private_name is required")
|
||||
}
|
||||
if len(req.LocationIDs) == 0 {
|
||||
return nil, fmt.Errorf("location_ids is required")
|
||||
}
|
||||
if req.HardwareID <= 0 {
|
||||
return nil, fmt.Errorf("hardware_id is required")
|
||||
}
|
||||
if req.RegistryConfig.ImageURL == "" {
|
||||
return nil, fmt.Errorf("registry_config.image_url is required")
|
||||
}
|
||||
if req.GPUsPerContainer < 1 {
|
||||
return nil, fmt.Errorf("gpus_per_container must be at least 1")
|
||||
}
|
||||
if req.DurationHours < 1 {
|
||||
return nil, fmt.Errorf("duration_hours must be at least 1")
|
||||
}
|
||||
if req.ContainerConfig.ReplicaCount < 1 {
|
||||
return nil, fmt.Errorf("container_config.replica_count must be at least 1")
|
||||
}
|
||||
|
||||
resp, err := c.makeRequest("POST", "/deploy", req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deploy container: %w", err)
|
||||
}
|
||||
|
||||
// API returns direct format:
|
||||
// {"status": "string", "deployment_id": "..."}
|
||||
var deployResp DeploymentResponse
|
||||
if err := json.Unmarshal(resp.Body, &deployResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse deployment response: %w", err)
|
||||
}
|
||||
|
||||
return &deployResp, nil
|
||||
}
|
||||
|
||||
// ListDeployments retrieves a list of deployments with optional filtering
|
||||
func (c *Client) ListDeployments(opts *ListDeploymentsOptions) (*DeploymentList, error) {
|
||||
params := make(map[string]interface{})
|
||||
|
||||
if opts != nil {
|
||||
params["status"] = opts.Status
|
||||
params["location_id"] = opts.LocationID
|
||||
params["page"] = opts.Page
|
||||
params["page_size"] = opts.PageSize
|
||||
params["sort_by"] = opts.SortBy
|
||||
params["sort_order"] = opts.SortOrder
|
||||
}
|
||||
|
||||
endpoint := "/deployments" + buildQueryParams(params)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list deployments: %w", err)
|
||||
}
|
||||
|
||||
var deploymentList DeploymentList
|
||||
if err := decodeData(resp.Body, &deploymentList); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse deployments list: %w", err)
|
||||
}
|
||||
|
||||
deploymentList.Deployments = lo.Map(deploymentList.Deployments, func(deployment Deployment, _ int) Deployment {
|
||||
deployment.GPUCount = deployment.HardwareQuantity
|
||||
deployment.Replicas = deployment.HardwareQuantity // Assuming 1:1 mapping for now
|
||||
return deployment
|
||||
})
|
||||
|
||||
return &deploymentList, nil
|
||||
}
|
||||
|
||||
// GetDeployment retrieves detailed information about a specific deployment
|
||||
func (c *Client) GetDeployment(deploymentID string) (*DeploymentDetail, error) {
|
||||
if deploymentID == "" {
|
||||
return nil, fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s", deploymentID)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get deployment details: %w", err)
|
||||
}
|
||||
|
||||
var deploymentDetail DeploymentDetail
|
||||
if err := decodeDataWithFlexibleTimes(resp.Body, &deploymentDetail); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse deployment details: %w", err)
|
||||
}
|
||||
|
||||
return &deploymentDetail, nil
|
||||
}
|
||||
|
||||
// UpdateDeployment updates the configuration of an existing deployment
|
||||
func (c *Client) UpdateDeployment(deploymentID string, req *UpdateDeploymentRequest) (*UpdateDeploymentResponse, error) {
|
||||
if deploymentID == "" {
|
||||
return nil, fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("update request cannot be nil")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s", deploymentID)
|
||||
|
||||
resp, err := c.makeRequest("PATCH", endpoint, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update deployment: %w", err)
|
||||
}
|
||||
|
||||
// API returns direct format:
|
||||
// {"status": "string", "deployment_id": "..."}
|
||||
var updateResp UpdateDeploymentResponse
|
||||
if err := json.Unmarshal(resp.Body, &updateResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse update deployment response: %w", err)
|
||||
}
|
||||
|
||||
return &updateResp, nil
|
||||
}
|
||||
|
||||
// ExtendDeployment extends the duration of an existing deployment
|
||||
func (c *Client) ExtendDeployment(deploymentID string, req *ExtendDurationRequest) (*DeploymentDetail, error) {
|
||||
if deploymentID == "" {
|
||||
return nil, fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("extend request cannot be nil")
|
||||
}
|
||||
if req.DurationHours < 1 {
|
||||
return nil, fmt.Errorf("duration_hours must be at least 1")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s/extend", deploymentID)
|
||||
|
||||
resp, err := c.makeRequest("POST", endpoint, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extend deployment: %w", err)
|
||||
}
|
||||
|
||||
var deploymentDetail DeploymentDetail
|
||||
if err := decodeDataWithFlexibleTimes(resp.Body, &deploymentDetail); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse extended deployment details: %w", err)
|
||||
}
|
||||
|
||||
return &deploymentDetail, nil
|
||||
}
|
||||
|
||||
// DeleteDeployment deletes an active deployment
|
||||
func (c *Client) DeleteDeployment(deploymentID string) (*UpdateDeploymentResponse, error) {
|
||||
if deploymentID == "" {
|
||||
return nil, fmt.Errorf("deployment ID cannot be empty")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/deployment/%s", deploymentID)
|
||||
|
||||
resp, err := c.makeRequest("DELETE", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete deployment: %w", err)
|
||||
}
|
||||
|
||||
// API returns direct format:
|
||||
// {"status": "string", "deployment_id": "..."}
|
||||
var deleteResp UpdateDeploymentResponse
|
||||
if err := json.Unmarshal(resp.Body, &deleteResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse delete deployment response: %w", err)
|
||||
}
|
||||
|
||||
return &deleteResp, nil
|
||||
}
|
||||
|
||||
// GetPriceEstimation calculates the estimated cost for a deployment
|
||||
func (c *Client) GetPriceEstimation(req *PriceEstimationRequest) (*PriceEstimationResponse, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("price estimation request cannot be nil")
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if len(req.LocationIDs) == 0 {
|
||||
return nil, fmt.Errorf("location_ids is required")
|
||||
}
|
||||
if req.HardwareID == 0 {
|
||||
return nil, fmt.Errorf("hardware_id is required")
|
||||
}
|
||||
if req.ReplicaCount < 1 {
|
||||
return nil, fmt.Errorf("replica_count must be at least 1")
|
||||
}
|
||||
|
||||
currency := strings.TrimSpace(req.Currency)
|
||||
if currency == "" {
|
||||
currency = "usdc"
|
||||
}
|
||||
|
||||
durationType := strings.TrimSpace(req.DurationType)
|
||||
if durationType == "" {
|
||||
durationType = "hour"
|
||||
}
|
||||
durationType = strings.ToLower(durationType)
|
||||
|
||||
apiDurationType := ""
|
||||
|
||||
durationQty := req.DurationQty
|
||||
if durationQty < 1 {
|
||||
durationQty = req.DurationHours
|
||||
}
|
||||
if durationQty < 1 {
|
||||
return nil, fmt.Errorf("duration_qty must be at least 1")
|
||||
}
|
||||
|
||||
hardwareQty := req.HardwareQty
|
||||
if hardwareQty < 1 {
|
||||
hardwareQty = req.GPUsPerContainer
|
||||
}
|
||||
if hardwareQty < 1 {
|
||||
return nil, fmt.Errorf("hardware_qty must be at least 1")
|
||||
}
|
||||
|
||||
durationHoursForRate := req.DurationHours
|
||||
if durationHoursForRate < 1 {
|
||||
durationHoursForRate = durationQty
|
||||
}
|
||||
switch durationType {
|
||||
case "hour", "hours", "hourly":
|
||||
durationHoursForRate = durationQty
|
||||
apiDurationType = "hourly"
|
||||
case "day", "days", "daily":
|
||||
durationHoursForRate = durationQty * 24
|
||||
apiDurationType = "daily"
|
||||
case "week", "weeks", "weekly":
|
||||
durationHoursForRate = durationQty * 24 * 7
|
||||
apiDurationType = "weekly"
|
||||
case "month", "months", "monthly":
|
||||
durationHoursForRate = durationQty * 24 * 30
|
||||
apiDurationType = "monthly"
|
||||
}
|
||||
if durationHoursForRate < 1 {
|
||||
durationHoursForRate = 1
|
||||
}
|
||||
if apiDurationType == "" {
|
||||
apiDurationType = "hourly"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"location_ids": req.LocationIDs,
|
||||
"hardware_id": req.HardwareID,
|
||||
"hardware_qty": hardwareQty,
|
||||
"gpus_per_container": req.GPUsPerContainer,
|
||||
"duration_type": apiDurationType,
|
||||
"duration_qty": durationQty,
|
||||
"duration_hours": req.DurationHours,
|
||||
"replica_count": req.ReplicaCount,
|
||||
"currency": currency,
|
||||
}
|
||||
|
||||
endpoint := "/price" + buildQueryParams(params)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get price estimation: %w", err)
|
||||
}
|
||||
|
||||
// Parse according to the actual API response format from docs:
|
||||
// {
|
||||
// "data": {
|
||||
// "replica_count": 0,
|
||||
// "gpus_per_container": 0,
|
||||
// "available_replica_count": [0],
|
||||
// "discount": 0,
|
||||
// "ionet_fee": 0,
|
||||
// "ionet_fee_percent": 0,
|
||||
// "currency_conversion_fee": 0,
|
||||
// "currency_conversion_fee_percent": 0,
|
||||
// "total_cost_usdc": 0
|
||||
// }
|
||||
// }
|
||||
var pricingData struct {
|
||||
ReplicaCount int `json:"replica_count"`
|
||||
GPUsPerContainer int `json:"gpus_per_container"`
|
||||
AvailableReplicaCount []int `json:"available_replica_count"`
|
||||
Discount float64 `json:"discount"`
|
||||
IonetFee float64 `json:"ionet_fee"`
|
||||
IonetFeePercent float64 `json:"ionet_fee_percent"`
|
||||
CurrencyConversionFee float64 `json:"currency_conversion_fee"`
|
||||
CurrencyConversionFeePercent float64 `json:"currency_conversion_fee_percent"`
|
||||
TotalCostUSDC float64 `json:"total_cost_usdc"`
|
||||
}
|
||||
|
||||
if err := decodeData(resp.Body, &pricingData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse price estimation response: %w", err)
|
||||
}
|
||||
|
||||
// Convert to our internal format
|
||||
durationHoursFloat := float64(durationHoursForRate)
|
||||
if durationHoursFloat <= 0 {
|
||||
durationHoursFloat = 1
|
||||
}
|
||||
|
||||
priceResp := &PriceEstimationResponse{
|
||||
EstimatedCost: pricingData.TotalCostUSDC,
|
||||
Currency: strings.ToUpper(currency),
|
||||
EstimationValid: true,
|
||||
PriceBreakdown: PriceBreakdown{
|
||||
ComputeCost: pricingData.TotalCostUSDC - pricingData.IonetFee - pricingData.CurrencyConversionFee,
|
||||
TotalCost: pricingData.TotalCostUSDC,
|
||||
HourlyRate: pricingData.TotalCostUSDC / durationHoursFloat,
|
||||
},
|
||||
}
|
||||
|
||||
return priceResp, nil
|
||||
}
|
||||
|
||||
// CheckClusterNameAvailability checks if a cluster name is available
|
||||
func (c *Client) CheckClusterNameAvailability(clusterName string) (bool, error) {
|
||||
if clusterName == "" {
|
||||
return false, fmt.Errorf("cluster name cannot be empty")
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"cluster_name": clusterName,
|
||||
}
|
||||
|
||||
endpoint := "/clusters/check_cluster_name_availability" + buildQueryParams(params)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check cluster name availability: %w", err)
|
||||
}
|
||||
|
||||
var availabilityResp bool
|
||||
if err := json.Unmarshal(resp.Body, &availabilityResp); err != nil {
|
||||
return false, fmt.Errorf("failed to parse cluster name availability response: %w", err)
|
||||
}
|
||||
|
||||
return availabilityResp, nil
|
||||
}
|
||||
|
||||
// UpdateClusterName updates the name of an existing cluster/deployment
|
||||
func (c *Client) UpdateClusterName(clusterID string, req *UpdateClusterNameRequest) (*UpdateClusterNameResponse, error) {
|
||||
if clusterID == "" {
|
||||
return nil, fmt.Errorf("cluster ID cannot be empty")
|
||||
}
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("update cluster name request cannot be nil")
|
||||
}
|
||||
if req.Name == "" {
|
||||
return nil, fmt.Errorf("cluster name cannot be empty")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/clusters/%s/update-name", clusterID)
|
||||
|
||||
resp, err := c.makeRequest("PUT", endpoint, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update cluster name: %w", err)
|
||||
}
|
||||
|
||||
// Parse the response directly without data wrapper based on API docs
|
||||
var updateResp UpdateClusterNameResponse
|
||||
if err := json.Unmarshal(resp.Body, &updateResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse update cluster name response: %w", err)
|
||||
}
|
||||
|
||||
return &updateResp, nil
|
||||
}
|
||||
202
pkg/ionet/hardware.go
Normal file
202
pkg/ionet/hardware.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package ionet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// GetAvailableReplicas retrieves available replicas per location for specified hardware
|
||||
func (c *Client) GetAvailableReplicas(hardwareID int, gpuCount int) (*AvailableReplicasResponse, error) {
|
||||
if hardwareID <= 0 {
|
||||
return nil, fmt.Errorf("hardware_id must be greater than 0")
|
||||
}
|
||||
if gpuCount < 1 {
|
||||
return nil, fmt.Errorf("gpu_count must be at least 1")
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"hardware_id": hardwareID,
|
||||
"hardware_qty": gpuCount,
|
||||
}
|
||||
|
||||
endpoint := "/available-replicas" + buildQueryParams(params)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get available replicas: %w", err)
|
||||
}
|
||||
|
||||
type availableReplicaPayload struct {
|
||||
ID int `json:"id"`
|
||||
ISO2 string `json:"iso2"`
|
||||
Name string `json:"name"`
|
||||
AvailableReplicas int `json:"available_replicas"`
|
||||
}
|
||||
var payload []availableReplicaPayload
|
||||
|
||||
if err := decodeData(resp.Body, &payload); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse available replicas response: %w", err)
|
||||
}
|
||||
|
||||
replicas := lo.Map(payload, func(item availableReplicaPayload, _ int) AvailableReplica {
|
||||
return AvailableReplica{
|
||||
LocationID: item.ID,
|
||||
LocationName: item.Name,
|
||||
HardwareID: hardwareID,
|
||||
HardwareName: "",
|
||||
AvailableCount: item.AvailableReplicas,
|
||||
MaxGPUs: gpuCount,
|
||||
}
|
||||
})
|
||||
|
||||
return &AvailableReplicasResponse{Replicas: replicas}, nil
|
||||
}
|
||||
|
||||
// GetMaxGPUsPerContainer retrieves the maximum number of GPUs available per hardware type
|
||||
func (c *Client) GetMaxGPUsPerContainer() (*MaxGPUResponse, error) {
|
||||
resp, err := c.makeRequest("GET", "/hardware/max-gpus-per-container", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get max GPUs per container: %w", err)
|
||||
}
|
||||
|
||||
var maxGPUResp MaxGPUResponse
|
||||
if err := decodeData(resp.Body, &maxGPUResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse max GPU response: %w", err)
|
||||
}
|
||||
|
||||
return &maxGPUResp, nil
|
||||
}
|
||||
|
||||
// ListHardwareTypes retrieves available hardware types using the max GPUs endpoint
|
||||
func (c *Client) ListHardwareTypes() ([]HardwareType, int, error) {
|
||||
maxGPUResp, err := c.GetMaxGPUsPerContainer()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to list hardware types: %w", err)
|
||||
}
|
||||
|
||||
mapped := lo.Map(maxGPUResp.Hardware, func(hw MaxGPUInfo, _ int) HardwareType {
|
||||
name := strings.TrimSpace(hw.HardwareName)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Hardware %d", hw.HardwareID)
|
||||
}
|
||||
|
||||
return HardwareType{
|
||||
ID: hw.HardwareID,
|
||||
Name: name,
|
||||
GPUType: "",
|
||||
GPUMemory: 0,
|
||||
MaxGPUs: hw.MaxGPUsPerContainer,
|
||||
CPU: "",
|
||||
Memory: 0,
|
||||
Storage: 0,
|
||||
HourlyRate: 0,
|
||||
Available: hw.Available > 0,
|
||||
BrandName: strings.TrimSpace(hw.BrandName),
|
||||
AvailableCount: hw.Available,
|
||||
}
|
||||
})
|
||||
|
||||
totalAvailable := maxGPUResp.Total
|
||||
if totalAvailable == 0 {
|
||||
totalAvailable = lo.SumBy(maxGPUResp.Hardware, func(hw MaxGPUInfo) int {
|
||||
return hw.Available
|
||||
})
|
||||
}
|
||||
|
||||
return mapped, totalAvailable, nil
|
||||
}
|
||||
|
||||
// ListLocations retrieves available deployment locations (if supported by the API)
|
||||
func (c *Client) ListLocations() (*LocationsResponse, error) {
|
||||
resp, err := c.makeRequest("GET", "/locations", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list locations: %w", err)
|
||||
}
|
||||
|
||||
var locations LocationsResponse
|
||||
if err := decodeData(resp.Body, &locations); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse locations response: %w", err)
|
||||
}
|
||||
|
||||
locations.Locations = lo.Map(locations.Locations, func(location Location, _ int) Location {
|
||||
location.ISO2 = strings.ToUpper(strings.TrimSpace(location.ISO2))
|
||||
return location
|
||||
})
|
||||
|
||||
if locations.Total == 0 {
|
||||
locations.Total = lo.SumBy(locations.Locations, func(location Location) int {
|
||||
return location.Available
|
||||
})
|
||||
}
|
||||
|
||||
return &locations, nil
|
||||
}
|
||||
|
||||
// GetHardwareType retrieves details about a specific hardware type
|
||||
func (c *Client) GetHardwareType(hardwareID int) (*HardwareType, error) {
|
||||
if hardwareID <= 0 {
|
||||
return nil, fmt.Errorf("hardware ID must be greater than 0")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/hardware/types/%d", hardwareID)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get hardware type: %w", err)
|
||||
}
|
||||
|
||||
// API response format not documented, assuming direct format
|
||||
var hardwareType HardwareType
|
||||
if err := json.Unmarshal(resp.Body, &hardwareType); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse hardware type: %w", err)
|
||||
}
|
||||
|
||||
return &hardwareType, nil
|
||||
}
|
||||
|
||||
// GetLocation retrieves details about a specific location
|
||||
func (c *Client) GetLocation(locationID int) (*Location, error) {
|
||||
if locationID <= 0 {
|
||||
return nil, fmt.Errorf("location ID must be greater than 0")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/locations/%d", locationID)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get location: %w", err)
|
||||
}
|
||||
|
||||
// API response format not documented, assuming direct format
|
||||
var location Location
|
||||
if err := json.Unmarshal(resp.Body, &location); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse location: %w", err)
|
||||
}
|
||||
|
||||
return &location, nil
|
||||
}
|
||||
|
||||
// GetLocationAvailability retrieves real-time availability for a specific location
|
||||
func (c *Client) GetLocationAvailability(locationID int) (*LocationAvailability, error) {
|
||||
if locationID <= 0 {
|
||||
return nil, fmt.Errorf("location ID must be greater than 0")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/locations/%d/availability", locationID)
|
||||
|
||||
resp, err := c.makeRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get location availability: %w", err)
|
||||
}
|
||||
|
||||
// API response format not documented, assuming direct format
|
||||
var availability LocationAvailability
|
||||
if err := json.Unmarshal(resp.Body, &availability); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse location availability: %w", err)
|
||||
}
|
||||
|
||||
return &availability, nil
|
||||
}
|
||||
96
pkg/ionet/jsonutil.go
Normal file
96
pkg/ionet/jsonutil.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package ionet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// decodeWithFlexibleTimes unmarshals API responses while tolerating timestamp strings
|
||||
// that omit timezone information by normalizing them to RFC3339Nano.
|
||||
func decodeWithFlexibleTimes(data []byte, target interface{}) error {
|
||||
var intermediate interface{}
|
||||
if err := json.Unmarshal(data, &intermediate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
normalized := normalizeTimeValues(intermediate)
|
||||
reencoded, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(reencoded, target)
|
||||
}
|
||||
|
||||
func decodeData[T any](data []byte, target *T) error {
|
||||
var wrapper struct {
|
||||
Data T `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wrapper); err != nil {
|
||||
return err
|
||||
}
|
||||
*target = wrapper.Data
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeDataWithFlexibleTimes[T any](data []byte, target *T) error {
|
||||
var wrapper struct {
|
||||
Data T `json:"data"`
|
||||
}
|
||||
if err := decodeWithFlexibleTimes(data, &wrapper); err != nil {
|
||||
return err
|
||||
}
|
||||
*target = wrapper.Data
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeTimeValues(value interface{}) interface{} {
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
return lo.MapValues(v, func(val interface{}, _ string) interface{} {
|
||||
return normalizeTimeValues(val)
|
||||
})
|
||||
case []interface{}:
|
||||
return lo.Map(v, func(item interface{}, _ int) interface{} {
|
||||
return normalizeTimeValues(item)
|
||||
})
|
||||
case string:
|
||||
if normalized, changed := normalizeTimeString(v); changed {
|
||||
return normalized
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTimeString(input string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return input, false
|
||||
}
|
||||
|
||||
if _, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
|
||||
return trimmed, trimmed != input
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, trimmed); err == nil {
|
||||
return trimmed, trimmed != input
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
"2006-01-02T15:04:05.999999999",
|
||||
"2006-01-02T15:04:05.999999",
|
||||
"2006-01-02T15:04:05",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, trimmed); err == nil {
|
||||
return parsed.UTC().Format(time.RFC3339Nano), true
|
||||
}
|
||||
}
|
||||
|
||||
return input, false
|
||||
}
|
||||
353
pkg/ionet/types.go
Normal file
353
pkg/ionet/types.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import Midjourney from './pages/Midjourney';
|
||||
import Pricing from './pages/Pricing';
|
||||
import Task from './pages/Task';
|
||||
import ModelPage from './pages/Model';
|
||||
import ModelDeploymentPage from './pages/ModelDeployment';
|
||||
import Playground from './pages/Playground';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||
import PersonalSetting from './components/settings/PersonalSetting';
|
||||
@@ -108,6 +109,14 @@ function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/deployment'
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ModelDeploymentPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/channel'
|
||||
element={
|
||||
|
||||
@@ -45,6 +45,7 @@ const routerMap = {
|
||||
pricing: '/pricing',
|
||||
task: '/console/task',
|
||||
models: '/console/models',
|
||||
deployment: '/console/deployment',
|
||||
playground: '/console/playground',
|
||||
personal: '/console/personal',
|
||||
};
|
||||
@@ -157,6 +158,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
to: '/console/models',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('模型部署'),
|
||||
itemKey: 'deployment',
|
||||
to: '/deployment',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('兑换码管理'),
|
||||
itemKey: 'redemption',
|
||||
|
||||
@@ -52,7 +52,6 @@ const SkeletonWrapper = ({
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: isMobile ? 40 : width, height }}
|
||||
/>
|
||||
}
|
||||
@@ -71,7 +70,7 @@ const SkeletonWrapper = ({
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar active size='extra-small' className='shadow-sm' />
|
||||
<Skeleton.Avatar size='extra-small' className='shadow-sm' />
|
||||
}
|
||||
/>
|
||||
<div className='ml-1.5 mr-1'>
|
||||
@@ -80,7 +79,6 @@ const SkeletonWrapper = ({
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: isMobile ? 15 : width, height: 12 }}
|
||||
/>
|
||||
}
|
||||
@@ -98,7 +96,6 @@ const SkeletonWrapper = ({
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Image
|
||||
active
|
||||
className={`absolute inset-0 !rounded-full ${className}`}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
@@ -113,7 +110,7 @@ const SkeletonWrapper = ({
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
|
||||
placeholder={<Skeleton.Title style={{ width, height: 24 }} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -125,7 +122,7 @@ const SkeletonWrapper = ({
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={<Skeleton.Title active style={{ width, height }} />}
|
||||
placeholder={<Skeleton.Title style={{ width, height }} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -140,7 +137,6 @@ const SkeletonWrapper = ({
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width, height, borderRadius: 9999 }}
|
||||
/>
|
||||
}
|
||||
@@ -164,7 +160,7 @@ const SkeletonWrapper = ({
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar active size='extra-small' shape='square' />
|
||||
<Skeleton.Avatar size='extra-small' shape='square' />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -174,7 +170,6 @@ const SkeletonWrapper = ({
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: width || 80, height: height || 14 }}
|
||||
/>
|
||||
}
|
||||
@@ -191,10 +186,7 @@ const SkeletonWrapper = ({
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: width || 60, height: height || 12 }}
|
||||
/>
|
||||
<Skeleton.Title style={{ width: width || 60, height: height || 12 }} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -217,7 +209,6 @@ const SkeletonWrapper = ({
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar
|
||||
active
|
||||
shape='square'
|
||||
style={{ width: ICON_SIZE, height: ICON_SIZE }}
|
||||
/>
|
||||
@@ -231,7 +222,6 @@ const SkeletonWrapper = ({
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: labelWidth, height: TEXT_HEIGHT }}
|
||||
/>
|
||||
}
|
||||
@@ -269,7 +259,6 @@ const SkeletonWrapper = ({
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar
|
||||
active
|
||||
shape='square'
|
||||
style={{ width: ICON_SIZE, height: ICON_SIZE }}
|
||||
/>
|
||||
@@ -329,7 +318,6 @@ const SkeletonWrapper = ({
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
|
||||
/>
|
||||
}
|
||||
@@ -350,7 +338,6 @@ const SkeletonWrapper = ({
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
|
||||
/>
|
||||
}
|
||||
|
||||
377
web/src/components/model-deployments/DeploymentAccessGuard.jsx
Normal file
377
web/src/components/model-deployments/DeploymentAccessGuard.jsx
Normal file
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<Card loading={true} style={{ minHeight: '400px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
||||
<Text type="secondary">{t('加载设置中...')}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEnabled) {
|
||||
return (
|
||||
<div
|
||||
className='mt-[60px] px-4'
|
||||
style={{
|
||||
minHeight: 'calc(100vh - 60px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
padding: '0 20px'
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
padding: '60px 40px',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
||||
background: 'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)'
|
||||
}}
|
||||
>
|
||||
{/* 图标区域 */}
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '120px',
|
||||
height: '120px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, rgba(var(--semi-orange-4), 0.15) 0%, rgba(var(--semi-orange-5), 0.1) 100%)',
|
||||
border: '3px solid rgba(var(--semi-orange-4), 0.3)',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<AlertCircle size={56} color="var(--semi-color-warning)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题区域 */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<Title
|
||||
heading={2}
|
||||
style={{
|
||||
color: 'var(--semi-color-text-0)',
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '28px',
|
||||
fontWeight: '700'
|
||||
}}
|
||||
>
|
||||
{t('模型部署服务未启用')}
|
||||
</Title>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--semi-color-text-1)',
|
||||
display: 'block'
|
||||
}}
|
||||
>
|
||||
{t('访问模型部署功能需要先启用 io.net 部署服务')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 配置要求区域 */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--semi-color-bg-1)',
|
||||
padding: '24px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
margin: '32px 0',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'rgba(var(--semi-blue-4), 0.15)'
|
||||
}}>
|
||||
<Server size={20} color="var(--semi-color-primary)" />
|
||||
</div>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: 'var(--semi-color-text-0)'
|
||||
}}
|
||||
>
|
||||
{t('需要配置的项目')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
alignItems: 'flex-start',
|
||||
textAlign: 'left',
|
||||
maxWidth: '320px',
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--semi-color-primary)',
|
||||
flexShrink: 0
|
||||
}}></div>
|
||||
<Text style={{ fontSize: '15px', color: 'var(--semi-color-text-1)' }}>
|
||||
{t('启用 io.net 部署开关')}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--semi-color-primary)',
|
||||
flexShrink: 0
|
||||
}}></div>
|
||||
<Text style={{ fontSize: '15px', color: 'var(--semi-color-text-1)' }}>
|
||||
{t('配置有效的 io.net API Key')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作链接区域 */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
onClick={handleGoToSettings}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
padding: '12px 24px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
color: 'var(--semi-color-primary)',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
transition: 'all 0.2s ease',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.target.style.background = 'var(--semi-color-fill-1)';
|
||||
e.target.style.transform = 'translateY(-1px)';
|
||||
e.target.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.target.style.background = 'var(--semi-color-fill-0)';
|
||||
e.target.style.transform = 'translateY(0)';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<Settings size={18} />
|
||||
{t('前往设置页面')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
<Text
|
||||
type="tertiary"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
>
|
||||
{t('配置完成后刷新页面即可使用模型部署功能')}
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionLoading || (connectionOk === null && !connectionError)) {
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<Card loading={true} style={{ minHeight: '400px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
||||
<Text type="secondary">{t('Checking io.net connection...')}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionOk === false) {
|
||||
const isExpired = connectionError?.type === 'expired';
|
||||
const title = isExpired
|
||||
? t('API key expired')
|
||||
: t('io.net connection unavailable');
|
||||
const description = isExpired
|
||||
? t('The current API key is expired. Please update it in settings.')
|
||||
: t('Unable to connect to io.net with the current configuration.');
|
||||
const detail = connectionError?.message || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className='mt-[60px] px-4'
|
||||
style={{
|
||||
minHeight: 'calc(100vh - 60px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '600px',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
padding: '0 20px',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
padding: '60px 40px',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.08)',
|
||||
background: 'linear-gradient(135deg, var(--semi-color-bg-0) 0%, var(--semi-color-fill-0) 100%)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '32px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '120px',
|
||||
height: '120px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, rgba(var(--semi-red-4), 0.15) 0%, rgba(var(--semi-red-5), 0.1) 100%)',
|
||||
border: '3px solid rgba(var(--semi-red-4), 0.3)',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<WifiOff size={56} color="var(--semi-color-danger)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<Title
|
||||
heading={2}
|
||||
style={{
|
||||
color: 'var(--semi-color-text-0)',
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '28px',
|
||||
fontWeight: '700',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Title>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--semi-color-text-1)',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
{detail ? (
|
||||
<Text
|
||||
type="tertiary"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
display: 'block',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
|
||||
<Button type="primary" icon={<Settings size={18} />} onClick={handleGoToSettings}>
|
||||
{t('Go to settings')}
|
||||
</Button>
|
||||
{onRetry ? (
|
||||
<Button type="tertiary" onClick={onRetry}>
|
||||
{t('Retry connection')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default DeploymentAccessGuard;
|
||||
85
web/src/components/settings/ModelDeploymentSetting.jsx
Normal file
85
web/src/components/settings/ModelDeploymentSetting.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<>
|
||||
<Spin spinning={loading} size='large'>
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingModelDeployment options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDeploymentSetting;
|
||||
@@ -47,7 +47,8 @@ import {
|
||||
import { FaRandom } from 'react-icons/fa';
|
||||
|
||||
// Render functions
|
||||
const renderType = (type, channelInfo = undefined, t) => {
|
||||
const renderType = (type, record = {}, t) => {
|
||||
const channelInfo = record?.channel_info;
|
||||
let type2label = new Map();
|
||||
for (let i = 0; i < CHANNEL_OPTIONS.length; i++) {
|
||||
type2label[CHANNEL_OPTIONS[i].value] = CHANNEL_OPTIONS[i];
|
||||
@@ -71,11 +72,65 @@ const renderType = (type, channelInfo = undefined, t) => {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const typeTag = (
|
||||
<Tag color={type2label[type]?.color} shape='circle' prefixIcon={icon}>
|
||||
{type2label[type]?.label}
|
||||
</Tag>
|
||||
);
|
||||
|
||||
let ionetMeta = null;
|
||||
if (record?.other_info) {
|
||||
try {
|
||||
const parsed = JSON.parse(record.other_info);
|
||||
if (parsed && typeof parsed === 'object' && parsed.source === 'ionet') {
|
||||
ionetMeta = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore invalid metadata
|
||||
}
|
||||
}
|
||||
|
||||
if (!ionetMeta) {
|
||||
return typeTag;
|
||||
}
|
||||
|
||||
const handleNavigate = (event) => {
|
||||
event?.stopPropagation?.();
|
||||
if (!ionetMeta?.deployment_id) {
|
||||
return;
|
||||
}
|
||||
const targetUrl = `/console/deployment?deployment_id=${ionetMeta.deployment_id}`;
|
||||
window.open(targetUrl, '_blank', 'noopener');
|
||||
};
|
||||
|
||||
return (
|
||||
<Space spacing={6}>
|
||||
{typeTag}
|
||||
<Tooltip
|
||||
content={
|
||||
<div className='max-w-xs'>
|
||||
<div className='text-xs text-gray-600'>{t('来源于 IO.NET 部署')}</div>
|
||||
{ionetMeta?.deployment_id && (
|
||||
<div className='text-xs text-gray-500 mt-1'>
|
||||
{t('部署 ID')}: {ionetMeta.deployment_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Tag
|
||||
color='purple'
|
||||
type='light'
|
||||
className='cursor-pointer'
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
IO.NET
|
||||
</Tag>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTagType = (t) => {
|
||||
@@ -231,6 +286,7 @@ export const getChannelsColumns = ({
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
checkOllamaVersion,
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
}) => {
|
||||
@@ -330,12 +386,7 @@ export const getChannelsColumns = ({
|
||||
dataIndex: 'type',
|
||||
render: (text, record, index) => {
|
||||
if (record.children === undefined) {
|
||||
if (record.channel_info) {
|
||||
if (record.channel_info.is_multi_key) {
|
||||
return <>{renderType(text, record.channel_info, t)}</>;
|
||||
}
|
||||
}
|
||||
return <>{renderType(text, undefined, t)}</>;
|
||||
return <>{renderType(text, record, t)}</>;
|
||||
} else {
|
||||
return <>{renderTagType(t)}</>;
|
||||
}
|
||||
@@ -569,6 +620,15 @@ export const getChannelsColumns = ({
|
||||
},
|
||||
];
|
||||
|
||||
if (record.type === 4) {
|
||||
moreMenuItems.unshift({
|
||||
node: 'item',
|
||||
name: t('测活'),
|
||||
type: 'tertiary',
|
||||
onClick: () => checkOllamaVersion(record),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
<SplitButtonGroup
|
||||
|
||||
@@ -57,6 +57,7 @@ const ChannelsTable = (channelsData) => {
|
||||
setEditingTag,
|
||||
copySelectedChannel,
|
||||
refresh,
|
||||
checkOllamaVersion,
|
||||
// Multi-key management
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
@@ -82,6 +83,7 @@ const ChannelsTable = (channelsData) => {
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
checkOllamaVersion,
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
});
|
||||
@@ -103,6 +105,7 @@ const ChannelsTable = (channelsData) => {
|
||||
refresh,
|
||||
activePage,
|
||||
channels,
|
||||
checkOllamaVersion,
|
||||
setShowMultiKeyManageModal,
|
||||
setCurrentMultiKeyChannel,
|
||||
]);
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
selectFilter,
|
||||
} from '../../../../helpers';
|
||||
import ModelSelectModal from './ModelSelectModal';
|
||||
import OllamaModelModal from './OllamaModelModal';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
||||
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
|
||||
@@ -180,6 +181,7 @@ const EditChannelModal = (props) => {
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const [modelModalVisible, setModelModalVisible] = useState(false);
|
||||
const [fetchedModels, setFetchedModels] = useState([]);
|
||||
const [ollamaModalVisible, setOllamaModalVisible] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
const [vertexKeys, setVertexKeys] = useState([]);
|
||||
const [vertexFileList, setVertexFileList] = useState([]);
|
||||
@@ -214,6 +216,8 @@ const EditChannelModal = (props) => {
|
||||
return [];
|
||||
}
|
||||
}, [inputs.model_mapping]);
|
||||
const [isIonetChannel, setIsIonetChannel] = useState(false);
|
||||
const [ionetMetadata, setIonetMetadata] = useState(null);
|
||||
|
||||
// 密钥显示状态
|
||||
const [keyDisplayState, setKeyDisplayState] = useState({
|
||||
@@ -224,6 +228,21 @@ const EditChannelModal = (props) => {
|
||||
// 专门的2FA验证状态(用于TwoFactorAuthModal)
|
||||
const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit) {
|
||||
setIsIonetChannel(false);
|
||||
setIonetMetadata(null);
|
||||
}
|
||||
}, [isEdit]);
|
||||
|
||||
const handleOpenIonetDeployment = () => {
|
||||
if (!ionetMetadata?.deployment_id) {
|
||||
return;
|
||||
}
|
||||
const targetUrl = `/console/deployment?deployment_id=${ionetMetadata.deployment_id}`;
|
||||
window.open(targetUrl, '_blank', 'noopener');
|
||||
};
|
||||
const [verifyLoading, setVerifyLoading] = useState(false);
|
||||
|
||||
// 表单块导航相关状态
|
||||
@@ -404,7 +423,12 @@ const EditChannelModal = (props) => {
|
||||
handleInputChange('settings', settingsJson);
|
||||
};
|
||||
|
||||
const isIonetLocked = isIonetChannel && isEdit;
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
if (isIonetChannel && isEdit && ['type', 'key', 'base_url'].includes(name)) {
|
||||
return;
|
||||
}
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(name, value);
|
||||
}
|
||||
@@ -625,6 +649,25 @@ const EditChannelModal = (props) => {
|
||||
.map((model) => (model || '').trim())
|
||||
.filter(Boolean);
|
||||
initialModelMappingRef.current = data.model_mapping || '';
|
||||
|
||||
let parsedIonet = null;
|
||||
if (data.other_info) {
|
||||
try {
|
||||
const maybeMeta = JSON.parse(data.other_info);
|
||||
if (
|
||||
maybeMeta &&
|
||||
typeof maybeMeta === 'object' &&
|
||||
maybeMeta.source === 'ionet'
|
||||
) {
|
||||
parsedIonet = maybeMeta;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
const managedByIonet = !!parsedIonet;
|
||||
setIsIonetChannel(managedByIonet);
|
||||
setIonetMetadata(parsedIonet);
|
||||
// console.log(data);
|
||||
} else {
|
||||
showError(message);
|
||||
@@ -632,7 +675,8 @@ const EditChannelModal = (props) => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchUpstreamModelList = async (name) => {
|
||||
const fetchUpstreamModelList = async (name, options = {}) => {
|
||||
const silent = !!options.silent;
|
||||
// if (inputs['type'] !== 1) {
|
||||
// showError(t('仅支持 OpenAI 接口格式'));
|
||||
// return;
|
||||
@@ -683,7 +727,9 @@ const EditChannelModal = (props) => {
|
||||
if (!err) {
|
||||
const uniqueModels = Array.from(new Set(models));
|
||||
setFetchedModels(uniqueModels);
|
||||
setModelModalVisible(true);
|
||||
if (!silent) {
|
||||
setModelModalVisible(true);
|
||||
}
|
||||
} else {
|
||||
showError(t('获取模型列表失败'));
|
||||
}
|
||||
@@ -1626,20 +1672,44 @@ const EditChannelModal = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Select
|
||||
field='type'
|
||||
label={t('类型')}
|
||||
placeholder={t('请选择渠道类型')}
|
||||
rules={[{ required: true, message: t('请选择渠道类型') }]}
|
||||
optionList={channelOptionList}
|
||||
style={{ width: '100%' }}
|
||||
filter={selectFilter}
|
||||
autoClearSearchValue={false}
|
||||
searchPosition='dropdown'
|
||||
onSearch={(value) => setChannelSearchValue(value)}
|
||||
renderOptionItem={renderChannelOption}
|
||||
onChange={(value) => handleInputChange('type', value)}
|
||||
/>
|
||||
{isIonetChannel && (
|
||||
<Banner
|
||||
type='info'
|
||||
closeIcon={null}
|
||||
className='mb-4 rounded-xl'
|
||||
description={t('此渠道由 IO.NET 自动同步,类型、密钥和 API 地址已锁定。')}
|
||||
>
|
||||
<Space>
|
||||
{ionetMetadata?.deployment_id && (
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='primary'
|
||||
icon={<IconGlobe />}
|
||||
onClick={handleOpenIonetDeployment}
|
||||
>
|
||||
{t('查看关联部署')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<Form.Select
|
||||
field='type'
|
||||
label={t('类型')}
|
||||
placeholder={t('请选择渠道类型')}
|
||||
rules={[{ required: true, message: t('请选择渠道类型') }]}
|
||||
optionList={channelOptionList}
|
||||
style={{ width: '100%' }}
|
||||
filter={selectFilter}
|
||||
autoClearSearchValue={false}
|
||||
searchPosition='dropdown'
|
||||
onSearch={(value) => setChannelSearchValue(value)}
|
||||
renderOptionItem={renderChannelOption}
|
||||
onChange={(value) => handleInputChange('type', value)}
|
||||
disabled={isIonetLocked}
|
||||
/>
|
||||
|
||||
{inputs.type === 20 && (
|
||||
<Form.Switch
|
||||
@@ -1778,87 +1848,86 @@ const EditChannelModal = (props) => {
|
||||
autosize
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
extraText={
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{isEdit &&
|
||||
isMultiKeyChannel &&
|
||||
keyMode === 'append' && (
|
||||
<Text type='warning' size='small'>
|
||||
{t(
|
||||
'追加模式:新密钥将添加到现有密钥列表的末尾',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{isEdit && (
|
||||
disabled={isIonetLocked}
|
||||
extraText={
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{isEdit &&
|
||||
isMultiKeyChannel &&
|
||||
keyMode === 'append' && (
|
||||
<Text type='warning' size='small'>
|
||||
{t(
|
||||
'追加模式:新密钥将添加到现有密钥列表的末尾',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
)}
|
||||
{batchExtra}
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<Text className='text-sm font-medium'>
|
||||
{t('密钥输入方式')}
|
||||
</Text>
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
type={
|
||||
!useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
onClick={() => {
|
||||
setUseManualInput(false);
|
||||
// 切换到文件上传模式时清空手动输入的密钥
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key', '');
|
||||
}
|
||||
handleInputChange('key', '');
|
||||
}}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
{t('文件上传')}
|
||||
</Button>
|
||||
)}
|
||||
{batchExtra}
|
||||
<Button
|
||||
size='small'
|
||||
type={useManualInput ? 'primary' : 'tertiary'}
|
||||
onClick={() => {
|
||||
setUseManualInput(true);
|
||||
// 切换到手动输入模式时清空文件上传相关状态
|
||||
setVertexKeys([]);
|
||||
setVertexFileList([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(
|
||||
'vertex_files',
|
||||
[],
|
||||
);
|
||||
}
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
vertex_files: [],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t('手动输入')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<Text className='text-sm font-medium'>
|
||||
{t('密钥输入方式')}
|
||||
</Text>
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type={
|
||||
!useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
onClick={() => {
|
||||
setUseManualInput(false);
|
||||
// 切换到文件上传模式时清空手动输入的密钥
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key', '');
|
||||
}
|
||||
handleInputChange('key', '');
|
||||
}}
|
||||
>
|
||||
{t('文件上传')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type={
|
||||
useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
onClick={() => {
|
||||
setUseManualInput(true);
|
||||
// 切换到手动输入模式时清空文件上传相关状态
|
||||
setVertexKeys([]);
|
||||
setVertexFileList([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(
|
||||
'vertex_files',
|
||||
[],
|
||||
);
|
||||
}
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
vertex_files: [],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t('手动输入')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{batch && (
|
||||
<Banner
|
||||
@@ -2189,84 +2258,86 @@ const EditChannelModal = (props) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{inputs.type === 3 && (
|
||||
<>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的"."',
|
||||
{inputs.type === 3 && (
|
||||
<>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的"."',
|
||||
)}
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
<div>
|
||||
<Form.Input
|
||||
field='base_url'
|
||||
label='AZURE_OPENAI_ENDPOINT'
|
||||
placeholder={t(
|
||||
'请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com',
|
||||
)}
|
||||
className='!rounded-lg'
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
showClear
|
||||
disabled={isIonetLocked}
|
||||
/>
|
||||
<div>
|
||||
<Form.Input
|
||||
field='base_url'
|
||||
label='AZURE_OPENAI_ENDPOINT'
|
||||
placeholder={t(
|
||||
'请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Form.Input
|
||||
field='other'
|
||||
label={t('默认 API 版本')}
|
||||
placeholder={t(
|
||||
'请输入默认 API 版本,例如:2025-04-01-preview',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
handleInputChange('other', value)
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Form.Input
|
||||
field='azure_responses_version'
|
||||
label={t(
|
||||
'默认 Responses API 版本,为空则使用上方版本',
|
||||
)}
|
||||
placeholder={t('例如:preview')}
|
||||
onChange={(value) =>
|
||||
handleChannelOtherSettingsChange(
|
||||
'azure_responses_version',
|
||||
value,
|
||||
)
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Form.Input
|
||||
field='other'
|
||||
label={t('默认 API 版本')}
|
||||
placeholder={t(
|
||||
'请输入默认 API 版本,例如:2025-04-01-preview',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
handleInputChange('other', value)
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Form.Input
|
||||
field='azure_responses_version'
|
||||
label={t(
|
||||
'默认 Responses API 版本,为空则使用上方版本',
|
||||
)}
|
||||
placeholder={t('例如:preview')}
|
||||
onChange={(value) =>
|
||||
handleChannelOtherSettingsChange(
|
||||
'azure_responses_version',
|
||||
value,
|
||||
)
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{inputs.type === 8 && (
|
||||
<>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。',
|
||||
{inputs.type === 8 && (
|
||||
<>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。',
|
||||
)}
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
<div>
|
||||
<Form.Input
|
||||
field='base_url'
|
||||
label={t('完整的 Base URL,支持变量{model}')}
|
||||
placeholder={t(
|
||||
'请输入完整的URL,例如:https://api.openai.com/v1/chat/completions',
|
||||
)}
|
||||
className='!rounded-lg'
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
showClear
|
||||
disabled={isIonetLocked}
|
||||
/>
|
||||
<div>
|
||||
<Form.Input
|
||||
field='base_url'
|
||||
label={t('完整的 Base URL,支持变量{model}')}
|
||||
placeholder={t(
|
||||
'请输入完整的URL,例如:https://api.openai.com/v1/chat/completions',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{inputs.type === 37 && (
|
||||
<Banner
|
||||
@@ -2294,76 +2365,77 @@ const EditChannelModal = (props) => {
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
showClear
|
||||
extraText={t(
|
||||
'对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputs.type === 22 && (
|
||||
<div>
|
||||
<Form.Input
|
||||
field='base_url'
|
||||
label={t('私有部署地址')}
|
||||
placeholder={t(
|
||||
'请输入私有部署地址,格式为:https://fastgpt.run/api/openapi',
|
||||
disabled={isIonetLocked}
|
||||
extraText={t(
|
||||
'对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputs.type === 36 && (
|
||||
<div>
|
||||
<Form.Input
|
||||
field='base_url'
|
||||
label={t(
|
||||
'注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用',
|
||||
)}
|
||||
placeholder={t(
|
||||
'请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{inputs.type === 22 && (
|
||||
<div>
|
||||
<Form.Input
|
||||
field='base_url'
|
||||
label={t('私有部署地址')}
|
||||
placeholder={t(
|
||||
'请输入私有部署地址,格式为:https://fastgpt.run/api/openapi',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
showClear
|
||||
disabled={isIonetLocked}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputs.type === 45 && !doubaoApiEditUnlocked && (
|
||||
<div>
|
||||
<Form.Select
|
||||
field='base_url'
|
||||
label={t('API地址')}
|
||||
placeholder={t('请选择API地址')}
|
||||
onChange={(value) =>
|
||||
{inputs.type === 36 && (
|
||||
<div>
|
||||
<Form.Input
|
||||
field='base_url'
|
||||
label={t(
|
||||
'注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用',
|
||||
)}
|
||||
placeholder={t(
|
||||
'请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com',
|
||||
)}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
showClear
|
||||
disabled={isIonetLocked}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputs.type === 45 && !doubaoApiEditUnlocked && (
|
||||
<div>
|
||||
<Form.Select
|
||||
field='base_url'
|
||||
label={t('API地址')}
|
||||
placeholder={t('请选择API地址')}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
optionList={[
|
||||
{
|
||||
value: 'https://ark.cn-beijing.volces.com',
|
||||
label: 'https://ark.cn-beijing.volces.com',
|
||||
},
|
||||
{
|
||||
value:
|
||||
'https://ark.ap-southeast.bytepluses.com',
|
||||
label:
|
||||
'https://ark.ap-southeast.bytepluses.com',
|
||||
},
|
||||
{
|
||||
value: 'doubao-coding-plan',
|
||||
}
|
||||
optionList={[
|
||||
{
|
||||
value: 'https://ark.cn-beijing.volces.com',
|
||||
label: 'https://ark.cn-beijing.volces.com',
|
||||
},
|
||||
{
|
||||
value: 'https://ark.ap-southeast.bytepluses.com',
|
||||
label: 'https://ark.ap-southeast.bytepluses.com',
|
||||
},
|
||||
{
|
||||
value: 'doubao-coding-plan',
|
||||
label: 'Doubao Coding Plan',
|
||||
},
|
||||
]}
|
||||
defaultValue='https://ark.cn-beijing.volces.com'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
]}defaultValue='https://ark.cn-beijing.volces.com'
|
||||
disabled={isIonetLocked}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
@@ -2458,72 +2530,80 @@ const EditChannelModal = (props) => {
|
||||
{t('获取模型列表')}
|
||||
</Button>
|
||||
)}
|
||||
{inputs.type === 4 && isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='warning'
|
||||
onClick={() => handleInputChange('models', [])}
|
||||
type='primary'
|
||||
theme='light'
|
||||
onClick={() => setOllamaModalVisible(true)}
|
||||
>
|
||||
{t('清除所有模型')}
|
||||
{t('Ollama 模型管理')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
if (inputs.models.length === 0) {
|
||||
showInfo(t('没有模型可以复制'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
copy(inputs.models.join(','));
|
||||
showSuccess(t('模型列表已复制到剪贴板'));
|
||||
} catch (error) {
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('复制所有模型')}
|
||||
</Button>
|
||||
{modelGroups &&
|
||||
modelGroups.length > 0 &&
|
||||
modelGroups.map((group) => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
let items = [];
|
||||
try {
|
||||
if (Array.isArray(group.items)) {
|
||||
items = group.items;
|
||||
} else if (
|
||||
typeof group.items === 'string'
|
||||
) {
|
||||
const parsed = JSON.parse(
|
||||
group.items || '[]',
|
||||
);
|
||||
if (Array.isArray(parsed)) items = parsed;
|
||||
}
|
||||
} catch {}
|
||||
const current =
|
||||
formApiRef.current?.getValue('models') ||
|
||||
inputs.models ||
|
||||
[];
|
||||
const merged = Array.from(
|
||||
new Set(
|
||||
[...current, ...items]
|
||||
.map((m) => (m || '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
handleInputChange('models', merged);
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size='small'
|
||||
type='warning'
|
||||
onClick={() => handleInputChange('models', [])}
|
||||
>
|
||||
{t('清除所有模型')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
if (inputs.models.length === 0) {
|
||||
showInfo(t('没有模型可以复制'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
copy(inputs.models.join(','));
|
||||
showSuccess(t('模型列表已复制到剪贴板'));
|
||||
} catch (error) {
|
||||
showError(t('复制失败'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('复制所有模型')}
|
||||
</Button>
|
||||
{modelGroups &&
|
||||
modelGroups.length > 0 &&
|
||||
modelGroups.map((group) => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
let items = [];
|
||||
try {
|
||||
if (Array.isArray(group.items)) {
|
||||
items = group.items;
|
||||
} else if (typeof group.items === 'string') {
|
||||
const parsed = JSON.parse(
|
||||
group.items || '[]',
|
||||
);
|
||||
if (Array.isArray(parsed)) items = parsed;
|
||||
}
|
||||
} catch {}
|
||||
const current =
|
||||
formApiRef.current?.getValue('models') ||
|
||||
inputs.models ||
|
||||
[];
|
||||
const merged = Array.from(
|
||||
new Set(
|
||||
[...current, ...items]
|
||||
.map((m) => (m || '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
handleInputChange('models', merged);
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='custom_model'
|
||||
@@ -3083,6 +3163,33 @@ const EditChannelModal = (props) => {
|
||||
}}
|
||||
onCancel={() => setModelModalVisible(false)}
|
||||
/>
|
||||
|
||||
<OllamaModelModal
|
||||
visible={ollamaModalVisible}
|
||||
onCancel={() => setOllamaModalVisible(false)}
|
||||
channelId={channelId}
|
||||
channelInfo={inputs}
|
||||
onModelsUpdate={(options = {}) => {
|
||||
// 当模型更新后,重新获取模型列表以更新表单
|
||||
fetchUpstreamModelList('models', { silent: !!options.silent });
|
||||
}}
|
||||
onApplyModels={({ mode, modelIds } = {}) => {
|
||||
if (!Array.isArray(modelIds) || modelIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const existingModels = Array.isArray(inputs.models)
|
||||
? inputs.models.map(String)
|
||||
: [];
|
||||
const incoming = modelIds.map(String);
|
||||
const nextModels = Array.from(new Set([...existingModels, ...incoming]));
|
||||
|
||||
handleInputChange('models', nextModels);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('models', nextModels);
|
||||
}
|
||||
showSuccess(t('模型列表已追加更新'));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,7 +47,20 @@ const ModelSelectModal = ({
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [checkedList, setCheckedList] = useState(selected);
|
||||
|
||||
const getModelName = (model) => {
|
||||
if (!model) return '';
|
||||
if (typeof model === 'string') return model;
|
||||
if (typeof model === 'object' && model.model_name) return model.model_name;
|
||||
return String(model ?? '');
|
||||
};
|
||||
|
||||
const normalizedSelected = useMemo(
|
||||
() => (selected || []).map(getModelName),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const [checkedList, setCheckedList] = useState(normalizedSelected);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('new');
|
||||
|
||||
@@ -105,9 +118,9 @@ const ModelSelectModal = ({
|
||||
// 同步外部选中值
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCheckedList(selected);
|
||||
setCheckedList(normalizedSelected);
|
||||
}
|
||||
}, [visible, selected]);
|
||||
}, [visible, normalizedSelected]);
|
||||
|
||||
// 当模型列表变化时,设置默认tab
|
||||
useEffect(() => {
|
||||
|
||||
806
web/src/components/table/channels/modals/OllamaModelModal.jsx
Normal file
806
web/src/components/table/channels/modals/OllamaModelModal.jsx
Normal file
@@ -0,0 +1,806 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='blue'
|
||||
className='mr-3 shadow-md'
|
||||
>
|
||||
<IconServer size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Title heading={4} className='m-0'>
|
||||
{t('Ollama 模型管理')}
|
||||
</Title>
|
||||
<Text type='tertiary' size='small'>
|
||||
{channelInfo?.name && `${channelInfo.name} - `}
|
||||
{t('管理 Ollama 模型的拉取和删除')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
width={800}
|
||||
style={{ maxWidth: '95vw' }}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={onCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('关闭')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
{/* 拉取新模型 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='green' className='mr-2'>
|
||||
<IconPlus size={16} />
|
||||
</Avatar>
|
||||
<Title heading={5} className='m-0'>
|
||||
{t('拉取新模型')}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Row gutter={12} align='middle'>
|
||||
<Col span={16}>
|
||||
<Input
|
||||
placeholder={t('请输入模型名称,例如: llama3.2, qwen2.5:7b')}
|
||||
value={pullModelName}
|
||||
onChange={(value) => setPullModelName(value)}
|
||||
onEnterPress={pullModel}
|
||||
disabled={pullLoading}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={pullModel}
|
||||
loading={pullLoading}
|
||||
disabled={!pullModelName.trim()}
|
||||
icon={<IconDownload />}
|
||||
block
|
||||
>
|
||||
{pullLoading ? t('拉取中...') : t('拉取模型')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 进度条显示 */}
|
||||
{pullProgress && (() => {
|
||||
const completedBytes = Number(pullProgress.completed) || 0;
|
||||
const totalBytes = Number(pullProgress.total) || 0;
|
||||
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
|
||||
const safePercent = hasTotal
|
||||
? Math.min(
|
||||
100,
|
||||
Math.max(0, Math.round((completedBytes / totalBytes) * 100)),
|
||||
)
|
||||
: null;
|
||||
const percentText = hasTotal && safePercent !== null
|
||||
? `${safePercent.toFixed(0)}%`
|
||||
: pullProgress.status || t('处理中');
|
||||
|
||||
return (
|
||||
<div className='mt-3 p-3 bg-gray-50 rounded-lg'>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<Text strong>{t('拉取进度')}</Text>
|
||||
<Text type='tertiary' size='small'>{percentText}</Text>
|
||||
</div>
|
||||
|
||||
{hasTotal && safePercent !== null ? (
|
||||
<div>
|
||||
<Progress
|
||||
percent={safePercent}
|
||||
showInfo={false}
|
||||
stroke='#1890ff'
|
||||
size='small'
|
||||
/>
|
||||
<div className='flex justify-between mt-1'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{(completedBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
|
||||
</Text>
|
||||
<Text type='tertiary' size='small'>
|
||||
{(totalBytes / (1024 * 1024 * 1024)).toFixed(2)} GB
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
|
||||
<Spin size='small' />
|
||||
<span>{t('准备中...')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t('支持拉取 Ollama 官方模型库中的所有模型,拉取过程可能需要几分钟时间')}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
{/* 已有模型列表 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center justify-between mb-4'>
|
||||
<div className='flex items-center'>
|
||||
<Avatar size='small' color='purple' className='mr-2'>
|
||||
<IconServer size={16} />
|
||||
</Avatar>
|
||||
<Title heading={5} className='m-0'>
|
||||
{t('已有模型')}
|
||||
{models.length > 0 && (
|
||||
<Tag color='blue' className='ml-2'>
|
||||
{models.length}
|
||||
</Tag>
|
||||
)}
|
||||
</Title>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型...')}
|
||||
value={searchValue}
|
||||
onChange={(value) => setSearchValue(value)}
|
||||
style={{ width: 200 }}
|
||||
showClear
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
theme='borderless'
|
||||
onClick={handleSelectAll}
|
||||
disabled={models.length === 0}
|
||||
>
|
||||
{t('全选')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
theme='borderless'
|
||||
onClick={handleClearSelection}
|
||||
disabled={selectedModelIds.length === 0}
|
||||
>
|
||||
{t('清空')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
icon={<IconPlus />}
|
||||
onClick={handleApplyAllModels}
|
||||
disabled={selectedModelIds.length === 0}
|
||||
size='small'
|
||||
>
|
||||
{t('加入渠道')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={fetchModels}
|
||||
loading={loading}
|
||||
icon={<IconRefresh />}
|
||||
size='small'
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{filteredModels.length === 0 ? (
|
||||
<Empty
|
||||
image={<IconServer size={60} />}
|
||||
title={searchValue ? t('未找到匹配的模型') : t('暂无模型')}
|
||||
description={
|
||||
searchValue
|
||||
? t('请尝试其他搜索关键词')
|
||||
: t('您可以在上方拉取需要的模型')
|
||||
}
|
||||
style={{ padding: '40px 0' }}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
dataSource={filteredModels}
|
||||
split={false}
|
||||
renderItem={(model, index) => (
|
||||
<List.Item
|
||||
key={model.id}
|
||||
className='hover:bg-gray-50 rounded-lg p-3 transition-colors'
|
||||
>
|
||||
<div className='flex items-center justify-between w-full'>
|
||||
<div className='flex items-center flex-1 min-w-0 gap-3'>
|
||||
<Checkbox
|
||||
checked={selectedModelIds.includes(model.id)}
|
||||
onChange={(checked) => handleToggleModel(model.id, checked)}
|
||||
/>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='blue'
|
||||
className='flex-shrink-0'
|
||||
>
|
||||
{model.id.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<Text strong className='block truncate'>
|
||||
{model.id}
|
||||
</Text>
|
||||
<div className='flex items-center space-x-2 mt-1'>
|
||||
<Tag color='cyan' size='small'>
|
||||
{model.owned_by || 'ollama'}
|
||||
</Tag>
|
||||
{model.size && (
|
||||
<Text type='tertiary' size='small'>
|
||||
{formatModelSize(model.size)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2 ml-4'>
|
||||
<Popconfirm
|
||||
title={t('确认删除模型')}
|
||||
content={t('删除后无法恢复,确定要删除模型 "{{name}}" 吗?', { name: model.id })}
|
||||
onConfirm={() => deleteModel(model.id)}
|
||||
okText={t('确认')}
|
||||
cancelText={t('取消')}
|
||||
>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='danger'
|
||||
size='small'
|
||||
icon={<IconDelete />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default OllamaModelModal;
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<div className='flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1'>
|
||||
<Button
|
||||
type='primary'
|
||||
className='flex-1 md:flex-initial'
|
||||
onClick={handleAddDeployment}
|
||||
size='small'
|
||||
>
|
||||
{t('新建容器')}
|
||||
</Button>
|
||||
|
||||
{hasSelected && (
|
||||
<>
|
||||
<Popconfirm
|
||||
title={t('确认删除')}
|
||||
content={`${t('确定要删除选中的')} ${selectedKeys.length} ${t('个部署吗?此操作不可逆。')}`}
|
||||
okText={t('删除')}
|
||||
cancelText={t('取消')}
|
||||
okType='danger'
|
||||
onConfirm={handleBatchDelete}
|
||||
>
|
||||
<Button
|
||||
type='danger'
|
||||
className='flex-1 md:flex-initial'
|
||||
disabled={selectedKeys.length === 0}
|
||||
size='small'
|
||||
>
|
||||
{t('批量删除')} ({selectedKeys.length})
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
className='flex-1 md:flex-initial'
|
||||
onClick={handleDeselectAll}
|
||||
size='small'
|
||||
>
|
||||
{t('取消选择')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Compact Mode */}
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentsActions;
|
||||
@@ -0,0 +1,672 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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: <FaPlay size={12} className='text-green-600' />,
|
||||
},
|
||||
deploying: {
|
||||
color: 'blue',
|
||||
label: t('部署中'),
|
||||
icon: <FaSpinner size={12} className='text-blue-600' />,
|
||||
},
|
||||
pending: {
|
||||
color: 'orange',
|
||||
label: t('待部署'),
|
||||
icon: <FaClock size={12} className='text-orange-600' />,
|
||||
},
|
||||
stopped: {
|
||||
color: 'grey',
|
||||
label: t('已停止'),
|
||||
icon: <FaStop size={12} className='text-gray-500' />,
|
||||
},
|
||||
error: {
|
||||
color: 'red',
|
||||
label: t('错误'),
|
||||
icon: <FaExclamationCircle size={12} className='text-red-500' />,
|
||||
},
|
||||
failed: {
|
||||
color: 'red',
|
||||
label: t('失败'),
|
||||
icon: <FaExclamationCircle size={12} className='text-red-500' />,
|
||||
},
|
||||
destroyed: {
|
||||
color: 'red',
|
||||
label: t('已销毁'),
|
||||
icon: <FaBan size={12} className='text-red-500' />,
|
||||
},
|
||||
completed: {
|
||||
color: 'green',
|
||||
label: t('已完成'),
|
||||
icon: <FaCheckCircle size={12} className='text-green-600' />,
|
||||
},
|
||||
'deployment requested': {
|
||||
color: 'blue',
|
||||
label: t('部署请求中'),
|
||||
icon: <FaSpinner size={12} className='text-blue-600' />,
|
||||
},
|
||||
'termination requested': {
|
||||
color: 'orange',
|
||||
label: t('终止请求中'),
|
||||
icon: <FaClock size={12} className='text-orange-600' />,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_STATUS_CONFIG = {
|
||||
color: 'grey',
|
||||
label: null,
|
||||
icon: <FaInfoCircle size={12} className='text-gray-500' />,
|
||||
};
|
||||
|
||||
const parsePercentValue = (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseFloat(value.replace(/[^0-9.+-]/g, ''));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const clampPercent = (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
return Math.min(100, Math.max(0, Math.round(value)));
|
||||
};
|
||||
|
||||
const formatRemainingMinutes = (minutes, t) => {
|
||||
if (minutes === null || minutes === undefined) return null;
|
||||
const numeric = Number(minutes);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
const totalMinutes = Math.max(0, Math.round(numeric));
|
||||
const days = Math.floor(totalMinutes / 1440);
|
||||
const hours = Math.floor((totalMinutes % 1440) / 60);
|
||||
const mins = totalMinutes % 60;
|
||||
const parts = [];
|
||||
|
||||
if (days > 0) {
|
||||
parts.push(`${days}${t('天')}`);
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}${t('小时')}`);
|
||||
}
|
||||
if (parts.length === 0 || mins > 0) {
|
||||
parts.push(`${mins}${t('分钟')}`);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const getRemainingTheme = (percentRemaining) => {
|
||||
if (percentRemaining === null) {
|
||||
return {
|
||||
iconColor: 'var(--semi-color-primary)',
|
||||
tagColor: 'blue',
|
||||
textColor: 'var(--semi-color-text-2)',
|
||||
};
|
||||
}
|
||||
|
||||
if (percentRemaining <= 10) {
|
||||
return {
|
||||
iconColor: '#ff5a5f',
|
||||
tagColor: 'red',
|
||||
textColor: '#ff5a5f',
|
||||
};
|
||||
}
|
||||
|
||||
if (percentRemaining <= 30) {
|
||||
return {
|
||||
iconColor: '#ffb400',
|
||||
tagColor: 'orange',
|
||||
textColor: '#ffb400',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
iconColor: '#2ecc71',
|
||||
tagColor: 'green',
|
||||
textColor: '#2ecc71',
|
||||
};
|
||||
};
|
||||
|
||||
const renderStatus = (status, t) => {
|
||||
const normalizedStatus = normalizeStatus(status);
|
||||
const config = STATUS_TAG_CONFIG[normalizedStatus] || DEFAULT_STATUS_CONFIG;
|
||||
const statusText = typeof status === 'string' ? status : '';
|
||||
const labelText = config.label ? t(config.label) : statusText || t('未知状态');
|
||||
|
||||
return (
|
||||
<Tag
|
||||
color={config.color}
|
||||
shape='circle'
|
||||
size='small'
|
||||
prefixIcon={config.icon}
|
||||
>
|
||||
{labelText}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Container Name Cell Component - to properly handle React hooks
|
||||
const ContainerNameCell = ({ text, record, t }) => {
|
||||
const handleCopyId = () => {
|
||||
navigator.clipboard.writeText(record.id);
|
||||
showSuccess(t('ID已复制到剪贴板'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Typography.Text strong className="text-base">
|
||||
{text}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
size="small"
|
||||
className="text-xs cursor-pointer hover:text-blue-600 transition-colors select-all"
|
||||
onClick={handleCopyId}
|
||||
title={t('点击复制ID')}
|
||||
>
|
||||
ID: {record.id}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render resource configuration
|
||||
const renderResourceConfig = (resource, t) => {
|
||||
if (!resource) return '-';
|
||||
|
||||
const { cpu, memory, gpu } = resource;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{cpu && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<FaMicrochip className="text-blue-500" />
|
||||
<span>CPU: {cpu}</span>
|
||||
</div>
|
||||
)}
|
||||
{memory && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<FaMemory className="text-green-500" />
|
||||
<span>内存: {memory}</span>
|
||||
</div>
|
||||
)}
|
||||
{gpu && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<FaServer className="text-purple-500" />
|
||||
<span>GPU: {gpu}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render instance count with status indicator
|
||||
const renderInstanceCount = (count, record, t) => {
|
||||
const normalizedStatus = normalizeStatus(record?.status);
|
||||
const statusConfig = STATUS_TAG_CONFIG[normalizedStatus];
|
||||
const countColor = statusConfig?.color ?? 'grey';
|
||||
|
||||
return (
|
||||
<Tag color={countColor} size="small" shape='circle'>
|
||||
{count || 0} {t('个实例')}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Main function to get all deployment columns
|
||||
export const getDeploymentsColumns = ({
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
startDeployment,
|
||||
restartDeployment,
|
||||
deleteDeployment,
|
||||
setEditingDeployment,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
activePage,
|
||||
deployments,
|
||||
// New handlers for enhanced operations
|
||||
onViewLogs,
|
||||
onExtendDuration,
|
||||
onViewDetails,
|
||||
onUpdateConfig,
|
||||
onSyncToChannel,
|
||||
}) => {
|
||||
const columns = [
|
||||
{
|
||||
title: t('容器名称'),
|
||||
dataIndex: 'container_name',
|
||||
key: COLUMN_KEYS.container_name,
|
||||
width: 300,
|
||||
ellipsis: true,
|
||||
render: (text, record) => (
|
||||
<ContainerNameCell
|
||||
text={text}
|
||||
record={record}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: 'status',
|
||||
key: COLUMN_KEYS.status,
|
||||
width: 140,
|
||||
render: (status) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{renderStatus(status, t)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('服务商'),
|
||||
dataIndex: 'provider',
|
||||
key: COLUMN_KEYS.provider,
|
||||
width: 140,
|
||||
render: (provider) =>
|
||||
provider ? (
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
|
||||
style={{
|
||||
borderColor: 'rgba(59, 130, 246, 0.4)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.08)',
|
||||
color: '#2563eb',
|
||||
}}
|
||||
>
|
||||
<FaGlobe className="text-[11px]" />
|
||||
<span>{provider}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="tertiary" size="small" className="text-xs text-gray-500">
|
||||
{t('暂无')}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('剩余时间'),
|
||||
dataIndex: 'time_remaining',
|
||||
key: COLUMN_KEYS.time_remaining,
|
||||
width: 140,
|
||||
render: (text, record) => {
|
||||
const normalizedStatus = normalizeStatus(record?.status);
|
||||
const percentUsedRaw = parsePercentValue(record?.completed_percent);
|
||||
const percentUsed = clampPercent(percentUsedRaw);
|
||||
const percentRemaining =
|
||||
percentUsed === null ? null : clampPercent(100 - percentUsed);
|
||||
const theme = getRemainingTheme(percentRemaining);
|
||||
const statusDisplayMap = {
|
||||
completed: t('已完成'),
|
||||
destroyed: t('已销毁'),
|
||||
failed: t('失败'),
|
||||
error: t('失败'),
|
||||
stopped: t('已停止'),
|
||||
pending: t('待部署'),
|
||||
deploying: t('部署中'),
|
||||
'deployment requested': t('部署请求中'),
|
||||
'termination requested': t('终止中'),
|
||||
};
|
||||
const statusOverride = statusDisplayMap[normalizedStatus];
|
||||
const baseTimeDisplay =
|
||||
text && String(text).trim() !== '' ? text : t('计算中');
|
||||
const timeDisplay = baseTimeDisplay;
|
||||
const humanReadable = formatRemainingMinutes(
|
||||
record.compute_minutes_remaining,
|
||||
t,
|
||||
);
|
||||
const showProgress = !statusOverride && normalizedStatus === 'running';
|
||||
const showExtraInfo = Boolean(humanReadable || percentUsed !== null);
|
||||
const showRemainingMeta =
|
||||
record.compute_minutes_remaining !== undefined &&
|
||||
record.compute_minutes_remaining !== null &&
|
||||
percentRemaining !== null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 leading-tight text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FaHourglassHalf
|
||||
className="text-sm"
|
||||
style={{ color: theme.iconColor }}
|
||||
/>
|
||||
<Typography.Text className="text-sm font-medium text-[var(--semi-color-text-0)]">
|
||||
{timeDisplay}
|
||||
</Typography.Text>
|
||||
{showProgress && percentRemaining !== null ? (
|
||||
<Tag size="small" color={theme.tagColor}>
|
||||
{percentRemaining}%
|
||||
</Tag>
|
||||
) : statusOverride ? (
|
||||
<Tag size="small" color="grey">
|
||||
{statusOverride}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
{showExtraInfo && (
|
||||
<div className="flex items-center gap-3 text-[var(--semi-color-text-2)]">
|
||||
{humanReadable && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FaClock className="text-[11px]" />
|
||||
{t('约')} {humanReadable}
|
||||
</span>
|
||||
)}
|
||||
{percentUsed !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FaCheckCircle className="text-[11px]" />
|
||||
{t('已用')} {percentUsed}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showProgress && showRemainingMeta && (
|
||||
<div className="text-[10px]" style={{ color: theme.textColor }}>
|
||||
{t('剩余')} {record.compute_minutes_remaining} {t('分钟')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('硬件配置'),
|
||||
dataIndex: 'hardware_info',
|
||||
key: COLUMN_KEYS.hardware_info,
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
render: (text, record) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-green-50 border border-green-200 rounded-md">
|
||||
<FaServer className="text-green-600 text-xs" />
|
||||
<span className="text-xs font-medium text-green-700">
|
||||
{record.hardware_name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-medium">x{record.hardware_quantity}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_at',
|
||||
key: COLUMN_KEYS.created_at,
|
||||
width: 150,
|
||||
render: (text) => (
|
||||
<span className="text-sm text-gray-600">{timestamp2string(text)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
key: COLUMN_KEYS.actions,
|
||||
fixed: 'right',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
const { status, id } = record;
|
||||
const normalizedStatus = normalizeStatus(status);
|
||||
const isEnded = normalizedStatus === 'completed' || normalizedStatus === 'destroyed';
|
||||
|
||||
const handleDelete = () => {
|
||||
// Use enhanced confirmation dialog
|
||||
onUpdateConfig?.(record, 'delete');
|
||||
};
|
||||
|
||||
// Get primary action based on status
|
||||
const getPrimaryAction = () => {
|
||||
switch (normalizedStatus) {
|
||||
case 'running':
|
||||
return {
|
||||
icon: <FaInfoCircle className="text-xs" />,
|
||||
text: t('查看详情'),
|
||||
onClick: () => onViewDetails?.(record),
|
||||
type: 'secondary',
|
||||
theme: 'borderless',
|
||||
};
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return {
|
||||
icon: <FaPlay className="text-xs" />,
|
||||
text: t('重试'),
|
||||
onClick: () => startDeployment(id),
|
||||
type: 'primary',
|
||||
theme: 'solid',
|
||||
};
|
||||
case 'stopped':
|
||||
return {
|
||||
icon: <FaPlay className="text-xs" />,
|
||||
text: t('启动'),
|
||||
onClick: () => startDeployment(id),
|
||||
type: 'primary',
|
||||
theme: 'solid',
|
||||
};
|
||||
case 'deployment requested':
|
||||
case 'deploying':
|
||||
return {
|
||||
icon: <FaClock className="text-xs" />,
|
||||
text: t('部署中'),
|
||||
onClick: () => {},
|
||||
type: 'secondary',
|
||||
theme: 'light',
|
||||
disabled: true,
|
||||
};
|
||||
case 'pending':
|
||||
return {
|
||||
icon: <FaClock className="text-xs" />,
|
||||
text: t('待部署'),
|
||||
onClick: () => {},
|
||||
type: 'secondary',
|
||||
theme: 'light',
|
||||
disabled: true,
|
||||
};
|
||||
case 'termination requested':
|
||||
return {
|
||||
icon: <FaClock className="text-xs" />,
|
||||
text: t('终止中'),
|
||||
onClick: () => {},
|
||||
type: 'secondary',
|
||||
theme: 'light',
|
||||
disabled: true,
|
||||
};
|
||||
case 'completed':
|
||||
case 'destroyed':
|
||||
default:
|
||||
return {
|
||||
icon: <FaInfoCircle className="text-xs" />,
|
||||
text: t('已结束'),
|
||||
onClick: () => {},
|
||||
type: 'tertiary',
|
||||
theme: 'borderless',
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const primaryAction = getPrimaryAction();
|
||||
const primaryTheme = primaryAction.theme || 'solid';
|
||||
const primaryType = primaryAction.type || 'primary';
|
||||
|
||||
if (isEnded) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-start gap-1 pr-2">
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
onClick={() => onViewDetails?.(record)}
|
||||
icon={<FaInfoCircle className="text-xs" />}
|
||||
>
|
||||
{t('查看详情')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// All actions dropdown with enhanced operations
|
||||
const dropdownItems = [
|
||||
<Dropdown.Item key="details" onClick={() => onViewDetails?.(record)} icon={<FaInfoCircle />}>
|
||||
{t('查看详情')}
|
||||
</Dropdown.Item>,
|
||||
];
|
||||
|
||||
if (!isEnded) {
|
||||
dropdownItems.push(
|
||||
<Dropdown.Item key="logs" onClick={() => onViewLogs?.(record)} icon={<FaTerminal />}>
|
||||
{t('查看日志')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
|
||||
const managementItems = [];
|
||||
if (normalizedStatus === 'running') {
|
||||
if (onSyncToChannel) {
|
||||
managementItems.push(
|
||||
<Dropdown.Item key="sync-channel" onClick={() => onSyncToChannel(record)} icon={<FaLink />}>
|
||||
{t('同步到渠道')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (normalizedStatus === 'failed' || normalizedStatus === 'error') {
|
||||
managementItems.push(
|
||||
<Dropdown.Item key="retry" onClick={() => startDeployment(id)} icon={<FaPlay />}>
|
||||
{t('重试')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
if (normalizedStatus === 'stopped') {
|
||||
managementItems.push(
|
||||
<Dropdown.Item key="start" onClick={() => startDeployment(id)} icon={<FaPlay />}>
|
||||
{t('启动')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
|
||||
if (managementItems.length > 0) {
|
||||
dropdownItems.push(<Dropdown.Divider key="management-divider" />);
|
||||
dropdownItems.push(...managementItems);
|
||||
}
|
||||
|
||||
const configItems = [];
|
||||
if (!isEnded && (normalizedStatus === 'running' || normalizedStatus === 'deployment requested')) {
|
||||
configItems.push(
|
||||
<Dropdown.Item key="extend" onClick={() => onExtendDuration?.(record)} icon={<FaPlus />}>
|
||||
{t('延长时长')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
// if (!isEnded && normalizedStatus === 'running') {
|
||||
// configItems.push(
|
||||
// <Dropdown.Item key="update-config" onClick={() => onUpdateConfig?.(record)} icon={<FaCog />}>
|
||||
// {t('更新配置')}
|
||||
// </Dropdown.Item>,
|
||||
// );
|
||||
// }
|
||||
|
||||
if (configItems.length > 0) {
|
||||
dropdownItems.push(<Dropdown.Divider key="config-divider" />);
|
||||
dropdownItems.push(...configItems);
|
||||
}
|
||||
if (!isEnded) {
|
||||
dropdownItems.push(<Dropdown.Divider key="danger-divider" />);
|
||||
dropdownItems.push(
|
||||
<Dropdown.Item key="delete" type="danger" onClick={handleDelete} icon={<FaTrash />}>
|
||||
{t('销毁容器')}
|
||||
</Dropdown.Item>,
|
||||
);
|
||||
}
|
||||
|
||||
const allActions = <Dropdown.Menu>{dropdownItems}</Dropdown.Menu>;
|
||||
const hasDropdown = dropdownItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-start gap-1 pr-2">
|
||||
<Button
|
||||
size="small"
|
||||
theme={primaryTheme}
|
||||
type={primaryType}
|
||||
icon={primaryAction.icon}
|
||||
onClick={primaryAction.onClick}
|
||||
className="px-2 text-xs"
|
||||
disabled={primaryAction.disabled}
|
||||
>
|
||||
{primaryAction.text}
|
||||
</Button>
|
||||
|
||||
{hasDropdown && (
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
render={allActions}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
icon={<IconMore />}
|
||||
className="px-1"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return columns;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<Form
|
||||
layout='horizontal'
|
||||
onSubmit={handleSubmit}
|
||||
initValues={formInitValues}
|
||||
getFormApi={(formApi) => {
|
||||
setFormApi(formApi);
|
||||
formApiRef.current = formApi;
|
||||
}}
|
||||
className='w-full md:w-auto order-1 md:order-2'
|
||||
>
|
||||
<div className='flex flex-col md:flex-row items-center gap-2 w-full md:w-auto'>
|
||||
<div className='w-full md:w-64'>
|
||||
<Form.Input
|
||||
field='searchKeyword'
|
||||
placeholder={t('搜索部署名称')}
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
size='small'
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='w-full md:w-48'>
|
||||
<Form.Select
|
||||
field='searchStatus'
|
||||
placeholder={t('选择状态')}
|
||||
optionList={statusOptions}
|
||||
className='w-full'
|
||||
showClear
|
||||
size='small'
|
||||
pure
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2 w-full md:w-auto'>
|
||||
<Button
|
||||
htmlType='submit'
|
||||
type='tertiary'
|
||||
icon={<IconSearch />}
|
||||
loading={searching}
|
||||
disabled={loading}
|
||||
size='small'
|
||||
className='flex-1 md:flex-initial md:w-auto'
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
icon={<IconRefresh />}
|
||||
onClick={handleReset}
|
||||
disabled={loading || searching}
|
||||
size='small'
|
||||
className='flex-1 md:flex-initial md:w-auto'
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={() => setShowColumnSelector(true)}
|
||||
size='small'
|
||||
className='flex-1 md:flex-initial md:w-auto'
|
||||
>
|
||||
{t('列设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentsFilters;
|
||||
247
web/src/components/table/model-deployments/DeploymentsTable.jsx
Normal file
247
web/src/components/table/model-deployments/DeploymentsTable.jsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<>
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={deployments}
|
||||
scroll={compactMode ? { x: 800 } : { x: 1200 }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: deploymentCount,
|
||||
pageSizeOpts: [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
expandAllRows={false}
|
||||
onRow={handleRow}
|
||||
rowSelection={{
|
||||
onChange: (selectedRowKeys, selectedRows) => {
|
||||
setSelectedKeys(selectedRows);
|
||||
},
|
||||
}}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className='rounded-xl overflow-hidden'
|
||||
size='middle'
|
||||
loading={loading || searching}
|
||||
/>
|
||||
|
||||
{/* Enhanced Modals */}
|
||||
<ViewLogsModal
|
||||
visible={showLogsModal}
|
||||
onCancel={() => setShowLogsModal(false)}
|
||||
deployment={selectedDeployment}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ExtendDurationModal
|
||||
visible={showExtendModal}
|
||||
onCancel={() => setShowExtendModal(false)}
|
||||
deployment={selectedDeployment}
|
||||
onSuccess={handleModalSuccess}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ViewDetailsModal
|
||||
visible={showDetailsModal}
|
||||
onCancel={() => setShowDetailsModal(false)}
|
||||
deployment={selectedDeployment}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<UpdateConfigModal
|
||||
visible={showConfigModal}
|
||||
onCancel={() => setShowConfigModal(false)}
|
||||
deployment={selectedDeployment}
|
||||
onSuccess={handleModalSuccess}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
visible={showConfirmDialog}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
onConfirm={handleConfirmAction}
|
||||
title={t('确认操作')}
|
||||
type="danger"
|
||||
deployment={selectedDeployment}
|
||||
operation={confirmOperation}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentsTable;
|
||||
147
web/src/components/table/model-deployments/index.jsx
Normal file
147
web/src/components/table/model-deployments/index.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 */}
|
||||
<EditDeploymentModal
|
||||
refresh={refresh}
|
||||
editingDeployment={editingDeployment}
|
||||
visible={showEdit}
|
||||
handleClose={closeEdit}
|
||||
/>
|
||||
|
||||
<CreateDeploymentModal
|
||||
visible={showCreateModal}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
onSuccess={refresh}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ColumnSelectorModal
|
||||
visible={showColumnSelector}
|
||||
onCancel={() => setShowColumnSelector(false)}
|
||||
visibleColumns={visibleColumns}
|
||||
onVisibleColumnsChange={setVisibleColumns}
|
||||
columnKeys={COLUMN_KEYS}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<CardPro
|
||||
type='type3'
|
||||
actionsArea={
|
||||
<div className='flex flex-col md:flex-row justify-between items-center gap-2 w-full'>
|
||||
<DeploymentsActions
|
||||
selectedKeys={selectedKeys}
|
||||
setSelectedKeys={setSelectedKeys}
|
||||
setEditingDeployment={setEditingDeployment}
|
||||
setShowEdit={setShowEdit}
|
||||
batchDeleteDeployments={batchDeleteDeployments}
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
showCreateModal={showCreateModal}
|
||||
setShowCreateModal={setShowCreateModal}
|
||||
setShowColumnSelector={setShowColumnSelector}
|
||||
t={t}
|
||||
/>
|
||||
<DeploymentsFilters
|
||||
formInitValues={formInitValues}
|
||||
setFormApi={setFormApi}
|
||||
searchDeployments={searchDeployments}
|
||||
loading={loading}
|
||||
searching={searching}
|
||||
setShowColumnSelector={setShowColumnSelector}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: deploymentsData.activePage,
|
||||
pageSize: deploymentsData.pageSize,
|
||||
total: deploymentsData.deploymentCount,
|
||||
onPageChange: deploymentsData.handlePageChange,
|
||||
onPageSizeChange: deploymentsData.handlePageSizeChange,
|
||||
isMobile: isMobile,
|
||||
t: deploymentsData.t,
|
||||
})}
|
||||
t={deploymentsData.t}
|
||||
>
|
||||
<DeploymentsTable {...deploymentsData} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentsPage;
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<Modal
|
||||
title={t('列设置')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button onClick={handleReset}>{t('重置')}</Button>
|
||||
<Button onClick={onCancel}>{t('取消')}</Button>
|
||||
<Button type='primary' onClick={handleConfirm}>
|
||||
{t('确定')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={indeterminate}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
>
|
||||
{t('全选')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div
|
||||
className='flex flex-wrap max-h-96 overflow-y-auto rounded-lg p-4'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
{columnOptions.map(({ key, label, required }) => (
|
||||
<div key={key} className='w-1/2 mb-4 pr-2'>
|
||||
<Checkbox
|
||||
checked={!!visibleColumns[key]}
|
||||
disabled={required}
|
||||
onChange={(e) =>
|
||||
handleColumnVisibilityChange(key, e.target.checked)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnSelectorModal;
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<Modal
|
||||
title={title}
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleConfirm}
|
||||
okText={t('确认')}
|
||||
cancelText={t('取消')}
|
||||
okButtonProps={{
|
||||
disabled: !isConfirmed,
|
||||
type: type === 'danger' ? 'danger' : 'primary',
|
||||
loading
|
||||
}}
|
||||
width={480}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Text type="danger" strong>
|
||||
{t('此操作具有风险,请确认要继续执行')}。
|
||||
</Text>
|
||||
<Text>
|
||||
{t('请输入部署名称以完成二次确认')}:
|
||||
<Text code className="ml-1">
|
||||
{requiredText || t('未知部署')}
|
||||
</Text>
|
||||
</Text>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={setConfirmText}
|
||||
placeholder={t('再次输入部署名称')}
|
||||
autoFocus
|
||||
/>
|
||||
{!isConfirmed && confirmText && (
|
||||
<Text type="danger" size="small">
|
||||
{t('部署名称不匹配,请检查后重新输入')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationDialog;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<SideSheet
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<Server size={20} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={handleClose}
|
||||
width={isMobile ? '100%' : 600}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
maskClosable={false}
|
||||
closeOnEsc={true}
|
||||
>
|
||||
<div className='p-6 h-full overflow-auto'>
|
||||
<Spin spinning={loading} style={{ width: '100%' }}>
|
||||
<Form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
labelPosition='top'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Card>
|
||||
<Title heading={5} style={{ marginBottom: 16 }}>
|
||||
{t('修改部署名称')}
|
||||
</Title>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='deployment_name'
|
||||
label={t('部署名称')}
|
||||
placeholder={t('请输入新的部署名称')}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入部署名称') },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9-_\u4e00-\u9fa5]+$/,
|
||||
message: t(
|
||||
'部署名称只能包含字母、数字、横线、下划线和中文',
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{isEdit && (
|
||||
<div className='mt-4 p-3 bg-gray-50 rounded'>
|
||||
<Text type='secondary'>{t('部署ID')}: </Text>
|
||||
<Text code>{editingDeployment.id}</Text>
|
||||
<br />
|
||||
<Text type='secondary'>{t('当前状态')}: </Text>
|
||||
<Tag
|
||||
color={
|
||||
editingDeployment.status === 'running' ? 'green' : 'grey'
|
||||
}
|
||||
>
|
||||
{editingDeployment.status}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Form>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
<div className='p-4 border-t border-gray-200 bg-gray-50 flex justify-end'>
|
||||
<Space>
|
||||
<Button theme='outline' onClick={handleClose} disabled={loading}>
|
||||
<X size={16} className='mr-1' />
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
onClick={() => formRef.current?.submitForm()}
|
||||
>
|
||||
<Save size={16} className='mr-1' />
|
||||
{isEdit ? t('更新') : t('创建')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditDeploymentModal;
|
||||
@@ -0,0 +1,548 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaClock className='text-blue-500' />
|
||||
<span>{t('延长容器时长')}</span>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleExtend}
|
||||
okText={t('确认延长')}
|
||||
cancelText={t('取消')}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{
|
||||
disabled:
|
||||
!deployment?.id || detailsLoading || !durationHours || durationHours < 1,
|
||||
}}
|
||||
width={600}
|
||||
className='extend-duration-modal'
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<Card className='border-0 bg-gray-50'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<Text strong className='text-base'>
|
||||
{deployment?.container_name || deployment?.deployment_name}
|
||||
</Text>
|
||||
<div className='mt-1'>
|
||||
<Text type='secondary' size='small'>
|
||||
ID: {deployment?.id}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-right'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
<Tag color='blue' size='small'>
|
||||
{resolvedHardwareName}
|
||||
{gpuCount ? ` x${gpuCount}` : ''}
|
||||
</Tag>
|
||||
</div>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('当前剩余')}: <Text strong>{currentRemainingTime}</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<FaExclamationTriangle />}
|
||||
title={t('重要提醒')}
|
||||
description={
|
||||
<div className='space-y-2'>
|
||||
<p>
|
||||
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
|
||||
</p>
|
||||
<p>
|
||||
{t('延长操作一旦确认无法撤销,费用将立即扣除。')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
getFormApi={(api) => (formRef.current = api)}
|
||||
layout='vertical'
|
||||
onValueChange={(values) => {
|
||||
if (values.duration_hours !== undefined) {
|
||||
const numericValue = Number(values.duration_hours);
|
||||
setDurationHours(Number.isFinite(numericValue) ? numericValue : 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.InputNumber
|
||||
field='duration_hours'
|
||||
label={t('延长时长(小时)')}
|
||||
placeholder={t('请输入要延长的小时数')}
|
||||
min={1}
|
||||
max={720}
|
||||
step={1}
|
||||
initValue={1}
|
||||
style={{ width: '100%' }}
|
||||
suffix={t('小时')}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入延长时长') },
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
message: t('延长时长至少为1小时'),
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
max: 720,
|
||||
message: t('延长时长不能超过720小时(30天)'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('快速选择')}:
|
||||
</Text>
|
||||
<Space wrap>
|
||||
{[1, 2, 6, 12, 24, 48, 72, 168].map((hours) => (
|
||||
<Button
|
||||
key={hours}
|
||||
size='small'
|
||||
theme={durationHours === hours ? 'solid' : 'borderless'}
|
||||
type={durationHours === hours ? 'primary' : 'secondary'}
|
||||
onClick={() => {
|
||||
setDurationHours(hours);
|
||||
if (formRef.current) {
|
||||
formRef.current.setValue('duration_hours', hours);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hours < 24
|
||||
? `${hours}${t('小时')}`
|
||||
: `${hours / 24}${t('天')}`}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<FaCalculator className='text-green-500' />
|
||||
<span>{t('费用预估')}</span>
|
||||
</div>
|
||||
}
|
||||
className='border border-green-200'
|
||||
>
|
||||
{priceEstimation ? (
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text>{t('延长时长')}:</Text>
|
||||
<Text strong>
|
||||
{Math.round(durationHours)} {t('小时')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text>{t('硬件配置')}:</Text>
|
||||
<Text strong>
|
||||
{resolvedHardwareName}
|
||||
{gpuCount ? ` x${gpuCount}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{containers ? (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text>{t('容器数量')}:</Text>
|
||||
<Text strong>{containers}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text>{t('单GPU小时费率')}:</Text>
|
||||
<Text strong>
|
||||
{typeof hourlyRate === 'number'
|
||||
? `${hourlyRate.toFixed(4)} ${currencyLabel}`
|
||||
: '--'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{typeof computeCost === 'number' && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text>{t('计算成本')}:</Text>
|
||||
<Text strong>
|
||||
{computeCost.toFixed(4)} {currencyLabel}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider margin='12px' />
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text strong className='text-lg'>
|
||||
{t('预估总费用')}:
|
||||
</Text>
|
||||
<Text strong className='text-lg text-green-600'>
|
||||
{typeof estimatedTotalCost === 'number'
|
||||
? `${estimatedTotalCost.toFixed(4)} ${currencyLabel}`
|
||||
: '--'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className='bg-blue-50 p-3 rounded-lg'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<FaInfoCircle className='text-blue-500 mt-0.5' />
|
||||
<div>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('延长后总时长')}: <Text strong>{newTotalTime}</Text>
|
||||
</Text>
|
||||
<br />
|
||||
<Text size='small' type='secondary'>
|
||||
{t('预估费用仅供参考,实际费用可能略有差异')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center text-gray-500 py-4'>
|
||||
{costLoading ? (
|
||||
<Space align='center' className='justify-center'>
|
||||
<Spin size='small' />
|
||||
<Text type='secondary'>{t('计算费用中...')}</Text>
|
||||
</Space>
|
||||
) : priceError ? (
|
||||
<Text type='danger'>{priceError}</Text>
|
||||
) : deploymentDetails ? (
|
||||
<Text type='secondary'>{t('请输入延长时长')}</Text>
|
||||
) : (
|
||||
<Text type='secondary'>{t('加载详情中...')}</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className='bg-red-50 border border-red-200 rounded-lg p-3'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<FaExclamationTriangle className='text-red-500 mt-0.5' />
|
||||
<div>
|
||||
<Text strong className='text-red-700'>
|
||||
{t('确认延长容器时长')}
|
||||
</Text>
|
||||
<div className='mt-1'>
|
||||
<Text size='small' className='text-red-600'>
|
||||
{t('点击"确认延长"后将立即扣除费用并延长容器运行时间')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtendDurationModal;
|
||||
@@ -0,0 +1,475 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaCog className="text-blue-500" />
|
||||
<span>{t('更新容器配置')}</span>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleUpdate}
|
||||
okText={t('更新配置')}
|
||||
cancelText={t('取消')}
|
||||
confirmLoading={loading}
|
||||
width={700}
|
||||
className="update-config-modal"
|
||||
>
|
||||
<div className="space-y-4 max-h-[600px] overflow-y-auto">
|
||||
{/* Container Info */}
|
||||
<Card className="border-0 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Text strong className="text-base">
|
||||
{deployment?.container_name}
|
||||
</Text>
|
||||
<div className="mt-1">
|
||||
<Text type="secondary" size="small">
|
||||
ID: {deployment?.id}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Tag color="blue">{deployment?.status}</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Warning Banner */}
|
||||
<Banner
|
||||
type="warning"
|
||||
icon={<FaExclamationTriangle />}
|
||||
title={t('重要提醒')}
|
||||
description={
|
||||
<div className="space-y-2">
|
||||
<p>{t('更新容器配置可能会导致容器重启,请确保在合适的时间进行此操作。')}</p>
|
||||
<p>{t('某些配置更改可能需要几分钟才能生效。')}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
getFormApi={(api) => (formRef.current = api)}
|
||||
layout="vertical"
|
||||
>
|
||||
<Collapse defaultActiveKey={['docker']}>
|
||||
{/* Docker Configuration */}
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaDocker className="text-blue-600" />
|
||||
<span>{t('Docker 配置')}</span>
|
||||
</div>
|
||||
}
|
||||
itemKey="docker"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Form.Input
|
||||
field="image_url"
|
||||
label={t('镜像地址')}
|
||||
placeholder={t('例如: nginx:latest')}
|
||||
rules={[
|
||||
{
|
||||
type: 'string',
|
||||
message: t('请输入有效的镜像地址')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="registry_username"
|
||||
label={t('镜像仓库用户名')}
|
||||
placeholder={t('如果镜像为私有,请填写用户名')}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="registry_secret"
|
||||
label={t('镜像仓库密码')}
|
||||
mode="password"
|
||||
placeholder={t('如果镜像为私有,请填写密码或Token')}
|
||||
/>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
|
||||
{/* Network Configuration */}
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaNetworkWired className="text-green-600" />
|
||||
<span>{t('网络配置')}</span>
|
||||
</div>
|
||||
}
|
||||
itemKey="network"
|
||||
>
|
||||
<Form.InputNumber
|
||||
field="traffic_port"
|
||||
label={t('流量端口')}
|
||||
placeholder={t('容器对外暴露的端口')}
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: '100%' }}
|
||||
rules={[
|
||||
{
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 65535,
|
||||
message: t('端口号必须在1-65535之间')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
|
||||
{/* Startup Configuration */}
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaTerminal className="text-purple-600" />
|
||||
<span>{t('启动配置')}</span>
|
||||
</div>
|
||||
}
|
||||
itemKey="startup"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Form.Input
|
||||
field="entrypoint"
|
||||
label={t('启动命令 (Entrypoint)')}
|
||||
placeholder={t('例如: /bin/bash -c "python app.py"')}
|
||||
helpText={t('多个命令用空格分隔')}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="command"
|
||||
label={t('运行命令 (Command)')}
|
||||
placeholder={t('容器启动后执行的命令')}
|
||||
/>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<Collapse.Panel
|
||||
header={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaKey className="text-orange-600" />
|
||||
<span>{t('环境变量')}</span>
|
||||
<Tag size="small">{envVars.length}</Tag>
|
||||
</div>
|
||||
}
|
||||
itemKey="env"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Regular Environment Variables */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Text strong>{t('普通环境变量')}</Text>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FaPlus />}
|
||||
onClick={addEnvVar}
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
>
|
||||
{t('添加')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{envVars.map((envVar, index) => (
|
||||
<div key={index} className="flex items-end gap-2 mb-2">
|
||||
<Input
|
||||
placeholder={t('变量名')}
|
||||
value={envVar.key}
|
||||
onChange={(value) => updateEnvVar(index, 'key', value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Text>=</Text>
|
||||
<Input
|
||||
placeholder={t('变量值')}
|
||||
value={envVar.value}
|
||||
onChange={(value) => updateEnvVar(index, 'value', value)}
|
||||
style={{ flex: 2 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FaMinus />}
|
||||
onClick={() => removeEnvVar(index)}
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{envVars.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-4 border-2 border-dashed border-gray-300 rounded-lg">
|
||||
<Text type="secondary">{t('暂无环境变量')}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Secret Environment Variables */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text strong>{t('机密环境变量')}</Text>
|
||||
<Tag size="small" type="danger">
|
||||
{t('加密存储')}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FaPlus />}
|
||||
onClick={addSecretEnvVar}
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
>
|
||||
{t('添加')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{secretEnvVars.map((envVar, index) => (
|
||||
<div key={index} className="flex items-end gap-2 mb-2">
|
||||
<Input
|
||||
placeholder={t('变量名')}
|
||||
value={envVar.key}
|
||||
onChange={(value) => updateSecretEnvVar(index, 'key', value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Text>=</Text>
|
||||
<Input
|
||||
mode="password"
|
||||
placeholder={t('变量值')}
|
||||
value={envVar.value}
|
||||
onChange={(value) => updateSecretEnvVar(index, 'value', value)}
|
||||
style={{ flex: 2 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FaMinus />}
|
||||
onClick={() => removeSecretEnvVar(index)}
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{secretEnvVars.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-4 border-2 border-dashed border-red-200 rounded-lg bg-red-50">
|
||||
<Text type="secondary">{t('暂无机密环境变量')}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Banner
|
||||
type="info"
|
||||
title={t('机密环境变量说明')}
|
||||
description={t('机密环境变量将被加密存储,适用于存储密码、API密钥等敏感信息。')}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Form>
|
||||
|
||||
{/* Final Warning */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<FaExclamationTriangle className="text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<Text strong className="text-yellow-800">
|
||||
{t('配置更新确认')}
|
||||
</Text>
|
||||
<div className="mt-1">
|
||||
<Text size="small" className="text-yellow-700">
|
||||
{t('更新配置后,容器可能需要重启以应用新的设置。请确保您了解这些更改的影响。')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateConfigModal;
|
||||
@@ -0,0 +1,517 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaInfoCircle className="text-blue-500" />
|
||||
<span>{t('容器详情')}</span>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
icon={<IconRefresh />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading || containersLoading}
|
||||
theme="borderless"
|
||||
>
|
||||
{t('刷新')}
|
||||
</Button>
|
||||
<Button onClick={onCancel}>
|
||||
{t('关闭')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
width={800}
|
||||
className="deployment-details-modal"
|
||||
>
|
||||
{loading && !details ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spin size="large" tip={t('加载详情中...')} />
|
||||
</div>
|
||||
) : details ? (
|
||||
<div className="space-y-4 max-h-[600px] overflow-y-auto">
|
||||
{/* Basic Info */}
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaServer className="text-blue-500" />
|
||||
<span>{t('基本信息')}</span>
|
||||
</div>
|
||||
}
|
||||
className="border-0 shadow-sm"
|
||||
>
|
||||
<Descriptions data={[
|
||||
{
|
||||
key: t('容器名称'),
|
||||
value: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Text strong className="text-base">
|
||||
{details.deployment_name || details.id}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
icon={<FaCopy />}
|
||||
onClick={handleCopyId}
|
||||
className="opacity-70 hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: t('容器ID'),
|
||||
value: (
|
||||
<Text type="secondary" className="font-mono text-sm">
|
||||
{details.id}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: t('状态'),
|
||||
value: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{statusConfig.icon}</span>
|
||||
<Tag color={statusConfig.color}>
|
||||
{t(statusConfig.text)}
|
||||
</Tag>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: t('创建时间'),
|
||||
value: timestamp2string(details.created_at)
|
||||
}
|
||||
]} />
|
||||
</Card>
|
||||
|
||||
{/* Hardware & Performance */}
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaChartLine className="text-green-500" />
|
||||
<span>{t('硬件与性能')}</span>
|
||||
</div>
|
||||
}
|
||||
className="border-0 shadow-sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Descriptions data={[
|
||||
{
|
||||
key: t('硬件类型'),
|
||||
value: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag color="blue">{details.brand_name}</Tag>
|
||||
<Text strong>{details.hardware_name}</Text>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: t('GPU数量'),
|
||||
value: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge count={details.total_gpus} theme="solid" type="primary">
|
||||
<FaServer className="text-purple-500" />
|
||||
</Badge>
|
||||
<Text>{t('总计')} {details.total_gpus} {t('个GPU')}</Text>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: t('容器配置'),
|
||||
value: (
|
||||
<div className="space-y-1">
|
||||
<div>{t('每容器GPU数')}: {details.gpus_per_container}</div>
|
||||
<div>{t('容器总数')}: {details.total_containers}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]} />
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text strong>{t('完成进度')}</Text>
|
||||
<Text>{details.completed_percent}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={details.completed_percent}
|
||||
status={details.completed_percent === 100 ? 'success' : 'normal'}
|
||||
strokeWidth={8}
|
||||
showInfo={false}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>{t('已服务')}: {details.compute_minutes_served} {t('分钟')}</span>
|
||||
<span>{t('剩余')}: {details.compute_minutes_remaining} {t('分钟')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Container Configuration */}
|
||||
{details.container_config && (
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaDocker className="text-blue-600" />
|
||||
<span>{t('容器配置')}</span>
|
||||
</div>
|
||||
}
|
||||
className="border-0 shadow-sm"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Descriptions data={[
|
||||
{
|
||||
key: t('镜像地址'),
|
||||
value: (
|
||||
<Text className="font-mono text-sm break-all">
|
||||
{details.container_config.image_url || 'N/A'}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: t('流量端口'),
|
||||
value: details.container_config.traffic_port || 'N/A'
|
||||
},
|
||||
{
|
||||
key: t('启动命令'),
|
||||
value: (
|
||||
<Text className="font-mono text-sm">
|
||||
{details.container_config.entrypoint ?
|
||||
details.container_config.entrypoint.join(' ') : 'N/A'
|
||||
}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
]} />
|
||||
|
||||
{/* Environment Variables */}
|
||||
{details.container_config.env_variables &&
|
||||
Object.keys(details.container_config.env_variables).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<Text strong className="block mb-2">{t('环境变量')}:</Text>
|
||||
<div className="bg-gray-50 p-3 rounded-lg max-h-32 overflow-y-auto">
|
||||
{Object.entries(details.container_config.env_variables).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2 text-sm font-mono mb-1">
|
||||
<span className="text-blue-600 font-medium">{key}=</span>
|
||||
<span className="text-gray-700 break-all">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Containers List */}
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaServer className="text-indigo-500" />
|
||||
<span>{t('容器实例')}</span>
|
||||
</div>
|
||||
}
|
||||
className="border-0 shadow-sm"
|
||||
>
|
||||
{containersLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Spin tip={t('加载容器信息中...')} />
|
||||
</div>
|
||||
) : containers.length === 0 ? (
|
||||
<Empty description={t('暂无容器信息')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{containers.map((ctr) => (
|
||||
<Card
|
||||
key={ctr.container_id}
|
||||
className="bg-gray-50 border border-gray-100"
|
||||
bodyStyle={{ padding: '12px 16px' }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Text strong className="font-mono text-sm">
|
||||
{ctr.container_id}
|
||||
</Text>
|
||||
<Text size="small" type="secondary">
|
||||
{t('设备')} {ctr.device_id || '--'} · {t('状态')} {ctr.status || '--'}
|
||||
</Text>
|
||||
<Text size="small" type="secondary">
|
||||
{t('创建时间')}: {ctr.created_at ? timestamp2string(ctr.created_at) : '--'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Tag color="blue" size="small">
|
||||
{t('GPU/容器')}: {ctr.gpus_per_container ?? '--'}
|
||||
</Tag>
|
||||
{ctr.public_url && (
|
||||
<Tooltip content={ctr.public_url}>
|
||||
<Button
|
||||
icon={<FaLink />}
|
||||
size="small"
|
||||
theme="light"
|
||||
onClick={() => window.open(ctr.public_url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{t('访问容器')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ctr.events && ctr.events.length > 0 && (
|
||||
<div className="mt-3 bg-white rounded-md border border-gray-100 p-3">
|
||||
<Text size="small" type="secondary" className="block mb-2">
|
||||
{t('最近事件')}
|
||||
</Text>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{ctr.events.map((event, index) => (
|
||||
<div key={`${ctr.container_id}-${event.time}-${index}`} className="flex gap-3 text-xs font-mono">
|
||||
<span className="text-gray-500 min-w-[140px]">
|
||||
{event.time ? timestamp2string(event.time) : '--'}
|
||||
</span>
|
||||
<span className="text-gray-700 break-all flex-1">
|
||||
{event.message || '--'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Location Information */}
|
||||
{details.locations && details.locations.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaMapMarkerAlt className="text-orange-500" />
|
||||
<span>{t('部署位置')}</span>
|
||||
</div>
|
||||
}
|
||||
className="border-0 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{details.locations.map((location) => (
|
||||
<Tag key={location.id} color="orange" size="large">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>🌍</span>
|
||||
<span>{location.name} ({location.iso2})</span>
|
||||
</div>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cost Information */}
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaMoneyBillWave className="text-green-500" />
|
||||
<span>{t('费用信息')}</span>
|
||||
</div>
|
||||
}
|
||||
className="border-0 shadow-sm"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<Text>{t('已支付金额')}</Text>
|
||||
<Text strong className="text-lg text-green-600">
|
||||
${details.amount_paid ? details.amount_paid.toFixed(2) : '0.00'} USDC
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<Text type="secondary">{t('计费开始')}:</Text>
|
||||
<Text>{details.started_at ? timestamp2string(details.started_at) : 'N/A'}</Text>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Text type="secondary">{t('预计结束')}:</Text>
|
||||
<Text>{details.finished_at ? timestamp2string(details.finished_at) : 'N/A'}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Time Information */}
|
||||
<Card
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaClock className="text-purple-500" />
|
||||
<span>{t('时间信息')}</span>
|
||||
</div>
|
||||
}
|
||||
className="border-0 shadow-sm"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text type="secondary">{t('已运行时间')}:</Text>
|
||||
<Text strong>
|
||||
{Math.floor(details.compute_minutes_served / 60)}h {details.compute_minutes_served % 60}m
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text type="secondary">{t('剩余时间')}:</Text>
|
||||
<Text strong className="text-orange-600">
|
||||
{Math.floor(details.compute_minutes_remaining / 60)}h {details.compute_minutes_remaining % 60}m
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Text type="secondary">{t('创建时间')}:</Text>
|
||||
<Text>{timestamp2string(details.created_at)}</Text>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Text type="secondary">{t('最后更新')}:</Text>
|
||||
<Text>{timestamp2string(details.updated_at)}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={t('无法获取容器详情')}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewDetailsModal;
|
||||
@@ -0,0 +1,660 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<Tag color="grey" size="small">
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : '';
|
||||
const statusMap = {
|
||||
running: { color: 'green', label: '运行中' },
|
||||
pending: { color: 'orange', label: '准备中' },
|
||||
deployed: { color: 'blue', label: '已部署' },
|
||||
failed: { color: 'red', label: '失败' },
|
||||
destroyed: { color: 'red', label: '已销毁' },
|
||||
stopping: { color: 'orange', label: '停止中' },
|
||||
terminated: { color: 'grey', label: '已终止' },
|
||||
};
|
||||
|
||||
const config = statusMap[normalized] || { color: 'grey', label: status };
|
||||
|
||||
return (
|
||||
<Tag color={config.color} size="small">
|
||||
{t(config.label)}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const currentContainer = selectedContainerId !== ALL_CONTAINERS
|
||||
? containers.find((ctr) => ctr.container_id === selectedContainerId)
|
||||
: null;
|
||||
|
||||
const refreshLogs = () => {
|
||||
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
|
||||
fetchContainerDetails(selectedContainerId);
|
||||
}
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
|
||||
if (sourceLogs.length === 0) {
|
||||
showError(t('暂无日志可下载'));
|
||||
return;
|
||||
}
|
||||
const logText = sourceLogs.join('\n');
|
||||
|
||||
const blob = new Blob([logText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const safeContainerId = selectedContainerId && selectedContainerId !== ALL_CONTAINERS
|
||||
? selectedContainerId.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
: '';
|
||||
const fileName = safeContainerId
|
||||
? `deployment-${deployment.id}-container-${safeContainerId}-logs.txt`
|
||||
: `deployment-${deployment.id}-logs.txt`;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showSuccess(t('日志已下载'));
|
||||
};
|
||||
|
||||
const copyAllLogs = async () => {
|
||||
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
|
||||
if (sourceLogs.length === 0) {
|
||||
showError(t('暂无日志可复制'));
|
||||
return;
|
||||
}
|
||||
const logText = sourceLogs.join('\n');
|
||||
|
||||
const copied = await copy(logText);
|
||||
if (copied) {
|
||||
showSuccess(t('日志已复制到剪贴板'));
|
||||
} else {
|
||||
showError(t('复制失败,请手动选择文本复制'));
|
||||
}
|
||||
};
|
||||
|
||||
// Auto refresh functionality
|
||||
useEffect(() => {
|
||||
if (autoRefresh && visible) {
|
||||
autoRefreshRef.current = setInterval(() => {
|
||||
fetchLogs();
|
||||
}, 5000);
|
||||
} else {
|
||||
if (autoRefreshRef.current) {
|
||||
clearInterval(autoRefreshRef.current);
|
||||
autoRefreshRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoRefreshRef.current) {
|
||||
clearInterval(autoRefreshRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, visible, selectedContainerId, streamFilter, following]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && deployment?.id) {
|
||||
fetchContainers();
|
||||
} else if (!visible) {
|
||||
setContainers([]);
|
||||
setSelectedContainerId(ALL_CONTAINERS);
|
||||
setContainerDetails(null);
|
||||
setStreamFilter('stdout');
|
||||
setLogLines([]);
|
||||
setLastUpdatedAt(null);
|
||||
}
|
||||
}, [visible, deployment?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setStreamFilter('stdout');
|
||||
}
|
||||
}, [selectedContainerId, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && deployment?.id) {
|
||||
fetchContainerDetails(selectedContainerId);
|
||||
}
|
||||
}, [visible, deployment?.id, selectedContainerId]);
|
||||
|
||||
// Initial load and cleanup
|
||||
useEffect(() => {
|
||||
if (visible && deployment?.id) {
|
||||
fetchLogs();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoRefreshRef.current) {
|
||||
clearInterval(autoRefreshRef.current);
|
||||
}
|
||||
};
|
||||
}, [visible, deployment?.id, streamFilter, selectedContainerId, following]);
|
||||
|
||||
// Filter logs based on search term
|
||||
const filteredLogs = logLines
|
||||
.map((line) => line ?? '')
|
||||
.filter((line) =>
|
||||
!searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const renderLogEntry = (line, index) => (
|
||||
<div
|
||||
key={`${index}-${line.slice(0, 20)}`}
|
||||
className="py-1 px-3 hover:bg-gray-50 font-mono text-sm border-b border-gray-100 whitespace-pre-wrap break-words"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FaTerminal className="text-blue-500" />
|
||||
<span>{t('容器日志')}</span>
|
||||
<Text type="secondary" size="small">
|
||||
- {deployment?.container_name || deployment?.id}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={1000}
|
||||
height={700}
|
||||
className="logs-modal"
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
<div className="flex flex-col h-full max-h-[600px]">
|
||||
{/* Controls */}
|
||||
<Card className="mb-4 border-0 shadow-sm">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<Space wrap>
|
||||
<Select
|
||||
prefix={<FaServer />}
|
||||
placeholder={t('选择容器')}
|
||||
value={selectedContainerId}
|
||||
onChange={handleContainerChange}
|
||||
style={{ width: 240 }}
|
||||
size="small"
|
||||
loading={containersLoading}
|
||||
dropdownStyle={{ maxHeight: 320, overflowY: 'auto' }}
|
||||
>
|
||||
<Select.Option value={ALL_CONTAINERS}>
|
||||
{t('全部容器')}
|
||||
</Select.Option>
|
||||
{containers.map((ctr) => (
|
||||
<Select.Option key={ctr.container_id} value={ctr.container_id}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs">{ctr.container_id}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{ctr.brand_name || 'IO.NET'}
|
||||
{ctr.hardware ? ` · ${ctr.hardware}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
prefix={<FaSearch />}
|
||||
placeholder={t('搜索日志内容')}
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
style={{ width: 200 }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Space align="center" className="ml-2">
|
||||
<Text size="small" type="secondary">
|
||||
{t('日志流')}
|
||||
</Text>
|
||||
<Radio.Group
|
||||
type="button"
|
||||
size="small"
|
||||
value={streamFilter}
|
||||
onChange={handleStreamChange}
|
||||
>
|
||||
<Radio value="stdout">STDOUT</Radio>
|
||||
<Radio value="stderr">STDERR</Radio>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onChange={setAutoRefresh}
|
||||
size="small"
|
||||
/>
|
||||
<Text size="small">{t('自动刷新')}</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={following}
|
||||
onChange={setFollowing}
|
||||
size="small"
|
||||
/>
|
||||
<Text size="small">{t('跟随日志')}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Tooltip content={t('刷新日志')}>
|
||||
<Button
|
||||
icon={<IconRefresh />}
|
||||
onClick={refreshLogs}
|
||||
loading={loading}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('复制日志')}>
|
||||
<Button
|
||||
icon={<FaCopy />}
|
||||
onClick={copyAllLogs}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
disabled={logLines.length === 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('下载日志')}>
|
||||
<Button
|
||||
icon={<IconDownload />}
|
||||
onClick={downloadLogs}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
disabled={logLines.length === 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Status Info */}
|
||||
<Divider margin="12px" />
|
||||
<div className="flex items-center justify-between">
|
||||
<Space size="large">
|
||||
<Text size="small" type="secondary">
|
||||
{t('共 {{count}} 条日志', { count: logLines.length })}
|
||||
</Text>
|
||||
{searchTerm && (
|
||||
<Text size="small" type="secondary">
|
||||
{t('(筛选后显示 {{count}} 条)', { count: filteredLogs.length })}
|
||||
</Text>
|
||||
)}
|
||||
{autoRefresh && (
|
||||
<Tag color="green" size="small">
|
||||
<FaClock className="mr-1" />
|
||||
{t('自动刷新中')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Text size="small" type="secondary">
|
||||
{t('状态')}: {deployment?.status || 'unknown'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{selectedContainerId !== ALL_CONTAINERS && (
|
||||
<>
|
||||
<Divider margin="12px" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<Space>
|
||||
<Tag color="blue" size="small">
|
||||
{t('容器')}
|
||||
</Tag>
|
||||
<Text className="font-mono text-xs">
|
||||
{selectedContainerId}
|
||||
</Text>
|
||||
{renderContainerStatusTag(containerDetails?.status || currentContainer?.status)}
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
{containerDetails?.public_url && (
|
||||
<Tooltip content={containerDetails.public_url}>
|
||||
<Button
|
||||
icon={<FaLink />}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
onClick={() => window.open(containerDetails.public_url, '_blank')}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={t('刷新容器信息')}>
|
||||
<Button
|
||||
icon={<IconRefresh />}
|
||||
onClick={refreshContainerDetails}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
loading={containerDetailsLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{containerDetailsLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Spin tip={t('加载容器详情中...')} />
|
||||
</div>
|
||||
) : containerDetails ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FaInfoCircle className="text-blue-500" />
|
||||
<Text type="secondary">{t('硬件')}</Text>
|
||||
<Text>
|
||||
{containerDetails?.brand_name || currentContainer?.brand_name || t('未知品牌')}
|
||||
{(containerDetails?.hardware || currentContainer?.hardware) ? ` · ${containerDetails?.hardware || currentContainer?.hardware}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaServer className="text-purple-500" />
|
||||
<Text type="secondary">{t('GPU/容器')}</Text>
|
||||
<Text>{containerDetails?.gpus_per_container ?? currentContainer?.gpus_per_container ?? 0}</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaClock className="text-orange-500" />
|
||||
<Text type="secondary">{t('创建时间')}</Text>
|
||||
<Text>
|
||||
{containerDetails?.created_at
|
||||
? timestamp2string(containerDetails.created_at)
|
||||
: currentContainer?.created_at
|
||||
? timestamp2string(currentContainer.created_at)
|
||||
: t('未知')}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaInfoCircle className="text-green-500" />
|
||||
<Text type="secondary">{t('运行时长')}</Text>
|
||||
<Text>{containerDetails?.uptime_percent ?? currentContainer?.uptime_percent ?? 0}%</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="small" type="secondary">
|
||||
{t('暂无容器详情')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{containerDetails?.events && containerDetails.events.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<Text size="small" type="secondary">
|
||||
{t('最近事件')}
|
||||
</Text>
|
||||
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto">
|
||||
{containerDetails.events.slice(0, 5).map((event, index) => (
|
||||
<div key={`${event.time}-${index}`} className="flex gap-3 text-xs font-mono">
|
||||
<span className="text-gray-500">
|
||||
{event.time ? timestamp2string(event.time) : '--'}
|
||||
</span>
|
||||
<span className="text-gray-700 break-all flex-1">
|
||||
{event.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Log Content */}
|
||||
<div className="flex-1 flex flex-col border rounded-lg bg-gray-50 overflow-hidden">
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 overflow-y-auto bg-white"
|
||||
style={{ maxHeight: '400px' }}
|
||||
>
|
||||
{loading && logLines.length === 0 ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Spin tip={t('加载日志中...')} />
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
searchTerm ? t('没有匹配的日志条目') : t('暂无日志')
|
||||
}
|
||||
style={{ padding: '60px 20px' }}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
{filteredLogs.map((log, index) => renderLogEntry(log, index))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer status */}
|
||||
{logLines.length > 0 && (
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-t text-xs text-gray-500">
|
||||
<span>
|
||||
{following ? t('正在跟随最新日志') : t('日志已加载')}
|
||||
</span>
|
||||
<span>
|
||||
{t('最后更新')}: {lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewLogsModal;
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
Settings,
|
||||
CircleUser,
|
||||
Package,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
@@ -114,6 +115,8 @@ export function getLucideIcon(key, selected = false) {
|
||||
return <User {...commonProps} color={iconColor} />;
|
||||
case 'models':
|
||||
return <Package {...commonProps} color={iconColor} />;
|
||||
case 'deployment':
|
||||
return <Server {...commonProps} color={iconColor} />;
|
||||
case 'setting':
|
||||
return <Settings {...commonProps} color={iconColor} />;
|
||||
default:
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from '../../constants';
|
||||
import { useIsMobile } from '../common/useIsMobile';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { Modal, Button } from '@douyinfe/semi-ui';
|
||||
|
||||
export const useChannelsData = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -775,6 +775,67 @@ export const useChannelsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkOllamaVersion = async (record) => {
|
||||
try {
|
||||
const res = await API.get(`/api/channel/ollama/version/${record.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
|
||||
if (success) {
|
||||
const version = data?.version || '-';
|
||||
const infoMessage = t('当前 Ollama 版本为 ${version}').replace(
|
||||
'${version}',
|
||||
version,
|
||||
);
|
||||
|
||||
const handleCopyVersion = async () => {
|
||||
if (!version || version === '-') {
|
||||
showInfo(t('暂无可复制的版本信息'));
|
||||
return;
|
||||
}
|
||||
|
||||
const copied = await copy(version);
|
||||
if (copied) {
|
||||
showSuccess(t('已复制版本号'));
|
||||
} else {
|
||||
showError(t('复制失败,请手动复制'));
|
||||
}
|
||||
};
|
||||
|
||||
Modal.info({
|
||||
title: t('Ollama 版本信息'),
|
||||
content: infoMessage,
|
||||
centered: true,
|
||||
footer: (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button type='tertiary' onClick={handleCopyVersion}>
|
||||
{t('复制版本号')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={() => Modal.destroyAll()}
|
||||
>
|
||||
{t('关闭')}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
hasCancel: false,
|
||||
hasOk: false,
|
||||
closable: true,
|
||||
maskClosable: true,
|
||||
});
|
||||
} else {
|
||||
showError(message || t('获取 Ollama 版本失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
t('获取 Ollama 版本失败');
|
||||
showError(errMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// Test channel - 单个模型测试,参考旧版实现
|
||||
const testChannel = async (record, model, endpointType = '') => {
|
||||
const testKey = `${record.id}-${model}`;
|
||||
@@ -1132,6 +1193,7 @@ export const useChannelsData = () => {
|
||||
updateAllChannelsBalance,
|
||||
updateChannelBalance,
|
||||
fixChannelsAbilities,
|
||||
checkOllamaVersion,
|
||||
testChannel,
|
||||
batchTestModels,
|
||||
handleCloseModal,
|
||||
|
||||
@@ -61,6 +61,7 @@ export const useSidebar = () => {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
deployment: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
|
||||
266
web/src/hooks/model-deployments/useDeploymentResources.js
Normal file
266
web/src/hooks/model-deployments/useDeploymentResources.js
Normal file
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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;
|
||||
507
web/src/hooks/model-deployments/useDeploymentsData.jsx
Normal file
507
web/src/hooks/model-deployments/useDeploymentsData.jsx
Normal file
@@ -0,0 +1,507 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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,
|
||||
};
|
||||
};
|
||||
249
web/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx
Normal file
249
web/src/hooks/model-deployments/useEnhancedDeploymentActions.jsx
Normal file
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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;
|
||||
143
web/src/hooks/model-deployments/useModelDeploymentSettings.js
Normal file
143
web/src/hooks/model-deployments/useModelDeploymentSettings.js
Normal file
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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,
|
||||
};
|
||||
};
|
||||
52
web/src/pages/ModelDeployment/index.jsx
Normal file
52
web/src/pages/ModelDeployment/index.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<DeploymentAccessGuard
|
||||
loading={loading}
|
||||
isEnabled={isIoNetEnabled}
|
||||
connectionLoading={connectionLoading}
|
||||
connectionOk={connectionOk}
|
||||
connectionError={connectionError}
|
||||
onRetry={() => testConnection(apiKey)}
|
||||
>
|
||||
<div className='mt-[60px] px-2'>
|
||||
<DeploymentsTable />
|
||||
</div>
|
||||
</DeploymentAccessGuard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDeploymentPage;
|
||||
334
web/src/pages/Setting/Model/SettingModelDeployment.jsx
Normal file
334
web/src/pages/Setting/Model/SettingModelDeployment.jsx
Normal file
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact 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 (
|
||||
<>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
values={inputs}
|
||||
getFormApi={(formAPI) => (refForm.current = formAPI)}
|
||||
style={{ marginBottom: 15 }}
|
||||
>
|
||||
<Form.Section
|
||||
text={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>{t('模型部署设置')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/*<Text */}
|
||||
{/* type="secondary" */}
|
||||
{/* size="small"*/}
|
||||
{/* style={{ */}
|
||||
{/* display: 'block', */}
|
||||
{/* marginBottom: '20px',*/}
|
||||
{/* color: 'var(--semi-color-text-2)'*/}
|
||||
{/* }}*/}
|
||||
{/*>*/}
|
||||
{/* {t('配置模型部署服务提供商的API密钥和启用状态')}*/}
|
||||
{/*</Text>*/}
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Cloud size={18} />
|
||||
<span>io.net</span>
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: '20px' }}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={14}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<Form.Switch
|
||||
label={t('启用 io.net 部署')}
|
||||
field={'model_deployment.ionet.enabled'}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'model_deployment.ionet.enabled': value,
|
||||
})
|
||||
}
|
||||
extraText={t('启用后可接入 io.net GPU 资源')}
|
||||
/>
|
||||
<Form.Input
|
||||
label={t('API Key')}
|
||||
field={'model_deployment.ionet.api_key'}
|
||||
placeholder={t('请输入 io.net API Key')}
|
||||
onChange={(value) =>
|
||||
setInputs({
|
||||
...inputs,
|
||||
'model_deployment.ionet.api_key': value,
|
||||
})
|
||||
}
|
||||
disabled={!inputs['model_deployment.ionet.enabled']}
|
||||
extraText={t('请使用 Project 为 io.cloud 的密钥')}
|
||||
mode="password"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<Button
|
||||
type="outline"
|
||||
size="small"
|
||||
icon={<Zap size={16} />}
|
||||
onClick={testApiKey}
|
||||
loading={testing}
|
||||
disabled={
|
||||
!inputs['model_deployment.ionet.enabled'] ||
|
||||
!inputs['model_deployment.ionet.api_key'] ||
|
||||
inputs['model_deployment.ionet.api_key'].trim() === ''
|
||||
}
|
||||
style={{
|
||||
height: '32px',
|
||||
fontSize: '13px',
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
borderColor: testing
|
||||
? 'var(--semi-color-primary)'
|
||||
: 'var(--semi-color-border)',
|
||||
color: testing
|
||||
? 'var(--semi-color-primary)'
|
||||
: 'var(--semi-color-text-0)',
|
||||
}}
|
||||
>
|
||||
{testing ? t('连接测试中...') : t('测试连接')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={10}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: '8px' }}>
|
||||
{t('获取 io.net API Key')}
|
||||
</Text>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: '18px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<li>{t('访问 io.net 控制台的 API Keys 页面')}</li>
|
||||
<li>{t('创建或选择密钥时,将 Project 设置为 io.cloud')}</li>
|
||||
<li>{t('复制生成的密钥并粘贴到此处')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
icon={<ArrowUpRight size={16} />}
|
||||
type="primary"
|
||||
theme="solid"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() =>
|
||||
window.open('https://ai.io.net/ai/api-keys', '_blank')
|
||||
}
|
||||
>
|
||||
{t('前往 io.net API Keys')}
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Row>
|
||||
<Button size='default' type="primary" onClick={onSubmit}>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form.Section>
|
||||
</Form>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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('兑换码管理'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
MessageSquare,
|
||||
Palette,
|
||||
CreditCard,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
|
||||
import SystemSetting from '../../components/settings/SystemSetting';
|
||||
@@ -45,6 +46,7 @@ import RatioSetting from '../../components/settings/RatioSetting';
|
||||
import ChatsSetting from '../../components/settings/ChatsSetting';
|
||||
import DrawingSetting from '../../components/settings/DrawingSetting';
|
||||
import PaymentSetting from '../../components/settings/PaymentSetting';
|
||||
import ModelDeploymentSetting from '../../components/settings/ModelDeploymentSetting';
|
||||
|
||||
const Setting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -134,6 +136,16 @@ const Setting = () => {
|
||||
content: <ModelSetting />,
|
||||
itemKey: 'models',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<Server size={18} />
|
||||
{t('模型部署设置')}
|
||||
</span>
|
||||
),
|
||||
content: <ModelDeploymentSetting />,
|
||||
itemKey: 'model-deployment',
|
||||
});
|
||||
panes.push({
|
||||
tab: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
|
||||
Reference in New Issue
Block a user