Compare commits

...
114 changed files with 8072 additions and 1622 deletions
+9 -2
View File
@@ -207,6 +207,7 @@ func (b *BaseApi) GetAppListUpdate(c *gin.Context) {
// @Accept json // @Accept json
// @Param key path string true "app key" // @Param key path string true "app key"
// @Success 200 {file} file "app icon" // @Success 200 {file} file "app icon"
// @Success 302 "redirect to the official icon URL"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /apps/icon/:key [get] // @Router /apps/icon/:key [get]
@@ -216,12 +217,18 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return return
} }
iconBytes, filename, etag, err := appService.GetAppIcon(appKey) iconBytes, source, etag, err := appService.GetAppIcon(appKey)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
if appicon.IsRemoteURL(source) {
c.Header("Cache-Control", "no-cache")
c.Redirect(http.StatusFound, source)
return
}
if len(iconBytes) == 0 { if len(iconBytes) == 0 {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
return return
@@ -229,7 +236,7 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=2592000") c.Header("Cache-Control", "public, max-age=2592000")
if etag != "" && filename != "" { if etag != "" && source != "" {
c.Header("ETag", etag) c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag { if c.GetHeader("If-None-Match") == etag {
c.Status(http.StatusNotModified) c.Status(http.StatusNotModified)
+109
View File
@@ -0,0 +1,109 @@
package v2
import (
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestAppIconEndpointPreservesRemoteAndHistoricalSources(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "icons.db")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.AppTag{}); err != nil {
t.Fatal(err)
}
oldDB, oldDir, oldLog := global.DB, global.Dir, global.LOG
global.DB = db
global.Dir.IconCacheDir = t.TempDir()
global.LOG = logrus.New()
global.LOG.SetOutput(io.Discard)
t.Cleanup(func() {
global.DB, global.Dir, global.LOG = oldDB, oldDir, oldLog
sqlDB, _ := db.DB()
_ = sqlDB.Close()
})
var imageRequests atomic.Int32
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
imageRequests.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(cdn.Close)
remoteURL := cdn.URL + "/icon.png?token=a%2Bb&v=42#image"
image := "\x89PNG\r\n\x1a\noriginal-icon"
fileName := "app_php.png"
if err := os.WriteFile(filepath.Join(global.Dir.IconCacheDir, fileName), []byte(image), 0644); err != nil {
t.Fatal(err)
}
app := model.App{Key: "php", Resource: constant.AppResourceRemote}
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/api/v2/apps/icon/:key", (&BaseApi{}).GetAppIcon)
for _, tc := range []struct {
name string
icon string
ifNoneMatch string
status int
body string
etag string
}{
{"remote URL", remoteURL, "", http.StatusFound, "", ""},
{"remote URL ignores legacy etag", remoteURL, `"old"`, http.StatusFound, "", ""},
{"historical file", appicon.BuildIconField(fileName, `"42"`), "", http.StatusOK, image, `"42"`},
{"historical file cached", appicon.BuildIconField(fileName, `"42"`), `"42"`, http.StatusNotModified, "", `"42"`},
{"historical file changed", appicon.BuildIconField(fileName, `"42"`), `"old"`, http.StatusOK, image, `"42"`},
{"historical base64", base64.StdEncoding.EncodeToString([]byte(image)), "", http.StatusOK, image, ""},
{"missing file", "app_missing.png?etag=old", "", http.StatusNoContent, "", ""},
{"invalid base64", "not-an-icon", "", http.StatusNoContent, "", ""},
{"invalid URL", "javascript:alert(1)", "", http.StatusNoContent, "", ""},
{"empty icon", "", "", http.StatusNoContent, "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
if err := db.Model(&app).UpdateColumn("icon", tc.icon).Error; err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v2/apps/icon/php?operateNode=child", nil)
req.Header.Set("If-None-Match", tc.ifNoneMatch)
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != tc.status {
t.Fatalf("status = %d, want %d; body = %s", res.Code, tc.status, res.Body.String())
}
if res.Header().Get("ETag") != tc.etag {
t.Fatalf("etag = %q, want %q", res.Header().Get("ETag"), tc.etag)
}
if tc.status == http.StatusFound {
if res.Header().Get("Location") != remoteURL || res.Header().Get("Cache-Control") != "no-cache" {
t.Fatalf("redirect URL or cache policy was changed: %v", res.Header())
}
} else if res.Body.String() != tc.body {
t.Fatalf("body = %q, want %q", res.Body.String(), tc.body)
}
if imageRequests.Load() != 0 {
t.Fatalf("icon endpoint fetched the image %d times", imageRequests.Load())
}
})
}
}
+1
View File
@@ -203,6 +203,7 @@ func (b *BaseApi) GetServices(c *gin.Context) {
// @Tags App // @Tags App
// @Summary Search app update version by install id // @Summary Search app update version by install id
// @Accept json // @Accept json
// @Param request body request.AppUpdateVersion true "request"
// @Success 200 {array} dto.AppVersion // @Success 200 {array} dto.AppVersion
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
+5
View File
@@ -3,11 +3,16 @@ package v2
import ( import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator" "github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func (b *BaseApi) LoadModelGPUDevices(c *gin.Context) {
helper.SuccessWithData(c, service.LoadModelGPUDevices())
}
// @Tags AI // @Tags AI
// @Summary Load gpu / xpu info // @Summary Load gpu / xpu info
// @Accept json // @Accept json
+22
View File
@@ -2,6 +2,28 @@ package dto
import "time" import "time"
type ModelGPUDevice struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
UUID string `json:"uuid,omitempty"`
Available bool `json:"available"`
}
type ModelGPUDevices struct {
Devices []ModelGPUDevice `json:"devices"`
Warnings []string `json:"warnings"`
}
type ModelGPUConfig struct {
Mode string `json:"mode"`
Count int `json:"count,omitempty"`
Error string `json:"error,omitempty"`
Enabled bool `json:"enabled"`
DeviceIDs []string `json:"deviceIDs"`
Capabilities []string `json:"capabilities"`
}
type OllamaModelInfo struct { type OllamaModelInfo struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
+1
View File
@@ -177,6 +177,7 @@ type DiskInfo struct {
} }
type GPUInfo struct { type GPUInfo struct {
SharedMemory bool `json:"sharedMemory"`
Type string `json:"type"` Type string `json:"type"`
Index uint `json:"index"` Index uint `json:"index"`
NPUIndex uint `json:"npuIndex"` NPUIndex uint `json:"npuIndex"`
+12 -11
View File
@@ -56,17 +56,18 @@ type MonitorGPUOptions struct {
Options []string `json:"options"` Options []string `json:"options"`
} }
type GPUChartHide struct { type GPUChartHide struct {
DeviceID string `json:"deviceID"` SharedMemory bool `json:"sharedMemory"`
Legacy bool `json:"legacy"` DeviceID string `json:"deviceID"`
ProductName string `json:"productName"` Legacy bool `json:"legacy"`
Type string `json:"type"` ProductName string `json:"productName"`
Process bool `json:"process"` Type string `json:"type"`
GPU bool `json:"gpu"` Process bool `json:"process"`
Memory bool `json:"memory"` GPU bool `json:"gpu"`
Power bool `json:"power"` Memory bool `json:"memory"`
PowerLimit bool `json:"powerLimit"` Power bool `json:"power"`
Temperature bool `json:"temperature"` PowerLimit bool `json:"powerLimit"`
Speed bool `json:"speed"` Temperature bool `json:"temperature"`
Speed bool `json:"speed"`
} }
type MonitorGPUSearch struct { type MonitorGPUSearch struct {
Aggregation string `json:"aggregation" validate:"omitempty,oneof=avg max"` Aggregation string `json:"aggregation" validate:"omitempty,oneof=avg max"`
+5
View File
@@ -22,6 +22,9 @@ type AppInstallCreate struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Services map[string]string `json:"services"` Services map[string]string `json:"services"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
GPU *dto.ModelGPUConfig `json:"gpu"`
GPUImageType string `json:"-"`
SyncModelAccount *bool `json:"syncModelAccount"` SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"` ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
@@ -128,6 +131,7 @@ type AppInstallDelete struct {
} }
type AppInstalledUpdate struct { type AppInstalledUpdate struct {
GPU *dto.ModelGPUConfig `json:"gpu"`
SyncModelAccount *bool `json:"syncModelAccount"` SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"` ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"` ModelAccountBaseURL string `json:"modelAccountBaseURL"`
@@ -156,6 +160,7 @@ type PortUpdate struct {
type AppUpdateVersion struct { type AppUpdateVersion struct {
AppInstallID uint `json:"appInstallID" validate:"required"` AppInstallID uint `json:"appInstallID" validate:"required"`
UpdateVersion string `json:"updateVersion"` UpdateVersion string `json:"updateVersion"`
LoadCompose *bool `json:"loadCompose,omitempty"`
} }
type AppInstallSortItem struct { type AppInstallSortItem struct {
+9 -8
View File
@@ -8,14 +8,15 @@ type TensorRTLLMSearch struct {
} }
type TensorRTLLMCreate struct { type TensorRTLLMCreate struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
ContainerName string `json:"containerName" validate:"required"` ContainerName string `json:"containerName" validate:"required"`
Version string `json:"version" validate:"required"` Version string `json:"version" validate:"required"`
ModelDir string `json:"modelDir" validate:"required"` ModelDir string `json:"modelDir" validate:"required"`
Image string `json:"image" validate:"required"` Image string `json:"image" validate:"required"`
Command string `json:"command" validate:"required"` Command string `json:"command" validate:"required"`
ModelType string `json:"modelType"` ModelType string `json:"modelType"`
ModelSpeedup bool `json:"modelSpeedup"` ModelSpeedup bool `json:"modelSpeedup"`
GPU *dto.ModelGPUConfig `json:"gpu"`
DockerConfig DockerConfig
} }
+11 -8
View File
@@ -31,6 +31,7 @@ type AppDTO struct {
type AppItem struct { type AppItem struct {
Name string `json:"name"` Name string `json:"name"`
Key string `json:"key"` Key string `json:"key"`
Icon string `json:"icon,omitempty"`
ID uint `json:"id"` ID uint `json:"id"`
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
@@ -186,13 +187,15 @@ type AppParam struct {
} }
type AppConfig struct { type AppConfig struct {
AppKey string `json:"appKey"` GPU *dto.ModelGPUConfig `json:"gpu,omitempty"`
Name string `json:"name"` AppKey string `json:"appKey"`
HttpPort int `json:"httpPort"` Name string `json:"name"`
SyncModelAccount bool `json:"syncModelAccount"` HttpPort int `json:"httpPort"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"` SyncModelAccount bool `json:"syncModelAccount"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"` ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
Params []AppParam `json:"params"` ModelAccountBaseURL string `json:"modelAccountBaseURL"`
RawCompose string `json:"rawCompose"` Params []AppParam `json:"params"`
RawCompose string `json:"rawCompose"`
IsEdit bool `json:"isEdit"`
request.AppContainerConfig request.AppContainerConfig
} }
+8 -6
View File
@@ -1,6 +1,7 @@
package response package response
import ( import (
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
) )
@@ -12,12 +13,13 @@ type TensorRTLLMsRes struct {
type TensorRTLLMDTO struct { type TensorRTLLMDTO struct {
model.TensorRTLLM model.TensorRTLLM
Version string `json:"version"` Version string `json:"version"`
Model string `json:"model"` Model string `json:"model"`
Dir string `json:"dir"` Dir string `json:"dir"`
ModelDir string `json:"modelDir"` ModelDir string `json:"modelDir"`
Image string `json:"image"` Image string `json:"image"`
Command string `json:"command"` Command string `json:"command"`
GPU *dto.ModelGPUConfig `json:"gpu"`
ExposedPorts []request.ExposedPort `json:"exposedPorts"` ExposedPorts []request.ExposedPort `json:"exposedPorts"`
Environments []request.Environment `json:"environments"` Environments []request.Environment `json:"environments"`
+6
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -30,6 +31,7 @@ type IAppRepo interface {
BatchCreate(ctx context.Context, apps []model.App) error BatchCreate(ctx context.Context, apps []model.App) error
Create(ctx context.Context, app *model.App) error Create(ctx context.Context, app *model.App) error
Save(ctx context.Context, app *model.App) error Save(ctx context.Context, app *model.App) error
UpdateIcon(id uint, icon string) error
BatchDelete(ctx context.Context, apps []model.App) error BatchDelete(ctx context.Context, apps []model.App) error
DeleteByIDs(ctx context.Context, ids []uint) error DeleteByIDs(ctx context.Context, ids []uint) error
DeleteBy(opts ...DBOption) error DeleteBy(opts ...DBOption) error
@@ -41,6 +43,10 @@ func NewIAppRepo() IAppRepo {
return &AppRepo{} return &AppRepo{}
} }
func (a AppRepo) UpdateIcon(id uint, icon string) error {
return getDb(WithByID(id), a.WithResource(constant.AppResourceRemote)).Model(&model.App{}).UpdateColumn("icon", icon).Error
}
func (a AppRepo) WithByLikeName(name string) DBOption { func (a AppRepo) WithByLikeName(name string) DBOption {
return func(g *gorm.DB) *gorm.DB { return func(g *gorm.DB) *gorm.DB {
if len(name) == 0 { if len(name) == 0 {
+16
View File
@@ -17,6 +17,8 @@ type IAppDetailRepo interface {
WithIgnored() DBOption WithIgnored() DBOption
GetFirst(opts ...DBOption) (model.AppDetail, error) GetFirst(opts ...DBOption) (model.AppDetail, error)
Update(ctx context.Context, detail model.AppDetail) error Update(ctx context.Context, detail model.AppDetail) error
CacheCompose(detail model.AppDetail, compose string) (bool, error)
MarkDownloaded(detail model.AppDetail) (bool, error)
BatchCreate(ctx context.Context, details []model.AppDetail) error BatchCreate(ctx context.Context, details []model.AppDetail) error
DeleteByAppIds(ctx context.Context, appIds []uint) error DeleteByAppIds(ctx context.Context, appIds []uint) error
DeleteByIDs(ctx context.Context, appIds []uint) error DeleteByIDs(ctx context.Context, appIds []uint) error
@@ -57,6 +59,20 @@ func (a AppDetailRepo) Update(ctx context.Context, detail model.AppDetail) error
return getTx(ctx).Save(&detail).Error return getTx(ctx).Save(&detail).Error
} }
func (a AppDetailRepo) CacheCompose(detail model.AppDetail, compose string) (bool, error) {
result := getDb().Model(&model.AppDetail{}).
Where("id = ? AND last_modified = ? AND params = ? AND download_url = ?", detail.ID, detail.LastModified, detail.Params, detail.DownloadUrl).
UpdateColumn("docker_compose", compose)
return result.RowsAffected > 0, result.Error
}
func (a AppDetailRepo) MarkDownloaded(detail model.AppDetail) (bool, error) {
result := getDb().Model(&model.AppDetail{}).
Where("id = ? AND last_modified = ? AND params = ? AND download_url = ?", detail.ID, detail.LastModified, detail.Params, detail.DownloadUrl).
UpdateColumn("update", false)
return result.RowsAffected > 0, result.Error
}
func (a AppDetailRepo) BatchCreate(ctx context.Context, details []model.AppDetail) error { func (a AppDetailRepo) BatchCreate(ctx context.Context, details []model.AppDetail) error {
return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error
} }
+1 -10
View File
@@ -1394,19 +1394,10 @@ func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error { func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
agentAccountMutationMu.Lock() agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock() defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID)) _, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil { if err != nil {
return err return err
} }
if account.AppInstallID != 0 && account.Provider != "ollama" {
_, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
if err == nil {
return buserr.WithDetail("ErrInvalidParams", "Delete the local inference engine instead", nil)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if err := global.DB.Transaction(func(tx *gorm.DB) error { if err := global.DB.Transaction(func(tx *gorm.DB) error {
var agentCount int64 var agentCount int64
+66 -74
View File
@@ -135,6 +135,7 @@ func (a AppService) PageApp(ctx *gin.Context, req request.AppSearch) (*response.
ID: ap.ID, ID: ap.ID,
Name: ap.Name, Name: ap.Name,
Key: ap.Key, Key: ap.Key,
Icon: appicon.RemoteURL(ap.Icon),
Limit: ap.Limit, Limit: ap.Limit,
GpuSupport: ap.GpuSupport, GpuSupport: ap.GpuSupport,
Recommend: ap.Recommend, Recommend: ap.Recommend,
@@ -262,10 +263,20 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) { if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
return appDetailDTO, err return appDetailDTO, err
} }
if err == nil && app.Resource == constant.AppResourceRemote { packageReady := err == nil
if packageReady && app.Resource == constant.AppResourceRemote {
detail.Update = false detail.Update = false
appDetailDTO.Update = false appDetailDTO.Update = false
} }
if packageReady && strings.TrimSpace(detail.DockerCompose) == "" {
compose, err := fileOp.GetContent(filepath.Join(versionPath, "docker-compose.yml"))
if err != nil {
return appDetailDTO, err
}
if err := cacheAppDetailCompose(&detail, compose); err != nil {
return appDetailDTO, err
}
}
switch app.Type { switch app.Type {
case constant.RuntimePHP: case constant.RuntimePHP:
paramMap := make(map[string]interface{}) paramMap := make(map[string]interface{})
@@ -296,15 +307,17 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if err != nil { if err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
composeMap := make(map[string]interface{}) if err := validateAppDetailCompose(compose); err != nil {
return appDetailDTO, err
}
var composeMap struct {
Services map[string]interface{} `yaml:"services"`
}
if err := yaml.Unmarshal(compose, &composeMap); err != nil { if err := yaml.Unmarshal(compose, &composeMap); err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
if service, ok := composeMap["services"]; ok { for k := range composeMap.Services {
servicesMap := service.(map[string]interface{}) appDetailDTO.Image = k
for k := range servicesMap {
appDetailDTO.Image = k
}
} }
} }
} else { } else {
@@ -315,29 +328,10 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
appDetailDTO.Params = paramMap appDetailDTO.Params = paramMap
} }
if appDetailDTO.DockerCompose == "" { if err := ensureAppDetailCompose(app, &detail); err != nil {
filename := filepath.Base(appDetailDTO.DownloadUrl) return appDetailDTO, err
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml")
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return appDetailDTO, err
}
}
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
return appDetailDTO, buserr.New("ErrAppVersionUnavailable")
}
if err != nil {
return appDetailDTO, buserr.WithDetail("ErrGetCompose", err.Error(), err)
}
if statusCode > 200 {
return appDetailDTO, buserr.WithDetail("ErrGetCompose", string(composeRes), err)
}
detail.DockerCompose = string(composeRes)
_ = appDetailRepo.Update(context.Background(), detail)
appDetailDTO.DockerCompose = string(composeRes)
} }
appDetailDTO.DockerCompose = detail.DockerCompose
appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose) appDetailDTO.HostMode = isHostModel(appDetailDTO.DockerCompose)
@@ -355,6 +349,13 @@ func (a AppService) GetAppDetailByID(id uint) (*response.AppDetailDTO, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
app, err := appRepo.GetFirst(repo.WithByID(appDetail.AppId))
if err != nil {
return nil, err
}
if err := ensureAppDetailCompose(app, &appDetail); err != nil {
return nil, err
}
res.AppDetail = appDetail res.AppDetail = appDetail
paramMap := make(map[string]interface{}) paramMap := make(map[string]interface{})
if err := json.Unmarshal([]byte(appDetail.Params), &paramMap); err != nil { if err := json.Unmarshal([]byte(appDetail.Params), &paramMap); err != nil {
@@ -442,31 +443,13 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
App: app, App: app,
} }
composeMap := make(map[string]interface{}) composeMap := make(map[string]interface{})
var composeRes []byte
if req.EditCompose { if req.EditCompose {
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil { if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
return return
} }
} else { } else {
if appDetail.DockerCompose == "" { if err = ensureAppDetailCompose(app, &appDetail); err != nil {
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version) return
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return
}
}
var statusCode int
statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
err = buserr.New("ErrAppVersionUnavailable")
return
}
if err != nil {
return
}
appDetail.DockerCompose = string(composeRes)
_ = appDetailRepo.Update(context.Background(), appDetail)
} }
if err = yaml.Unmarshal([]byte(appDetail.DockerCompose), &composeMap); err != nil { if err = yaml.Unmarshal([]byte(appDetail.DockerCompose), &composeMap); err != nil {
return return
@@ -542,6 +525,26 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
composeByte []byte composeByte []byte
paramByte []byte paramByte []byte
) )
if (app.Key == "ollama" || app.Key == "vllm") && req.GPU != nil {
gpuServiceName := appInstall.ServiceName
imageType := "nvidia"
if app.Key == "vllm" {
imageType = req.GPUImageType
if len(servicesMap) > 1 {
gpuServiceName = "vllm"
if _, ok := servicesMap[gpuServiceName]; !ok {
gpuServiceName = "vllm-dspark"
}
}
}
service, ok := servicesMap[gpuServiceName].(map[string]interface{})
if !ok {
return nil, buserr.New("ErrFileParse")
}
if err = ApplyModelGPU(service, req.GPU, req.Params, imageType); err != nil {
return
}
}
composeByte, err = yaml.Marshal(composeMap) composeByte, err = yaml.Marshal(composeMap)
if err != nil { if err != nil {
@@ -922,22 +925,10 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
res.CanUpdate = true res.CanUpdate = true
return res, err return res, err
} }
apps, _ := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote)) list, err := readAppList()
for _, app := range apps { if err != nil || list.LastModified != lastModified {
if app.Icon == "" { list, err = getAppList()
res.CanUpdate = true
return res, err
}
if appicon.IsIconFile(app.Icon) {
fileName, _ := appicon.ParseIconField(app.Icon)
if fileName == "" || !appicon.IconFileExists(fileName) {
res.CanUpdate = true
return res, err
}
}
} }
list, err := getAppList()
if err != nil { if err != nil {
return res, err return res, err
} }
@@ -968,29 +959,26 @@ func getAppFromRepo(downloadPath string) error {
} }
func getAppList() (*dto.AppList, error) { func getAppList() (*dto.AppList, error) {
list := &dto.AppList{}
if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.AppRepoURL(), global.CONF.Base.Mode)); err != nil { if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.AppRepoURL(), global.CONF.Base.Mode)); err != nil {
return nil, err return nil, err
} }
return readAppList()
}
func readAppList() (*dto.AppList, error) {
listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json") listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json")
file, err := os.Open(listFile) data, err := os.ReadFile(listFile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer file.Close() list := &dto.AppList{}
if err = json.NewDecoder(file).Decode(list); err != nil { if err = json.Unmarshal(data, list); err != nil {
return nil, err return nil, err
} }
return list, nil return list, nil
} }
var InitTypes = map[string]struct{}{
"runtime": {},
"php": {},
"node": {},
}
func deleteCustomApp() { func deleteCustomApp() {
installs, err := appInstallRepo.ListBy(context.Background()) installs, err := appInstallRepo.ListBy(context.Background())
if err != nil { if err != nil {
@@ -1088,6 +1076,10 @@ func (a AppService) GetAppIcon(key string) ([]byte, string, string, error) {
return nil, "", "", err return nil, "", "", err
} }
if appicon.IsRemoteURL(app.Icon) {
return nil, app.Icon, "", nil
}
if appicon.IsIconFile(app.Icon) { if appicon.IsIconFile(app.Icon) {
fileName, etag := appicon.ParseIconField(app.Icon) fileName, etag := appicon.ParseIconField(app.Icon)
iconBytes, err := appicon.ReadIconFile(fileName) iconBytes, err := appicon.ReadIconFile(fileName)
+341
View File
@@ -0,0 +1,341 @@
package service
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
func appCacheTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "cache.db")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.App{}, &model.AppDetail{}, &model.AppTag{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
oldDB, oldDir, oldLog := global.DB, global.Dir, global.LOG
global.DB = db
global.Dir = global.SystemDir{AppResourceDir: t.TempDir()}
global.Dir.RemoteAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceRemote)
global.Dir.LocalAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceLocal)
global.Dir.CustomAppResourceDir = filepath.Join(global.Dir.AppResourceDir, constant.AppResourceCustom)
global.LOG = logrus.New()
global.LOG.SetOutput(io.Discard)
t.Cleanup(func() {
global.DB, global.Dir, global.LOG = oldDB, oldDir, oldLog
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.Create(&model.Setting{Key: "AppStoreLastModified", Value: "42"}).Error; err != nil {
t.Fatal(err)
}
return db
}
func appCacheTestArchive(t *testing.T, name, content string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0644, Size: int64(len(content))}); err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(tw, content); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func appCacheTestWrite(t *testing.T, name, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestAppDownloadURLPreservesSourceQuery(t *testing.T) {
for _, rawURL := range []string{
"https://apps.1panel.pro/php/8/php-8.tar.gz",
"https://apps.1panel.pro/php/8/php-8.tar.gz?token=a%2Bb&region=intl&v=old#archive",
} {
t.Run(rawURL, func(t *testing.T) {
got, err := appDownloadURL(rawURL, "42 /next")
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(got)
if err != nil {
t.Fatal(err)
}
original, _ := url.Parse(rawURL)
wantQuery := original.Query()
wantQuery.Set("v", "42 /next")
if !reflect.DeepEqual(parsed.Query(), wantQuery) || parsed.Path != original.Path || parsed.Fragment != original.Fragment {
t.Fatalf("download URL lost source fields: %s", got)
}
})
}
if _, err := appDownloadURL("https://apps.1panel.pro/%zz", "42"); err == nil {
t.Fatal("invalid source URL was accepted")
}
}
func TestDownloadAppRefreshesAllOfficialCachedPackages(t *testing.T) {
db := appCacheTestDB(t)
app := model.App{Key: "mysql", Name: "MySQL", Type: "website", Resource: constant.AppResourceRemote}
versionDir := filepath.Join(app.GetAppResourcePath(), "8")
dataPath := filepath.Join(versionDir, "data.yml")
stampPath := filepath.Join(versionDir, ".1panel-appstore-version")
appCacheTestWrite(t, dataPath, "stale")
archives := map[string][]byte{
"42": appCacheTestArchive(t, "mysql/8/data.yml", "fresh-42"),
"43": appCacheTestArchive(t, "mysql/8/data.yml", "fresh-43"),
}
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Query().Get("token") != "a+b" || r.URL.Query().Get("region") != "intl" {
t.Errorf("source query was lost: %s", r.URL.RawQuery)
}
version := r.URL.Query().Get("v")
if version != "42" && version != "43" {
t.Errorf("unexpected app store version: %q", version)
}
_, _ = w.Write(archives[version])
}))
t.Cleanup(server.Close)
detail := model.AppDetail{Version: "8", DownloadUrl: server.URL + "/mysql-8.tar.gz?token=a%2Bb&region=intl", Update: false}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
for _, step := range []struct {
version string
calls int32
update bool
}{
{"42", 1, false}, // An existing directory without a stamp must be refreshed.
{"42", 1, false},
{"43", 2, false},
{"43", 3, true},
} {
if err := db.Model(&model.Setting{}).Where("key = ?", "AppStoreLastModified").Update("value", step.version).Error; err != nil {
t.Fatal(err)
}
detail.Update = step.update
if err := db.Model(&detail).UpdateColumn("update", detail.Update).Error; err != nil {
t.Fatal(err)
}
if err := downloadApp(app, detail, nil, nil); err != nil {
t.Fatal(err)
}
if calls.Load() != step.calls {
t.Fatalf("version %s: HTTP calls = %d, want %d", step.version, calls.Load(), step.calls)
}
for name, want := range map[string]string{dataPath: "fresh-" + step.version, stampPath: step.version} {
data, err := os.ReadFile(name)
if err != nil || string(data) != want {
t.Fatalf("%s = %q, %v; want %q", name, data, err, want)
}
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Update || stored.DownloadUrl != detail.DownloadUrl {
t.Fatalf("refresh altered source URL or left update flag: %+v", stored)
}
}
}
func TestDownloadAppFailureKeepsCacheVersionAndUpdateFlag(t *testing.T) {
for _, failure := range []string{"download", "decompress"} {
t.Run(failure, func(t *testing.T) {
db := appCacheTestDB(t)
app := model.App{Key: "mysql", Resource: constant.AppResourceRemote}
stampPath := filepath.Join(app.GetAppResourcePath(), "8", ".1panel-appstore-version")
appCacheTestWrite(t, stampPath, "41")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if failure == "download" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = io.WriteString(w, "not an archive")
}))
t.Cleanup(server.Close)
detail := model.AppDetail{Version: "8", DownloadUrl: server.URL + "/mysql-8.tar.gz", Update: true}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
install := model.AppInstall{}
if err := downloadApp(app, detail, &install, nil); err == nil {
t.Fatal("failed refresh was accepted")
}
if install.Status != constant.StatusDownloadErr || install.Message == "" {
t.Fatalf("installation did not report refresh failure: %+v", install)
}
stamp, err := os.ReadFile(stampPath)
if err != nil || string(stamp) != "41" {
t.Fatalf("failed refresh updated stamp: %q, %v", stamp, err)
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil {
t.Fatal(err)
}
if !stored.Update || stored.DownloadUrl != detail.DownloadUrl {
t.Fatalf("failed refresh changed database state: %+v", stored)
}
})
}
}
func TestGetPHPAppDetailUsesSynchronizedOfficialParams(t *testing.T) {
const oldYAML = "additionalProperties:\n formFields:\n - envKey: PHP_VERSION\n type: select\n default: 8.1.34\n"
const newJSON = `{"formFields":[{"envKey":"PHP_VERSION","type":"select","default":"8.1.35"}]}`
const compose = "services:\n php:\n image: php:test\n"
for _, tc := range []struct {
name, resource, compose string
update bool
}{
{"remote", constant.AppResourceRemote, compose, false},
{"local", constant.AppResourceLocal, compose, false},
{"custom", constant.AppResourceCustom, compose, false},
{"remote-fetches-compose", constant.AppResourceRemote, "", true},
} {
t.Run(tc.name, func(t *testing.T) {
db := appCacheTestDB(t)
app := model.App{Key: "php", Name: "PHP", Type: constant.RuntimePHP, Resource: tc.resource}
if err := db.Omit(clause.Associations).Create(&app).Error; err != nil {
t.Fatal(err)
}
versionDir := filepath.Join(app.GetAppResourcePath(), "8")
appCacheTestWrite(t, filepath.Join(versionDir, "data.yml"), oldYAML)
appCacheTestWrite(t, filepath.Join(versionDir, "docker-compose.yml"), compose)
archive := appCacheTestArchive(t, "php/8/data.yml", oldYAML)
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Query().Get("v") != "42" {
t.Errorf("missing app store version: %s", r.URL.RawQuery)
}
if r.URL.Path == "/docker-compose.yml" {
_, _ = io.WriteString(w, compose)
return
}
_, _ = w.Write(archive)
}))
t.Cleanup(server.Close)
detail := model.AppDetail{AppId: app.ID, Version: "8", Params: newJSON, DockerCompose: tc.compose, Update: tc.update, DownloadUrl: server.URL + "/php-8.tar.gz"}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
got, err := (AppService{}).GetAppDetail(app.ID, "8", "runtime")
if err != nil {
t.Fatal(err)
}
wantVersion, wantCalls := "8.1.34", int32(0)
if tc.resource == constant.AppResourceRemote {
wantVersion, wantCalls = "8.1.35", 1
}
want := map[string]interface{}{"formFields": []interface{}{map[string]interface{}{
"envKey": "PHP_VERSION", "type": "select", "default": wantVersion,
}}}
if !reflect.DeepEqual(got.Params, want) || got.Image != "php" || calls.Load() != wantCalls {
encoded, _ := json.Marshal(got.Params)
t.Fatalf("params=%s, image=%s, HTTP calls=%d; want version %s, calls %d", encoded, got.Image, calls.Load(), wantVersion, wantCalls)
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Update || got.Update {
t.Fatal("fetching the compose file restored a stale update flag")
}
})
}
}
func TestProcessOneAppDefersComposeUntilRequested(t *testing.T) {
db := appCacheTestDB(t)
const compose = "services:\n php:\n image: php:test\n"
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/php/8/docker-compose.yml" || r.URL.Query().Get("v") != "42" {
t.Errorf("unversioned initial compose request: %s", r.URL.String())
}
_, _ = io.WriteString(w, compose)
}))
t.Cleanup(server.Close)
app := model.App{Key: "php", Type: constant.RuntimePHP, Resource: constant.AppResourceRemote}
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
ctx := appSyncContext{
baseRemoteUrl: server.URL,
systemVersion: "2.4.0",
list: &dto.AppList{LastModified: 42},
}
result := ctx.processOneApp(appWorkItem{
app: app,
appDef: dto.AppDefine{
AppProperty: dto.AppProperty{Key: "php", Type: constant.RuntimePHP},
Versions: []dto.AppConfigVersion{{Name: "8", AppForm: dto.AppForm{
FormFields: []dto.AppFormFields{{EnvKey: "PHP_VERSION", Default: "8.1.35", Type: "select"}},
}}},
},
})
if calls.Load() != 0 || len(result.Details) != 1 {
t.Fatalf("initial compose refresh failed: calls=%d, result=%+v", calls.Load(), result)
}
detail := result.Details[0]
if detail.DockerCompose != "" || detail.DownloadUrl != server.URL+"/php/8/php-8.tar.gz" {
t.Fatalf("sync fetched compose or changed the canonical archive URL: %+v", detail)
}
detail.AppId = app.ID
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
for range 2 {
if err := ensureAppDetailCompose(app, &detail); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 || detail.DockerCompose != compose {
t.Fatalf("on-demand compose did not download once and cache: calls=%d, detail=%+v", calls.Load(), detail)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package service
import (
"fmt"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"gopkg.in/yaml.v3"
)
func ensureAppDetailCompose(app model.App, detail *model.AppDetail) error {
if strings.TrimSpace(detail.DockerCompose) != "" && validateAppDetailCompose([]byte(detail.DockerCompose)) == nil {
return nil
}
var content []byte
var err error
if app.IsLocalApp() || app.IsCustomApp() {
content, err = os.ReadFile(filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"))
} else {
var composeURL string
if detail.DownloadUrl == "" {
composeURL = fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, detail.Version)
} else {
var source *url.URL
source, err = url.Parse(detail.DownloadUrl)
if err == nil {
source.Path = path.Join(path.Dir(source.Path), "docker-compose.yml")
source.RawPath = ""
composeURL = source.String()
}
}
if err == nil {
composeURL, err = currentAppDownloadURL(composeURL)
}
if err == nil {
var status int
status, content, err = req_helper.HandleRequest(composeURL, http.MethodGet, constant.TimeOut20s)
if status == http.StatusNotFound {
return buserr.New("ErrAppVersionUnavailable")
}
}
}
if err != nil {
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
}
return cacheAppDetailCompose(detail, content)
}
func validateAppDetailCompose(content []byte) error {
var compose struct {
Services map[string]map[string]interface{} `yaml:"services"`
}
if err := yaml.Unmarshal(content, &compose); err != nil {
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
}
if len(compose.Services) == 0 {
return buserr.WithDetail("ErrGetCompose", "docker-compose.yml has no services", nil)
}
for name, service := range compose.Services {
if strings.TrimSpace(name) == "" || len(service) == 0 {
return buserr.WithDetail("ErrGetCompose", "docker-compose.yml has an empty service", nil)
}
}
return nil
}
func cacheAppDetailCompose(detail *model.AppDetail, content []byte) error {
if err := validateAppDetailCompose(content); err != nil {
return err
}
stored, err := appDetailRepo.CacheCompose(*detail, string(content))
if err != nil {
return buserr.WithDetail("ErrGetCompose", err.Error(), err)
}
if !stored {
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
}
detail.DockerCompose = string(content)
return nil
}
@@ -0,0 +1,70 @@
package service
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
)
func TestComposeSyncOnlyInvalidatesChangedVersionResources(t *testing.T) {
form := dto.AppForm{FormFields: []dto.AppFormFields{{EnvKey: "PORT", Default: 8080}}}
params, err := json.Marshal(form)
if err != nil {
t.Fatal(err)
}
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
_, _ = io.WriteString(w, "services:\n demo:\n image: demo:unexpected\n")
}))
t.Cleanup(server.Close)
for _, appType := range []string{"runtime", "php", "node", "java", "go", "python", "dotnet", "website"} {
t.Run(appType, func(t *testing.T) {
for _, tc := range []struct {
name string
mutate func(*model.AppDetail, *dto.AppConfigVersion)
keepCache bool
}{
{"unchanged", func(_ *model.AppDetail, _ *dto.AppConfigVersion) {}, true},
{"package update flag does not expire compose", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.Update = true }, true},
{"version revision changed", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified++ }, false},
{"unknown revision", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified = 0 }, false},
{"negative revision", func(_ *model.AppDetail, v *dto.AppConfigVersion) { v.LastModified = -1 }, false},
{"form changed", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.Params = `{"formFields":[]}` }, false},
{"source changed", func(d *model.AppDetail, _ *dto.AppConfigVersion) { d.DownloadUrl += ".old" }, false},
{"new version", func(d *model.AppDetail, _ *dto.AppConfigVersion) { *d = model.AppDetail{Version: "1.0"} }, false},
} {
t.Run(tc.name, func(t *testing.T) {
const cached = "services:\n demo:\n image: demo:cached\n"
old := model.AppDetail{Version: "1.0", LastModified: 42, Params: string(params), DownloadUrl: server.URL + "/demo/1.0/demo-1.0.tar.gz", DockerCompose: cached}
version := dto.AppConfigVersion{Name: "1.0", LastModified: 42, AppForm: form}
tc.mutate(&old, &version)
ctx := appSyncContext{baseRemoteUrl: server.URL, systemVersion: "2.4.0"}
app := model.App{Key: "demo", Type: appType, Resource: constant.AppResourceRemote, Details: []model.AppDetail{old}}
got := ctx.processOneApp(appWorkItem{app: app, appDef: dto.AppDefine{AppProperty: dto.AppProperty{Key: "demo"}, Versions: []dto.AppConfigVersion{version}}})
if len(got.Details) != 1 || calls.Load() != 0 {
t.Fatalf("sync fetched compose or lost version: %+v, calls=%d", got, calls.Load())
}
detail := got.Details[0]
want := ""
if tc.keepCache {
want = cached
}
if detail.DockerCompose != want || detail.LastModified != version.LastModified || detail.Params != string(params) || !detail.Update {
t.Fatalf("cache decision or version metadata changed: %+v", detail)
}
if app.Details[0].DockerCompose != old.DockerCompose {
t.Fatal("sync mutated its input template")
}
})
}
})
}
}
+253
View File
@@ -0,0 +1,253 @@
package service
import (
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
const composeTestTemplate = "services:\n demo:\n image: demo:latest\n network_mode: host\n"
func composeTestDetail(t *testing.T, resource, source string) (*gorm.DB, model.App, model.AppDetail) {
t.Helper()
db := appCacheTestDB(t)
app := model.App{Key: "demo", Name: "Demo", Resource: resource, Type: "website"}
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
detail := model.AppDetail{AppId: app.ID, Version: "1.0", LastModified: 42, Params: `{"formFields":[]}`, DownloadUrl: source}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
return db, app, detail
}
func TestComposeOnDemandLoadsOnceAndPreservesSource(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/demo/1.0/docker-compose.yml" || r.URL.Query().Get("signature") != "a+b" || r.URL.Query().Get("v") != "42" {
t.Errorf("unexpected Compose source: %s", r.URL)
}
_, _ = io.WriteString(w, composeTestTemplate)
}))
t.Cleanup(server.Close)
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo/1.0/demo-1.0.tar.gz?signature=a%2Bb")
for i := 0; i < 2; i++ {
if err := ensureAppDetailCompose(app, &detail); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 || detail.DockerCompose != composeTestTemplate {
t.Fatalf("cache miss after loading: calls=%d, detail=%+v", calls.Load(), detail)
}
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil {
t.Fatal(err)
}
if stored.DockerCompose != composeTestTemplate || stored.LastModified != 42 || stored.DownloadUrl != detail.DownloadUrl || stored.Params != detail.Params || !stored.UpdatedAt.Equal(detail.UpdatedAt) {
t.Fatalf("cache write altered version metadata: %+v", stored)
}
res, err := (AppService{}).GetAppDetailByID(detail.ID)
if err != nil || !res.HostMode || res.DockerCompose != composeTestTemplate || calls.Load() != 1 {
t.Fatalf("ID details did not reuse template/HostMode: %+v, %v, calls=%d", res, err, calls.Load())
}
}
func TestComposeOnDemandIDDetailsFillMissingTemplate(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
_, _ = io.WriteString(w, composeTestTemplate)
}))
t.Cleanup(server.Close)
_, _, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
res, err := (AppService{}).GetAppDetailByID(detail.ID)
if err != nil || !res.HostMode || res.DockerCompose != composeTestTemplate || calls.Load() != 1 {
t.Fatalf("missing template gave wrong ID details: %+v, %v, calls=%d", res, err, calls.Load())
}
}
func TestComposeOnDemandLocalAndCustomUseTheirDirectories(t *testing.T) {
for _, resource := range []string{constant.AppResourceLocal, constant.AppResourceCustom} {
t.Run(resource, func(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) }))
t.Cleanup(server.Close)
_, app, detail := composeTestDetail(t, resource, server.URL+"/must-not-fetch.tar.gz")
appCacheTestWrite(t, filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"), composeTestTemplate)
if err := ensureAppDetailCompose(app, &detail); err != nil {
t.Fatal(err)
}
if detail.DockerCompose != composeTestTemplate || calls.Load() != 0 {
t.Fatalf("local resource used remote template: %+v, calls=%d", detail, calls.Load())
}
})
}
}
func TestComposeOnDemandRejectsDownloadAndTemplateFailures(t *testing.T) {
for _, tc := range []struct {
name, body string
status int
}{
{"not found", "missing", http.StatusNotFound},
{"server error", "failure", http.StatusServiceUnavailable},
{"empty", "", http.StatusOK},
{"HTML", "<html>blocked</html>", http.StatusOK},
{"malformed YAML", "services: [", http.StatusOK},
{"missing services", "version: '3'", http.StatusOK},
{"empty services", "services: {}", http.StatusOK},
{"empty service", "services:\n demo:", http.StatusOK},
{"string service", "services:\n demo: blocked", http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
_, _ = io.WriteString(w, tc.body)
}))
t.Cleanup(server.Close)
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
if err := ensureAppDetailCompose(app, &detail); err == nil {
t.Fatal("invalid template was accepted")
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil {
t.Fatal(err)
}
if detail.DockerCompose != "" || stored.DockerCompose != "" {
t.Fatal("failed response was cached")
}
})
}
}
func TestComposeCacheRejectsConcurrentMetadataUpdates(t *testing.T) {
for _, field := range []string{"last_modified", "params", "download_url"} {
t.Run(field, func(t *testing.T) {
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
value := interface{}("new value")
if field == "last_modified" {
value = 43
}
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{field: value, "docker_compose": "new template"}).Error; err != nil {
t.Fatal(err)
}
if err := cacheAppDetailCompose(&detail, []byte(composeTestTemplate)); err == nil {
t.Fatal("stale template overwrote newer metadata")
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil {
t.Fatal(err)
}
if stored.DockerCompose != "new template" || detail.DockerCompose != "" {
t.Fatalf("stale cache result was retained: %+v", stored)
}
})
}
}
func TestComposeCachePropagatesWriteFailure(t *testing.T) {
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
if err := db.Exec("CREATE TRIGGER reject_compose BEFORE UPDATE OF docker_compose ON app_details BEGIN SELECT RAISE(FAIL, 'test write failure'); END").Error; err != nil {
t.Fatal(err)
}
if err := cacheAppDetailCompose(&detail, []byte(composeTestTemplate)); err == nil || detail.DockerCompose != "" {
t.Fatal("failed cache write was ignored")
}
}
func TestMarkDownloadedPreservesComposeAndRejectsChangedMetadata(t *testing.T) {
db, _, detail := composeTestDetail(t, constant.AppResourceRemote, "https://example.com/demo.tar.gz")
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{"update": true, "docker_compose": composeTestTemplate}).Error; err != nil {
t.Fatal(err)
}
stored, err := appDetailRepo.MarkDownloaded(detail)
if err != nil || !stored {
t.Fatalf("matching download marker failed: stored=%v err=%v", stored, err)
}
var fresh model.AppDetail
if err := db.First(&fresh, detail.ID).Error; err != nil || fresh.Update || fresh.DockerCompose != composeTestTemplate {
t.Fatalf("download marker overwrote Compose: %+v, %v", fresh, err)
}
for _, field := range []string{"last_modified", "params", "download_url"} {
t.Run(field, func(t *testing.T) {
var snapshot model.AppDetail
if err := db.First(&snapshot, detail.ID).Error; err != nil {
t.Fatal(err)
}
value := interface{}("new metadata")
if field == "last_modified" {
value = snapshot.LastModified + 1
}
if err := db.Model(&model.AppDetail{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{field: value, "update": true}).Error; err != nil {
t.Fatal(err)
}
stored, err := appDetailRepo.MarkDownloaded(snapshot)
if err != nil || stored {
t.Fatalf("stale download marked new resources ready: stored=%v err=%v", stored, err)
}
if err := db.First(&fresh, detail.ID).Error; err != nil || !fresh.Update || fresh.DockerCompose != composeTestTemplate {
t.Fatalf("stale marker overwrote template or update flag: %+v, %v", fresh, err)
}
})
}
}
func TestRuntimeDetailUsesValidPackageComposeWithoutAnotherGET(t *testing.T) {
var calls atomic.Int32
archive := appCacheTestArchive(t, "demo/1.0/docker-compose.yml", composeTestTemplate)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if !strings.HasSuffix(r.URL.Path, ".tar.gz") {
t.Errorf("runtime requested Compose after obtaining a valid package: %s", r.URL)
}
_, _ = w.Write(archive)
}))
t.Cleanup(server.Close)
_, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
app.Type = constant.RuntimeNode
if err := global.DB.Model(&app).UpdateColumn("type", app.Type).Error; err != nil {
t.Fatal(err)
}
res, err := (AppService{}).GetAppDetail(app.ID, detail.Version, "runtime")
if err != nil || res.DockerCompose != composeTestTemplate || !res.HostMode || calls.Load() != 1 {
t.Fatalf("package Compose was not reused: %+v, %v, calls=%d", res, err, calls.Load())
}
}
func TestRuntimeDetailDoesNotCacheStaleTemplateAfterPackageFailure(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if strings.HasSuffix(r.URL.Path, ".tar.gz") {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = io.WriteString(w, composeTestTemplate)
}))
t.Cleanup(server.Close)
db, app, detail := composeTestDetail(t, constant.AppResourceRemote, server.URL+"/demo.tar.gz")
app.Type = constant.RuntimeNode
if err := db.Model(&app).UpdateColumn("type", app.Type).Error; err != nil {
t.Fatal(err)
}
appCacheTestWrite(t, filepath.Join(app.GetAppResourcePath(), detail.Version, "docker-compose.yml"), "services:\n stale:\n image: stale:old\n")
res, err := (AppService{}).GetAppDetail(app.ID, detail.Version, "runtime")
if err != nil || res.DockerCompose != composeTestTemplate || calls.Load() != 2 {
t.Fatalf("failed package download reused a stale local template: %+v, %v, calls=%d", res, err, calls.Load())
}
var stored model.AppDetail
if err := db.First(&stored, detail.ID).Error; err != nil || stored.DockerCompose != composeTestTemplate {
t.Fatalf("stale package contents were cached: %+v, %v", stored, err)
}
}
@@ -0,0 +1,253 @@
package service
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const composeUpgradeCurrent = `services:
demo:
image: demo:1.0
deploy:
resources:
limits:
cpus: ${CPUS}
memory: ${MEMORY_LIMIT}
`
func composeUpgradeDB(t *testing.T, resourceURL string) (*gorm.DB, model.App, model.AppInstall, []model.AppDetail) {
t.Helper()
db := iconSyncDB(t)
if err := db.AutoMigrate(&model.AppIgnoreUpgrade{}); err != nil {
t.Fatal(err)
}
app := model.App{Key: "demo", Name: "Demo", Type: "runtime", Resource: constant.AppResourceRemote, CrossVersionUpdate: true}
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
var details []model.AppDetail
for _, version := range []string{"1.1", "1.0", "1.3", "1.2"} {
detail := model.AppDetail{
AppId: app.ID, Version: version, LastModified: 42, Params: `{"formFields":[]}`, Status: constant.AppNormal,
DownloadUrl: resourceURL + "/demo/" + version + "/demo-" + version + ".tar.gz",
}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
details = append(details, detail)
}
if err := db.Create(&model.AppIgnoreUpgrade{AppID: app.ID, AppDetailID: details[2].ID, Scope: "version"}).Error; err != nil {
t.Fatal(err)
}
install := model.AppInstall{
AppId: app.ID, AppDetailId: details[1].ID, Name: "demo-install", Version: "1.0", App: app,
ServiceName: "demo", ContainerName: "demo-container", Status: constant.StatusRunning,
Env: `{"CONTAINER_NAME":"demo-container"}`, DockerCompose: composeUpgradeCurrent,
}
if err := db.Omit(clause.Associations).Create(&install).Error; err != nil {
t.Fatal(err)
}
return db, app, install, details
}
func TestUpgradeComposeLoadsOnlyEffectiveTarget(t *testing.T) {
var mu sync.Mutex
requests := make(map[string]int)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
requests[r.URL.Path]++
mu.Unlock()
if r.URL.Query().Get("v") != "42" {
t.Errorf("missing synchronized version: %s", r.URL)
}
version := strings.Split(r.URL.Path, "/")[2]
_, _ = fmt.Fprintf(w, "services:\n source:\n image: demo:%s\n", version)
}))
t.Cleanup(server.Close)
db, _, install, details := composeUpgradeDB(t, server.URL)
// An unselected bad cached template must not prevent listing or default preview.
if err := db.Model(&details[0]).UpdateColumn("docker_compose", "invalid: [").Error; err != nil {
t.Fatal(err)
}
service := NewIAppInstalledService()
noCompose := false
versions, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, LoadCompose: &noCompose})
if err != nil || len(versions) != 2 || versions[0].Version != "1.2" || versions[1].Version != "1.1" {
t.Fatalf("pure versions did not preserve filtering/sorting: %+v, %v", versions, err)
}
for _, version := range versions {
if version.DockerCompose != "" {
t.Fatal("pure version request returned a rendered template")
}
}
if len(requests) != 0 {
t.Fatalf("pure version request fetched templates: %v", requests)
}
for i := 0; i < 2; i++ {
versions, err = service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID})
if err != nil || len(versions) != 2 || !strings.Contains(versions[0].DockerCompose, "demo:1.2") || versions[1].DockerCompose != "" {
t.Fatalf("default target preview = %+v, %v", versions, err)
}
}
if requests["/demo/1.2/docker-compose.yml"] != 1 || len(requests) != 1 {
t.Fatalf("default preview did not reuse only its own cache: %v", requests)
}
if err := db.Model(&details[0]).UpdateColumn("docker_compose", "").Error; err != nil {
t.Fatal(err)
}
versions, err = service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, UpdateVersion: "1.1"})
if err != nil || !strings.Contains(versions[1].DockerCompose, "demo:1.1") || versions[0].DockerCompose != "" {
t.Fatalf("selected target preview = %+v, %v", versions, err)
}
if requests["/demo/1.1/docker-compose.yml"] != 1 || len(requests) != 2 {
t.Fatalf("selection prefetched other candidates: %v", requests)
}
if _, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID, UpdateVersion: "1.3"}); err == nil {
t.Fatal("ignored target was accepted")
}
if len(requests) != 2 {
t.Fatal("invalid target triggered a template request")
}
}
func TestUpgradeComposeFetchFailurePreservesInstanceAndCache(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) }))
t.Cleanup(server.Close)
db, _, install, details := composeUpgradeDB(t, server.URL)
if _, err := NewIAppInstalledService().GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID}); err == nil {
t.Fatal("failed target template load was accepted")
}
var stored model.AppInstall
if err := db.First(&stored, install.ID).Error; err != nil || stored.DockerCompose != install.DockerCompose || stored.Status != install.Status || stored.Version != install.Version {
t.Fatalf("preview modified installed instance: %+v, %v", stored, err)
}
var detail model.AppDetail
if err := db.First(&detail, details[3].ID).Error; err != nil || detail.DockerCompose != "" {
t.Fatalf("failed response cached a template: %+v, %v", detail, err)
}
}
func TestUpgradePrepareComposeFailureDoesNotCutover(t *testing.T) {
for _, failure := range []string{"package download", "invalid package template"} {
t.Run(failure, func(t *testing.T) {
archive := appCacheTestArchive(t, "demo/1.2/docker-compose.yml", "services: []")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if failure == "package download" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write(archive)
}))
t.Cleanup(server.Close)
db, _, install, details := composeUpgradeDB(t, server.URL)
upgrade := &appUpgradeContext{original: install, detail: details[3]}
if err := upgrade.prepare(&task.Task{Logger: global.LOG}); err == nil {
t.Fatal("invalid upgrade resources passed prepare")
}
if upgrade.stopAttempted || upgrade.downAttempted || upgrade.stageDir != "" {
t.Fatal("failed template preparation reached live cutover")
}
var stored model.AppInstall
if err := db.First(&stored, install.ID).Error; err != nil || stored.DockerCompose != install.DockerCompose || stored.Env != install.Env || stored.Version != install.Version || stored.Status != install.Status {
t.Fatalf("failed prepare modified the instance: %+v, %v", stored, err)
}
})
}
}
func TestExplicitConfigComposeDetectsCustomAndReusesCurrentCache(t *testing.T) {
for _, tc := range []struct {
name string
custom bool
failure bool
}{
{name: "original"},
{name: "custom", custom: true},
{name: "fetch failure", failure: true},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/demo/1.0/docker-compose.yml" {
t.Errorf("configuration loaded a different version: %s", r.URL)
}
if tc.failure {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = io.WriteString(w, "services:\n source:\n image: demo:1.0\n")
}))
t.Cleanup(server.Close)
db, _, install, _ := composeUpgradeDB(t, server.URL)
if tc.custom {
install.DockerCompose = strings.Replace(install.DockerCompose, "image: demo:1.0", "image: demo:1.0\n environment:\n USER_SETTING: preserve", 1)
if err := db.Omit(clause.Associations).Save(&install).Error; err != nil {
t.Fatal(err)
}
}
if isEditCompose(install) || calls != 0 {
t.Fatal("list-time custom check fetched a missing template")
}
result, err := NewIAppInstalledService().GetParams(install.ID)
if tc.failure {
if err == nil || result != nil {
t.Fatal("failed current-template load was treated as an unedited configuration")
}
return
}
if err != nil || result.RawCompose == "" || result.IsEdit != tc.custom || result.DockerCompose != install.DockerCompose {
t.Fatalf("explicit custom state = %+v, %v; want custom=%v", result, err, tc.custom)
}
if _, err := NewIAppInstalledService().GetParams(install.ID); err != nil || calls != 1 {
t.Fatalf("configuration cache was not reused: calls=%d, %v", calls, err)
}
if isEditCompose(install) != tc.custom || calls != 1 {
t.Fatal("cached list-time comparison changed custom state or made network requests")
}
})
}
}
func TestVllmManagedComposeDoesNotRequireOfficialTemplate(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(server.Close)
db, app, install, _ := composeUpgradeDB(t, server.URL)
if err := db.Model(&app).UpdateColumn("key", vllmAppKeyForUpgrade).Error; err != nil {
t.Fatal(err)
}
install.DockerCompose = strings.Replace(install.DockerCompose, "image: demo:1.0", "image: demo:1.0\n command: [--managed-user-option]", 1)
if err := db.Omit(clause.Associations).Save(&install).Error; err != nil {
t.Fatal(err)
}
service := NewIAppInstalledService()
config, err := service.GetParams(install.ID)
if err != nil || config.RawCompose != install.DockerCompose || config.DockerCompose != install.DockerCompose || config.IsEdit {
t.Fatalf("managed configuration lost the instance template: %+v, %v", config, err)
}
versions, err := service.GetUpdateVersions(request.AppUpdateVersion{AppInstallID: install.ID})
if err != nil || len(versions) != 2 || versions[0].DockerCompose != install.DockerCompose {
t.Fatalf("managed upgrade preview lost the instance template: %+v, %v", versions, err)
}
if calls.Load() != 0 {
t.Fatalf("managed template attempted %d official resource requests", calls.Load())
}
}
+265
View File
@@ -0,0 +1,265 @@
package service
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/1Panel-dev/1Panel/agent/utils/xpack/providers"
"github.com/gin-gonic/gin"
goi18n "github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
"gorm.io/gorm"
)
type iconSyncTransport struct {
providers.MultiNodeProvider
transport *http.Transport
}
func (p iconSyncTransport) LoadRequestTransport() *http.Transport { return p.transport }
func iconSyncDB(t *testing.T) *gorm.DB {
t.Helper()
db := appCacheTestDB(t)
if err := db.AutoMigrate(&model.Tag{}, &model.AppInstall{}, &model.AppInstallResource{}); err != nil {
t.Fatal(err)
}
if err := db.Create(&[]model.Setting{{Key: "SystemVersion", Value: "2.4.0"}, {Key: "AppStoreSyncStatus", Value: constant.StatusSyncSuccess}}).Error; err != nil {
t.Fatal(err)
}
oldI18n := global.I18n
global.I18n = goi18n.NewLocalizer(goi18n.NewBundle(language.English), "en")
t.Cleanup(func() { global.I18n = oldI18n })
return db
}
func TestOfficialIconSyncNeverDownloadsImages(t *testing.T) {
iconSyncDB(t)
var calls atomic.Int32
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(cdn.Close)
taskLog := &task.Task{Logger: global.LOG}
for _, oldIcon := range []string{"", "app_demo.png?etag=old", "b2xk", cdn.URL + "/old.png"} {
t.Run(oldIcon, func(t *testing.T) {
icon := cdn.URL + "/demo.png?signature=a%2Bb&v=one"
def := dto.AppDefine{Icon: icon, AppProperty: dto.AppProperty{Key: "demo", Type: "website"}, Versions: []dto.AppConfigVersion{{Name: "1.0", AppForm: dto.AppForm{}}}}
apps := getApps([]model.App{{Key: "demo", Icon: oldIcon}}, []dto.AppDefine{def}, "2.4.0", taskLog)
ctx := appSyncContext{baseRemoteUrl: cdn.URL, list: &dto.AppList{LastModified: 42}}
result := ctx.processOneApp(appWorkItem{app: apps["demo"], appDef: def})
if result.Icon != icon || len(result.Details) != 1 || result.Details[0].Version != "1.0" || calls.Load() != 0 {
t.Fatalf("sync changed metadata or fetched a CDN image: %+v, calls=%d", result, calls.Load())
}
})
}
for _, invalid := range []string{"", "javascript:alert(1)", "https://user:secret@example.com/a.png", "//example.com/a.png", "https://"} {
def := dto.AppDefine{Icon: invalid, AppProperty: dto.AppProperty{Key: "demo"}}
apps := getApps([]model.App{{Key: "demo", Icon: "b2xk"}}, []dto.AppDefine{def}, "2.4.0", taskLog)
if apps["demo"].Icon != "b2xk" {
t.Fatalf("invalid icon %q erased history", invalid)
}
}
}
func TestOfficialIconSameRevisionRefreshIsReadOnlyUntilSync(t *testing.T) {
db := iconSyncDB(t)
apps := []model.App{
{Key: "mysql", Type: "website", Status: constant.AppNormal, Icon: "app_mysql.png", BatchInstallSupport: true},
{Key: "demo", Name: "original", Type: "website", Status: constant.AppNormal, Icon: "b2xk", LastModified: 7},
{Key: "future", Type: "website", Status: constant.AppNormal, Icon: "b2xk"},
{Key: "local-demo", Type: "website", Resource: constant.AppResourceLocal, Status: constant.AppNormal, Icon: "bG9jYWw="},
{Key: "custom-demo", Type: "website", Resource: constant.AppResourceCustom, Status: constant.AppNormal, Icon: "Y3VzdG9t"},
{Key: "down", Type: "website", Status: constant.AppTakeDown, Icon: "b2xk"},
}
if err := db.Create(&apps).Error; err != nil {
t.Fatal(err)
}
detail := model.AppDetail{AppId: apps[1].ID, Version: "1.0", Params: "keep", DockerCompose: "keep", Update: false}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&detail, detail.ID).Error; err != nil {
t.Fatal(err)
}
var imageCalls, indexCalls atomic.Int32
var failIndex atomic.Bool
list := dto.AppList{LastModified: 42}
for _, app := range apps {
version := 0.0
if app.Key == "future" {
version = 9.9
}
list.Apps = append(list.Apps, dto.AppDefine{Icon: "https://cdn.example/" + app.Key + ".png?signature=a%2Bb", AppProperty: dto.AppProperty{Key: app.Key, Version: version}})
}
var zipped bytes.Buffer
zw := zip.NewWriter(&zipped)
w, err := zw.Create("1panel.json")
if err != nil {
t.Fatal(err)
}
if err := json.NewEncoder(w).Encode(list); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "1panel.json.version.txt"):
_, _ = io.WriteString(w, "42")
case strings.HasSuffix(r.URL.Path, "1panel.json.zip"):
indexCalls.Add(1)
if failIndex.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write(zipped.Bytes())
default:
imageCalls.Add(1)
w.WriteHeader(http.StatusServiceUnavailable)
}
}))
t.Cleanup(server.Close)
transport := server.Client().Transport.(*http.Transport).Clone()
transport.TLSClientConfig.InsecureSkipVerify = true
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
}
oldProvider := xpack.MultiNodeProvider
xpack.MultiNodeProvider = iconSyncTransport{MultiNodeProvider: oldProvider, transport: transport}
t.Cleanup(func() { xpack.MultiNodeProvider = oldProvider; transport.CloseIdleConnections() })
global.Dir.ResourceDir = t.TempDir()
global.Dir.IconCacheDir = t.TempDir()
appCacheTestWrite(t, filepath.Join(global.Dir.IconCacheDir, "app_mysql.png"), "keep old image")
encodedList, err := json.Marshal(list)
if err != nil {
t.Fatal(err)
}
appCacheTestWrite(t, filepath.Join(global.Dir.ResourceDir, "1panel.json"), string(encodedList))
res, err := (AppService{}).GetAppUpdate()
if err != nil || res.CanUpdate || res.AppList == nil || indexCalls.Load() != 0 {
t.Fatalf("same revision/missing files forced a full sync: %+v, %v", res, err)
}
var original model.App
if err := db.First(&original, apps[1].ID).Error; err != nil || original.Icon != apps[1].Icon {
t.Fatalf("update check modified icon: %+v, %v", original, err)
}
var writes atomic.Int32
if err := db.Callback().Update().After("gorm:update").Register("test:icon-writes", func(tx *gorm.DB) {
if values, ok := tx.Statement.Dest.(map[string]interface{}); ok && tx.Statement.Table == "apps" && values["icon"] != nil {
writes.Add(1)
}
}); err != nil {
t.Fatal(err)
}
var shared *appSyncContext
for i := 0; i < 2; i++ {
if err := (AppService{}).createSyncAppStoreTask(&shared)(&task.Task{Logger: global.LOG}); err != nil {
t.Fatal(err)
}
if shared == nil || !shared.skipMetaSync || writes.Load() != 2 || imageCalls.Load() != 0 || indexCalls.Load() != 0 {
t.Fatalf("unexpected same revision sync: writes=%d, image calls=%d, index calls=%d, context=%+v", writes.Load(), imageCalls.Load(), indexCalls.Load(), shared)
}
}
var stored model.App
if err := db.First(&stored, apps[1].ID).Error; err != nil {
t.Fatal(err)
}
want := original
want.Icon = list.Apps[1].Icon
if !reflect.DeepEqual(stored, want) {
t.Fatalf("light refresh changed non-icon fields: got %+v, want %+v", stored, want)
}
for _, original := range apps[2:] {
stored = model.App{}
if err := db.First(&stored, original.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Icon != original.Icon || stored.Status != original.Status {
t.Fatalf("filtered app was changed: %+v", stored)
}
}
var storedDetail model.AppDetail
if err := db.First(&storedDetail, detail.ID).Error; err != nil || !reflect.DeepEqual(storedDetail, detail) {
t.Fatalf("light refresh touched app details: %+v, %v", storedDetail, err)
}
failIndex.Store(true)
appCacheTestWrite(t, filepath.Join(global.Dir.ResourceDir, "1panel.json"), "{invalid")
if err := (AppService{}).createSyncAppStoreTask(&shared)(&task.Task{Logger: global.LOG}); err == nil || writes.Load() != 2 || indexCalls.Load() != 1 {
t.Fatal("failed index read was accepted or modified icons")
}
}
func TestAppIconURLsReachListDetailAndInstalled(t *testing.T) {
db := iconSyncDB(t)
for _, icon := range []string{"https://cdn.example/icon.png?signature=a%2Bb", "app_legacy.png?etag=old", "bG9jYWw="} {
app := model.App{Key: "demo-" + icon[:4], Name: "demo", Type: "website", Status: constant.AppNormal, Icon: icon}
app.Name = app.Key
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
got, err := (AppService{}).PageApp(ctx, request.AppSearch{PageInfo: dto.PageInfo{Page: 1, PageSize: 10}, Name: app.Key})
if err != nil || len(got.Items) != 1 {
t.Fatalf("list failed: %+v, %v", got, err)
}
want := ""
if strings.HasPrefix(icon, "https:") {
want = icon
}
if got.Items[0].Icon != want {
t.Fatalf("list icon = %q, want %q", got.Items[0].Icon, want)
}
detail, err := (AppService{}).GetApp(ctx, app.Key)
if err != nil || detail.Icon != icon {
t.Fatalf("detail icon = %+v, %v", detail, err)
}
installed, err := handleInstalled([]model.AppInstall{{App: app, Status: constant.StatusRunning}}, false, false, false)
if err != nil || len(installed) != 1 || installed[0].Icon != want {
t.Fatalf("installed icon = %+v, %v", installed, err)
}
}
}
func TestLocalAppLogoKeepsBase64Delivery(t *testing.T) {
db := iconSyncDB(t)
dir := t.TempDir()
const image = "\x89PNG\r\n\x1a\nlocal-icon"
appCacheTestWrite(t, filepath.Join(dir, "data.yml"), "additionalProperties:\n key: demo\n name: Local Demo\n type: website\n")
appCacheTestWrite(t, filepath.Join(dir, "README.md"), "local application")
appCacheTestWrite(t, filepath.Join(dir, "logo.png"), image)
app, err := handleLocalApp(dir)
if err != nil || app.Resource != constant.AppResourceLocal || app.Icon != base64.StdEncoding.EncodeToString([]byte(image)) {
t.Fatalf("local logo parsing changed: %+v, %v", app, err)
}
if err := db.Create(app).Error; err != nil {
t.Fatal(err)
}
data, source, etag, err := (AppService{}).GetAppIcon(app.Key)
if err != nil || string(data) != image || source != "" || etag != "" {
t.Fatalf("local logo no longer uses historical delivery: %q, %q, %q, %v", data, source, etag, err)
}
}
+115 -42
View File
@@ -6,10 +6,8 @@ import (
"fmt" "fmt"
"maps" "maps"
"math" "math"
"net/http"
"os" "os"
"path" "path"
"path/filepath"
"reflect" "reflect"
"sort" "sort"
"strconv" "strconv"
@@ -31,7 +29,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/env" "github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/nginx" "github.com/1Panel-dev/1Panel/agent/utils/nginx"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -259,6 +256,15 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
if err != nil { if err != nil {
return err return err
} }
if install.Status == constant.StatusRestarting && (req.Operate == constant.Stop || req.Operate == constant.Delete) && !req.ForceDelete {
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(install.ID), taskRepo.WithOperate(task.TaskRestart))
if err != nil {
return err
}
if count > 0 {
return buserr.New("TaskIsExecuting")
}
}
if !req.ForceDelete && !files.NewFileOp().Stat(install.GetPath()) { if !req.ForceDelete && !files.NewFileOp().Stat(install.GetPath()) {
return buserr.New("ErrInstallDirNotFound") return buserr.New("ErrInstallDirNotFound")
} }
@@ -342,9 +348,6 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
return errors.New("lifecycle script operation not supported") return errors.New("lifecycle script operation not supported")
} }
install.Message = "" install.Message = ""
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
return err
}
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID) operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil { if err != nil {
@@ -355,10 +358,17 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
} }
return err return err
} }
ready := make(chan error, 1)
started := make(chan struct{})
operation := string(req.Operate) operation := string(req.Operate)
operationTask.AddSubTaskWithOps( operationTask.AddSubTaskWithOps(
task.GetTaskName(install.Name, taskType, task.TaskScopeApp), task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
func(t *task.Task) error { func(t *task.Task) error {
if err := appInstallRepo.Save(context.Background(), &install); err != nil {
return err
}
close(started)
ready <- nil
if err := runScript(t, &install, operation); err != nil { if err := runScript(t, &install, operation); err != nil {
return err return err
} }
@@ -385,6 +395,12 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
) )
go func() { go func() {
if taskErr := operationTask.Execute(); taskErr != nil { if taskErr := operationTask.Execute(); taskErr != nil {
select {
case <-started:
default:
ready <- taskErr
return
}
if onFailure != nil { if onFailure != nil {
onFailure(taskErr) onFailure(taskErr)
return return
@@ -394,7 +410,7 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
_ = appInstallRepo.Save(context.Background(), &install) _ = appInstallRepo.Save(context.Background(), &install)
} }
}() }()
return nil return <-ready
} }
func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error { func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
@@ -474,9 +490,10 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
} }
backupDockerCompose := installed.DockerCompose backupDockerCompose := installed.DockerCompose
if req.Advanced { updateGPU := installed.App.Key == "ollama" && req.GPU != nil
if req.Advanced || updateGPU {
composeMap := make(map[string]interface{}) composeMap := make(map[string]interface{})
if req.EditCompose { if req.Advanced && req.EditCompose {
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil { if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
return err return err
} }
@@ -485,16 +502,35 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
return err return err
} }
} }
if !req.SkipComposeCommonConfig { if req.Advanced && !req.SkipComposeCommonConfig {
if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil { if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil {
return err return err
} }
} }
if updateGPU {
services, _ := composeMap["services"].(map[string]interface{})
service, ok := services[installed.ServiceName].(map[string]interface{})
if !ok {
return buserr.New("ErrFileParse")
}
gpuParams := map[string]interface{}{}
if err := json.Unmarshal([]byte(installed.Env), &gpuParams); err != nil {
return err
}
for key, value := range req.Params {
gpuParams[key] = value
}
if err := ApplyModelGPU(service, req.GPU, gpuParams, "nvidia"); err != nil {
return err
}
}
composeByte, err := yaml.Marshal(composeMap) composeByte, err := yaml.Marshal(composeMap)
if err != nil { if err != nil {
return err return err
} }
installed.DockerCompose = string(composeByte) installed.DockerCompose = string(composeByte)
}
if req.Advanced {
if req.ContainerName == "" { if req.ContainerName == "" {
req.Params[constant.ContainerName] = installed.ContainerName req.Params[constant.ContainerName] = installed.ContainerName
} else { } else {
@@ -743,7 +779,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
} }
if canUpgrade { if canUpgrade {
feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version) feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version)
if feishuTarget && !feishuChecked { if feishuTarget && !feishuChecked && (req.LoadCompose == nil || *req.LoadCompose) {
conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json")) conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json"))
if readErr != nil { if readErr != nil {
return versions, readErr return versions, readErr
@@ -754,33 +790,6 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
} }
feishuChecked = true feishuChecked = true
} }
var newCompose string
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
filename := filepath.Base(detail.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml")
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return versions, err
}
}
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound {
return versions, buserr.New("ErrAppVersionUnavailable")
}
if err != nil {
return versions, err
}
if statusCode > 200 {
return versions, err
}
detail.DockerCompose = string(composeRes)
_ = appDetailRepo.Update(context.Background(), detail)
}
newCompose, err = getUpgradeCompose(install, detail)
if err != nil {
return versions, err
}
if app.Key == constant.AppMysql { if app.Key == constant.AppMysql {
majorVersion := getMajorVersion(install.Version) majorVersion := getMajorVersion(install.Version)
if !strings.HasPrefix(detail.Version, majorVersion) { if !strings.HasPrefix(detail.Version, majorVersion) {
@@ -792,13 +801,46 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
FeishuConfigured: feishuTarget && feishuConfigured, FeishuConfigured: feishuTarget && feishuConfigured,
Version: detail.Version, Version: detail.Version,
DetailId: detail.ID, DetailId: detail.ID,
DockerCompose: newCompose,
}) })
} }
} }
sort.Slice(versions, func(i, j int) bool { sort.Slice(versions, func(i, j int) bool {
return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version) return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version)
}) })
if len(versions) == 0 || (req.LoadCompose != nil && !*req.LoadCompose) {
return versions, nil
}
target := 0
if req.UpdateVersion != "" {
target = -1
for i := range versions {
if versions[i].Version == req.UpdateVersion {
target = i
break
}
}
if target == -1 {
return versions, buserr.New("ErrAppVersionUnavailable")
}
}
if app.Key == vllmAppKeyForUpgrade {
versions[target].DockerCompose = install.DockerCompose
return versions, nil
}
for _, detail := range details {
if detail.ID != versions[target].DetailId {
continue
}
if err := ensureAppDetailCompose(app, &detail); err != nil {
return versions, err
}
content, err := getUpgradeCompose(install, detail)
if err != nil {
return versions, err
}
versions[target].DockerCompose = content
break
}
return versions, nil return versions, nil
} }
@@ -994,6 +1036,16 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
res.ModelAccountBaseURLType = install.ModelAccountBaseURLType res.ModelAccountBaseURLType = install.ModelAccountBaseURLType
res.ModelAccountBaseURL = install.ModelAccountBaseURL res.ModelAccountBaseURL = install.ModelAccountBaseURL
if install.App.Key == "ollama" { if install.App.Key == "ollama" {
var project map[string]interface{}
if err := yaml.Unmarshal([]byte(install.DockerCompose), &project); err != nil {
return nil, err
}
services, _ := project["services"].(map[string]interface{})
service, ok := services[install.ServiceName].(map[string]interface{})
if !ok {
return nil, buserr.New("ErrFileParse")
}
res.GPU = ReadModelGPU(service, envs, "nvidia")
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(install) res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(install)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1007,9 +1059,22 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
res.RestartPolicy = getRestartPolicy(install.DockerCompose) res.RestartPolicy = getRestartPolicy(install.DockerCompose)
res.WebUI = install.WebUI res.WebUI = install.WebUI
res.Type = install.App.Type res.Type = install.App.Type
if rawCompose, err := getUpgradeCompose(install, detail); err == nil { rawCompose := install.DockerCompose
res.RawCompose = rawCompose if install.App.Key != vllmAppKeyForUpgrade {
if err := ensureAppDetailCompose(install.App, &detail); err != nil {
return nil, err
}
rawCompose, err = getUpgradeCompose(install, detail)
if err != nil {
return nil, err
}
} }
equal, err := composeEqualExceptImage(rawCompose, install.DockerCompose)
if err != nil {
return nil, err
}
res.RawCompose = rawCompose
res.IsEdit = !equal
return &res, nil return &res, nil
} }
@@ -1036,8 +1101,16 @@ func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
} }
switch appInstall.Status { switch appInstall.Status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling, case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting: constant.StatusStarting, constant.StatusWaiting:
return nil return nil
case constant.StatusRestarting:
count, err := taskRepo.CountExecutingTask(repo.WithByType(task.TaskScopeApp), taskRepo.WithResourceID(appInstall.ID), taskRepo.WithOperate(task.TaskRestart))
if err != nil {
return err
}
if count > 0 {
return nil
}
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr: case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
if !force { if !force {
return nil return nil
+128
View File
@@ -0,0 +1,128 @@
package service
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
)
func TestAppStoreIndexUsesCacheUntilRevisionChanges(t *testing.T) {
for _, tc := range []struct {
name string
cache string
version int
failZIP bool
wantZIP int32
wantUpdate bool
wantErr bool
}{
{name: "matching cache", cache: `{"lastModified":42}`, version: 42},
{name: "missing cache", version: 42, wantZIP: 1},
{name: "malformed cache", cache: `{invalid`, version: 42, wantZIP: 1},
{name: "null cache", cache: `null`, version: 42, wantZIP: 1},
{name: "trailing corrupt data", cache: `{"lastModified":42} garbage`, version: 42, wantZIP: 1},
{name: "outdated cache", cache: `{"lastModified":41}`, version: 42, wantZIP: 1},
{name: "remote revision changed", cache: `{"lastModified":42}`, version: 43, wantUpdate: true},
{name: "download failure", cache: `{invalid`, version: 42, failZIP: true, wantZIP: 1, wantErr: true},
{name: "cached minimum panel version", cache: `{"lastModified":42,"additionalProperties":{"version":"9.9.0"}}`, version: 42, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
db := iconSyncDB(t)
if err := db.Create(&model.App{Key: "mysql", Type: "website", Status: constant.AppNormal, Icon: "app_mysql.png", BatchInstallSupport: true}).Error; err != nil {
t.Fatal(err)
}
global.Dir.ResourceDir = t.TempDir()
cachePath := filepath.Join(global.Dir.ResourceDir, "1panel.json")
if tc.cache != "" {
appCacheTestWrite(t, cachePath, tc.cache)
}
list := dto.AppList{LastModified: tc.version, Apps: []dto.AppDefine{{Icon: "https://cdn.example/mysql.png", AppProperty: dto.AppProperty{Key: "mysql"}}}}
var archive bytes.Buffer
zw := zip.NewWriter(&archive)
entry, err := zw.Create("1panel.json")
if err != nil {
t.Fatal(err)
}
if err := json.NewEncoder(entry).Encode(list); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
var versionCalls, zipCalls atomic.Int32
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "1panel.json.version.txt"):
versionCalls.Add(1)
_, _ = io.WriteString(w, strconv.Itoa(tc.version))
case strings.HasSuffix(r.URL.Path, "1panel.json.zip"):
zipCalls.Add(1)
if tc.failZIP {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write(archive.Bytes())
default:
t.Errorf("unexpected remote resource request: %s", r.URL)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
transport := server.Client().Transport.(*http.Transport).Clone()
transport.TLSClientConfig.InsecureSkipVerify = true
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
}
oldProvider := xpack.MultiNodeProvider
xpack.MultiNodeProvider = iconSyncTransport{MultiNodeProvider: oldProvider, transport: transport}
t.Cleanup(func() { xpack.MultiNodeProvider = oldProvider; transport.CloseIdleConnections() })
res, err := (AppService{}).GetAppUpdate()
if (err != nil) != tc.wantErr || res == nil && !tc.wantErr {
t.Fatalf("update check = %+v, %v; want error %v", res, err, tc.wantErr)
}
if zipCalls.Load() != tc.wantZIP || versionCalls.Load() != 1 {
t.Fatalf("first check requests: version=%d, ZIP=%d; want 1/%d", versionCalls.Load(), zipCalls.Load(), tc.wantZIP)
}
if tc.wantErr {
if tc.wantZIP == 0 && !strings.Contains(err.Error(), "ErrVersionTooLow") {
t.Fatalf("cached panel-version check was lost: %v", err)
}
return
}
if res.CanUpdate != tc.wantUpdate {
t.Fatalf("CanUpdate=%v, want %v", res.CanUpdate, tc.wantUpdate)
}
if tc.wantUpdate {
// Full synchronization still fetches the new index after detecting a revision change.
fresh, err := getAppList()
if err != nil || fresh.LastModified != tc.version || zipCalls.Load() != 1 {
t.Fatalf("changed revision did not fetch the new index: %+v, %v, ZIP=%d", fresh, err, zipCalls.Load())
}
return
}
if res.AppList == nil || res.AppList.LastModified != 42 {
t.Fatalf("same-revision icon refresh lost its index: %+v", res)
}
if _, err := (AppService{}).GetAppUpdate(); err != nil || zipCalls.Load() != tc.wantZIP || versionCalls.Load() != 2 {
t.Fatalf("second check redownloaded cached index: %v, version=%d, ZIP=%d", err, versionCalls.Load(), zipCalls.Load())
}
})
}
}
+48 -214
View File
@@ -4,12 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"strconv" "strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
@@ -17,15 +12,11 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
) )
type appSyncContext struct { type appSyncContext struct {
task *task.Task task *task.Task
httpClient http.Client
baseRemoteUrl string baseRemoteUrl string
systemVersion string systemVersion string
appsMap map[string]model.App appsMap map[string]model.App
@@ -34,7 +25,6 @@ type appSyncContext struct {
oldAppIds []uint oldAppIds []uint
appTags []*model.AppTag appTags []*model.AppTag
skipMetaSync bool skipMetaSync bool
pendingIcons map[string]string
} }
func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error { func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error {
@@ -51,6 +41,10 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing")) t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
return nil return nil
} }
if err := refreshOfficialAppIcons(updateRes.AppList, t); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("PersistApps"), err)
return err
}
global.LOG.Infof("[AppStore] Appstore is up to date") global.LOG.Infof("[AppStore] Appstore is up to date")
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate")) t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
*sharedCtx = &appSyncContext{skipMetaSync: true} *sharedCtx = &appSyncContext{skipMetaSync: true}
@@ -82,13 +76,11 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
ctx := &appSyncContext{ ctx := &appSyncContext{
task: t, task: t,
httpClient: http.Client{Timeout: time.Duration(constant.TimeOut20s) * time.Second, Transport: xpack.MultiNodeProvider.LoadRequestTransport()},
baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.AppRepoURL(), global.CONF.Base.Mode), baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.AppRepoURL(), global.CONF.Base.Mode),
systemVersion: setting.SystemVersion, systemVersion: setting.SystemVersion,
settingService: settingService, settingService: settingService,
list: list, list: list,
appTags: make([]*model.AppTag, 0), appTags: make([]*model.AppTag, 0),
pendingIcons: make(map[string]string),
} }
if err = SyncTags(list.Extra); err != nil { if err = SyncTags(list.Extra); err != nil {
@@ -107,7 +99,7 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
ctx.oldAppIds = append(ctx.oldAppIds, old.ID) ctx.oldAppIds = append(ctx.oldAppIds, old.ID)
} }
ctx.appsMap, ctx.pendingIcons = getApps(oldApps, list.Apps, setting.SystemVersion, t) ctx.appsMap = getApps(oldApps, list.Apps, setting.SystemVersion, t)
var addCount, updateCount, deleteCount int var addCount, updateCount, deleteCount int
if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil { if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil {
@@ -129,40 +121,39 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
} }
} }
type appWorkItem struct { func refreshOfficialAppIcons(list *dto.AppList, t *task.Task) error {
appDef dto.AppDefine if list == nil {
app model.App return nil
iconUrl string
hadIcon bool
}
type appWorkResult struct {
appKey string
app model.App
iconStatus int
hadIcon bool
httpFailed bool
}
func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
app := item.app
l := item.appDef
result := appWorkResult{
appKey: l.AppProperty.Key,
hadIcon: item.hadIcon,
} }
apps, err := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
if item.hadIcon { if err != nil {
status, iconField := c.downloadAppIcon(item.iconUrl, l.AppProperty.Key, app.Icon) return err
result.iconStatus = status }
switch status { systemVersion, err := settingRepo.GetValueByKey("SystemVersion")
case http.StatusOK: if err != nil {
app.Icon = iconField return err
case http.StatusNotModified: }
default: updated := getApps(apps, list.Apps, systemVersion, t)
result.httpFailed = true for _, app := range apps {
candidate := updated[app.Key]
if app.Status != constant.AppNormal || candidate.Status != constant.AppNormal || candidate.Icon == app.Icon {
continue
}
if err := appRepo.UpdateIcon(app.ID, candidate.Icon); err != nil {
return err
} }
} }
return nil
}
type appWorkItem struct {
appDef dto.AppDefine
app model.App
}
func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
app := item.app
l := item.appDef
app.TagsKey = l.AppProperty.Tags app.TagsKey = l.AppProperty.Tags
if l.AppProperty.Recommend > 0 { if l.AppProperty.Recommend > 0 {
@@ -189,24 +180,12 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
continue continue
} }
if _, ok := InitTypes[app.Type]; ok { downloadURL := fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml") if v.LastModified <= 0 || detail.LastModified != v.LastModified || detail.Params != string(paramByte) || detail.DownloadUrl != downloadURL {
dockerComposeUrl, err := appDownloadURL(dockerComposeUrl, strconv.Itoa(c.list.LastModified))
var composeRes []byte
if err == nil {
_, composeRes, err = req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
}
if err == nil {
detail.DockerCompose = string(composeRes)
} else {
result.httpFailed = true
}
} else {
detail.DockerCompose = "" detail.DockerCompose = ""
} }
detail.Params = string(paramByte) detail.Params = string(paramByte)
detail.DownloadUrl = fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz") detail.DownloadUrl = downloadURL
detail.DownloadCallBackUrl = v.DownloadCallBackUrl detail.DownloadCallBackUrl = v.DownloadCallBackUrl
detail.Update = true detail.Update = true
detail.LastModified = v.LastModified detail.LastModified = v.LastModified
@@ -219,170 +198,25 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
} }
app.Details = newDetails app.Details = newDetails
result.app = app return app
return result
} }
func (c *appSyncContext) syncAppIconsAndDetails() error { func (c *appSyncContext) syncAppDetails() error {
total := len(c.list.Apps) total := len(c.list.Apps)
global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total) global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total)
for i, definition := range c.list.Apps {
workItems := make([]appWorkItem, 0, total) app, ok := c.appsMap[definition.AppProperty.Key]
for _, l := range c.list.Apps { if ok {
app, ok := c.appsMap[l.AppProperty.Key] c.appsMap[app.Key] = c.processOneApp(appWorkItem{appDef: definition, app: app})
if !ok {
continue
} }
iconUrl, hasPending := c.pendingIcons[l.AppProperty.Key] if (i+1)%max(1, total/4) == 0 || i+1 == total {
workItems = append(workItems, appWorkItem{ c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), i+1, total)
appDef: l,
app: app,
iconUrl: iconUrl,
hadIcon: hasPending,
})
}
totalWork := len(workItems)
if totalWork == 0 {
return nil
}
const maxWorkers = 4
var (
failFlag atomic.Bool
workCh = make(chan int, maxWorkers)
resultCh = make(chan appWorkResult, maxWorkers)
wg sync.WaitGroup
)
wg.Add(maxWorkers)
for range maxWorkers {
go func() {
defer wg.Done()
for idx := range workCh {
result := c.processOneApp(workItems[idx])
resultCh <- result
if result.httpFailed {
failFlag.Store(true)
}
}
}()
}
go func() { wg.Wait(); close(resultCh) }()
var fed atomic.Int32
go func() {
for i := range workItems {
if failFlag.Load() {
break
}
workCh <- i
fed.Store(int32(i + 1))
}
close(workCh)
}()
var (
completed int
icon200Count int
icon304Count int
iconFailCount int
)
milestones := [4]int{totalWork / 4, totalWork / 2, totalWork * 3 / 4, totalWork}
nextMS := 0
checkProgress := func() {
if nextMS < len(milestones) && completed >= milestones[nextMS] {
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), completed, totalWork)
nextMS++
} }
} }
global.LOG.Infof("[AppStore] app detail sync completed, total: %d", total)
applyResult := func(result appWorkResult) {
c.appsMap[result.appKey] = result.app
if result.hadIcon {
switch result.iconStatus {
case http.StatusOK:
icon200Count++
case http.StatusNotModified:
icon304Count++
default:
iconFailCount++
}
}
completed++
checkProgress()
}
for result := range resultCh {
applyResult(result)
}
fedCount := int(fed.Load())
if failFlag.Load() && fedCount < len(workItems) {
global.LOG.Warnf("[AppStore] HTTP failure detected, falling back to serial for remaining %d apps", len(workItems)-fedCount)
for i := fedCount; i < len(workItems); i++ {
result := c.processOneApp(workItems[i])
applyResult(result)
}
}
global.LOG.Infof("[AppStore] icon download completed - total: %d, success(200): %d, cached(304): %d, failed: %d",
total, icon200Count, icon304Count, iconFailCount)
return nil return nil
} }
func (c *appSyncContext) downloadAppIcon(iconUrl, appKey, oldIcon string) (status int, iconField string) {
iconFileName, existingEtag := appicon.ParseIconField(oldIcon)
reqHeaders := make(map[string]string)
if existingEtag != "" && iconFileName != "" && appicon.IconFileExists(iconFileName) {
reqHeaders["If-None-Match"] = existingEtag
}
resp, err := req_helper.HandleRequestWithHeaders(&c.httpClient, iconUrl, http.MethodGet, constant.TimeOut20s, reqHeaders)
if err != nil {
global.LOG.Warnf("[AppStore] request icon failed url=%s, err=%v", iconUrl, err)
return 0, ""
}
if resp.StatusCode == http.StatusNotModified {
return http.StatusNotModified, ""
}
if resp.StatusCode != http.StatusOK {
global.LOG.Warnf("[AppStore] download icon failed url=%s, status=%d", iconUrl, resp.StatusCode)
return 0, ""
}
if len(resp.Body) == 0 {
global.LOG.Warnf("[AppStore] download icon empty body url=%s", iconUrl)
return 0, ""
}
if resp.Body[0] == '<' {
global.LOG.Warnf("[AppStore] download icon got HTML response url=%s", iconUrl)
return 0, ""
}
contentType := resp.Header.Get("Content-Type")
ct := strings.TrimSpace(strings.Split(contentType, ";")[0])
if strings.ToLower(ct) != "image/png" {
global.LOG.Warnf("[AppStore] unexpected icon content-type: %s, expected image/png, url=%s", ct, iconUrl)
}
fileName, err := appicon.WriteIconFile(appKey, resp.Body)
if err != nil {
global.LOG.Warnf("[AppStore] write icon file failed appKey=%s, err=%v", appKey, err)
return 0, ""
}
newEtag := resp.Header.Get("ETag")
iconField = appicon.BuildIconField(fileName, newEtag)
return http.StatusOK, iconField
}
func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(t *task.Task) error { func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(t *task.Task) error {
return func(t *task.Task) (err error) { return func(t *task.Task) (err error) {
t.LogStart(i18n.GetMsgByKey("SyncAppDetail")) t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
@@ -411,7 +245,7 @@ func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(
ctx.task = t ctx.task = t
ctx.appTags = make([]*model.AppTag, 0) ctx.appTags = make([]*model.AppTag, 0)
if err = ctx.syncAppIconsAndDetails(); err != nil { if err = ctx.syncAppDetails(); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err) t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
return err return err
} }
+3 -2
View File
@@ -216,8 +216,9 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if err != nil { if err != nil {
return err return err
} }
u.detail.DockerCompose = string(composeContent) if err := cacheAppDetailCompose(&u.detail, composeContent); err != nil {
_ = appDetailRepo.Update(context.Background(), u.detail) return err
}
} }
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" { if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
return buserr.WithName("ErrFileNotFound", "docker-compose.yml") return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
+15 -7
View File
@@ -28,6 +28,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/cmd" "github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose" "github.com/1Panel-dev/1Panel/agent/utils/compose"
@@ -993,8 +994,13 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil { if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil {
return err return err
} }
appDetail.Update = false marked, err := appDetailRepo.MarkDownloaded(appDetail)
_ = appDetailRepo.Update(context.Background(), appDetail) if err != nil {
return err
}
if !marked {
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
}
return return
} }
@@ -1268,9 +1274,8 @@ func getAppDetails(details []model.AppDetail, versions []dto.AppConfigVersion) m
return appDetails return appDetails
} }
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) (map[string]model.App, map[string]string) { func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) map[string]model.App {
apps := make(map[string]model.App, len(oldApps)) apps := make(map[string]model.App, len(oldApps))
pendingIcons := make(map[string]string, len(items))
for _, old := range oldApps { for _, old := range oldApps {
old.Status = constant.AppTakeDown old.Status = constant.AppTakeDown
apps[old.Key] = old apps[old.Key] = old
@@ -1311,12 +1316,14 @@ func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, t
app.Architectures = strings.Join(config.Architectures, ",") app.Architectures = strings.Join(config.Architectures, ",")
app.GpuSupport = config.GpuSupport app.GpuSupport = config.GpuSupport
app.BatchInstallSupport = config.BatchInstallSupport app.BatchInstallSupport = config.BatchInstallSupport
if item.Icon != "" { if appicon.IsRemoteURL(item.Icon) {
pendingIcons[key] = item.Icon app.Icon = item.Icon
} else if item.Icon != "" {
global.LOG.Warnf("[AppStore] invalid icon URL for app %s, keeping existing icon", key)
} }
apps[key] = app apps[key] = app
} }
return apps, pendingIcons return apps
} }
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error { func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {
@@ -1579,6 +1586,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
HttpPort: installed.HttpPort, HttpPort: installed.HttpPort,
HttpsPort: installed.HttpsPort, HttpsPort: installed.HttpsPort,
AppName: installed.App.Name, AppName: installed.App.Name,
Icon: appicon.RemoteURL(installed.App.Icon),
AppKey: installed.App.Key, AppKey: installed.App.Key,
AppType: installed.App.Type, AppType: installed.App.Type,
Path: installed.GetPath(), Path: installed.GetPath(),
+663
View File
@@ -0,0 +1,663 @@
package service
import (
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"github.com/compose-spec/compose-go/v2/interpolation"
"github.com/compose-spec/compose-go/v2/template"
)
func LoadModelGPUDevices() dto.ModelGPUDevices {
result := dto.ModelGPUDevices{Devices: []dto.ModelGPUDevice{}, Warnings: []string{}}
uuidPattern := re.GetRegex(re.NvidiaGPUUUIDPattern)
renderPattern := re.GetRegex(re.IntelGPURenderNamePattern)
ascendPattern := re.GetRegex(re.AscendGPUDeviceNamePattern)
for _, directory := range []string{"/proc/driver/nvidia/gpus", "/sys/class/drm", "/dev"} {
entries, err := os.ReadDir(directory)
if err != nil {
if !os.IsNotExist(err) {
result.Warnings = append(result.Warnings, err.Error())
}
continue
}
for _, entry := range entries {
var device dto.ModelGPUDevice
var devicePath string
switch directory {
case "/proc/driver/nvidia/gpus":
informationPath := filepath.Join(directory, entry.Name(), "information")
content, err := os.ReadFile(informationPath)
if err != nil {
result.Warnings = append(result.Warnings, err.Error())
continue
}
fields := map[string]string{}
for _, line := range strings.Split(string(content), "\n") {
key, value, found := strings.Cut(line, ":")
if found {
fields[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
uuid := fields["GPU UUID"]
minor, err := strconv.Atoi(fields["Device Minor"])
if !uuidPattern.MatchString(uuid) || err != nil || minor < 0 {
result.Warnings = append(result.Warnings, fmt.Sprintf("invalid NVIDIA GPU UUID or device minor in %s", informationPath))
continue
}
name := fields["Model"]
if name == "" {
name = "NVIDIA"
}
device = dto.ModelGPUDevice{ID: "nvidia:" + uuid, Name: name + " · " + entry.Name(), Type: "nvidia", UUID: uuid}
devicePath = filepath.Join("/dev", "nvidia"+strconv.Itoa(minor))
case "/sys/class/drm":
if !renderPattern.MatchString(entry.Name()) {
continue
}
vendor, err := os.ReadFile(filepath.Join(directory, entry.Name(), "device/vendor"))
if err != nil {
result.Warnings = append(result.Warnings, err.Error())
continue
}
if strings.TrimSpace(string(vendor)) != "0x8086" {
continue
}
devicePath = filepath.Join("/dev/dri", entry.Name())
device = dto.ModelGPUDevice{ID: "intel:" + devicePath, Name: "Intel · " + entry.Name(), Type: "intel"}
case "/dev":
if !ascendPattern.MatchString(entry.Name()) {
continue
}
devicePath = filepath.Join(directory, entry.Name())
device = dto.ModelGPUDevice{ID: "ascend:" + strings.TrimPrefix(entry.Name(), "davinci"), Name: "Ascend · " + entry.Name(), Type: "ascend"}
}
info, err := os.Stat(devicePath)
if err != nil {
result.Warnings = append(result.Warnings, err.Error())
} else {
device.Available = info.Mode()&os.ModeCharDevice != 0
}
result.Devices = append(result.Devices, device)
}
}
return result
}
func ApplyModelGPU(service map[string]interface{}, config *dto.ModelGPUConfig, params map[string]interface{}, imageType string) error {
if config == nil {
return nil
}
if err := validateModelGPU(config, imageType); err != nil {
return err
}
resolved, err := resolveModelGPUService(service, params, false)
if err != nil {
return err
}
devicePaths := []string{}
ids := []string{}
logicalIDs := []string{}
if config.Enabled && imageType != "nvidia" {
for _, deviceID := range config.DeviceIDs {
id := strings.TrimPrefix(deviceID, imageType+":")
if !slices.Contains(ids, id) {
ids = append(ids, id)
}
}
if imageType == "ascend" {
slices.SortFunc(ids, func(a, b string) int {
left, _ := strconv.Atoi(a)
right, _ := strconv.Atoi(b)
return left - right
})
visibleIDs := ids
if config.Mode == "all" || resolved["privileged"] == true || resolved["privileged"] == "true" {
visibleIDs = loadModelAscendDeviceIDs()
if len(visibleIDs) == 0 {
return fmt.Errorf("no available Ascend devices")
}
}
if config.Mode == "all" {
ids = visibleIDs
} else {
for _, id := range ids {
index := slices.Index(visibleIDs, id)
if index < 0 {
return fmt.Errorf("Ascend GPU is unavailable: %s", id)
}
logicalIDs = append(logicalIDs, strconv.Itoa(index))
}
}
for _, id := range ids {
devicePaths = append(devicePaths, "/dev/davinci"+id)
}
devicePaths = append(devicePaths, "/dev/davinci_manager", "/dev/devmm_svm", "/dev/hisi_hdc")
} else if config.Mode == "all" {
devicePaths = append(devicePaths, "/dev/dri")
} else {
devicePaths = append(devicePaths, ids...)
}
}
if imageType == "nvidia" {
delete(service, "gpus")
}
if imageType == "nvidia" && resolved["runtime"] == "nvidia" && !config.Enabled {
delete(service, "runtime")
}
deploy, _ := service["deploy"].(map[string]interface{})
resources, _ := deploy["resources"].(map[string]interface{})
reservations, _ := resources["reservations"].(map[string]interface{})
devices, _ := reservations["devices"].([]interface{})
resolvedDevices, _ := resolved["reservations"].([]interface{})
kept := []interface{}{}
var gpuDevice map[string]interface{}
for index, item := range devices {
device, _ := resolvedDevices[index].(map[string]interface{})
capabilities, _ := device["capabilities"].([]interface{})
driver, _ := device["driver"].(string)
isNvidiaGPU := driver == "nvidia" || (driver == "" && slices.Contains(capabilities, interface{}("gpu")))
if imageType != "nvidia" || !isNvidiaGPU {
kept = append(kept, item)
} else if gpuDevice == nil {
gpuDevice, _ = item.(map[string]interface{})
}
}
if config.Enabled && imageType == "nvidia" {
device := gpuDevice
if device == nil {
device = map[string]interface{}{}
}
device["driver"] = "nvidia"
device["capabilities"] = config.Capabilities
delete(device, "count")
delete(device, "device_ids")
switch config.Mode {
case "all":
device["count"] = "all"
case "count":
device["count"] = config.Count
default:
ids := []string{}
for _, id := range config.DeviceIDs {
ids = append(ids, strings.TrimPrefix(id, "nvidia:"))
}
device["device_ids"] = ids
}
kept = append(kept, device)
}
if len(kept) > 0 {
if deploy == nil {
deploy = map[string]interface{}{}
service["deploy"] = deploy
}
if resources == nil {
resources = map[string]interface{}{}
deploy["resources"] = resources
}
if reservations == nil {
reservations = map[string]interface{}{}
resources["reservations"] = reservations
}
reservations["devices"] = kept
} else {
delete(reservations, "devices")
if len(reservations) == 0 {
delete(resources, "reservations")
}
if len(resources) == 0 {
delete(deploy, "resources")
}
if len(deploy) == 0 {
delete(service, "deploy")
}
}
mappedPaths := []string{}
for _, key := range []string{"devices", "volumes"} {
mappings, _ := service[key].([]interface{})
resolvedMappings, _ := resolved[key].([]interface{})
kept := []interface{}{}
for index, item := range mappings {
source := modelGPUDeviceSource(resolvedMappings[index])
managed := false
switch imageType {
case "nvidia":
managed = strings.HasPrefix(source, "/dev/nvidia") || strings.HasPrefix(source, "nvidia.com/gpu=")
case "intel":
managed = source == "/dev/dri" || strings.HasPrefix(source, "/dev/dri/")
case "ascend":
managed = strings.HasPrefix(source, "/dev/davinci") || source == "/dev/devmm_svm" || source == "/dev/hisi_hdc"
}
if managed {
selected := slices.Contains(devicePaths, source)
if config.Enabled && config.Mode == "all" && imageType == "intel" {
selected = true
}
if selected {
kept = append(kept, item)
mappedPaths = append(mappedPaths, source)
}
continue
}
kept = append(kept, item)
}
if len(kept) == 0 {
delete(service, key)
} else {
service[key] = kept
}
}
if config.Enabled && imageType != "nvidia" {
devices, _ := service["devices"].([]interface{})
for _, path := range devicePaths {
if !slices.Contains(mappedPaths, path) {
devices = append(devices, path+":"+path)
}
}
if len(devices) > 0 {
service["devices"] = devices
}
}
gpuEnvironment := map[string]string{}
if config.Enabled && imageType == "ascend" && config.Mode != "all" {
gpuEnvironment["ASCEND_RT_VISIBLE_DEVICES"] = strings.Join(logicalIDs, ",")
}
var environmentKeys []string
switch imageType {
case "nvidia":
environmentKeys = []string{"NVIDIA_VISIBLE_DEVICES", "NVIDIA_DRIVER_CAPABILITIES"}
case "ascend":
environmentKeys = []string{"ASCEND_RT_VISIBLE_DEVICES"}
if params != nil {
params["ASCEND_RT_VISIBLE_DEVICES"] = strings.Join(logicalIDs, ",")
}
default:
return nil
}
switch environment := service["environment"].(type) {
case map[string]interface{}:
for _, key := range environmentKeys {
delete(environment, key)
}
for key, value := range gpuEnvironment {
environment[key] = value
}
case []interface{}:
kept := []interface{}{}
for _, item := range environment {
value, ok := item.(string)
if !ok {
return fmt.Errorf("invalid Compose environment")
}
key, _, _ := strings.Cut(value, "=")
if !slices.Contains(environmentKeys, key) {
kept = append(kept, item)
}
}
for key, value := range gpuEnvironment {
kept = append(kept, key+"="+value)
}
service["environment"] = kept
case nil:
if len(gpuEnvironment) > 0 {
environment := map[string]interface{}{}
for key, value := range gpuEnvironment {
environment[key] = value
}
service["environment"] = environment
}
default:
return fmt.Errorf("invalid Compose environment")
}
return nil
}
func ReadModelGPU(service map[string]interface{}, params map[string]interface{}, imageType string) *dto.ModelGPUConfig {
resolved, err := resolveModelGPUService(service, params, true)
if err != nil {
return &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}, Error: err.Error()}
}
if imageType == "nvidia" {
config, err := readModelGPU(service, resolved)
if err != nil {
return &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}, Error: err.Error()}
}
return config
}
config := &dto.ModelGPUConfig{Mode: "devices", DeviceIDs: []string{}, Capabilities: []string{"gpu"}}
privileged := resolved["privileged"] == true || resolved["privileged"] == "true"
all := false
for _, key := range []string{"devices", "volumes"} {
mappings, _ := resolved[key].([]interface{})
for _, item := range mappings {
source := modelGPUDeviceSource(item)
id := ""
if imageType == "intel" {
switch {
case source == "/dev/dri":
all = true
case re.GetRegex(re.IntelGPUDevicePathPattern).MatchString(source):
id = imageType + ":" + source
case strings.HasPrefix(source, "/dev/dri/"):
config.Error = "unsupported Intel GPU mapping requires an explicit selection: " + source
return config
}
}
if imageType == "ascend" && re.GetRegex(re.AscendGPUDevicePathPattern).MatchString(source) {
id = imageType + ":" + strings.TrimPrefix(source, "/dev/davinci")
}
if id != "" && !slices.Contains(config.DeviceIDs, id) {
config.DeviceIDs = append(config.DeviceIDs, id)
}
}
}
config.Enabled = all || len(config.DeviceIDs) > 0
config.Mode = "devices"
if all {
config.Mode = "all"
config.DeviceIDs = []string{}
}
if imageType == "ascend" {
const key = "ASCEND_RT_VISIBLE_DEVICES"
visible := ""
switch environment := service["environment"].(type) {
case map[string]interface{}:
if value, exists := environment[key]; exists {
if value == nil {
visible = "${" + key + "}"
} else {
visible = fmt.Sprint(value)
}
}
case []interface{}:
for _, item := range environment {
value, _ := item.(string)
if value == key {
visible = "${" + key + "}"
} else if strings.HasPrefix(value, key+"=") {
visible = strings.TrimPrefix(value, key+"=")
}
}
}
visible, err := resolveModelGPUValue(visible, params)
if err != nil {
config.Error = err.Error()
return config
}
visible = strings.TrimSpace(visible)
if visible == "all" || (visible == "" && config.Enabled) {
config.Enabled = true
config.Mode = "all"
config.DeviceIDs = []string{}
} else if visible != "" {
physicalIDs := []string{}
for _, id := range config.DeviceIDs {
physicalIDs = append(physicalIDs, strings.TrimPrefix(id, "ascend:"))
}
slices.SortFunc(physicalIDs, func(a, b string) int {
left, _ := strconv.Atoi(a)
right, _ := strconv.Atoi(b)
return left - right
})
if privileged {
physicalIDs = loadModelAscendDeviceIDs()
}
selected := []string{}
for _, id := range strings.Split(visible, ",") {
id = strings.TrimSpace(id)
if !re.GetRegex(re.AscendGPUDeviceIDPattern).MatchString(id) {
config.Error = "invalid Ascend GPU device: " + id
return config
}
index, err := strconv.Atoi(id)
if err != nil || index < 0 || index >= len(physicalIDs) {
config.Error = "Ascend logical GPU is not available in the container: " + id
return config
}
selected = append(selected, "ascend:"+physicalIDs[index])
}
config.Enabled = true
config.Mode = "devices"
config.DeviceIDs = selected
}
}
return config
}
func readModelGPU(service, resolved map[string]interface{}) (*dto.ModelGPUConfig, error) {
config := &dto.ModelGPUConfig{Mode: "all", DeviceIDs: []string{}, Capabilities: []string{"gpu"}}
devices, _ := resolved["reservations"].([]interface{})
if service["gpus"] != nil {
return nil, fmt.Errorf("custom GPU declarations require an explicit selection")
}
for _, item := range devices {
device, ok := item.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid GPU reservation")
}
driver, _ := device["driver"].(string)
capabilities, _ := device["capabilities"].([]interface{})
isGPU := driver == "nvidia" || (driver == "" && slices.Contains(capabilities, interface{}("gpu")))
if !isGPU {
continue
}
if config.Enabled {
return nil, fmt.Errorf("multiple GPU declarations require an explicit selection")
}
config.Enabled = true
ids, _ := device["device_ids"].([]interface{})
if len(ids) > 0 {
if device["count"] != nil {
return nil, fmt.Errorf("GPU count and device_ids cannot be combined")
}
config.Mode = "devices"
for _, id := range ids {
config.DeviceIDs = append(config.DeviceIDs, "nvidia:"+fmt.Sprint(id))
}
} else if count := device["count"]; count != nil && fmt.Sprint(count) != "all" && fmt.Sprint(count) != "-1" {
config.Mode = "count"
countValue, err := strconv.Atoi(fmt.Sprint(count))
config.Count = countValue
if err != nil {
return nil, fmt.Errorf("invalid GPU count: %v", count)
}
}
for _, capability := range capabilities {
value, ok := capability.(string)
if !ok {
return nil, fmt.Errorf("unsupported GPU capabilities: %v", capabilities)
}
if !slices.Contains(config.Capabilities, value) {
config.Capabilities = append(config.Capabilities, value)
}
}
}
for _, key := range []string{"devices", "volumes"} {
mappings, _ := resolved[key].([]interface{})
for _, item := range mappings {
source := modelGPUDeviceSource(item)
if strings.HasPrefix(source, "/dev/nvidia") || strings.HasPrefix(source, "nvidia.com/gpu=") {
return nil, fmt.Errorf("direct NVIDIA device mappings require an explicit selection")
}
}
}
if !config.Enabled && resolved["runtime"] == "nvidia" {
return nil, fmt.Errorf("custom NVIDIA runtime configuration requires an explicit selection")
}
if err := validateModelGPU(config, "nvidia"); err != nil {
return nil, err
}
return config, nil
}
func validateModelGPU(config *dto.ModelGPUConfig, imageType string) error {
if config.Error != "" {
return fmt.Errorf("GPU configuration must be selected explicitly: %s", config.Error)
}
if !config.Enabled {
return nil
}
patterns := map[string]string{
"nvidia": re.NvidiaGPUDeviceIDPattern,
"intel": re.IntelGPUDevicePathPattern,
"ascend": re.AscendGPUDeviceIDPattern,
}
pattern, supported := patterns[imageType]
if !supported {
return fmt.Errorf("unsupported GPU type: %s", imageType)
}
switch config.Mode {
case "all":
if config.Count != 0 || len(config.DeviceIDs) != 0 {
return fmt.Errorf("all GPUs cannot be combined with count or device IDs")
}
case "count":
if imageType != "nvidia" || config.Count < 1 || len(config.DeviceIDs) != 0 {
return fmt.Errorf("GPU count requires a positive number and runtime device allocation")
}
case "devices", "":
if config.Count != 0 || len(config.DeviceIDs) == 0 {
return fmt.Errorf("select at least one GPU device")
}
for _, deviceID := range config.DeviceIDs {
id := strings.TrimPrefix(deviceID, imageType+":")
if !re.GetRegex(pattern).MatchString(id) {
return fmt.Errorf("invalid %s GPU device: %s", imageType, deviceID)
}
}
default:
return fmt.Errorf("unsupported GPU selection mode: %s", config.Mode)
}
if imageType != "nvidia" {
return nil
}
if !slices.Contains(config.Capabilities, "gpu") {
return fmt.Errorf("NVIDIA capabilities must include gpu")
}
for _, capability := range config.Capabilities {
if capability != "gpu" && capability != "utility" && capability != "compute" {
return fmt.Errorf("unsupported NVIDIA capability: %s", capability)
}
}
return nil
}
func resolveModelGPUService(service map[string]interface{}, params map[string]interface{}, resolveSelection bool) (map[string]interface{}, error) {
fields := map[string]interface{}{}
fields["runtime"] = service["runtime"]
deploy, _ := service["deploy"].(map[string]interface{})
resources, _ := deploy["resources"].(map[string]interface{})
reservations, _ := resources["reservations"].(map[string]interface{})
fields["reservations"] = reservations["devices"]
if devices, ok := reservations["devices"].([]interface{}); ok && !resolveSelection {
declarations := make([]interface{}, 0, len(devices))
for _, item := range devices {
device, _ := item.(map[string]interface{})
declarations = append(declarations, map[string]interface{}{"driver": device["driver"], "capabilities": device["capabilities"]})
}
fields["reservations"] = declarations
}
resolved, err := interpolation.Interpolate(fields, interpolation.Options{
Substitute: func(value string, _ template.Mapping) (string, error) { return resolveModelGPUValue(value, params) },
})
if err != nil {
return nil, err
}
for _, key := range []string{"devices", "volumes"} {
mappings, _ := service[key].([]interface{})
sources := make([]interface{}, 0, len(mappings))
for _, item := range mappings {
source, err := template.SubstituteWithOptions(modelGPUDeviceSource(item), func(name string) (string, bool) {
value, ok := params[name]
if !ok || value == nil {
return "", false
}
return fmt.Sprint(value), true
}, template.WithoutLogging)
if err != nil {
return nil, err
}
if _, short := item.(string); short {
source = modelGPUDeviceSource(source)
}
sources = append(sources, map[string]interface{}{"source": source})
}
resolved[key] = sources
}
resolved["privileged"] = service["privileged"]
if value, ok := service["privileged"].(string); ok {
if value, err := resolveModelGPUValue(value, params); err == nil {
resolved["privileged"] = value
}
}
return resolved, nil
}
func modelGPUDeviceSource(item interface{}) string {
switch mapping := item.(type) {
case string:
if strings.HasPrefix(mapping, "nvidia.com/gpu=") {
return mapping
}
depth := 0
for index, ch := range mapping {
switch ch {
case '{':
depth++
case '}':
depth--
case ':':
if depth == 0 {
return mapping[:index]
}
}
}
return mapping
case map[string]interface{}:
source, _ := mapping["source"].(string)
return source
}
return ""
}
func resolveModelGPUValue(value string, params map[string]interface{}) (string, error) {
return template.SubstituteWithOptions(value, func(name string) (string, bool) {
value, ok := params[name]
if !ok || value == nil {
return "", false
}
return fmt.Sprint(value), true
}, template.WithoutLogging, template.WithReplacementFunction(func(value string, mapping template.Mapping, config *template.Config) (string, error) {
resolved, applied, err := template.DefaultReplacementAppliedFunc(value, mapping, config)
if err != nil {
return "", err
}
if !applied {
return "", fmt.Errorf("unresolved GPU variable: %s", value)
}
return resolved, nil
}))
}
func loadModelAscendDeviceIDs() []string {
ids := []string{}
for _, device := range LoadModelGPUDevices().Devices {
if device.Type == "ascend" && device.Available {
ids = append(ids, strings.TrimPrefix(device.ID, "ascend:"))
}
}
slices.SortFunc(ids, func(a, b string) int {
left, _ := strconv.Atoi(a)
right, _ := strconv.Atoi(b)
return left - right
})
return ids
}
+14 -4
View File
@@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
@@ -19,6 +20,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator" "github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/gpu"
"github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/psutil" "github.com/1Panel-dev/1Panel/agent/utils/psutil"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
@@ -205,7 +207,14 @@ func (m *MonitorService) LoadGPUOptions() dto.MonitorGPUOptions {
continue continue
} }
seen[key] = true seen[key] = true
data.ChartHide = append(data.ChartHide, dto.GPUChartHide{DeviceID: device.DeviceID, ProductName: device.ProductName, Type: device.DeviceType, Legacy: device.DeviceID == ""}) _, productName, _ := strings.Cut(device.ProductName, " - ")
data.ChartHide = append(data.ChartHide, dto.GPUChartHide{
DeviceID: device.DeviceID,
ProductName: device.ProductName,
Type: device.DeviceType,
Legacy: device.DeviceID == "",
SharedMemory: strings.HasPrefix(device.DeviceID, "nvidia:") && gpu.UsesSystemMemory(productName),
})
data.Options = append(data.Options, device.ProductName) data.Options = append(data.Options, device.ProductName)
} }
return data return data
@@ -800,9 +809,10 @@ func loadGPUOptions(snapshot *accelerator.Snapshot) dto.MonitorGPUOptions {
}) })
for _, item := range snapshot.Devices { for _, item := range snapshot.Devices {
chartHide := dto.GPUChartHide{ chartHide := dto.GPUChartHide{
DeviceID: item.ID, SharedMemory: item.GPU != nil && item.GPU.SharedMemory,
ProductName: item.Label, DeviceID: item.ID,
Type: string(item.Kind), ProductName: item.Label,
Type: string(item.Kind),
} }
data.ChartHide = append(data.ChartHide, chartHide) data.ChartHide = append(data.ChartHide, chartHide)
data.Options = append(data.Options, chartHide.ProductName) data.Options = append(data.Options, chartHide.ProductName)
+115
View File
@@ -0,0 +1,115 @@
package service
import (
"encoding/json"
"reflect"
"slices"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
)
func TestGetPHPRuntimeRefreshesVersionOptionsWithoutChangingConfig(t *testing.T) {
for _, resource := range []string{constant.AppResourceRemote, constant.AppResourceLocal, constant.AppResourceCustom} {
t.Run(resource, func(t *testing.T) {
db := appCacheTestDB(t)
if err := db.AutoMigrate(&model.Runtime{}); err != nil {
t.Fatal(err)
}
app := model.App{Key: "php", Type: constant.RuntimePHP, Resource: resource}
if err := db.Create(&app).Error; err != nil {
t.Fatal(err)
}
oldVersion := dto.AppFormValue{Label: "8.1.34", Value: "8.1.34"}
newVersion := dto.AppFormValue{Label: "8.1.35", Value: "8.1.35"}
savedForm := dto.AppForm{FormFields: []dto.AppFormFields{
{EnvKey: "PHP_VERSION", Type: "select", Default: oldVersion.Value, Values: []dto.AppFormValue{oldVersion}},
{EnvKey: "PHP_EXTENSIONS", Type: "select", Multiple: true, Values: []dto.AppFormValue{{Label: "redis", Value: "redis"}}},
{EnvKey: "PANEL_APP_PORT_HTTP", Type: "number"},
{EnvKey: "CONTAINER_PACKAGE_URL", Type: "select", Values: []dto.AppFormValue{{Label: "custom mirror", Value: "https://mirror.example"}}},
}}
encode := func(form dto.AppForm) string {
t.Helper()
data, err := json.Marshal(form)
if err != nil {
t.Fatal(err)
}
return string(data)
}
detail := model.AppDetail{AppId: app.ID, Version: "8.1", Params: encode(savedForm), Update: true}
if err := db.Create(&detail).Error; err != nil {
t.Fatal(err)
}
otherMajor := model.AppDetail{AppId: app.ID, Version: "8.2", Params: `{"formFields":[{"envKey":"PHP_VERSION","values":[{"label":"8.2.31","value":"8.2.31"}]}]}`}
if err := db.Create(&otherMajor).Error; err != nil {
t.Fatal(err)
}
runtime := model.Runtime{
Name: "existing-php", Type: constant.RuntimePHP, Resource: constant.ResourceAppstore,
Version: detail.Version, AppDetailID: detail.ID, Image: "custom-php:existing", Params: detail.Params,
Env: "PHP_VERSION=8.1.34\nPHP_EXTENSIONS=redis,custom-extension\nPANEL_APP_PORT_HTTP=9070\nCONTAINER_PACKAGE_URL=https://mirror.example\nCONTAINER_NAME=my-php\nCUSTOM_SETTING=keep-me\n",
DockerCompose: "existing compose", Remark: "keep remark",
}
if err := db.Create(&runtime).Error; err != nil {
t.Fatal(err)
}
service := NewRuntimeService()
before, err := service.Get(runtime.ID)
if err != nil {
t.Fatal(err)
}
latestForm := dto.AppForm{FormFields: []dto.AppFormFields{
{EnvKey: "PHP_VERSION", Type: "select", Default: newVersion.Value, Values: []dto.AppFormValue{newVersion, oldVersion}},
{EnvKey: "PHP_EXTENSIONS", Type: "select", Multiple: true, Default: "opcache"},
{EnvKey: "PANEL_APP_PORT_HTTP", Default: 9000},
}}
removedForm := dto.AppForm{FormFields: []dto.AppFormFields{
{EnvKey: "PHP_VERSION", Type: "select", Default: newVersion.Value, Values: []dto.AppFormValue{newVersion}},
}}
for _, tc := range []struct {
name string
params string
values []dto.AppFormValue
}{
{"new patch on reopen", encode(latestForm), []dto.AppFormValue{newVersion, oldVersion}},
{"selected version removed", encode(removedForm), []dto.AppFormValue{newVersion, oldVersion}},
{"invalid metadata", "{invalid", []dto.AppFormValue{oldVersion}},
{"missing metadata", "", []dto.AppFormValue{oldVersion}},
{"missing version field", `{"formFields":[{"envKey":"PHP_EXTENSIONS"}]}`, []dto.AppFormValue{oldVersion}},
{"empty version list", `{"formFields":[{"envKey":"PHP_VERSION","values":[]}]}`, []dto.AppFormValue{oldVersion}},
} {
t.Run(tc.name, func(t *testing.T) {
if err := db.Model(&detail).UpdateColumn("params", tc.params).Error; err != nil {
t.Fatal(err)
}
got, err := service.Get(runtime.ID)
if err != nil {
t.Fatal(err)
}
want := *before
want.AppParams = slices.Clone(before.AppParams)
want.AppParams[0].Values = tc.values
if !reflect.DeepEqual(*got, want) {
t.Fatalf("edit response changed configuration or returned stale options:\ngot: %+v\nwant: %+v", *got, want)
}
var stored model.Runtime
if err := db.First(&stored, runtime.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Params != runtime.Params || stored.Env != runtime.Env || stored.Image != runtime.Image || stored.DockerCompose != runtime.DockerCompose || stored.AppDetailID != runtime.AppDetailID || stored.Version != runtime.Version {
t.Fatal("opening edit mutated the saved runtime")
}
var storedDetail model.AppDetail
if err := db.First(&storedDetail, detail.ID).Error; err != nil {
t.Fatal(err)
}
if !storedDetail.Update {
t.Fatal("opening edit cleared the pending template update")
}
})
}
})
}
}
+34
View File
@@ -57,12 +57,31 @@ func (t TensorRTLLMService) Page(req request.TensorRTLLMSearch) response.TensorR
serverDTO.Dir = path.Join(global.Dir.TensorRTLLMDir, item.Name) serverDTO.Dir = path.Join(global.Dir.TensorRTLLMDir, item.Name)
serverDTO.Image = envs["IMAGE"] serverDTO.Image = envs["IMAGE"]
serverDTO.Command = getCommand(item.Env) serverDTO.Command = getCommand(item.Env)
var project map[string]interface{}
if yaml.Unmarshal([]byte(item.DockerCompose), &project) == nil {
services, _ := project["services"].(map[string]interface{})
if service, ok := services[item.Name].(map[string]interface{}); ok {
params := make(map[string]interface{}, len(envs))
for key, value := range envs {
params[key] = value
}
serverDTO.GPU = ReadModelGPU(service, params, "nvidia")
}
}
serverDTO.ExposedPorts, _ = loadComposeExposedPortsFromEnv(envs, "0.0.0.0", false) serverDTO.ExposedPorts, _ = loadComposeExposedPortsFromEnv(envs, "0.0.0.0", false)
composeByte, err := files.NewFileOp().GetContent(path.Join(global.Dir.TensorRTLLMDir, item.Name, "docker-compose.yml")) composeByte, err := files.NewFileOp().GetContent(path.Join(global.Dir.TensorRTLLMDir, item.Name, "docker-compose.yml"))
if err == nil { if err == nil {
serverDTO.Environments, _ = getDockerComposeEnvironments(composeByte) serverDTO.Environments, _ = getDockerComposeEnvironments(composeByte)
environments := make([]request.Environment, 0, len(serverDTO.Environments))
for _, item := range serverDTO.Environments {
if item.Key == "MODEL_PATH" || (serverDTO.GPU != nil && (item.Key == "NVIDIA_VISIBLE_DEVICES" || item.Key == "NVIDIA_DRIVER_CAPABILITIES")) {
continue
}
environments = append(environments, item)
}
serverDTO.Environments = environments
} }
volumes, err := getDockerComposeVolumes(composeByte) volumes, err := getDockerComposeVolumes(composeByte)
if err == nil { if err == nil {
@@ -133,6 +152,9 @@ func handleLLMParams(llm *model.TensorRTLLM, create request.TensorRTLLMCreate) e
var environments []interface{} var environments []interface{}
environments = append(environments, fmt.Sprintf("MODEL_PATH=%s", create.ModelDir)) environments = append(environments, fmt.Sprintf("MODEL_PATH=%s", create.ModelDir))
for _, e := range create.Environments { for _, e := range create.Environments {
if e.Key == "MODEL_PATH" {
continue
}
environments = append(environments, fmt.Sprintf("%s=%s", e.Key, e.Value)) environments = append(environments, fmt.Sprintf("%s=%s", e.Key, e.Value))
} }
serviceValue["environment"] = environments serviceValue["environment"] = environments
@@ -148,6 +170,18 @@ func handleLLMParams(llm *model.TensorRTLLM, create request.TensorRTLLMCreate) e
volumes = append(volumes, formatComposeVolume(volume.Source, volume.Target, volume.Mode)) volumes = append(volumes, formatComposeVolume(volume.Source, volume.Target, volume.Mode))
} }
serviceValue["volumes"] = volumes serviceValue["volumes"] = volumes
gpuParams := map[string]interface{}{}
existingEnv, err := gotenv.Unmarshal(llm.Env)
if err != nil {
return err
}
for key, value := range existingEnv {
gpuParams[key] = value
}
gpuParams["MODEL_PATH"] = create.ModelDir
if err := ApplyModelGPU(serviceValue, create.GPU, gpuParams, "nvidia"); err != nil {
return err
}
services[llm.Name] = serviceValue services[llm.Name] = serviceValue
composeByte, err := yaml.Marshal(composeMap) composeByte, err := yaml.Marshal(composeMap)
+2
View File
@@ -316,6 +316,8 @@ func (t *Task) ExecuteQueued(slots chan struct{}, waitingStep string, onFinish f
func (t *Task) Execute() error { func (t *Task) Execute() error {
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil { if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
_ = t.logFile.Close()
global.RemoveTaskCancel(t.TaskID)
return err return err
} }
return t.executeRegistered() return t.executeRegistered()
+1
View File
@@ -26,6 +26,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/domain/update", baseApi.UpdateBindDomain) aiToolsRouter.POST("/domain/update", baseApi.UpdateBindDomain)
aiToolsRouter.GET("/gpu/load", baseApi.LoadGpuInfo) aiToolsRouter.GET("/gpu/load", baseApi.LoadGpuInfo)
aiToolsRouter.GET("/model/gpu/devices", baseApi.LoadModelGPUDevices)
aiToolsRouter.POST("/gpu/search", baseApi.LoadGPUMonitor) aiToolsRouter.POST("/gpu/search", baseApi.LoadGPUMonitor)
aiToolsRouter.GET("/gpu/options", baseApi.GetCPUOptions) aiToolsRouter.GET("/gpu/options", baseApi.GetCPUOptions)
aiToolsRouter.POST("/vllm/monitor/search", baseApi.LoadVLLMMonitor) aiToolsRouter.POST("/vllm/monitor/search", baseApi.LoadVLLMMonitor)
+34 -1
View File
@@ -4,10 +4,12 @@ import (
"context" "context"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"slices"
"strings" "strings"
"time" "time"
"github.com/1Panel-dev/1Panel/agent/utils/cmd" "github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/shirou/gopsutil/v4/mem"
) )
const nvidiaSMICommand = "nvidia-smi" const nvidiaSMICommand = "nvidia-smi"
@@ -20,7 +22,31 @@ func (n nvidiaSMI) LoadInfo(ctx context.Context) (*Info, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("calling %s failed: %w", nvidiaSMICommand, err) return nil, fmt.Errorf("calling %s failed: %w", nvidiaSMICommand, err)
} }
return parseNvidiaSMI([]byte(itemData)) info, err := parseNvidiaSMI([]byte(itemData))
if err != nil {
return nil, err
}
if slices.ContainsFunc(info.Devices, func(device Device) bool { return device.SharedMemory }) {
memory, err := mem.VirtualMemoryWithContext(ctx)
if err != nil {
info.Warnings = append(info.Warnings, fmt.Sprintf("read system shared memory: %v", err))
} else {
for index := range info.Devices {
device := &info.Devices[index]
if !device.SharedMemory {
continue
}
device.MemUsed = fmt.Sprintf("%d B", memory.Used)
device.MemTotal = fmt.Sprintf("%d B", memory.Total)
device.MemoryFree = fmt.Sprintf("%.2f MiB", float64(memory.Available)/(1024*1024))
}
}
}
return info, nil
}
func UsesSystemMemory(productName string) bool {
return strings.EqualFold(strings.TrimSpace(productName), "NVIDIA GB10")
} }
func parseNvidiaSMI(buf []byte) (*Info, error) { func parseNvidiaSMI(buf []byte) (*Info, error) {
@@ -38,6 +64,7 @@ func parseNvidiaSMI(buf []byte) (*Info, error) {
for i := range s.Gpu { for i := range s.Gpu {
gpuItem := Device{ gpuItem := Device{
Type: "nvidia", Type: "nvidia",
SharedMemory: UsesSystemMemory(s.Gpu[i].ProductName),
MemoryActivity: s.Gpu[i].Utilization.MemoryUtil, MemoryActivity: s.Gpu[i].Utilization.MemoryUtil,
EncoderUtil: s.Gpu[i].Utilization.EncoderUtil, EncoderUtil: s.Gpu[i].Utilization.EncoderUtil,
DecoderUtil: s.Gpu[i].Utilization.DecoderUtil, DecoderUtil: s.Gpu[i].Utilization.DecoderUtil,
@@ -72,6 +99,12 @@ func parseNvidiaSMI(buf []byte) (*Info, error) {
ComputeMode: s.Gpu[i].ComputeMode, ComputeMode: s.Gpu[i].ComputeMode,
MigMode: s.Gpu[i].MigMode.CurrentMig, MigMode: s.Gpu[i].MigMode.CurrentMig,
} }
if gpuItem.SharedMemory {
gpuItem.MemUsed = "N/A"
gpuItem.MemTotal = "N/A"
gpuItem.MemoryFree = "N/A"
gpuItem.MemoryReserved = "N/A"
}
gpuItem.ECCPending = s.Gpu[i].EccMode.PendingEcc gpuItem.ECCPending = s.Gpu[i].EccMode.PendingEcc
gpuItem.ECCErrors = []ECCError{ gpuItem.ECCErrors = []ECCError{
{Scope: "Volatile Total", Correctable: s.Gpu[i].EccErrors.Volatile.SingleBit.Total, Uncorrectable: s.Gpu[i].EccErrors.Volatile.DoubleBit.Total}, {Scope: "Volatile Total", Correctable: s.Gpu[i].EccErrors.Volatile.SingleBit.Total, Uncorrectable: s.Gpu[i].EccErrors.Volatile.DoubleBit.Total},
+1
View File
@@ -17,6 +17,7 @@ type ECCError struct {
} }
type Device struct { type Device struct {
SharedMemory bool `json:"sharedMemory"`
ECCPending string `json:"eccPending"` ECCPending string `json:"eccPending"`
ECCErrors []ECCError `json:"eccErrors"` ECCErrors []ECCError `json:"eccErrors"`
MemoryActivity string `json:"memoryActivity"` MemoryActivity string `json:"memoryActivity"`
+29
View File
@@ -6,13 +6,42 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"unicode"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
) )
const IconPrefix = "app_" const IconPrefix = "app_"
func IsRemoteURL(icon string) bool {
for _, ch := range icon {
if unicode.IsSpace(ch) || unicode.IsControl(ch) || ch == '\\' {
return false
}
}
u, err := url.Parse(icon)
if err != nil || (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) || u.Hostname() == "" || u.User != nil {
return false
}
if u.Port() != "" {
port, err := strconv.Atoi(u.Port())
if err != nil || port > 65535 {
return false
}
}
_, err = url.QueryUnescape(u.RawQuery)
return err == nil
}
func RemoteURL(icon string) string {
if IsRemoteURL(icon) {
return icon
}
return ""
}
func IsIconFile(icon string) bool { func IsIconFile(icon string) bool {
return strings.HasPrefix(icon, IconPrefix) return strings.HasPrefix(icon, IconPrefix)
} }
+37
View File
@@ -0,0 +1,37 @@
package appicon
import "testing"
func TestRemoteURLPreservesValidSources(t *testing.T) {
for _, value := range []string{
"https://cdn.example/icon.png",
"http://cdn.example:8080/icon.png",
"HTTPS://cdn.example/icon.png?token=a%2Bb&v=42#image",
"https://[::1]:8443/icon.png",
} {
t.Run(value, func(t *testing.T) {
if !IsRemoteURL(value) || RemoteURL(value) != value {
t.Fatalf("URL was rejected or rewritten: %q", value)
}
})
}
}
func TestRemoteURLRejectsOtherIconFormatsAndAmbiguousURLs(t *testing.T) {
for _, value := range []string{
"", "app_php.png?etag=42", "iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo=",
"javascript:alert(1)", "file:///icon.png", "//cdn.example/icon.png", "https:///icon.png",
"https://", "https://user:secret@cdn.example/icon.png", "https://@cdn.example/icon.png",
" https://cdn.example/icon.png", "https://cdn.example/icon.png ", "https://cdn.example/icon image.png",
"https://cdn.example/\nicon.png", "https://cdn.example/icon.png\r\nLocation: https://other.example",
"https://cdn.example/\\other.png", "https://cdn.example:bad/icon.png", "https://cdn.example/%zz",
"https://cdn.example/icon.png?token=%zz", "https://cdn.example/\u00a0icon.png", "https://cdn.example/\x7ficon.png",
"https://cdn.example:65536/icon.png",
} {
t.Run(value, func(t *testing.T) {
if IsRemoteURL(value) || RemoteURL(value) != "" {
t.Fatalf("invalid or historical icon was classified as a remote URL: %q", value)
}
})
}
}
+14
View File
@@ -61,6 +61,13 @@ const (
AcceleratorMetricValuePattern = `^\s*([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\s*(.*?)\s*$` AcceleratorMetricValuePattern = `^\s*([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\s*(.*?)\s*$`
AscendVersionPattern = `(?i)\bVersion:\s*([^\s|]+)` AscendVersionPattern = `(?i)\bVersion:\s*([^\s|]+)`
AscendMemoryPattern = `([0-9]+(?:\.[0-9]+)?)\s*/\s*([0-9]+(?:\.[0-9]+)?)` AscendMemoryPattern = `([0-9]+(?:\.[0-9]+)?)\s*/\s*([0-9]+(?:\.[0-9]+)?)`
NvidiaGPUUUIDPattern = `^GPU-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`
NvidiaGPUDeviceIDPattern = `^([0-9]+|GPU-[a-zA-Z0-9-]+|MIG-[a-zA-Z0-9/-]+)$`
IntelGPURenderNamePattern = `^renderD[0-9]+$`
IntelGPUDevicePathPattern = `^/dev/dri/renderD[0-9]+$`
AscendGPUDeviceNamePattern = `^davinci[0-9]+$`
AscendGPUDevicePathPattern = `^/dev/davinci[0-9]+$`
AscendGPUDeviceIDPattern = `^[0-9]+$`
) )
var regexMap = make(map[string]*regexp.Regexp) var regexMap = make(map[string]*regexp.Regexp)
@@ -122,6 +129,13 @@ func Init() {
AcceleratorMetricValuePattern, AcceleratorMetricValuePattern,
AscendVersionPattern, AscendVersionPattern,
AscendMemoryPattern, AscendMemoryPattern,
NvidiaGPUUUIDPattern,
NvidiaGPUDeviceIDPattern,
IntelGPURenderNamePattern,
IntelGPUDevicePathPattern,
AscendGPUDeviceNamePattern,
AscendGPUDevicePathPattern,
AscendGPUDeviceIDPattern,
} }
for _, pattern := range patterns { for _, pattern := range patterns {
+144 -2
View File
@@ -15,7 +15,7 @@ import (
) )
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool { func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
if _, err := auth.RequireAPIKeySession(c); err != nil { if _, err := auth.RequireAPIKeyManagement(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil) helper.BadAuth(c, "ErrNotLogin", nil)
return false return false
} }
@@ -41,6 +41,24 @@ func apiKeyError(c *gin.Context, err error) {
helper.InternalServer(c, err) helper.InternalServer(c, err)
} }
// Swag's legacy || syntax combines schemes in one requirement (AND);
// separate @Security lines represent alternative authentication methods.
// @Tags API Key
// @Summary List the current account's API keys
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The authenticated account determines ownership; another account cannot be selected in the request.
// @Description Returns key metadata and masked hints, never plaintext secrets. The legacy key is included when present; deleted keys are excluded.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone.
// @Accept json
// @Produce json
// @Param request body dto.APIKeySearch true "Pagination and revoked-key filter"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyPage} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Header 200 {string} Cache-Control "no-store"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/search [post]
func (b *BaseApi) SearchAPIKeys(c *gin.Context) { func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
var req dto.APIKeySearch var req dto.APIKeySearch
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -55,6 +73,25 @@ func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags API Key
// @Summary Create an API key for the current account
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. The new key belongs to the authenticated account.
// @Description requestID is optional. Omitted or empty values are generated by the server. For idempotent retries, supply a UUID and reuse it with the same payload.
// @Description A successful first request returns the plaintext secret. Repeating the same requestID and payload returns alreadyCreated=true and an empty apiKey; changing the payload for that requestID is rejected.
// @Description Omitted expiresAt defaults to 90 days; explicit null means no expiration. Omitted apiKeyValidityTime defaults to 120 minutes; zero disables timestamp validation.
// @Description API-created keys may have a longer lifetime or broader IP policy than the calling key. Revoking the calling key does not revoke keys it created.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500; check code rather than HTTP status alone. Unknown fields and bodies larger than 32 KiB are rejected.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyCreate true "Key policy and an optional UUID requestID for idempotency"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyCreated} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Header 200 {string} Cache-Control "no-store"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/create [post]
func (b *BaseApi) CreateAPIKey(c *gin.Context) { func (b *BaseApi) CreateAPIKey(c *gin.Context) {
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}} req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -69,6 +106,22 @@ func (b *BaseApi) CreateAPIKey(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags API Key
// @Summary Update an API key's policy
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed.
// @Description Submit the current revision returned by search; stale revisions are rejected. This replaces editable policy fields without changing the secret. expiresAt must be supplied as an RFC 3339 timestamp or null.
// @Description Use id=legacy for the legacy key; its name is fixed and expiresAt must be null. Revoked or deleted independent keys cannot be updated.
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyUpdate true "Key ID, current revision and complete editable policy"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/update [post]
func (b *BaseApi) UpdateAPIKey(c *gin.Context) { func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
var req dto.APIKeyUpdate var req dto.APIKeyUpdate
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -81,6 +134,21 @@ func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
apiKeyMutationSuccess(c) apiKeyMutationSuccess(c)
} }
// @Tags API Key
// @Summary Enable or disable an API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be changed; id=legacy is supported.
// @Description Submit the current revision returned by search. Revoked or deleted keys cannot be enabled; expired keys must first receive a future expiration time. Disabling a key also closes its active terminal sessions.
// @Description Success increments the revision; search again before another mutation. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyStatus true "Key ID, current revision and Enable or Disable status"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/status [post]
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) { func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
var req dto.APIKeyStatus var req dto.APIKeyStatus
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -93,6 +161,22 @@ func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
apiKeyMutationSuccess(c) apiKeyMutationSuccess(c)
} }
// @Tags API Key
// @Summary Revoke an API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be revoked.
// @Description Submit the current revision returned by search. Revocation destroys the independent key's stored secret and cannot be undone; its metadata remains listed. For id=legacy, the legacy secret is cleared and API access is disabled.
// @Description A key can revoke itself; subsequent requests signed with it fail. Keys previously created by the revoked key remain independent. Success increments the revision and closes associated terminal sessions.
// @Description Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Key ID and current revision"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/revoke [post]
func (b *BaseApi) RevokeAPIKey(c *gin.Context) { func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
var req dto.APIKeyMutation var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) { if !bindAPIKeyRequest(c, &req) {
@@ -106,5 +190,63 @@ func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
} }
func apiKeyMutationSuccess(c *gin.Context) { func apiKeyMutationSuccess(c *gin.Context) {
helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")}) helper.SuccessWithData(c, dto.APIKeyMutationResult{TerminalClosePending: c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
}
// @Tags API Key
// @Summary Delete an independent API key
// @Description Accepts a panel login session with CSRF protection, or a valid API Key signature. Only keys owned by the authenticated account can be deleted; id=legacy is not supported.
// @Description Submit the current revision returned by search. Deletion immediately revokes the key, destroys its stored secret and removes it from lists. Audit and idempotency records are retained. Already-revoked keys can be deleted; deletion cannot be undone.
// @Description A key can delete itself; subsequent requests signed with it fail. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
// @Param 1Panel-Key-ID header string false "Optional ID of the authenticating independent key; omit for legacy keys"
// @Param 1Panel-Signature-Version header string false "Signature algorithm; omission accepts either supported algorithm" Enums(v1,md5,hmac-sha256)
// @Success 200 {object} dto.Response{data=dto.APIKeyMutationResult} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token for a session request"
// @Security ApiKeyAuth || Timestamp
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/delete [post]
func (b *BaseApi) DeleteAPIKey(c *gin.Context) {
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
if err := service.NewAPIKeyService().Delete(c, req); err != nil {
apiKeyError(c, err)
return
}
apiKeyMutationSuccess(c)
}
// @Tags API Key
// @Summary Reveal an independent API key's plaintext secret
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported, including requests that also contain a valid session cookie; omit all 1Panel authentication headers.
// @Description Only the current account's independent keys can be revealed. Submit the current revision returned by search. Legacy, revoked and deleted keys are rejected; disabled or expired keys can still be revealed.
// @Description Secret disclosure is audited. Authentication and business errors use HTTP 200 with envelope code 400, 401 or 500. The response must not be cached or logged.
// @Accept json
// @Produce json
// @Param request body dto.APIKeyMutation true "Independent key ID and current revision"
// @Success 200 {object} dto.Response{data=dto.APIKeyRevealed} "code=200 on success; data is null on errors"
// @Failure 403 {object} dto.Response "Invalid or missing CSRF token"
// @Header 200 {string} Cache-Control "no-store"
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/keys/reveal [post]
func (b *BaseApi) RevealAPIKey(c *gin.Context) {
c.Header("Cache-Control", "no-store")
if _, err := auth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil)
return
}
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
result, err := service.NewAPIKeyService().Reveal(c, req)
if err != nil {
apiKeyError(c, err)
return
}
helper.SuccessWithData(c, result)
} }
+11 -8
View File
@@ -417,11 +417,13 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
} }
// @Tags Auth // @Tags Auth
// @Summary generate api key // @Summary Generate or reset the legacy API key
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This replaces the current account's legacy secret; existing clients using the previous secret must be updated. Independent API keys are unaffected.
// @Accept json // @Accept json
// @Success 200 {string} key // @Produce json
// @Security ApiKeyAuth // @Success 200 {object} dto.Response{data=string} "code=200 with the new secret; authentication and business errors use envelope code 400, 401 or 500"
// @Security Timestamp // @Header 200 {string} Cache-Control "no-store"
// @Security PanelSession || CSRFToken
// @Router /core/auth/api/generate [post] // @Router /core/auth/api/generate [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
func (b *BaseApi) GenerateApiKey(c *gin.Context) { func (b *BaseApi) GenerateApiKey(c *gin.Context) {
@@ -440,12 +442,13 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
} }
// @Tags Auth // @Tags Auth
// @Summary Update api config // @Summary Update the legacy API key configuration
// @Description Requires a panel login session and CSRF token. API Key authentication is not supported. This updates only the current account's legacy API configuration.
// @Accept json // @Accept json
// @Produce json
// @Param request body dto.ApiInterfaceConfig true "request" // @Param request body dto.ApiInterfaceConfig true "request"
// @Success 200 // @Success 200 {object} dto.Response "code=200 on success; authentication and business errors use envelope code 400, 401 or 500"
// @Security ApiKeyAuth // @Security PanelSession || CSRFToken
// @Security Timestamp
// @Router /core/auth/api/update [post] // @Router /core/auth/api/update [post]
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"} // @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
func (b *BaseApi) UpdateApiConfig(c *gin.Context) { func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
+16 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"net" "net"
"net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -42,7 +43,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
ownerProvider = ownerProviders[0] ownerProvider = ownerProviders[0]
} }
return func(c *gin.Context) { return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") { if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") && !isAPIKeyManagementRequest(c) {
c.Next() c.Next()
return return
} }
@@ -97,6 +98,20 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
} }
} }
func isAPIKeyManagementRequest(c *gin.Context) bool {
if c.Request.Method != http.MethodPost {
return false
}
switch c.Request.URL.Path {
case "/api/v2/core/auth/api/keys/search", "/api/v2/core/auth/api/keys/create",
"/api/v2/core/auth/api/keys/update", "/api/v2/core/auth/api/keys/status",
"/api/v2/core/auth/api/keys/revoke", "/api/v2/core/auth/api/keys/delete":
return true
default:
return false
}
}
func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) { func LoadAPIAuthConfig(_ *gin.Context) (APIAuthConfig, error) {
settingRepo := repo.NewISettingRepo() settingRepo := repo.NewISettingRepo()
config := APIAuthConfig{} config := APIAuthConfig{}
+23 -3
View File
@@ -66,6 +66,26 @@ func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
return u, nil return u, nil
} }
func RequireAPIKeyManagement(c *gin.Context) (psession.SessionUser, error) {
if c == nil || !c.GetBool("API_AUTH") {
return RequireAPIKeySession(c)
}
if c.GetBool("LOCAL_REQUEST") {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
kind, keyID := c.GetString("API_AUTH_KEY_KIND"), c.GetString("API_AUTH_KEY_ID")
if (kind != "apiKey" && kind != "legacy") || keyID == "" || (kind == "legacy") != (keyID == "legacy") {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
value, _ := c.Get(psession.GinContextSessionUserKey)
u, ok := value.(psession.SessionUser)
if (ownerType != APIKeyOwnerPanelAdmin && ownerType != APIKeyOwnerEnterpriseUser) || ownerID == "" || !ok || u.ID != ownerID || u.Name == "" {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
return u, nil
}
func HasAPICredentials(c *gin.Context) bool { func HasAPICredentials(c *gin.Context) bool {
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != "" return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
} }
@@ -87,7 +107,7 @@ func LegacyAPIKeyFingerprint(owner APIKeyOwner, config APIAuthConfig, policy mod
} }
func APIKeySecret(key model.APIKey) (string, error) { func APIKeySecret(key model.APIKey) (string, error) {
if key.SecretVersion != 1 || key.SecretCiphertext == "" { if key.DeletedAt != nil || key.Status == "Revoked" || key.SecretVersion != 1 || key.SecretCiphertext == "" {
return "", buserr.New("ErrApiConfigKeyInvalid") return "", buserr.New("ErrApiConfigKeyInvalid")
} }
master, err := encrypt.APIKeyEncryptionKey() master, err := encrypt.APIKeyEncryptionKey()
@@ -98,7 +118,7 @@ func APIKeySecret(key model.APIKey) (string, error) {
} }
func ValidateAPIKeyState(key model.APIKey, now time.Time) error { func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
if key.Status != constant.StatusEnable { if key.DeletedAt != nil || key.Status != constant.StatusEnable {
return buserr.New("ErrApiConfigStatusInvalid") return buserr.New("ErrApiConfigStatusInvalid")
} }
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) { if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
@@ -113,7 +133,7 @@ func configForAPIKey(key model.APIKey, secret string, owner APIKeyOwner) APIAuth
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) { func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
keyID := c.GetHeader("1Panel-Key-ID") keyID := c.GetHeader("1Panel-Key-ID")
q := global.DB.Model(&model.APIKey{}).Select("id", "secret_ciphertext", "secret_version", "revision") q := global.DB.Model(&model.APIKey{}).Where("deleted_at IS NULL").Select("id", "secret_ciphertext", "secret_version", "revision")
if keyID != "" { if keyID != "" {
q = q.Where("id = ?", keyID) q = q.Where("id = ?", keyID)
} else { } else {
+35 -27
View File
@@ -6,41 +6,42 @@ import (
) )
type APIKeyItem struct { type APIKeyItem struct {
ID string `json:"id"` ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
Kind string `json:"kind"` Kind string `json:"kind" enums:"apiKey,legacy" example:"apiKey"`
Name string `json:"name"` Name string `json:"name" example:"Deployment automation"`
Description string `json:"description"` Description string `json:"description" example:"Used by the deployment service"`
KeyHint string `json:"keyHint"` KeyHint string `json:"keyHint" example:"abcd••••1234"`
Status string `json:"status"` Status string `json:"status" enums:"Enable,Disable,Revoked,Expired" example:"Enable"`
IPWhiteList string `json:"ipWhiteList"` IPWhiteList string `json:"ipWhiteList" example:"192.0.2.10"`
APITrustedProxies string `json:"apiTrustedProxies"` APITrustedProxies string `json:"apiTrustedProxies" example:"192.0.2.1"`
APIKeyValidityTime int `json:"apiKeyValidityTime"` APIKeyValidityTime int `json:"apiKeyValidityTime" example:"120"`
ExpiresAt *time.Time `json:"expiresAt"` ExpiresAt *time.Time `json:"expiresAt" format:"date-time" extensions:"x-nullable"`
AllowAppBinding bool `json:"allowAppBinding"` AllowAppBinding bool `json:"allowAppBinding" example:"false"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision" minimum:"1" example:"1"`
CreatedAt *time.Time `json:"createdAt"` CreatedAt *time.Time `json:"createdAt" format:"date-time" extensions:"x-nullable"`
} }
type APIKeySearch struct { type APIKeySearch struct {
Page int `json:"page"` Page int `json:"page" minimum:"1" example:"1"`
PageSize int `json:"pageSize"` PageSize int `json:"pageSize" minimum:"1" maximum:"100" example:"20"`
ExcludeRevoked bool `json:"excludeRevoked"` ExcludeRevoked bool `json:"excludeRevoked" default:"false"`
} }
type APIKeyPage struct { type APIKeyPage struct {
Items []APIKeyItem `json:"items"` Items []APIKeyItem `json:"items"`
Total int `json:"total"` Total int `json:"total"`
} }
type APIKeyFields struct { type APIKeyFields struct {
Name string `json:"name"` Name string `json:"name" minLength:"1" maxLength:"64" example:"Deployment automation"`
Description string `json:"description"` Description string `json:"description" maxLength:"256" example:"Used by the deployment service"`
IPWhiteList string `json:"ipWhiteList"` IPWhiteList string `json:"ipWhiteList" maxLength:"4096" example:"192.0.2.10"`
APITrustedProxies string `json:"apiTrustedProxies"` APITrustedProxies string `json:"apiTrustedProxies" maxLength:"4096" example:"192.0.2.1"`
APIKeyValidityTime int `json:"apiKeyValidityTime"` APIKeyValidityTime int `json:"apiKeyValidityTime" minimum:"0" maximum:"1440" example:"120"`
ExpiresAt json.RawMessage `json:"expiresAt"` ExpiresAt json.RawMessage `json:"expiresAt" swaggertype:"string" format:"date-time" extensions:"x-nullable" example:"2030-01-01T00:00:00Z"`
AllowAppBinding bool `json:"allowAppBinding"` AllowAppBinding bool `json:"allowAppBinding" default:"false"`
} }
type APIKeyCreate struct { type APIKeyCreate struct {
RequestID string `json:"requestID"` // Optional client-generated UUID for idempotent retries. Omitted or empty values are generated by the server.
RequestID string `json:"requestID" format:"uuid" example:"df1a75a3-5f56-4fa3-9e7a-a573a853d894"`
APIKeyFields APIKeyFields
} }
type APIKeyCreated struct { type APIKeyCreated struct {
@@ -49,8 +50,15 @@ type APIKeyCreated struct {
AlreadyCreated bool `json:"alreadyCreated,omitempty"` AlreadyCreated bool `json:"alreadyCreated,omitempty"`
} }
type APIKeyMutation struct { type APIKeyMutation struct {
ID string `json:"id"` ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
Revision uint64 `json:"revision"` Revision uint64 `json:"revision" minimum:"1" example:"1"`
}
type APIKeyMutationResult struct {
TerminalClosePending bool `json:"terminalClosePending"`
}
type APIKeyRevealed struct {
APIKeyMutation
APIKey string `json:"apiKey"`
} }
type APIKeyUpdate struct { type APIKeyUpdate struct {
APIKeyMutation APIKeyMutation
@@ -58,5 +66,5 @@ type APIKeyUpdate struct {
} }
type APIKeyStatus struct { type APIKeyStatus struct {
APIKeyMutation APIKeyMutation
Status string `json:"status"` Status string `json:"status" enums:"Enable,Disable" example:"Disable"`
} }
+1
View File
@@ -24,6 +24,7 @@ type APIKey struct {
CreatedAt time.Time `json:"-"` CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"` UpdatedAt time.Time `json:"-"`
RevokedAt *time.Time `json:"-"` RevokedAt *time.Time `json:"-"`
DeletedAt *time.Time `json:"-"`
} }
type LegacyAPIKeyPolicy struct { type LegacyAPIKeyPolicy struct {
+2 -2
View File
@@ -14,12 +14,12 @@ func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
} }
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) { func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
var key model.APIKey var key model.APIKey
err := global.DB.Where("id = ?", id).First(&key).Error err := global.DB.Where("id = ? AND deleted_at IS NULL", id).First(&key).Error
return key, err return key, err
} }
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) { func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
items := make([]model.APIKey, 0) items := make([]model.APIKey, 0)
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID) q := APIKeyOwnerQuery(global.DB, ownerType, ownerID).Where("deleted_at IS NULL")
if excludeRevoked { if excludeRevoked {
q = q.Where("status <> ?", "Revoked") q = q.Where("status <> ?", "Revoked")
} }
+89 -9
View File
@@ -52,6 +52,27 @@ func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) {
return s.Provider.CurrentAPIKeyOwner(c) return s.Provider.CurrentAPIKeyOwner(c)
} }
func (s *APIKeyService) managementOwner(c *gin.Context) (auth.APIKeyOwner, error) {
if _, err := auth.RequireAPIKeyManagement(c); err != nil {
return auth.APIKeyOwner{}, err
}
if s.Provider == nil {
return auth.APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
}
if !c.GetBool("API_AUTH") {
return s.Provider.CurrentAPIKeyOwner(c)
}
ownerType, ownerID := c.GetString("API_AUTH_OWNER_TYPE"), c.GetString("API_AUTH_OWNER_ID")
owner, err := s.Provider.ResolveAPIKeyOwner(c, ownerType, ownerID)
if err != nil {
return auth.APIKeyOwner{}, err
}
if owner.Type != ownerType || owner.ID != ownerID {
return auth.APIKeyOwner{}, buserr.New("ErrNotLogin")
}
return owner, nil
}
func apiKeyItem(key model.APIKey) dto.APIKeyItem { func apiKeyItem(key model.APIKey) dto.APIKeyItem {
status := key.Status status := key.Status
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) { if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
@@ -75,7 +96,7 @@ func (s *APIKeyService) legacy(owner auth.APIKeyOwner) (dto.APIKeyItem, auth.API
} }
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) { func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
owner, err := s.owner(c) owner, err := s.managementOwner(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -146,11 +167,13 @@ func keyHint(secret string) string {
} }
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) { func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
owner, err := s.owner(c) owner, err := s.managementOwner(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if _, err = uuid.Parse(req.RequestID); err != nil { if req.RequestID == "" {
req.RequestID = uuid.NewString()
} else if _, err = uuid.Parse(req.RequestID); err != nil {
return nil, buserr.New("ErrInvalidParams") return nil, buserr.New("ErrInvalidParams")
} }
expires, err := validateAPIKeyFields(&req.APIKeyFields, true) expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
@@ -172,6 +195,9 @@ func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKe
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID) q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
err := q.Where("request_id = ?", req.RequestID).First(&key).Error err := q.Where("request_id = ?", req.RequestID).First(&key).Error
if err == nil { if err == nil {
if key.DeletedAt != nil {
return buserr.New("ErrAPIKeyNotFound")
}
if key.RequestHash != requestHash { if key.RequestHash != requestHash {
return buserr.New("ErrAPIKeyConflict") return buserr.New("ErrAPIKeyConflict")
} }
@@ -219,7 +245,7 @@ func ensureAPIKeyName(tx *gorm.DB, owner auth.APIKeyOwner, name, id string) erro
} }
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error { func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
owner, err := s.owner(c) owner, err := s.managementOwner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -247,7 +273,7 @@ func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
} }
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error { func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
owner, err := s.owner(c) owner, err := s.managementOwner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -271,7 +297,7 @@ func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
} }
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error { func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.owner(c) owner, err := s.managementOwner(c)
if err != nil { if err != nil {
return err return err
} }
@@ -289,21 +315,75 @@ func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
}) })
} }
func (s *APIKeyService) Delete(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.managementOwner(c)
if err != nil {
return err
}
if req.ID == "legacy" {
return buserr.New("ErrInvalidParams")
}
return s.mutate(c, owner, req, "delete", func(_ *gorm.DB, key *model.APIKey) error {
now := time.Now()
key.Status = "Revoked"
if key.RevokedAt == nil {
key.RevokedAt = &now
}
key.DeletedAt = &now
key.ActiveName = nil
key.SecretCiphertext = ""
key.KeyHint = ""
key.AllowAppBinding = false
return nil
})
}
func (s *APIKeyService) Reveal(c *gin.Context, req dto.APIKeyMutation) (*dto.APIKeyRevealed, error) {
owner, err := s.owner(c)
if err != nil {
return nil, err
}
if req.ID == "" || req.ID == "legacy" || req.Revision == 0 {
return nil, buserr.New("ErrInvalidParams")
}
// Serialize the snapshot and disclosure audit with revocation/deletion.
apiKeyMutationMu.Lock()
defer apiKeyMutationMu.Unlock()
var key model.APIKey
if err = repo.APIKeyOwnerQuery(global.DB, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, buserr.New("ErrAPIKeyNotFound")
}
return nil, err
}
if key.Revision != req.Revision || key.Status == "Revoked" {
return nil, buserr.New("ErrAPIKeyConflict")
}
secret, err := auth.APIKeySecret(key)
if err != nil {
return nil, buserr.New("ErrApiConfigKeyInvalid")
}
if err = apikey_audit.Record(c, apikey_audit.Event{OwnerType: owner.Type, OwnerID: owner.ID, KeyID: key.ID, KeyName: key.Name, Action: "reveal", Status: constant.StatusSuccess}); err != nil {
return nil, err
}
return &dto.APIKeyRevealed{APIKeyMutation: req, APIKey: secret}, nil
}
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error { func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
if req.ID == "" || req.Revision == 0 { if req.ID == "" || req.Revision == 0 {
return buserr.New("ErrInvalidParams") return buserr.New("ErrInvalidParams")
} }
apiKeyMutationMu.Lock() apiKeyMutationMu.Lock()
var key model.APIKey var key model.APIKey
closeTerminals := action == "disable" || action == "revoke" closeTerminals := action == "disable" || action == "revoke" || action == "delete"
err := global.DB.Transaction(func(tx *gorm.DB) error { err := global.DB.Transaction(func(tx *gorm.DB) error {
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ?", req.ID).First(&key).Error; err != nil { if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ? AND deleted_at IS NULL", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return buserr.New("ErrAPIKeyNotFound") return buserr.New("ErrAPIKeyNotFound")
} }
return err return err
} }
if key.Revision != req.Revision || key.Status == "Revoked" { if key.Revision != req.Revision || (key.Status == "Revoked" && action != "delete") {
return buserr.New("ErrAPIKeyConflict") return buserr.New("ErrAPIKeyConflict")
} }
before := key before := key
+2180 -267
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -4542,6 +4542,9 @@
"schema": { "schema": {
"type": "file" "type": "file"
} }
},
"302": {
"description": "redirect to the official icon URL"
} }
}, },
"security": [ "security": [
@@ -5339,6 +5342,17 @@
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/request.AppUpdateVersion"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
@@ -47477,6 +47491,23 @@
], ],
"type": "object" "type": "object"
}, },
"request.AppUpdateVersion": {
"properties": {
"appInstallID": {
"type": "integer"
},
"loadCompose": {
"type": "boolean"
},
"updateVersion": {
"type": "string"
}
},
"required": [
"appInstallID"
],
"type": "object"
},
"response.AppConfig": { "response.AppConfig": {
"properties": { "properties": {
"advanced": { "advanced": {
@@ -47503,6 +47534,9 @@
"hostMode": { "hostMode": {
"type": "boolean" "type": "boolean"
}, },
"isEdit": {
"type": "boolean"
},
"memoryLimit": { "memoryLimit": {
"type": "number" "type": "number"
}, },
@@ -47763,6 +47797,9 @@
"gpuSupport": { "gpuSupport": {
"type": "boolean" "type": "boolean"
}, },
"icon": {
"type": "string"
},
"id": { "id": {
"type": "integer" "type": "integer"
}, },
+5 -2
View File
@@ -18,7 +18,10 @@ import (
// @schemes http https // @schemes http https
// @securityDefinitions.apikey ApiKeyAuth // @securityDefinitions.apikey ApiKeyAuth
// @description Custom Token Format, Format: md5('1panel' + API-Key + UnixTimestamp). // @description Signed API Key authentication. Send the signature, never the plaintext API Key, in 1Panel-Token.
// @description Supported formats: hex(HMAC-SHA256(API-Key, '1panel:' + UnixTimestamp)) or md5('1panel' + API-Key + UnixTimestamp).
// @description Send the matching Unix timestamp in 1Panel-Timestamp. Optionally set 1Panel-Signature-Version to hmac-sha256, v1 or md5; omission accepts either algorithm.
// @description Independent keys may include their ID in 1Panel-Key-ID to select the credential; omit that header for a legacy key.
// @description ``` // @description ```
// @description eg: // @description eg:
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \ // @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
@@ -27,7 +30,7 @@ import (
// @description ``` // @description ```
// @description - `1Panel-Token` is the key for the panel API Key. // @description - `1Panel-Token` is the key for the panel API Key.
// @type apiKey // @type apiKey
// @in Header // @in header
// @name 1Panel-Token // @name 1Panel-Token
// @securityDefinitions.apikey Timestamp // @securityDefinitions.apikey Timestamp
// @type apiKey // @type apiKey
+12 -9
View File
@@ -156,15 +156,18 @@ var WebUrlMap = map[string]struct{}{
"/toolbox/fail2ban": {}, "/toolbox/fail2ban": {},
"/toolbox/clean": {}, "/toolbox/clean": {},
"/websites": {}, "/websites": {},
"/websites/templates": {}, "/websites/templates": {},
"/websites/ssl": {}, "/websites/ssl": {},
"/websites/runtimes/php": {}, "/websites/ssl/ca": {},
"/websites/runtimes/node": {}, "/websites/ssl/acme-account": {},
"/websites/runtimes/java": {}, "/websites/ssl/dns-account": {},
"/websites/runtimes/go": {}, "/websites/runtimes/php": {},
"/websites/runtimes/python": {}, "/websites/runtimes/node": {},
"/websites/runtimes/dotnet": {}, "/websites/runtimes/java": {},
"/websites/runtimes/go": {},
"/websites/runtimes/python": {},
"/websites/runtimes/dotnet": {},
"/login": {}, "/login": {},
+1
View File
@@ -72,5 +72,6 @@ func coreMigrations() []*gormigrate.Migration {
migrations.UpdateAiMenuOrder, migrations.UpdateAiMenuOrder,
migrations.UpdateAiMenuOrderV2, migrations.UpdateAiMenuOrderV2,
migrations.AddPanelMCP, migrations.AddPanelMCP,
migrations.AddAPIKeyDeletedAt,
} }
} }
@@ -15,3 +15,8 @@ var AddOperationLogAPIKey = &gormigrate.Migration{
ID: "20260915-add-operation-log-api-key", ID: "20260915-add-operation-log-api-key",
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) }, Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) },
} }
var AddAPIKeyDeletedAt = &gormigrate.Migration{
ID: "20261028-api-key-deleted-at",
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.APIKey{}) },
}
+34
View File
@@ -0,0 +1,34 @@
package proxy
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type iconRedirectTransport func(*http.Request) (*http.Response, error)
func (f iconRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func TestLocalAppIconRedirectIsPassedToBrowser(t *testing.T) {
const icon = "https://cdn.example/icon.png?signature=a%2Bb&v=42"
calls := 0
proxy := newLocalAgentProxy(iconRedirectTransport(func(req *http.Request) (*http.Response, error) {
calls++
if req.URL.Host != "unix" || req.URL.Path != "/api/v2/apps/icon/php" {
t.Fatalf("proxy attempted to fetch a remote image: %s", req.URL)
}
return &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": {icon}, "Cache-Control": {"no-cache"}},
Body: io.NopCloser(strings.NewReader("")),
}, nil
}))
rw := httptest.NewRecorder()
proxy.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/v2/apps/icon/php", nil))
if rw.Code != http.StatusFound || rw.Header().Get("Location") != icon || rw.Header().Get("Cache-Control") != "no-cache" || calls != 1 {
t.Fatalf("local proxy did not preserve the redirect: status=%d headers=%v calls=%d", rw.Code, rw.Header(), calls)
}
}
+4
View File
@@ -42,6 +42,10 @@ func OperationLog() gin.HandlerFunc {
return return
} }
switch c.Request.URL.Path { switch c.Request.URL.Path {
case "/api/v2/core/auth/api/keys/reveal":
// Disclosure has its own audit and must not enter response-body capture.
c.Next()
return
case "/api/v2/core/mcp/credentials/create", "/api/v2/core/mcp/credentials/reveal", "/api/v2/core/mcp/credentials/rotate", "/api/v2/core/mcp/credentials/export": case "/api/v2/core/mcp/credentials/create", "/api/v2/core/mcp/credentials/reveal", "/api/v2/core/mcp/credentials/rotate", "/api/v2/core/mcp/credentials/export":
c.Next() c.Next()
return return
+2
View File
@@ -40,6 +40,8 @@ func (s *BaseRouter) InitRouter(Router *gin.RouterGroup) {
authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey) authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey)
authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus) authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus)
authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey) authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey)
authRouter.POST("/api/keys/delete", baseApi.DeleteAPIKey)
authRouter.POST("/api/keys/reveal", baseApi.RevealAPIKey)
authRouter.GET("/current", baseApi.GetCurrentUser) authRouter.GET("/current", baseApi.GetCurrentUser)
authRouter.POST("/current/update", baseApi.UpdateCurrentUser) authRouter.POST("/current/update", baseApi.UpdateCurrentUser)
+13
View File
@@ -45,6 +45,8 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
"enable": {"启用 API Key", "Enable API key"}, "enable": {"启用 API Key", "Enable API key"},
"disable": {"停用 API Key", "Disable API key"}, "disable": {"停用 API Key", "Disable API key"},
"revoke": {"撤销 API Key", "Revoke API key"}, "revoke": {"撤销 API Key", "Revoke API key"},
"delete": {"删除 API Key", "Delete API key"},
"reveal": {"查看 API Key", "Reveal API key"},
"app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"}, "app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"},
} }
label, ok := labels[event.Action] label, ok := labels[event.Action]
@@ -80,6 +82,17 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
record.UserAgent = c.Request.UserAgent() record.UserAgent = c.Request.UserAgent()
record.IP = c.ClientIP() record.IP = c.ClientIP()
} }
if c.GetBool("API_AUTH") && event.Action != "app_binding" {
record.AuthMethod = "api_key"
record.APIKeyID = c.GetString("API_AUTH_KEY_ID")
record.APIKeyName = c.GetString("API_AUTH_KEY_NAME")
if c.GetString("API_AUTH_KEY_KIND") == "legacy" {
record.APIKeyID = "legacy:" + c.GetString("API_AUTH_OWNER_TYPE") + ":" + c.GetString("API_AUTH_OWNER_ID")
}
if clientIP := c.GetString("API_AUTH_CLIENT_IP"); clientIP != "" {
record.IP = clientIP
}
}
} }
if event.ClientIP != "" { if event.ClientIP != "" {
record.IP = event.ClientIP record.IP = event.ClientIP
+18
View File
@@ -1,6 +1,21 @@
import { ReqPage } from '.'; import { ReqPage } from '.';
export namespace AI { export namespace AI {
export interface ModelGPUDevice {
id: string;
name: string;
type: string;
uuid?: string;
available: boolean;
}
export interface ModelGPUConfig {
mode: 'all' | 'count' | 'devices';
count?: number;
error?: string;
enabled: boolean;
deviceIDs: string[];
capabilities: string[];
}
export interface ModelAccountSync { export interface ModelAccountSync {
id: number; id: number;
modelAccountBaseURLType: 'container' | 'localhost' | 'systemIP' | 'custom'; modelAccountBaseURLType: 'container' | 'localhost' | 'systemIP' | 'custom';
@@ -34,6 +49,7 @@ export namespace AI {
xpu: XpuInfo['xpu']; xpu: XpuInfo['xpu'];
} }
export interface GPU { export interface GPU {
sharedMemory?: boolean;
eccPending?: string; eccPending?: string;
eccErrors?: { scope: string; correctable: string; uncorrectable: string }[]; eccErrors?: { scope: string; correctable: string; uncorrectable: string }[];
memoryActivity?: string; memoryActivity?: string;
@@ -124,6 +140,7 @@ export namespace AI {
chartHide: Array<ChartHide>; chartHide: Array<ChartHide>;
} }
export interface ChartHide { export interface ChartHide {
sharedMemory?: boolean;
deviceID?: string; deviceID?: string;
legacy?: boolean; legacy?: boolean;
productName: string; productName: string;
@@ -392,6 +409,7 @@ export namespace AI {
} }
export interface TensorRTLLM { export interface TensorRTLLM {
gpu?: ModelGPUConfig;
id?: number; id?: number;
name: string; name: string;
containerName: string; containerName: string;
+3
View File
@@ -30,4 +30,7 @@ export namespace APIKey {
id: string; id: string;
revision: number; revision: number;
} }
export interface Revealed extends Reference {
apiKey: string;
}
} }
+6
View File
@@ -53,6 +53,7 @@ export namespace App {
export interface AppItem { export interface AppItem {
name: string; name: string;
icon?: string;
key: string; key: string;
id: number; id: number;
description: string; description: string;
@@ -137,6 +138,7 @@ export namespace App {
} }
export interface AppInstall { export interface AppInstall {
gpu?: AI.ModelGPUConfig;
syncModelAccount?: boolean; syncModelAccount?: boolean;
modelAccountBaseURLType?: string; modelAccountBaseURLType?: string;
modelAccountBaseURL?: string; modelAccountBaseURL?: string;
@@ -291,6 +293,7 @@ export namespace App {
feishuConfigured?: boolean; feishuConfigured?: boolean;
version: string; version: string;
detailId: number; detailId: number;
dockerCompose?: string;
} }
export interface InstallParams { export interface InstallParams {
@@ -310,6 +313,7 @@ export namespace App {
} }
export interface AppConfig { export interface AppConfig {
gpu?: AI.ModelGPUConfig;
appKey: string; appKey: string;
name: string; name: string;
httpPort: number; httpPort: number;
@@ -318,6 +322,7 @@ export namespace App {
modelAccountBaseURL: string; modelAccountBaseURL: string;
params: InstallParams[]; params: InstallParams[];
rawCompose?: string; rawCompose?: string;
isEdit?: boolean;
cpuQuota: number; cpuQuota: number;
memoryLimit: number; memoryLimit: number;
memoryUnit: string; memoryUnit: string;
@@ -341,6 +346,7 @@ export namespace App {
export interface AppUpdateVersionReq { export interface AppUpdateVersionReq {
appInstallID: number; appInstallID: number;
updateVersion?: string; updateVersion?: string;
loadCompose?: boolean;
} }
export interface AppIgnoreReq { export interface AppIgnoreReq {
+1
View File
@@ -151,6 +151,7 @@ export namespace Dashboard {
inodesUsedPercent: number; inodesUsedPercent: number;
} }
export interface GPUInfo { export interface GPUInfo {
sharedMemory?: boolean;
type: string; type: string;
index: number; index: number;
npuIndex: number; npuIndex: number;
+4
View File
@@ -43,6 +43,10 @@ export const updateBindDomain = (req: AI.BindDomain) => {
return http.post(`/ai/domain/update`, req); return http.post(`/ai/domain/update`, req);
}; };
export const loadModelGPUDevices = () => {
return http.get<{ devices: AI.ModelGPUDevice[]; warnings: string[] }>('/ai/model/gpu/devices');
};
export const loadGPUInfo = () => { export const loadGPUInfo = () => {
return http.get<AI.Info>(`/ai/gpu/load`); return http.get<AI.Info>(`/ai/gpu/load`);
}; };
+7 -1
View File
@@ -6,7 +6,7 @@ const prefix = '/core/auth/api/keys';
export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => { export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => {
return http.post<APIKey.Search>(`${prefix}/search`, params); return http.post<APIKey.Search>(`${prefix}/search`, params);
}; };
export const createAPIKey = (params: APIKey.Editable & { requestID: string }) => { export const createAPIKey = (params: APIKey.Editable & { requestID?: string }) => {
return http.post<APIKey.Created>(`${prefix}/create`, params); return http.post<APIKey.Created>(`${prefix}/create`, params);
}; };
export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => { export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => {
@@ -18,3 +18,9 @@ export const setAPIKeyStatus = (params: APIKey.Reference & { status: 'Enable' |
export const revokeAPIKey = (params: APIKey.Reference) => { export const revokeAPIKey = (params: APIKey.Reference) => {
return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params); return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params);
}; };
export const deleteAPIKey = (params: APIKey.Reference) => {
return http.post<{ terminalClosePending: boolean }>(`${prefix}/delete`, params);
};
export const revealAPIKey = (params: APIKey.Reference) => {
return http.post<APIKey.Revealed>(`${prefix}/reveal`, params);
};
+6 -5
View File
@@ -2,6 +2,7 @@ import http from '@/api';
import { ResPage } from '../interface'; import { ResPage } from '../interface';
import { App } from '../interface/app'; import { App } from '../interface/app';
import { TimeoutEnum } from '@/enums/http-enum'; import { TimeoutEnum } from '@/enums/http-enum';
import { resolveAppIconUrl } from '@/utils/app-icon';
export const syncApp = (req: App.AppStoreSync) => { export const syncApp = (req: App.AppStoreSync) => {
return http.post('apps/sync/remote', req); return http.post('apps/sync/remote', req);
@@ -99,8 +100,9 @@ export const getAppDefaultConfig = (key: string, name: string) => {
return http.post<string>(`apps/installed/conf`, { type: key, name: name }); return http.post<string>(`apps/installed/conf`, { type: key, name: name });
}; };
export const getAppInstallParams = (id: number) => { export const getAppInstallParams = (id: number, node?: string) => {
return http.get<App.AppConfig>(`apps/installed/params/${id}`); const params = node ? `?operateNode=${encodeURIComponent(node)}` : '';
return http.get<App.AppConfig>(`apps/installed/params/${id}${params}`);
}; };
export const updateAppInstallParams = (req: any) => { export const updateAppInstallParams = (req: any) => {
@@ -135,10 +137,9 @@ export const getCurrentNodeCustomAppConfig = () => {
return http.get<App.CustomAppStoreConfig>(`/custom/app/config`); return http.get<App.CustomAppStoreConfig>(`/custom/app/config`);
}; };
export function getAppIconUrl(appKey: string, node?: string): string { export function getAppIconUrl(appKey: string, node?: string, icon = ''): string {
const baseURL = import.meta.env.VITE_API_URL as string; const baseURL = import.meta.env.VITE_API_URL as string;
const params = node ? `?operateNode=${node}` : ''; return resolveAppIconUrl(appKey, node, icon, baseURL);
return `${baseURL}/apps/icon/${appKey}${params}`;
} }
export const installAppToNodes = (param: App.InstallAppToNodes) => { export const installAppToNodes = (param: App.InstallAppToNodes) => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 382 KiB

@@ -1,5 +1,5 @@
<template> <template>
<el-form-item prop="advanced"> <el-form-item v-if="showToggle" prop="advanced">
<el-checkbox v-model="form.advanced" :label="$t('app.advanced')" size="large" /> <el-checkbox v-model="form.advanced" :label="$t('app.advanced')" size="large" />
</el-form-item> </el-form-item>
<div v-if="form.advanced"> <div v-if="form.advanced">
@@ -83,6 +83,7 @@ const props = withDefaults(
editCompose: boolean; editCompose: boolean;
dockerCompose: string; dockerCompose: string;
}; };
showToggle?: boolean;
showAllowPort?: boolean; showAllowPort?: boolean;
showSpecifyIP?: boolean; showSpecifyIP?: boolean;
showContainerName?: boolean; showContainerName?: boolean;
@@ -93,6 +94,7 @@ const props = withDefaults(
autoLoadLimit?: boolean; autoLoadLimit?: boolean;
}>(), }>(),
{ {
showToggle: true,
showAllowPort: true, showAllowPort: true,
showSpecifyIP: true, showSpecifyIP: true,
showContainerName: true, showContainerName: true,
@@ -6,27 +6,14 @@
:confirm-before-close="true" :confirm-before-close="true"
@before-close="beforeClose" @before-close="beforeClose"
> >
<template v-if="created"> <el-form
<el-alert ref="formRef"
:title="$t(secret ? 'apiKeyManagement.saveSecret' : 'apiKeyManagement.alreadyCreated')" :model="form"
:type="secret ? 'warning' : 'info'" :rules="rules"
:closable="false" label-position="top"
show-icon class="text-[var(--el-text-color-regular)]"
class="mb-4" @submit.prevent="submit"
/> >
<el-form label-position="top">
<el-form-item :label="$t('commons.table.name')">{{ created.name }}</el-form-item>
<el-form-item v-if="secret" :label="$t('setting.apiKey')">
<el-input :model-value="secret" readonly autocomplete="off">
<template #append>
<CopyButton :content="secret" :is-icon="false" />
</template>
</el-input>
</el-form-item>
</el-form>
<el-checkbox v-if="secret" v-model="saved">{{ $t('apiKeyManagement.saved') }}</el-checkbox>
</template>
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
<el-alert v-if="!item" type="warning" :closable="false" class="!mb-4"> <el-alert v-if="!item" type="warning" :closable="false" class="!mb-4">
<ul class="m-0 list-disc space-y-1 pl-4 break-words leading-6"> <ul class="m-0 list-disc space-y-1 pl-4 break-words leading-6">
<li> <li>
@@ -139,13 +126,10 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button v-if="!created" :disabled="busy" @click="beforeClose(() => (visible = false))"> <el-button :disabled="busy" @click="beforeClose(() => (visible = false))">
{{ $t('commons.button.cancel') }} {{ $t('commons.button.cancel') }}
</el-button> </el-button>
<el-button v-if="created" type="primary" :disabled="!!secret && !saved" @click="finish"> <el-button type="primary" :loading="busy" @click="submit">
{{ $t('commons.button.confirm') }}
</el-button>
<el-button v-else type="primary" :loading="busy" @click="submit">
{{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }} {{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }}
</el-button> </el-button>
</template> </template>
@@ -154,7 +138,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { ElMessageBox, type FormInstance } from 'element-plus'; import type { FormInstance } from 'element-plus';
import { isAxiosError } from 'axios'; import { isAxiosError } from 'axios';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { onBeforeRouteLeave } from 'vue-router'; import { onBeforeRouteLeave } from 'vue-router';
@@ -174,9 +158,6 @@ const visible = ref(false);
const busy = ref(false); const busy = ref(false);
const uncertain = ref(false); const uncertain = ref(false);
const item = ref<APIKey.Item>(); const item = ref<APIKey.Item>();
const created = ref<APIKey.Item>();
const secret = ref('');
const saved = ref(false);
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const ipMode = ref('restricted'); const ipMode = ref('restricted');
const neverExpires = ref(false); const neverExpires = ref(false);
@@ -196,8 +177,6 @@ let editorVersion = 0;
const discardEditor = () => { const discardEditor = () => {
editorVersion++; editorVersion++;
secret.value = '';
created.value = undefined;
visible.value = false; visible.value = false;
busy.value = false; busy.value = false;
}; };
@@ -240,9 +219,6 @@ const rules = {
const open = (existing?: APIKey.Item, fromApp = false) => { const open = (existing?: APIKey.Item, fromApp = false) => {
editorVersion++; editorVersion++;
item.value = existing; item.value = existing;
created.value = undefined;
secret.value = '';
saved.value = false;
uncertain.value = false; uncertain.value = false;
pendingCreate = undefined; pendingCreate = undefined;
requestID = uuidv4(); requestID = uuidv4();
@@ -269,19 +245,9 @@ const open = (existing?: APIKey.Item, fromApp = false) => {
nextTick(() => formRef.value?.clearValidate()); nextTick(() => formRef.value?.clearValidate());
}; };
const finish = () => { const beforeClose = (done: () => void) => {
secret.value = '';
visible.value = false;
if (created.value) emit('created', created.value);
};
const beforeClose = async (done: () => void) => {
if (busy.value) return; if (busy.value) return;
if (secret.value && !saved.value) { if (uncertain.value) emit('changed');
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
return;
}
if (created.value) finish();
else if (uncertain.value) emit('changed');
done(); done();
}; };
@@ -310,9 +276,13 @@ const submit = async () => {
pendingCreate ??= { ...payload, requestID }; pendingCreate ??= { ...payload, requestID };
const response = await createAPIKey(pendingCreate); const response = await createAPIKey(pendingCreate);
if (ticket !== editorVersion) return; if (ticket !== editorVersion) return;
created.value = response.data.item;
secret.value = response.data.apiKey;
uncertain.value = false; uncertain.value = false;
pendingCreate = undefined;
MsgSuccess(i18n.global.t('commons.msg.createSuccess'));
visible.value = false;
emit('changed');
emit('created', response.data.item);
return;
} }
emit('changed'); emit('changed');
} catch (error) { } catch (error) {
@@ -341,16 +311,12 @@ watch(
{ flush: 'sync' }, { flush: 'sync' },
); );
onBeforeUnmount(discardEditor); onBeforeUnmount(discardEditor);
onBeforeRouteLeave(async (to) => { onBeforeRouteLeave((to) => {
if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) { if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) {
discardEditor(); discardEditor();
return true; return true;
} }
if (busy.value) return false; if (busy.value) return false;
if (secret.value && !saved.value) {
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
return false;
}
discardEditor(); discardEditor();
return true; return true;
}); });
+2 -2
View File
@@ -11,14 +11,14 @@
<el-col :span="22"> <el-col :span="22">
<el-alert class="mt-2" :show-icon="true" type="warning" :closable="false"> <el-alert class="mt-2" :show-icon="true" type="warning" :closable="false">
<div v-for="(item, index) in form.msgs" :key="index"> <div v-for="(item, index) in form.msgs" :key="index">
<div style="line-height: 20px; word-wrap: break-word"> <div class="dialog-object-name" style="line-height: 20px">
<span>{{ item }}</span> <span>{{ item }}</span>
</div> </div>
</div> </div>
</el-alert> </el-alert>
<slot name="content"></slot> <slot name="content"></slot>
<ul v-for="(item, index) in form.names" :key="index"> <ul v-for="(item, index) in form.names" :key="index">
<div style="word-wrap: break-word"> <div class="dialog-object-name">
<li>{{ item }}</li> <li>{{ item }}</li>
</div> </div>
</ul> </ul>
+1 -1
View File
@@ -59,7 +59,7 @@
class="mr-2 flex-shrink-0" class="mr-2 flex-shrink-0"
></svg-icon> ></svg-icon>
<el-tooltip :content="item.path" placement="top"> <el-tooltip :content="item.path" placement="top">
<span class="truncate text-sm">{{ item.name }}</span> <span class="dialog-object-name text-sm">{{ item.name }}</span>
</el-tooltip> </el-tooltip>
</div> </div>
</div> </div>
+32 -12
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: 'Show secret',
hideSecret: 'Hide secret',
revealHelp: 'Keep this secret secure. Disabled or expired keys remain unusable even when displayed.',
deleteConfirm:
'Delete “{0}”? Its secret will be destroyed and the key removed from the list. New requests will be rejected and terminal connections closed. Audit records are retained; running jobs are not cancelled. This cannot be undone.',
title: 'API Key', title: 'API Key',
manage: 'Manage API Keys', manage: 'Manage API Keys',
personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.', personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'Allow APP QR binding', allowAppBinding: 'Allow APP QR binding',
bindingDisableHelp: bindingDisableHelp:
'Disabling QR binding prevents new device bindings. Devices already bound can continue to use the key until it is disabled, revoked, or expires.', 'Disabling QR binding prevents new device bindings. Devices already bound can continue to use the key until it is disabled, revoked, or expires.',
saveSecret: 'Copy and save this secret now. After closing, ordinary key management cannot display it again.',
saved: 'I have saved the secret',
alreadyCreated:
'This key was already created. Its secret will not be shown again. If the original response was lost, revoke and recreate it, or use QR binding if allowed.',
uncertain: uncertain:
'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.', 'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.',
legacyHelp: legacyHelp:
@@ -1036,6 +1037,23 @@ const message = {
validateImageAvailabilityHelper: 'Generates one minimal image before saving and may incur charges', validateImageAvailabilityHelper: 'Generates one minimal image before saving and may incur charges',
}, },
model: { model: {
gpuAcceleration: 'GPU acceleration',
gpuMode: 'Accelerator selection',
gpuAll: 'All accelerators',
gpuCount: 'Specify count',
gpuSpecific: 'Select devices',
gpuAllHelper: 'Use all available accelerators of this type on the current node.',
gpuCountHelper:
'Let the runtime allocate the requested number of accelerators without binding specific devices.',
gpuSpecificHelper: 'Manually select specific accelerators.',
gpuCountRequired: 'Enter an integer greater than 0',
gpuDevices: 'GPU devices',
gpuCapabilitiesHelper:
'GPU access is required; enable monitoring and management (utility) and computing support (compute) as needed.',
gpuSelectRequired: 'Select at least one available GPU device.',
gpuUnsupported: 'Unsupported',
gpuNotDetected: 'No supported accelerators detected',
gpuMissing: 'Unavailable',
modelCategory: 'Model category', modelCategory: 'Model category',
modelName: 'Model name', modelName: 'Model name',
modelArchitecture: 'Model type', modelArchitecture: 'Model type',
@@ -1053,11 +1071,11 @@ const message = {
onlineModel: 'Online models', onlineModel: 'Online models',
instanceDeployment: 'Deploy by instance', instanceDeployment: 'Deploy by instance',
nvidiaDeployment: 'NVIDIA-optimized deployment', nvidiaDeployment: 'NVIDIA-optimized deployment',
engines: 'Inference engines', engines: 'Local inference engines',
modelManagement: 'Model management', modelManagement: 'Model management',
serviceDetails: 'Service details', serviceDetails: 'Service details',
deployments: 'Local deployments', deployments: 'Local deployments',
files: 'Model files', files: 'Model hub',
connectExternal: 'Connect external service', connectExternal: 'Connect external service',
deploymentTemplates: 'vLLM startup command templates', deploymentTemplates: 'vLLM startup command templates',
engineProvider: 'Engine / Provider', engineProvider: 'Engine / Provider',
@@ -1074,7 +1092,7 @@ const message = {
container_conn_helper: 'Use this address for inter-container access or connection', container_conn_helper: 'Use this address for inter-container access or connection',
from_remote: 'This model was not downloaded via 1Panel, no related pull logs.', from_remote: 'This model was not downloaded via 1Panel, no related pull logs.',
no_logs: 'The pull logs for this model have been deleted and cannot be viewed.', no_logs: 'The pull logs for this model have been deleted and cannot be viewed.',
vllmVersionHelper: 'For FusionXpark GB 10 servers, please select the -cu130 version.', vllmVersionHelper: 'For FusionXpark GB10 servers, please select the -cu130 version.',
vllmDeployProfile: 'Deployment model', vllmDeployProfile: 'Deployment model',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1625,6 +1643,11 @@ const message = {
timePerOutputTokenChart: 'Inter-token latency', timePerOutputTokenChart: 'Inter-token latency',
}, },
gpu: { gpu: {
systemMemory: 'Shared system memory',
systemMemoryUsed: 'System memory used',
systemMemoryTotal: 'Total system memory',
systemMemoryAvailable: 'Available system memory',
systemMemoryHelper: 'Shared by the CPU and GPU. Shows system-wide memory usage, not GPU-only usage.',
memoryActivity: 'Memory read/write activity', memoryActivity: 'Memory read/write activity',
hotspotTemperature: 'Hotspot temperature', hotspotTemperature: 'Hotspot temperature',
fanRPM: 'Fan speed (RPM)', fanRPM: 'Fan speed (RPM)',
@@ -1660,7 +1683,6 @@ const message = {
historyPeak: 'Peak', historyPeak: 'Peak',
historyAggregated: historyAggregated:
'{count} samples aggregated into {seconds}-second intervals; processes show counts only.', '{count} samples aggregated into {seconds}-second intervals; processes show counts only.',
historyRaw: '{count} original samples.',
historyMetricEmpty: 'No data for this metric in the selected time range', historyMetricEmpty: 'No data for this metric in the selected time range',
historyLoadFailed: 'Failed to load history. Please retry.', historyLoadFailed: 'Failed to load history. Please retry.',
collectedAt: 'Collected at', collectedAt: 'Collected at',
@@ -1733,8 +1755,8 @@ const message = {
prohibitedHelper: 'Prohibited: Concurrent process execution is not allowed', prohibitedHelper: 'Prohibited: Concurrent process execution is not allowed',
migModeHelper: 'Used to create MIG instances, implementing physical GPU isolation at the user layer.', migModeHelper: 'Used to create MIG instances, implementing physical GPU isolation at the user layer.',
migModeNA: 'Not Supported', migModeNA: 'Not Supported',
current: 'Real-time Monitoring', current: 'Real-time Status',
history: 'Historical Records', history: 'Monitoring Data',
notSupport: 'The current version or driver does not support displaying this parameter.', notSupport: 'The current version or driver does not support displaying this parameter.',
}, },
mcp: { mcp: {
@@ -2042,8 +2064,6 @@ const message = {
retry: 'Retry selected points', retry: 'Retry selected points',
environment: 'Environment', environment: 'Environment',
plan: 'Frozen configuration', plan: 'Frozen configuration',
clientHelp:
'The client container runs on the main node and sends requests to an existing service. It does not start inference or require a GPU.',
retryHelp: 'Only selected abnormal points are retried, with the same parameters and new seeds.', retryHelp: 'Only selected abnormal points are retried, with the same parameters and new seeds.',
secretHelp: 'API keys and headers are excluded from copies. Enter them again if needed.', secretHelp: 'API keys and headers are excluded from copies. Enter them again if needed.',
resultHelp: resultHelp:
+33 -12
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: 'Mostrar secreto',
hideSecret: 'Ocultar secreto',
revealHelp: 'Guarde el secreto de forma segura. Mostrarlo no reactiva claves deshabilitadas o caducadas.',
deleteConfirm:
'¿Eliminar «{0}»? Se destruirá el secreto y se quitará la clave de la lista. Se rechazarán nuevas solicitudes y se cerrarán sus terminales. Se conservará la auditoría; las tareas en ejecución no se cancelarán. Esta acción es irreversible.',
title: 'API Key', title: 'API Key',
manage: 'Gestionar claves API', manage: 'Gestionar claves API',
personalEntry: 'Gestiona tus claves, condiciones de acceso y vinculación de la APP en Ajustes.', personalEntry: 'Gestiona tus claves, condiciones de acceso y vinculación de la APP en Ajustes.',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'Permitir vinculación de APP por QR', allowAppBinding: 'Permitir vinculación de APP por QR',
bindingDisableHelp: bindingDisableHelp:
'Desactivar la vinculación por QR impide nuevas vinculaciones. Los dispositivos ya vinculados pueden seguir usando la clave hasta que se desactive, se revoque o caduque.', 'Desactivar la vinculación por QR impide nuevas vinculaciones. Los dispositivos ya vinculados pueden seguir usando la clave hasta que se desactive, se revoque o caduque.',
saveSecret: 'Copia y guarda la clave ahora. Tras cerrar, la gestión normal no podrá mostrarla de nuevo.',
saved: 'He guardado la clave',
alreadyCreated:
'La clave ya se creó y no se mostrará de nuevo. Si se perdió la respuesta original, revócala y crea otra, o usa QR si está permitido.',
uncertain: uncertain:
'El resultado es incierto. Reintenta con los mismos datos para evitar duplicados, o cierra y revisa la lista antes de crear otra.', 'El resultado es incierto. Reintenta con los mismos datos para evitar duplicados, o cierra y revisa la lista antes de crear otra.',
legacyHelp: legacyHelp:
@@ -1038,6 +1039,23 @@ const message = {
validateImageAvailabilityHelper: 'Genera una imagen mínima antes de guardar y puede generar costes', validateImageAvailabilityHelper: 'Genera una imagen mínima antes de guardar y puede generar costes',
}, },
model: { model: {
gpuAcceleration: 'Aceleración GPU',
gpuMode: 'Selección de aceleradores',
gpuAll: 'Todos los aceleradores',
gpuCount: 'Especificar cantidad',
gpuSpecific: 'Seleccionar dispositivos',
gpuAllHelper: 'Usar todos los aceleradores disponibles de este tipo en el nodo actual.',
gpuCountHelper:
'El entorno de ejecución asigna la cantidad solicitada sin vincular dispositivos específicos.',
gpuSpecificHelper: 'Seleccione manualmente los aceleradores específicos.',
gpuCountRequired: 'Introduzca un entero mayor que 0',
gpuDevices: 'Dispositivos GPU',
gpuCapabilitiesHelper:
'El acceso a la GPU es obligatorio; active la supervisión y gestión (utility) y el cálculo (compute) según sea necesario.',
gpuSelectRequired: 'Seleccione al menos un dispositivo GPU disponible.',
gpuUnsupported: 'No compatible',
gpuNotDetected: 'No se detectaron aceleradores compatibles',
gpuMissing: 'No disponible',
modelCategory: 'Categoría del modelo', modelCategory: 'Categoría del modelo',
modelName: 'Nombre del modelo', modelName: 'Nombre del modelo',
modelArchitecture: 'Tipo de modelo', modelArchitecture: 'Tipo de modelo',
@@ -1055,11 +1073,11 @@ const message = {
onlineModel: 'Modelos en línea', onlineModel: 'Modelos en línea',
instanceDeployment: 'Despliegue por instancia', instanceDeployment: 'Despliegue por instancia',
nvidiaDeployment: 'Despliegue optimizado para NVIDIA', nvidiaDeployment: 'Despliegue optimizado para NVIDIA',
engines: 'Motores de inferencia', engines: 'Motores de inferencia locales',
modelManagement: 'Gestión de modelos', modelManagement: 'Gestión de modelos',
serviceDetails: 'Detalles del servicio', serviceDetails: 'Detalles del servicio',
deployments: 'Despliegues locales', deployments: 'Despliegues locales',
files: 'Archivos de modelos', files: 'Catálogo de modelos',
connectExternal: 'Conectar servicio externo', connectExternal: 'Conectar servicio externo',
deploymentTemplates: 'Plantillas de comandos de inicio de vLLM', deploymentTemplates: 'Plantillas de comandos de inicio de vLLM',
engineProvider: 'Motor / Proveedor', engineProvider: 'Motor / Proveedor',
@@ -1076,7 +1094,7 @@ const message = {
container_conn_helper: 'Utilice esta dirección para el acceso o conexión entre contenedores', container_conn_helper: 'Utilice esta dirección para el acceso o conexión entre contenedores',
from_remote: 'Este modelo no fue descargado vía 1Panel, no hay registros de descarga relacionados.', from_remote: 'Este modelo no fue descargado vía 1Panel, no hay registros de descarga relacionados.',
no_logs: 'Los registros de descarga de este modelo han sido eliminados y no se pueden consultar.', no_logs: 'Los registros de descarga de este modelo han sido eliminados y no se pueden consultar.',
vllmVersionHelper: 'Para servidores FusionXpark GB 10, seleccione la versión -cu130.', vllmVersionHelper: 'Para servidores FusionXpark GB10, seleccione la versión -cu130.',
vllmDeployProfile: 'Modelo de despliegue', vllmDeployProfile: 'Modelo de despliegue',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1635,6 +1653,12 @@ const message = {
timePerOutputTokenChart: 'Latencia entre tokens', timePerOutputTokenChart: 'Latencia entre tokens',
}, },
gpu: { gpu: {
systemMemory: 'Memoria compartida del sistema',
systemMemoryUsed: 'Memoria del sistema utilizada',
systemMemoryTotal: 'Memoria total del sistema',
systemMemoryAvailable: 'Memoria disponible del sistema',
systemMemoryHelper:
'Compartida por la CPU y la GPU. Muestra el uso de memoria de todo el sistema, no solo de la GPU.',
memoryActivity: 'Actividad de lectura/escritura de memoria', memoryActivity: 'Actividad de lectura/escritura de memoria',
hotspotTemperature: 'Temperatura del punto caliente', hotspotTemperature: 'Temperatura del punto caliente',
fanRPM: 'Velocidad del ventilador (RPM)', fanRPM: 'Velocidad del ventilador (RPM)',
@@ -1670,7 +1694,6 @@ const message = {
historyPeak: 'Máximo', historyPeak: 'Máximo',
historyAggregated: historyAggregated:
'{count} muestras agrupadas en intervalos de {seconds} segundos; los procesos solo muestran cantidades.', '{count} muestras agrupadas en intervalos de {seconds} segundos; los procesos solo muestran cantidades.',
historyRaw: '{count} muestras originales.',
historyMetricEmpty: 'No hay datos de esta métrica en el período seleccionado', historyMetricEmpty: 'No hay datos de esta métrica en el período seleccionado',
historyLoadFailed: 'No se pudo cargar el historial. Inténtelo de nuevo.', historyLoadFailed: 'No se pudo cargar el historial. Inténtelo de nuevo.',
collectedAt: 'Fecha de recopilación', collectedAt: 'Fecha de recopilación',
@@ -1744,8 +1767,8 @@ const message = {
migModeHelper: migModeHelper:
'Se utiliza para crear instancias MIG, implementando aislamiento físico de GPU en la capa de usuario.', 'Se utiliza para crear instancias MIG, implementando aislamiento físico de GPU en la capa de usuario.',
migModeNA: 'No Compatible', migModeNA: 'No Compatible',
current: 'Monitoreo en Tiempo Real', current: 'Estado en tiempo real',
history: 'Registros Históricos', history: 'Datos de monitoreo',
notSupport: 'La versión actual o el controlador no admiten mostrar este parámetro.', notSupport: 'La versión actual o el controlador no admiten mostrar este parámetro.',
processCount: 'Procesos', processCount: 'Procesos',
}, },
@@ -2060,8 +2083,6 @@ const message = {
retry: 'Repetir puntos seleccionados', retry: 'Repetir puntos seleccionados',
environment: 'Entorno', environment: 'Entorno',
plan: 'Configuración fijada', plan: 'Configuración fijada',
clientHelp:
'El contenedor cliente se ejecuta en el nodo principal y envía solicitudes a un servicio existente. No inicia inferencia ni requiere GPU.',
retryHelp: 'Solo se repiten los puntos anómalos seleccionados, con los mismos parámetros y nuevas semillas.', retryHelp: 'Solo se repiten los puntos anómalos seleccionados, con los mismos parámetros y nuevas semillas.',
secretHelp: 'Las copias no incluyen claves API ni cabeceras. Vuelva a introducirlas si es necesario.', secretHelp: 'Las copias no incluyen claves API ni cabeceras. Vuelva a introducirlas si es necesario.',
resultHelp: resultHelp:
+31 -12
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: 'نمایش کلید محرمانه',
hideSecret: 'پنهان کردن کلید محرمانه',
revealHelp: 'کلید را در جای امن نگه دارید. نمایش آن، کلید غیرفعال یا منقضی‌شده را قابل استفاده نمی‌کند.',
deleteConfirm:
'«{0}» حذف شود؟ کلید محرمانه نابود و از فهرست حذف می‌شود. درخواست‌های جدید رد و اتصال‌های ترمینال مرتبط بسته می‌شوند. سوابق حسابرسی حفظ و کارهای در حال اجرا لغو نمی‌شوند. این عملیات برگشت‌پذیر نیست.',
title: 'API Key', title: 'API Key',
manage: 'مدیریت کلیدهای API', manage: 'مدیریت کلیدهای API',
personalEntry: 'کلیدها، شرایط دسترسی و اتصال برنامه را در تنظیمات مدیریت کنید.', personalEntry: 'کلیدها، شرایط دسترسی و اتصال برنامه را در تنظیمات مدیریت کنید.',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'اجازه اتصال برنامه با QR', allowAppBinding: 'اجازه اتصال برنامه با QR',
bindingDisableHelp: bindingDisableHelp:
'با غیرفعال کردن اتصال از طریق QR، اتصال دستگاه‌های جدید متوقف می‌شود. دستگاه‌های متصل می‌توانند تا زمان غیرفعال‌سازی، لغو یا انقضای کلید به استفاده از آن ادامه دهند.', 'با غیرفعال کردن اتصال از طریق QR، اتصال دستگاه‌های جدید متوقف می‌شود. دستگاه‌های متصل می‌توانند تا زمان غیرفعال‌سازی، لغو یا انقضای کلید به استفاده از آن ادامه دهند.',
saveSecret: 'اکنون کلید را کپی و ذخیره کنید. پس از بستن، مدیریت عادی نمی‌تواند کلید کامل را دوباره نشان دهد.',
saved: 'کلید را ذخیره کردم',
alreadyCreated:
'این کلید قبلاً ایجاد شده و دوباره نمایش داده نمی‌شود. اگر پاسخ اصلی گم شده است، کلید را لغو و دوباره ایجاد کنید یا در صورت اجازه از QR استفاده کنید.',
uncertain: uncertain:
'نتیجه درخواست نامشخص است. برای جلوگیری از تکرار، با همان اطلاعات دوباره تلاش کنید یا ببندید و پیش از ایجاد کلید دیگر فهرست را بررسی کنید.', 'نتیجه درخواست نامشخص است. برای جلوگیری از تکرار، با همان اطلاعات دوباره تلاش کنید یا ببندید و پیش از ایجاد کلید دیگر فهرست را بررسی کنید.',
legacyHelp: legacyHelp:
@@ -1021,6 +1022,22 @@ const message = {
validateImageAvailabilityHelper: 'پیش از ذخیره یک تصویر حداقلی تولید می‌شود و ممکن است هزینه داشته باشد', validateImageAvailabilityHelper: 'پیش از ذخیره یک تصویر حداقلی تولید می‌شود و ممکن است هزینه داشته باشد',
}, },
model: { model: {
gpuAcceleration: 'شتاب‌دهی GPU',
gpuMode: 'انتخاب شتاب‌دهنده',
gpuAll: 'همه شتاب‌دهنده‌ها',
gpuCount: 'تعیین تعداد',
gpuSpecific: 'انتخاب دستگاه‌ها',
gpuAllHelper: 'از همه شتاب‌دهنده‌های موجود از این نوع در گره فعلی استفاده شود.',
gpuCountHelper: 'محیط اجرا تعداد درخواستی شتاب‌دهنده‌ها را بدون اتصال به دستگاه‌های مشخص تخصیص می‌دهد.',
gpuSpecificHelper: 'شتاب‌دهنده‌های مشخص را به‌صورت دستی انتخاب کنید.',
gpuCountRequired: 'یک عدد صحیح بزرگ‌تر از صفر وارد کنید',
gpuDevices: 'دستگاه‌های GPU',
gpuCapabilitiesHelper:
'دسترسی به GPU الزامی است؛ نظارت و مدیریت (utility) و پشتیبانی محاسباتی (compute) را در صورت نیاز فعال کنید.',
gpuSelectRequired: 'حداقل یک دستگاه GPU موجود را انتخاب کنید.',
gpuUnsupported: 'پشتیبانی نمی‌شود',
gpuNotDetected: 'هیچ شتاب‌دهنده پشتیبانی‌شده‌ای شناسایی نشد',
gpuMissing: 'در دسترس نیست',
modelCategory: 'دسته‌بندی مدل', modelCategory: 'دسته‌بندی مدل',
modelName: 'نام مدل', modelName: 'نام مدل',
modelArchitecture: 'نوع مدل', modelArchitecture: 'نوع مدل',
@@ -1038,11 +1055,11 @@ const message = {
onlineModel: 'مدل‌های آنلاین', onlineModel: 'مدل‌های آنلاین',
instanceDeployment: 'استقرار بر اساس نمونه', instanceDeployment: 'استقرار بر اساس نمونه',
nvidiaDeployment: 'استقرار بهینه‌شده برای NVIDIA', nvidiaDeployment: 'استقرار بهینه‌شده برای NVIDIA',
engines: 'موتورهای استنتاج', engines: 'موتورهای استنتاج محلی',
modelManagement: 'مدیریت مدل‌ها', modelManagement: 'مدیریت مدل‌ها',
serviceDetails: 'جزئیات سرویس', serviceDetails: 'جزئیات سرویس',
deployments: 'استقرارهای محلی', deployments: 'استقرارهای محلی',
files: 'فایل‌های مدل', files: 'مرکز مدل‌ها',
connectExternal: 'اتصال به سرویس خارجی', connectExternal: 'اتصال به سرویس خارجی',
deploymentTemplates: 'الگوهای فرمان راه‌اندازی vLLM', deploymentTemplates: 'الگوهای فرمان راه‌اندازی vLLM',
engineProvider: 'موتور / ارائه‌دهنده', engineProvider: 'موتور / ارائه‌دهنده',
@@ -1059,7 +1076,7 @@ const message = {
container_conn_helper: 'از این آدرس برای دسترسی بین کانتینرها یا اتصال استفاده کنید', container_conn_helper: 'از این آدرس برای دسترسی بین کانتینرها یا اتصال استفاده کنید',
from_remote: 'این مدل از طریق 1Panel دانلود نشده است، لاگ مربوط به دریافت وجود ندارد.', from_remote: 'این مدل از طریق 1Panel دانلود نشده است، لاگ مربوط به دریافت وجود ندارد.',
no_logs: 'لاگ دریافت این مدل حذف شده است و قابل مشاهده نیست.', no_logs: 'لاگ دریافت این مدل حذف شده است و قابل مشاهده نیست.',
vllmVersionHelper: 'برای سرورهای FusionXpark GB 10، لطفاً نسخه -cu130 را انتخاب کنید.', vllmVersionHelper: 'برای سرورهای FusionXpark GB10، لطفاً نسخه -cu130 را انتخاب کنید.',
vllmDeployProfile: 'مدل استقرار', vllmDeployProfile: 'مدل استقرار',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1608,6 +1625,11 @@ const message = {
timePerOutputTokenChart: 'تأخیر بین توکن‌ها', timePerOutputTokenChart: 'تأخیر بین توکن‌ها',
}, },
gpu: { gpu: {
systemMemory: 'حافظهٔ مشترک سیستم',
systemMemoryUsed: 'حافظهٔ مصرف‌شدهٔ سیستم',
systemMemoryTotal: 'کل حافظهٔ سیستم',
systemMemoryAvailable: 'حافظهٔ در دسترس سیستم',
systemMemoryHelper: 'بین CPU و GPU مشترک است. مصرف حافظهٔ کل سیستم را نشان می‌دهد، نه فقط مصرف GPU.',
memoryActivity: 'فعالیت خواندن/نوشتن حافظه', memoryActivity: 'فعالیت خواندن/نوشتن حافظه',
hotspotTemperature: 'دمای نقطه داغ', hotspotTemperature: 'دمای نقطه داغ',
fanRPM: 'سرعت فن (RPM)', fanRPM: 'سرعت فن (RPM)',
@@ -1643,7 +1665,6 @@ const message = {
historyPeak: 'بیشینه', historyPeak: 'بیشینه',
historyAggregated: historyAggregated:
'{count} نمونه در بازه‌های {seconds} ثانیه‌ای تجمیع شده‌اند؛ فقط تعداد فرایندها نمایش داده می‌شود.', '{count} نمونه در بازه‌های {seconds} ثانیه‌ای تجمیع شده‌اند؛ فقط تعداد فرایندها نمایش داده می‌شود.',
historyRaw: '{count} نمونه اصلی.',
historyMetricEmpty: 'در بازه زمانی انتخاب‌شده داده‌ای برای این شاخص وجود ندارد', historyMetricEmpty: 'در بازه زمانی انتخاب‌شده داده‌ای برای این شاخص وجود ندارد',
historyLoadFailed: 'بارگیری سابقه ناموفق بود. دوباره تلاش کنید.', historyLoadFailed: 'بارگیری سابقه ناموفق بود. دوباره تلاش کنید.',
collectedAt: 'زمان گردآوری', collectedAt: 'زمان گردآوری',
@@ -1716,8 +1737,8 @@ const message = {
migModeHelper: migModeHelper:
'برای ایجاد نمونه‌های MIG استفاده می‌شود و ایزوله‌سازی فیزیکی GPU را در لایه کاربر پیاده‌سازی می‌کند.', 'برای ایجاد نمونه‌های MIG استفاده می‌شود و ایزوله‌سازی فیزیکی GPU را در لایه کاربر پیاده‌سازی می‌کند.',
migModeNA: 'پشتیبانی نمی‌شود', migModeNA: 'پشتیبانی نمی‌شود',
current: 'نظارت بلادرنگ', current: 'وضعیت بلادرنگ',
history: 'سوابق تاریخی', history: 'داده‌های نظارتی',
notSupport: 'نسخه فعلی یا درایور از نمایش این پارامتر پشتیبانی نمی‌کند.', notSupport: 'نسخه فعلی یا درایور از نمایش این پارامتر پشتیبانی نمی‌کند.',
}, },
mcp: { mcp: {
@@ -2020,8 +2041,6 @@ const message = {
retry: 'تکرار نقاط انتخاب‌شده', retry: 'تکرار نقاط انتخاب‌شده',
environment: 'محیط اجرا', environment: 'محیط اجرا',
plan: 'پیکربندی ثابت', plan: 'پیکربندی ثابت',
clientHelp:
'کانتینر کلاینت در گره اصلی به سرویس موجود درخواست می‌فرستد. سرویس استنتاج راه‌اندازی نمی‌کند و به GPU نیاز ندارد.',
retryHelp: 'فقط نقاط غیرعادی انتخاب‌شده با همان پارامترها و بذرهای جدید تکرار می‌شوند.', retryHelp: 'فقط نقاط غیرعادی انتخاب‌شده با همان پارامترها و بذرهای جدید تکرار می‌شوند.',
secretHelp: 'کپی‌ها شامل کلید API و سرآیندها نیستند. در صورت نیاز دوباره وارد کنید.', secretHelp: 'کپی‌ها شامل کلید API و سرآیندها نیستند. در صورت نیاز دوباره وارد کنید.',
resultHelp: resultHelp:
+31 -12
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: 'シークレットを表示',
hideSecret: 'シークレットを隠す',
revealHelp: 'シークレットを安全に保管してください。表示しても無効または期限切れのキーは使用できません。',
deleteConfirm:
'「{0}」を削除しますか?シークレットは破棄され、キーは一覧から削除されます。新しいリクエストは拒否され、関連ターミナルは切断されます。監査記録は保持され、実行中のタスクは中止されません。この操作は元に戻せません。',
title: 'API Key', title: 'API Key',
manage: 'API Key を管理', manage: 'API Key を管理',
personalEntry: '設定で自分のキー、アクセス条件、APP の QR 連携を管理します。', personalEntry: '設定で自分のキー、アクセス条件、APP の QR 連携を管理します。',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'APP の QR 連携を許可', allowAppBinding: 'APP の QR 連携を許可',
bindingDisableHelp: bindingDisableHelp:
'QR 連携を無効にすると、新規の連携を受け付けなくなります。連携済みの端末は、キーが無効化、失効、または期限切れになるまで引き続き利用できます。', 'QR 連携を無効にすると、新規の連携を受け付けなくなります。連携済みの端末は、キーが無効化、失効、または期限切れになるまで引き続き利用できます。',
saveSecret: '今すぐキーをコピーして保存してください。閉じると通常のキー管理から完全なキーを再表示できません。',
saved: 'キーを保存しました',
alreadyCreated:
'このキーは作成済みで、再表示できません。最初の応答を受け取れなかった場合は失効させて再作成するか、許可されていれば QR を使用してください。',
uncertain: 'リクエスト結果が不明です。重複を避けるため同じ設定で再試行するか、閉じて一覧を確認してください。', uncertain: 'リクエスト結果が不明です。重複を避けるため同じ設定で再試行するか、閉じて一覧を確認してください。',
legacyHelp: legacyHelp:
'旧版キーは従来の API 接続との互換性を維持します。有効化、無効化、リセットはこのキーにのみ適用されます。', '旧版キーは従来の API 接続との互換性を維持します。有効化、無効化、リセットはこのキーにのみ適用されます。',
@@ -1025,6 +1026,22 @@ const message = {
validateImageAvailabilityHelper: '保存前に最小画像を1枚生成するため、料金が発生する場合があります', validateImageAvailabilityHelper: '保存前に最小画像を1枚生成するため、料金が発生する場合があります',
}, },
model: { model: {
gpuAcceleration: 'GPU アクセラレーション',
gpuMode: 'アクセラレーターの選択',
gpuAll: 'すべてのアクセラレーター',
gpuCount: '台数を指定',
gpuSpecific: 'デバイスを指定',
gpuAllHelper: '現在のノードで利用可能な同じ種類のアクセラレーターをすべて使用します。',
gpuCountHelper: '特定のデバイスに固定せず、ランタイムが指定台数を割り当てます。',
gpuSpecificHelper: '使用するアクセラレーターを手動で選択します。',
gpuCountRequired: '0 より大きい整数を入力してください',
gpuDevices: 'GPU デバイス',
gpuCapabilitiesHelper:
'GPU アクセスは必須です。必要に応じて監視と管理(utility)や計算サポート(compute)を有効にしてください。',
gpuSelectRequired: '使用可能な GPU を少なくとも 1 つ選択してください。',
gpuUnsupported: '非対応',
gpuNotDetected: '対応するアクセラレーターが検出されませんでした',
gpuMissing: '利用不可',
modelCategory: 'モデルカテゴリ', modelCategory: 'モデルカテゴリ',
modelName: 'モデル名', modelName: 'モデル名',
modelArchitecture: 'モデルの種類', modelArchitecture: 'モデルの種類',
@@ -1042,11 +1059,11 @@ const message = {
onlineModel: 'オンラインモデル', onlineModel: 'オンラインモデル',
instanceDeployment: 'インスタンス単位でデプロイ', instanceDeployment: 'インスタンス単位でデプロイ',
nvidiaDeployment: 'NVIDIA 向け最適化デプロイ', nvidiaDeployment: 'NVIDIA 向け最適化デプロイ',
engines: '推論エンジン', engines: 'ローカル推論エンジン',
modelManagement: 'モデル管理', modelManagement: 'モデル管理',
serviceDetails: 'サービス詳細', serviceDetails: 'サービス詳細',
deployments: 'ローカルデプロイ', deployments: 'ローカルデプロイ',
files: 'モデルファイル', files: 'モデル広場',
connectExternal: '外部サービスに接続', connectExternal: '外部サービスに接続',
deploymentTemplates: 'vLLM 起動コマンドテンプレート', deploymentTemplates: 'vLLM 起動コマンドテンプレート',
engineProvider: 'エンジン / プロバイダー', engineProvider: 'エンジン / プロバイダー',
@@ -1063,7 +1080,7 @@ const message = {
container_conn_helper: 'コンテナ間のアクセスまたは接続にこのアドレスを使用', container_conn_helper: 'コンテナ間のアクセスまたは接続にこのアドレスを使用',
from_remote: 'このモデルは1Panelを介してダウンロードされておらず、関連するプルログはありません。', from_remote: 'このモデルは1Panelを介してダウンロードされておらず、関連するプルログはありません。',
no_logs: 'このモデルのプルログは削除されており、関連するログを表示できません。', no_logs: 'このモデルのプルログは削除されており、関連するログを表示できません。',
vllmVersionHelper: 'FusionXpark GB 10 サーバーでは -cu130 バージョンを選択してください。', vllmVersionHelper: 'FusionXpark GB10 サーバーでは -cu130 バージョンを選択してください。',
vllmDeployProfile: 'デプロイモデル', vllmDeployProfile: 'デプロイモデル',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1612,6 +1629,11 @@ const message = {
timePerOutputTokenChart: 'トークン間遅延', timePerOutputTokenChart: 'トークン間遅延',
}, },
gpu: { gpu: {
systemMemory: '共有システムメモリ',
systemMemoryUsed: 'システムメモリ使用量',
systemMemoryTotal: 'システムメモリ合計',
systemMemoryAvailable: '利用可能なシステムメモリ',
systemMemoryHelper: 'CPU と GPU で共有します。GPU 単体ではなく、システム全体のメモリ使用量を表示します。',
memoryActivity: 'メモリ読み書き稼働率', memoryActivity: 'メモリ読み書き稼働率',
hotspotTemperature: 'ホットスポット温度', hotspotTemperature: 'ホットスポット温度',
fanRPM: 'ファン回転数(RPM)', fanRPM: 'ファン回転数(RPM)',
@@ -1646,7 +1668,6 @@ const message = {
historyAverage: '平均値', historyAverage: '平均値',
historyPeak: '最大値', historyPeak: '最大値',
historyAggregated: '{count} 件のサンプルを {seconds} 秒間隔で集計。プロセスは件数のみ表示します。', historyAggregated: '{count} 件のサンプルを {seconds} 秒間隔で集計。プロセスは件数のみ表示します。',
historyRaw: '元のサンプル {count} 件。',
historyMetricEmpty: '選択した期間にこの指標のデータはありません', historyMetricEmpty: '選択した期間にこの指標のデータはありません',
historyLoadFailed: '履歴の読み込みに失敗しました。再試行してください。', historyLoadFailed: '履歴の読み込みに失敗しました。再試行してください。',
collectedAt: '収集日時', collectedAt: '収集日時',
@@ -1716,8 +1737,8 @@ const message = {
prohibitedHelper: '禁止: プロセスの同時実行は許可されません', prohibitedHelper: '禁止: プロセスの同時実行は許可されません',
migModeHelper: 'MIGインスタンスを作成するために使用され、ユーザーレイヤーでGPUの物理的隔離を実装します。', migModeHelper: 'MIGインスタンスを作成するために使用され、ユーザーレイヤーでGPUの物理的隔離を実装します。',
migModeNA: 'サポートされていません', migModeNA: 'サポートされていません',
current: 'リアルタイム監視', current: 'リアルタイム状態',
history: '履歴記録', history: '監視データ',
notSupport: '現在のバージョンまたはドライバーはこのパラメータの表示をサポートしていません。', notSupport: '現在のバージョンまたはドライバーはこのパラメータの表示をサポートしていません。',
processCount: 'プロセス数', processCount: 'プロセス数',
}, },
@@ -2025,8 +2046,6 @@ const message = {
retry: '選択した測定点を再試行', retry: '選択した測定点を再試行',
environment: '実行環境', environment: '実行環境',
plan: '固定された設定', plan: '固定された設定',
clientHelp:
'クライアントコンテナはメインノードで既存サービスに要求を送信します。推論サービスの起動や GPU の割り当ては不要です。',
retryHelp: '選択した異常な測定点のみ、同じパラメータと新しいシードで再試行します。', retryHelp: '選択した異常な測定点のみ、同じパラメータと新しいシードで再試行します。',
secretHelp: 'コピーに API キーとヘッダーは含まれません。必要に応じて再入力してください。', secretHelp: 'コピーに API キーとヘッダーは含まれません。必要に応じて再入力してください。',
resultHelp: resultHelp:
+31 -12
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: '비밀 키 표시',
hideSecret: '비밀 키 숨기기',
revealHelp: '비밀 키를 안전하게 보관하세요. 표시해도 비활성화되거나 만료된 키는 사용할 수 없습니다.',
deleteConfirm:
'“{0}”을(를) 삭제하시겠습니까? 비밀 키가 파기되고 목록에서 제거됩니다. 새 요청은 거부되고 연결된 터미널은 종료됩니다. 감사 기록은 유지되며 실행 중인 작업은 취소되지 않습니다. 이 작업은 되돌릴 수 없습니다.',
title: 'API Key', title: 'API Key',
manage: 'API Key 관리', manage: 'API Key 관리',
personalEntry: '설정에서 내 키, 접근 조건 및 APP QR 연결을 관리합니다.', personalEntry: '설정에서 내 키, 접근 조건 및 APP QR 연결을 관리합니다.',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'APP QR 연결 허용', allowAppBinding: 'APP QR 연결 허용',
bindingDisableHelp: bindingDisableHelp:
'QR 연결을 비활성화하면 새로운 기기를 연결할 수 없습니다. 이미 연결된 기기는 키가 비활성화, 폐기 또는 만료될 때까지 계속 사용할 수 있습니다.', 'QR 연결을 비활성화하면 새로운 기기를 연결할 수 없습니다. 이미 연결된 기기는 키가 비활성화, 폐기 또는 만료될 때까지 계속 사용할 수 있습니다.',
saveSecret: '지금 키를 복사하여 저장하세요. 닫으면 일반 키 관리에서 전체 키를 다시 표시할 수 없습니다.',
saved: '키를 저장했습니다',
alreadyCreated:
'이 키는 이미 생성되어 다시 표시할 수 없습니다. 원래 응답을 잃었다면 폐기 후 다시 만들거나 허용된 QR 연결을 사용하세요.',
uncertain: uncertain:
'요청 결과를 확인할 수 없습니다. 중복 생성을 피하려면 같은 설정으로 재시도하거나 닫고 목록을 확인하세요.', '요청 결과를 확인할 수 없습니다. 중복 생성을 피하려면 같은 설정으로 재시도하거나 닫고 목록을 확인하세요.',
legacyHelp: legacyHelp:
@@ -1015,6 +1016,22 @@ const message = {
validateImageAvailabilityHelper: '저장 전에 최소 이미지 한 장을 생성하며 비용이 발생할 수 있습니다', validateImageAvailabilityHelper: '저장 전에 최소 이미지 한 장을 생성하며 비용이 발생할 수 있습니다',
}, },
model: { model: {
gpuAcceleration: 'GPU 가속',
gpuMode: '가속기 선택',
gpuAll: '모든 가속기',
gpuCount: '수량 지정',
gpuSpecific: '장치 선택',
gpuAllHelper: '현재 노드에서 사용 가능한 같은 유형의 가속기를 모두 사용합니다.',
gpuCountHelper: '특정 장치에 고정하지 않고 런타임이 지정한 수량의 가속기를 할당합니다.',
gpuSpecificHelper: '사용할 가속기를 직접 선택합니다.',
gpuCountRequired: '0보다 큰 정수를 입력하세요',
gpuDevices: 'GPU 장치',
gpuCapabilitiesHelper:
'GPU 접근은 필수이며, 필요에 따라 모니터링 및 관리(utility)와 연산 지원(compute)을 활성화할 수 있습니다.',
gpuSelectRequired: '사용 가능한 GPU 장치를 하나 이상 선택하세요.',
gpuUnsupported: '지원 안 됨',
gpuNotDetected: '지원되는 가속기가 감지되지 않았습니다',
gpuMissing: '사용 불가',
modelCategory: '모델 카테고리', modelCategory: '모델 카테고리',
modelName: '모델 이름', modelName: '모델 이름',
modelArchitecture: '모델 유형', modelArchitecture: '모델 유형',
@@ -1032,11 +1049,11 @@ const message = {
onlineModel: '온라인 모델', onlineModel: '온라인 모델',
instanceDeployment: '인스턴스별 배포', instanceDeployment: '인스턴스별 배포',
nvidiaDeployment: 'NVIDIA 최적화 배포', nvidiaDeployment: 'NVIDIA 최적화 배포',
engines: '추론 엔진', engines: '로컬 추론 엔진',
modelManagement: '모델 관리', modelManagement: '모델 관리',
serviceDetails: '서비스 상세 정보', serviceDetails: '서비스 상세 정보',
deployments: '로컬 배포', deployments: '로컬 배포',
files: '모델 파일', files: '모델 허브',
connectExternal: '외부 서비스 연결', connectExternal: '외부 서비스 연결',
deploymentTemplates: 'vLLM 시작 명령 템플릿', deploymentTemplates: 'vLLM 시작 명령 템플릿',
engineProvider: '엔진 / 공급자', engineProvider: '엔진 / 공급자',
@@ -1053,7 +1070,7 @@ const message = {
container_conn_helper: '컨테이너 간 접근 또는 연결에 이 주소를 사용', container_conn_helper: '컨테이너 간 접근 또는 연결에 이 주소를 사용',
from_remote: '이 모델은 1Panel을 통해 다운로드되지 않았으며 관련 풀 로그가 없습니다.', from_remote: '이 모델은 1Panel을 통해 다운로드되지 않았으며 관련 풀 로그가 없습니다.',
no_logs: '이 모델의 풀 로그가 삭제되어 관련 로그를 볼 수 없습니다.', no_logs: '이 모델의 풀 로그가 삭제되어 관련 로그를 볼 수 없습니다.',
vllmVersionHelper: 'FusionXpark GB 10 서버는 -cu130 버전을 선택하세요.', vllmVersionHelper: 'FusionXpark GB10 서버는 -cu130 버전을 선택하세요.',
vllmDeployProfile: '배포 모델', vllmDeployProfile: '배포 모델',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1597,6 +1614,11 @@ const message = {
timePerOutputTokenChart: '토큰 간 지연', timePerOutputTokenChart: '토큰 간 지연',
}, },
gpu: { gpu: {
systemMemory: '공유 시스템 메모리',
systemMemoryUsed: '사용 중인 시스템 메모리',
systemMemoryTotal: '전체 시스템 메모리',
systemMemoryAvailable: '사용 가능한 시스템 메모리',
systemMemoryHelper: 'CPU와 GPU가 공유합니다. GPU만의 사용량이 아닌 시스템 전체 메모리 사용량을 표시합니다.',
memoryActivity: '메모리 읽기/쓰기 활성률', memoryActivity: '메모리 읽기/쓰기 활성률',
hotspotTemperature: '핫스폿 온도', hotspotTemperature: '핫스폿 온도',
fanRPM: '팬 회전 속도 (RPM)', fanRPM: '팬 회전 속도 (RPM)',
@@ -1631,7 +1653,6 @@ const message = {
historyAverage: '평균', historyAverage: '평균',
historyPeak: '최댓값', historyPeak: '최댓값',
historyAggregated: '샘플 {count}개를 {seconds}초 간격으로 집계합니다. 프로세스는 개수만 표시합니다.', historyAggregated: '샘플 {count}개를 {seconds}초 간격으로 집계합니다. 프로세스는 개수만 표시합니다.',
historyRaw: '원본 샘플 {count}개.',
historyMetricEmpty: '선택한 기간에 이 지표의 데이터가 없습니다', historyMetricEmpty: '선택한 기간에 이 지표의 데이터가 없습니다',
historyLoadFailed: '기록을 불러오지 못했습니다. 다시 시도하세요.', historyLoadFailed: '기록을 불러오지 못했습니다. 다시 시도하세요.',
collectedAt: '수집 시간', collectedAt: '수집 시간',
@@ -1701,8 +1722,8 @@ const message = {
prohibitedHelper: '금지됨: 프로세스 동시 실행이 허용되지 않음', prohibitedHelper: '금지됨: 프로세스 동시 실행이 허용되지 않음',
migModeHelper: 'MIG 인스턴스를 생성하는 데 사용되며 사용자 레이어에서 GPU의 물리적 격리를 구현합니다.', migModeHelper: 'MIG 인스턴스를 생성하는 데 사용되며 사용자 레이어에서 GPU의 물리적 격리를 구현합니다.',
migModeNA: '지원되지 않음', migModeNA: '지원되지 않음',
current: '실시간 모니터링', current: '실시간 상태',
history: '기록', history: '모니터링 데이터',
notSupport: '현재 버전 또는 드라이버는 이 매개변수 표시를 지원하지 않습니다.', notSupport: '현재 버전 또는 드라이버는 이 매개변수 표시를 지원하지 않습니다.',
processCount: '프로세스 수', processCount: '프로세스 수',
}, },
@@ -2001,8 +2022,6 @@ const message = {
retry: '선택한 측정점 재시도', retry: '선택한 측정점 재시도',
environment: '실행 환경', environment: '실행 환경',
plan: '고정 설정', plan: '고정 설정',
clientHelp:
'클라이언트 컨테이너는 주 노드에서 기존 서비스에 요청을 보냅니다. 추론 서비스를 시작하거나 GPU를 할당하지 않습니다.',
retryHelp: '선택한 비정상 측정점만 같은 매개변수와 새 시드로 재시도합니다.', retryHelp: '선택한 비정상 측정점만 같은 매개변수와 새 시드로 재시도합니다.',
secretHelp: '복사본에는 API 키와 헤더가 없습니다. 필요한 경우 다시 입력하세요.', secretHelp: '복사본에는 API 키와 헤더가 없습니다. 필요한 경우 다시 입력하세요.',
resultHelp: resultHelp:
+31 -11
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: 'ສະແດງຄີລັບ',
hideSecret: 'ເຊື່ອງຄີລັບ',
revealHelp: 'ກະລຸນາເກັບຄີລັບໃຫ້ປອດໄພ. ການສະແດງບໍ່ເຮັດໃຫ້ຄີທີ່ປິດໃຊ້ງານ ຫຼືໝົດອາຍຸກັບມາໃຊ້ໄດ້.',
deleteConfirm:
'ລຶບ “{0}” ຫຼືບໍ່? ຄີລັບຈະຖືກທຳລາຍ ແລະລຶບອອກຈາກລາຍການ. ຄຳຮ້ອງຂໍໃໝ່ຈະຖືກປະຕິເສດ ແລະປິດເທີມິນັລທີ່ກ່ຽວຂ້ອງ. ບັນທຶກການກວດສອບຈະຍັງຄົງຢູ່; ວຽກທີ່ກຳລັງເຮັດຈະບໍ່ຖືກຍົກເລີກ. ບໍ່ສາມາດກູ້ຄືນໄດ້.',
title: 'API Key', title: 'API Key',
manage: 'ຈັດການ API Key', manage: 'ຈັດການ API Key',
personalEntry: 'ຈັດການກະແຈ, ເງື່ອນໄຂເຂົ້າໃຊ້ ແລະ ການຜູກ APP ໃນການຕັ້ງຄ່າ.', personalEntry: 'ຈັດການກະແຈ, ເງື່ອນໄຂເຂົ້າໃຊ້ ແລະ ການຜູກ APP ໃນການຕັ້ງຄ່າ.',
@@ -23,9 +28,6 @@ const message = {
allowAppBinding: 'ອະນຸຍາດຜູກ APP ຜ່ານ QR', allowAppBinding: 'ອະນຸຍາດຜູກ APP ຜ່ານ QR',
bindingDisableHelp: bindingDisableHelp:
'ເມື່ອປິດການຜູກມັດຜ່ານ QR, ລະບົບຈະບໍ່ຮັບການຜູກມັດໃໝ່. ອຸປະກອນທີ່ຜູກມັດແລ້ວຍັງສາມາດໃຊ້ງານໄດ້ ຈົນກວ່າກະແຈຈະຖືກປິດໃຊ້, ຖອນ ຫຼື ໝົດອາຍຸ.', 'ເມື່ອປິດການຜູກມັດຜ່ານ QR, ລະບົບຈະບໍ່ຮັບການຜູກມັດໃໝ່. ອຸປະກອນທີ່ຜູກມັດແລ້ວຍັງສາມາດໃຊ້ງານໄດ້ ຈົນກວ່າກະແຈຈະຖືກປິດໃຊ້, ຖອນ ຫຼື ໝົດອາຍຸ.',
saveSecret: 'ຄັດລອກ ແລະ ບັນທຶກກະແຈດຽວນີ້. ຫຼັງປິດແລ້ວ ການຈັດການປົກກະຕິຈະບໍ່ສະແດງກະແຈເຕັມອີກ.',
saved: 'ຂ້ອຍບັນທຶກກະແຈແລ້ວ',
alreadyCreated: 'ກະແຈນີ້ສ້າງແລ້ວ ແລະ ບໍ່ສະແດງອີກ. ຖ້າຄຳຕອບເກົ່າຫາຍ ໃຫ້ຖອນແລ້ວສ້າງໃໝ່ ຫຼື ໃຊ້ QR ຖ້າອະນຸຍາດ.',
uncertain: 'ຍັງບໍ່ແນ່ໃຈຜົນຄຳຮ້ອງ. ລອງໃໝ່ດ້ວຍຂໍ້ມູນເດີມເພື່ອບໍ່ສ້າງຊ້ຳ ຫຼື ປິດແລ້ວກວດລາຍການກ່ອນສ້າງອີກ.', uncertain: 'ຍັງບໍ່ແນ່ໃຈຜົນຄຳຮ້ອງ. ລອງໃໝ່ດ້ວຍຂໍ້ມູນເດີມເພື່ອບໍ່ສ້າງຊ້ຳ ຫຼື ປິດແລ້ວກວດລາຍການກ່ອນສ້າງອີກ.',
legacyHelp: legacyHelp:
'ກະແຈເກົ່າໃຊ້ເພື່ອຮອງຮັບການເຊື່ອມຕໍ່ API ເກົ່າ. ການເປີດໃຊ້, ປິດໃຊ້ ແລະ ຣີເຊັດ ມີຜົນສະເພາະກະແຈນີ້ເທົ່ານັ້ນ.', 'ກະແຈເກົ່າໃຊ້ເພື່ອຮອງຮັບການເຊື່ອມຕໍ່ API ເກົ່າ. ການເປີດໃຊ້, ປິດໃຊ້ ແລະ ຣີເຊັດ ມີຜົນສະເພາະກະແຈນີ້ເທົ່ານັ້ນ.',
@@ -1011,6 +1013,22 @@ const message = {
validateImageAvailabilityHelper: 'ສ້າງຮູບຂະໜາດນ້ອຍໜຶ່ງຮູບກ່ອນບັນທຶກ ແລະ ອາດມີຄ່າໃຊ້ຈ່າຍ', validateImageAvailabilityHelper: 'ສ້າງຮູບຂະໜາດນ້ອຍໜຶ່ງຮູບກ່ອນບັນທຶກ ແລະ ອາດມີຄ່າໃຊ້ຈ່າຍ',
}, },
model: { model: {
gpuAcceleration: 'ການເລັ່ງ GPU',
gpuMode: 'ເລືອກຕົວເລັ່ງ',
gpuAll: 'ຕົວເລັ່ງທັງໝົດ',
gpuCount: 'ລະບຸຈຳນວນ',
gpuSpecific: 'ເລືອກອຸປະກອນ',
gpuAllHelper: 'ໃຊ້ຕົວເລັ່ງປະເພດດຽວກັນທັງໝົດທີ່ມີໃນໂນດປັດຈຸບັນ.',
gpuCountHelper: 'ລະບົບຈະຈັດສັນຕົວເລັ່ງຕາມຈຳນວນທີ່ລະບຸ ໂດຍບໍ່ຜູກກັບອຸປະກອນສະເພາະ.',
gpuSpecificHelper: 'ເລືອກຕົວເລັ່ງທີ່ຕ້ອງການດ້ວຍຕົນເອງ.',
gpuCountRequired: 'ກະລຸນາປ້ອນຈຳນວນເຕັມທີ່ຫຼາຍກວ່າ 0',
gpuDevices: 'ອຸປະກອນ GPU',
gpuCapabilitiesHelper:
'ຕ້ອງເປີດການເຂົ້າເຖິງ GPU ແລະ ສາມາດເປີດການຕິດຕາມແລະຈັດການ (utility) ແລະ ການຄຳນວນ (compute) ໄດ້ຕາມຕ້ອງການ.',
gpuSelectRequired: 'ກະລຸນາເລືອກ GPU ທີ່ພ້ອມໃຊ້ຢ່າງໜ້ອຍໜຶ່ງອຸປະກອນ',
gpuUnsupported: 'ບໍ່ຮອງຮັບ',
gpuNotDetected: 'ບໍ່ພົບຕົວເລັ່ງທີ່ຮອງຮັບ',
gpuMissing: 'ບໍ່ພ້ອມໃຊ້',
modelCategory: 'ໝວດໝູ່ໂມເດວ', modelCategory: 'ໝວດໝູ່ໂມເດວ',
modelName: 'ຊື່ໂມເດວ', modelName: 'ຊື່ໂມເດວ',
modelArchitecture: 'ປະເພດໂມເດວ', modelArchitecture: 'ປະເພດໂມເດວ',
@@ -1028,11 +1046,11 @@ const message = {
onlineModel: 'ໂມເດວອອນລາຍ', onlineModel: 'ໂມເດວອອນລາຍ',
instanceDeployment: 'ນຳໃຊ້ຕາມອິນສະແຕນ', instanceDeployment: 'ນຳໃຊ້ຕາມອິນສະແຕນ',
nvidiaDeployment: 'ການນຳໃຊ້ທີ່ປັບໃຫ້ເໝາະກັບ NVIDIA', nvidiaDeployment: 'ການນຳໃຊ້ທີ່ປັບໃຫ້ເໝາະກັບ NVIDIA',
engines: 'ເຄື່ອງຈັກອະນຸມານ', engines: 'ເຄື່ອງຈັກອະນຸມານພາຍໃນ',
modelManagement: 'ຈັດການໂມເດວ', modelManagement: 'ຈັດການໂມເດວ',
serviceDetails: 'ລາຍລະອຽດບໍລິການ', serviceDetails: 'ລາຍລະອຽດບໍລິການ',
deployments: 'ການນຳໃຊ້ພາຍໃນເຄື່ອງ', deployments: 'ການນຳໃຊ້ພາຍໃນເຄື່ອງ',
files: 'ໄຟລ໌ໂມເດວ', files: 'ສູນລວມໂມເດວ',
connectExternal: 'ເຊື່ອມຕໍ່ບໍລິການພາຍນອກ', connectExternal: 'ເຊື່ອມຕໍ່ບໍລິການພາຍນອກ',
deploymentTemplates: 'ແມ່ແບບຄຳສັ່ງເລີ່ມຕົ້ນ vLLM', deploymentTemplates: 'ແມ່ແບບຄຳສັ່ງເລີ່ມຕົ້ນ vLLM',
engineProvider: 'ເຄື່ອງຈັກ / ຜູ້ໃຫ້ບໍລິການ', engineProvider: 'ເຄື່ອງຈັກ / ຜູ້ໃຫ້ບໍລິການ',
@@ -1049,7 +1067,7 @@ const message = {
container_conn_helper: 'ໃຊ້ທີ່ຢູ່ນີ້ສຳລັບການເຂົ້າເຖິງ ຫຼື ເຊື່ອມຕໍ່ລະຫວ່າງຄອນເທນເນີ', container_conn_helper: 'ໃຊ້ທີ່ຢູ່ນີ້ສຳລັບການເຂົ້າເຖິງ ຫຼື ເຊື່ອມຕໍ່ລະຫວ່າງຄອນເທນເນີ',
from_remote: 'ໂມເດວນີ້ບໍ່ໄດ້ດາວໂຫຼດຜ່ານ 1Panel, ບໍ່ມີລັອກການດຶງຂໍ້ມູນທີ່ກ່ຽວຂ້ອງ.', from_remote: 'ໂມເດວນີ້ບໍ່ໄດ້ດາວໂຫຼດຜ່ານ 1Panel, ບໍ່ມີລັອກການດຶງຂໍ້ມູນທີ່ກ່ຽວຂ້ອງ.',
no_logs: 'ລັອກການດຶງຂໍ້ມູນຂອງໂມເດວນີ້ຖືກລຶບແລ້ວ ແລະ ບໍ່ສາມາດເບິ່ງໄດ້.', no_logs: 'ລັອກການດຶງຂໍ້ມູນຂອງໂມເດວນີ້ຖືກລຶບແລ້ວ ແລະ ບໍ່ສາມາດເບິ່ງໄດ້.',
vllmVersionHelper: 'ສຳລັບເຊີເວີ FusionXpark GB 10, ກະລຸນາເລືອກເວີຊັນ -cu130.', vllmVersionHelper: 'ສຳລັບເຊີເວີ FusionXpark GB10, ກະລຸນາເລືອກເວີຊັນ -cu130.',
vllmDeployProfile: 'ໂມເດວສຳລັບຕິດຕັ້ງ', vllmDeployProfile: 'ໂມເດວສຳລັບຕິດຕັ້ງ',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1588,6 +1606,11 @@ const message = {
timePerOutputTokenChart: 'ເວລາລໍຖ້າລະຫວ່າງ Token', timePerOutputTokenChart: 'ເວລາລໍຖ້າລະຫວ່າງ Token',
}, },
gpu: { gpu: {
systemMemory: 'ໜ່ວຍຄວາມຈຳລະບົບທີ່ໃຊ້ຮ່ວມກັນ',
systemMemoryUsed: 'ໜ່ວຍຄວາມຈຳລະບົບທີ່ໃຊ້ແລ້ວ',
systemMemoryTotal: 'ໜ່ວຍຄວາມຈຳລະບົບທັງໝົດ',
systemMemoryAvailable: 'ໜ່ວຍຄວາມຈຳລະບົບທີ່ພ້ອມໃຊ້',
systemMemoryHelper: 'CPU ແລະ GPU ໃຊ້ຮ່ວມກັນ. ສະແດງການໃຊ້ໜ່ວຍຄວາມຈຳຂອງທັງລະບົບ ບໍ່ແມ່ນສະເພາະ GPU.',
memoryActivity: 'ກິດຈະກຳອ່ານ/ຂຽນໜ່ວຍຄວາມຈຳ', memoryActivity: 'ກິດຈະກຳອ່ານ/ຂຽນໜ່ວຍຄວາມຈຳ',
hotspotTemperature: 'ອຸນຫະພູມຈຸດຮ້ອນ', hotspotTemperature: 'ອຸນຫະພູມຈຸດຮ້ອນ',
fanRPM: 'ຄວາມໄວພັດລົມ (RPM)', fanRPM: 'ຄວາມໄວພັດລົມ (RPM)',
@@ -1622,7 +1645,6 @@ const message = {
historyAverage: 'ຄ່າສະເລ່ຍ', historyAverage: 'ຄ່າສະເລ່ຍ',
historyPeak: 'ຄ່າສູງສຸດ', historyPeak: 'ຄ່າສູງສຸດ',
historyAggregated: 'ລວມ {count} ຕົວຢ່າງເປັນຊ່ວງ {seconds} ວິນາທີ; ສະແດງພຽງຈຳນວນໂປຣເຊສ.', historyAggregated: 'ລວມ {count} ຕົວຢ່າງເປັນຊ່ວງ {seconds} ວິນາທີ; ສະແດງພຽງຈຳນວນໂປຣເຊສ.',
historyRaw: '{count} ຕົວຢ່າງຕົ້ນສະບັບ.',
historyMetricEmpty: 'ບໍ່ມີຂໍ້ມູນຕົວຊີ້ວັດນີ້ໃນຊ່ວງເວລາທີ່ເລືອກ', historyMetricEmpty: 'ບໍ່ມີຂໍ້ມູນຕົວຊີ້ວັດນີ້ໃນຊ່ວງເວລາທີ່ເລືອກ',
historyLoadFailed: 'ໂຫຼດປະຫວັດບໍ່ສຳເລັດ. ກະລຸນາລອງໃໝ່.', historyLoadFailed: 'ໂຫຼດປະຫວັດບໍ່ສຳເລັດ. ກະລຸນາລອງໃໝ່.',
collectedAt: 'ເວລາເກັບຂໍ້ມູນ', collectedAt: 'ເວລາເກັບຂໍ້ມູນ',
@@ -1692,8 +1714,8 @@ const message = {
prohibitedHelper: 'ຫ້າມ: ບໍ່ອະນຸຍາດໃຫ້ລັນໂປຣເຊສພ້ອມກັນ', prohibitedHelper: 'ຫ້າມ: ບໍ່ອະນຸຍາດໃຫ້ລັນໂປຣເຊສພ້ອມກັນ',
migModeHelper: 'ໃຊ້ສຳລັບສ້າງ MIG instances ເພື່ອແຍກສ່ວນ GPU ທາງກາຍະພາບ.', migModeHelper: 'ໃຊ້ສຳລັບສ້າງ MIG instances ເພື່ອແຍກສ່ວນ GPU ທາງກາຍະພາບ.',
migModeNA: 'ບໍ່ຮອງຮັບ', migModeNA: 'ບໍ່ຮອງຮັບ',
current: 'ຕິດຕາມແບບ Real-time', current: 'ສະຖານະແບບທັນທີ',
history: 'ບັນທຶກຍ້ອນຫຼັງ', history: 'ຂໍ້ມູນການຕິດຕາມ',
notSupport: 'ເວີຊັນ ຫຼື driver ປັດຈຸບັນບໍ່ຮອງຮັບການສະແດງຄ່ານີ້.', notSupport: 'ເວີຊັນ ຫຼື driver ປັດຈຸບັນບໍ່ຮອງຮັບການສະແດງຄ່ານີ້.',
}, },
mcp: { mcp: {
@@ -1989,8 +2011,6 @@ const message = {
retry: 'ທົດສອບຈຸດທີ່ເລືອກຄືນ', retry: 'ທົດສອບຈຸດທີ່ເລືອກຄືນ',
environment: 'ສະພາບແວດລ້ອມ', environment: 'ສະພາບແວດລ້ອມ',
plan: 'ການຕັ້ງຄ່າຄົງທີ່', plan: 'ການຕັ້ງຄ່າຄົງທີ່',
clientHelp:
'ຄອນເທນເນີລູກຂ່າຍເຮັດວຽກຢູ່ໂນດຫຼັກ ແລະ ສົ່ງຄຳຮ້ອງຂໍໄປບໍລິການທີ່ມີແລ້ວ. ບໍ່ເລີ່ມບໍລິການອະນຸມານ ແລະ ບໍ່ຕ້ອງການ GPU.',
retryHelp: 'ທົດສອບຄືນສະເພາະຈຸດຜິດປົກກະຕິທີ່ເລືອກ ດ້ວຍພາລາມິເຕີເດີມ ແລະ ຄ່າສຸ່ມໃໝ່.', retryHelp: 'ທົດສອບຄືນສະເພາະຈຸດຜິດປົກກະຕິທີ່ເລືອກ ດ້ວຍພາລາມິເຕີເດີມ ແລະ ຄ່າສຸ່ມໃໝ່.',
secretHelp: 'ສຳເນົາບໍ່ມີລະຫັດ API ແລະ ສ່ວນຫົວ. ກະລຸນາປ້ອນໃໝ່ຖ້າຈຳເປັນ.', secretHelp: 'ສຳເນົາບໍ່ມີລະຫັດ API ແລະ ສ່ວນຫົວ. ກະລຸນາປ້ອນໃໝ່ຖ້າຈຳເປັນ.',
resultHelp: resultHelp:
+33 -13
View File
@@ -2,6 +2,12 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: 'Papar rahsia',
hideSecret: 'Sembunyikan rahsia',
revealHelp:
'Simpan rahsia ini dengan selamat. Memaparkannya tidak mengaktifkan semula kunci yang dinyahdayakan atau tamat tempoh.',
deleteConfirm:
'Padam “{0}”? Rahsianya akan dimusnahkan dan kunci dibuang daripada senarai. Permintaan baharu akan ditolak dan terminal berkaitan ditutup. Rekod audit dikekalkan; tugas yang sedang berjalan tidak dibatalkan. Tindakan ini tidak boleh dibuat asal.',
title: 'API Key', title: 'API Key',
manage: 'Urus kunci API', manage: 'Urus kunci API',
personalEntry: 'Urus kunci, syarat akses dan pautan APP anda dalam Tetapan.', personalEntry: 'Urus kunci, syarat akses dan pautan APP anda dalam Tetapan.',
@@ -23,11 +29,6 @@ const message = {
allowAppBinding: 'Benarkan pautan APP melalui QR', allowAppBinding: 'Benarkan pautan APP melalui QR',
bindingDisableHelp: bindingDisableHelp:
'Menyahdayakan pautan QR menghalang pautan peranti baharu. Peranti yang telah dipautkan boleh terus digunakan sehingga kunci dinyahdayakan, dibatalkan atau tamat tempoh.', 'Menyahdayakan pautan QR menghalang pautan peranti baharu. Peranti yang telah dipautkan boleh terus digunakan sehingga kunci dinyahdayakan, dibatalkan atau tamat tempoh.',
saveSecret:
'Salin dan simpan kunci sekarang. Selepas ditutup, pengurusan biasa tidak dapat memaparkan kunci penuh lagi.',
saved: 'Saya telah menyimpan kunci',
alreadyCreated:
'Kunci ini telah dicipta dan tidak akan dipaparkan semula. Jika respons asal hilang, batalkan dan cipta semula atau gunakan QR jika dibenarkan.',
uncertain: uncertain:
'Hasil permintaan belum pasti. Cuba semula dengan butiran sama untuk mengelakkan pendua, atau tutup dan semak senarai sebelum mencipta lagi.', 'Hasil permintaan belum pasti. Cuba semula dengan butiran sama untuk mengelakkan pendua, atau tutup dan semak senarai sebelum mencipta lagi.',
legacyHelp: legacyHelp:
@@ -1047,6 +1048,22 @@ const message = {
validateImageAvailabilityHelper: 'Menjana satu imej minimum sebelum menyimpan dan mungkin dikenakan caj', validateImageAvailabilityHelper: 'Menjana satu imej minimum sebelum menyimpan dan mungkin dikenakan caj',
}, },
model: { model: {
gpuAcceleration: 'Pecutan GPU',
gpuMode: 'Pemilihan pemecut',
gpuAll: 'Semua pemecut',
gpuCount: 'Nyatakan bilangan',
gpuSpecific: 'Pilih peranti',
gpuAllHelper: 'Gunakan semua pemecut jenis ini yang tersedia pada nod semasa.',
gpuCountHelper: 'Masa jalan memperuntukkan bilangan pemecut yang diminta tanpa mengikat peranti tertentu.',
gpuSpecificHelper: 'Pilih pemecut tertentu secara manual.',
gpuCountRequired: 'Masukkan integer yang lebih besar daripada 0',
gpuDevices: 'Peranti GPU',
gpuCapabilitiesHelper:
'Akses GPU diperlukan; dayakan pemantauan dan pengurusan (utility) serta sokongan pengiraan (compute) mengikut keperluan.',
gpuSelectRequired: 'Pilih sekurang-kurangnya satu peranti GPU yang tersedia.',
gpuUnsupported: 'Tidak disokong',
gpuNotDetected: 'Tiada pemecut yang disokong dikesan',
gpuMissing: 'Tidak tersedia',
modelCategory: 'Kategori model', modelCategory: 'Kategori model',
modelName: 'Nama model', modelName: 'Nama model',
modelArchitecture: 'Jenis model', modelArchitecture: 'Jenis model',
@@ -1064,11 +1081,11 @@ const message = {
onlineModel: 'Model dalam talian', onlineModel: 'Model dalam talian',
instanceDeployment: 'Pengerahan mengikut instans', instanceDeployment: 'Pengerahan mengikut instans',
nvidiaDeployment: 'Pengerahan dioptimumkan untuk NVIDIA', nvidiaDeployment: 'Pengerahan dioptimumkan untuk NVIDIA',
engines: 'Enjin inferens', engines: 'Enjin inferens setempat',
modelManagement: 'Pengurusan model', modelManagement: 'Pengurusan model',
serviceDetails: 'Butiran perkhidmatan', serviceDetails: 'Butiran perkhidmatan',
deployments: 'Pengerahan setempat', deployments: 'Pengerahan setempat',
files: 'Fail model', files: 'Hab model',
connectExternal: 'Sambungkan perkhidmatan luaran', connectExternal: 'Sambungkan perkhidmatan luaran',
deploymentTemplates: 'Templat arahan permulaan vLLM', deploymentTemplates: 'Templat arahan permulaan vLLM',
engineProvider: 'Enjin / Pembekal', engineProvider: 'Enjin / Pembekal',
@@ -1085,7 +1102,7 @@ const message = {
container_conn_helper: 'Gunakan alamat ini untuk akses atau sambungan antara kontena', container_conn_helper: 'Gunakan alamat ini untuk akses atau sambungan antara kontena',
from_remote: 'Model ini tidak dimuat turun melalui 1Panel; tiada log muat turun berkaitan.', from_remote: 'Model ini tidak dimuat turun melalui 1Panel; tiada log muat turun berkaitan.',
no_logs: 'Log muat turun model ini telah dipadam dan tidak boleh dilihat.', no_logs: 'Log muat turun model ini telah dipadam dan tidak boleh dilihat.',
vllmVersionHelper: 'Untuk pelayan FusionXpark GB 10, sila pilih versi -cu130.', vllmVersionHelper: 'Untuk pelayan FusionXpark GB10, sila pilih versi -cu130.',
vllmDeployProfile: 'Model penggunaan', vllmDeployProfile: 'Model penggunaan',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1638,6 +1655,12 @@ const message = {
timePerOutputTokenChart: 'Kependaman antara token', timePerOutputTokenChart: 'Kependaman antara token',
}, },
gpu: { gpu: {
systemMemory: 'Memori sistem dikongsi',
systemMemoryUsed: 'Memori sistem digunakan',
systemMemoryTotal: 'Jumlah memori sistem',
systemMemoryAvailable: 'Memori sistem tersedia',
systemMemoryHelper:
'Dikongsi oleh CPU dan GPU. Memaparkan penggunaan memori seluruh sistem, bukan GPU sahaja.',
memoryActivity: 'Aktiviti baca/tulis memori', memoryActivity: 'Aktiviti baca/tulis memori',
hotspotTemperature: 'Suhu titik panas', hotspotTemperature: 'Suhu titik panas',
fanRPM: 'Kelajuan kipas (RPM)', fanRPM: 'Kelajuan kipas (RPM)',
@@ -1673,7 +1696,6 @@ const message = {
historyPeak: 'Puncak', historyPeak: 'Puncak',
historyAggregated: historyAggregated:
'{count} sampel diagregatkan dalam sela {seconds} saat; proses hanya memaparkan bilangan.', '{count} sampel diagregatkan dalam sela {seconds} saat; proses hanya memaparkan bilangan.',
historyRaw: '{count} sampel asal.',
historyMetricEmpty: 'Tiada data untuk metrik ini dalam julat masa yang dipilih', historyMetricEmpty: 'Tiada data untuk metrik ini dalam julat masa yang dipilih',
historyLoadFailed: 'Gagal memuatkan sejarah. Sila cuba lagi.', historyLoadFailed: 'Gagal memuatkan sejarah. Sila cuba lagi.',
collectedAt: 'Masa pengumpulan', collectedAt: 'Masa pengumpulan',
@@ -1746,8 +1768,8 @@ const message = {
migModeHelper: migModeHelper:
'Digunakan untuk mencipta contoh MIG, melaksanakan pengasingan fizikal GPU pada lapisan pengguna.', 'Digunakan untuk mencipta contoh MIG, melaksanakan pengasingan fizikal GPU pada lapisan pengguna.',
migModeNA: 'Tidak Disokong', migModeNA: 'Tidak Disokong',
current: 'Pemantauan Masa Nyata', current: 'Status Masa Nyata',
history: 'Rekod Sejarah', history: 'Data Pemantauan',
notSupport: 'Versi atau pemacu semasa tidak menyokong paparan parameter ini.', notSupport: 'Versi atau pemacu semasa tidak menyokong paparan parameter ini.',
processCount: 'Bilangan proses', processCount: 'Bilangan proses',
}, },
@@ -2057,8 +2079,6 @@ const message = {
retry: 'Ulang titik terpilih', retry: 'Ulang titik terpilih',
environment: 'Persekitaran', environment: 'Persekitaran',
plan: 'Konfigurasi dibekukan', plan: 'Konfigurasi dibekukan',
clientHelp:
'Kontena klien berjalan pada nod utama dan menghantar permintaan kepada perkhidmatan sedia ada. Ia tidak memulakan inferens atau memerlukan GPU.',
retryHelp: 'Hanya titik bermasalah yang dipilih diulang, dengan parameter sama dan benih baharu.', retryHelp: 'Hanya titik bermasalah yang dipilih diulang, dengan parameter sama dan benih baharu.',
secretHelp: 'Salinan tidak mengandungi kunci API atau pengepala. Masukkan semula jika perlu.', secretHelp: 'Salinan tidak mengandungi kunci API atau pengepala. Masukkan semula jika perlu.',
resultHelp: resultHelp:
+33 -12
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: 'Mostrar segredo',
hideSecret: 'Ocultar segredo',
revealHelp: 'Guarde o segredo com segurança. Exibi-lo não reativa chaves desabilitadas ou expiradas.',
deleteConfirm:
'Excluir “{0}”? O segredo será destruído e a chave removida da lista. Novas solicitações serão rejeitadas e os terminais associados serão fechados. Os registros de auditoria serão mantidos; tarefas em execução não serão canceladas. Esta ação é irreversível.',
title: 'API Key', title: 'API Key',
manage: 'Gerenciar chaves API', manage: 'Gerenciar chaves API',
personalEntry: 'Gerencie suas chaves, condições de acesso e vínculo do APP nas Configurações.', personalEntry: 'Gerencie suas chaves, condições de acesso e vínculo do APP nas Configurações.',
@@ -23,10 +28,6 @@ const message = {
allowAppBinding: 'Permitir vínculo do APP por QR', allowAppBinding: 'Permitir vínculo do APP por QR',
bindingDisableHelp: bindingDisableHelp:
'Desativar a vinculação por QR impede novas vinculações. Os dispositivos já vinculados podem continuar usando a chave até que ela seja desativada, revogada ou expire.', 'Desativar a vinculação por QR impede novas vinculações. Os dispositivos já vinculados podem continuar usando a chave até que ela seja desativada, revogada ou expire.',
saveSecret: 'Copie e salve a chave agora. Após fechar, o gerenciamento normal não poderá exibi-la novamente.',
saved: 'Salvei a chave',
alreadyCreated:
'Esta chave já foi criada e não será exibida novamente. Se a resposta original foi perdida, revogue e recrie a chave ou use o QR, se permitido.',
uncertain: uncertain:
'O resultado é incerto. Tente novamente com os mesmos dados para evitar duplicações ou feche e verifique a lista antes de criar outra.', 'O resultado é incerto. Tente novamente com os mesmos dados para evitar duplicações ou feche e verifique a lista antes de criar outra.',
legacyHelp: legacyHelp:
@@ -1042,6 +1043,23 @@ const message = {
validateImageAvailabilityHelper: 'Gera uma imagem mínima antes de salvar e pode gerar custos', validateImageAvailabilityHelper: 'Gera uma imagem mínima antes de salvar e pode gerar custos',
}, },
model: { model: {
gpuAcceleration: 'Aceleração de GPU',
gpuMode: 'Seleção de aceleradores',
gpuAll: 'Todos os aceleradores',
gpuCount: 'Especificar quantidade',
gpuSpecific: 'Selecionar dispositivos',
gpuAllHelper: 'Usar todos os aceleradores disponíveis deste tipo no nó atual.',
gpuCountHelper:
'O ambiente de execução aloca a quantidade solicitada sem vincular dispositivos específicos.',
gpuSpecificHelper: 'Selecione manualmente os aceleradores específicos.',
gpuCountRequired: 'Insira um número inteiro maior que 0',
gpuDevices: 'Dispositivos GPU',
gpuCapabilitiesHelper:
'O acesso à GPU é obrigatório; habilite o monitoramento e gerenciamento (utility) e o suporte a cálculos (compute) conforme necessário.',
gpuSelectRequired: 'Selecione pelo menos um dispositivo GPU disponível.',
gpuUnsupported: 'Não suportado',
gpuNotDetected: 'Nenhum acelerador compatível detectado',
gpuMissing: 'Indisponível',
modelCategory: 'Categoria do modelo', modelCategory: 'Categoria do modelo',
modelName: 'Nome do modelo', modelName: 'Nome do modelo',
modelArchitecture: 'Tipo de modelo', modelArchitecture: 'Tipo de modelo',
@@ -1059,11 +1077,11 @@ const message = {
onlineModel: 'Modelos online', onlineModel: 'Modelos online',
instanceDeployment: 'Implantação por instância', instanceDeployment: 'Implantação por instância',
nvidiaDeployment: 'Implantação otimizada para NVIDIA', nvidiaDeployment: 'Implantação otimizada para NVIDIA',
engines: 'Mecanismos de inferência', engines: 'Mecanismos de inferência locais',
modelManagement: 'Gerenciamento de modelos', modelManagement: 'Gerenciamento de modelos',
serviceDetails: 'Detalhes do serviço', serviceDetails: 'Detalhes do serviço',
deployments: 'Implantações locais', deployments: 'Implantações locais',
files: 'Arquivos de modelos', files: 'Central de modelos',
connectExternal: 'Conectar serviço externo', connectExternal: 'Conectar serviço externo',
deploymentTemplates: 'Modelos de comandos de inicialização do vLLM', deploymentTemplates: 'Modelos de comandos de inicialização do vLLM',
engineProvider: 'Mecanismo / Provedor', engineProvider: 'Mecanismo / Provedor',
@@ -1080,7 +1098,7 @@ const message = {
container_conn_helper: 'Use este endereço para acesso ou conexão entre contêineres', container_conn_helper: 'Use este endereço para acesso ou conexão entre contêineres',
from_remote: 'Este modelo não foi baixado pelo 1Panel; não há logs de download relacionados.', from_remote: 'Este modelo não foi baixado pelo 1Panel; não há logs de download relacionados.',
no_logs: 'Os logs de download deste modelo foram excluídos e não podem ser visualizados.', no_logs: 'Os logs de download deste modelo foram excluídos e não podem ser visualizados.',
vllmVersionHelper: 'Para servidores FusionXpark GB 10, selecione a versão -cu130.', vllmVersionHelper: 'Para servidores FusionXpark GB10, selecione a versão -cu130.',
vllmDeployProfile: 'Modelo de implantação', vllmDeployProfile: 'Modelo de implantação',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1636,6 +1654,12 @@ const message = {
timePerOutputTokenChart: 'Latência entre tokens', timePerOutputTokenChart: 'Latência entre tokens',
}, },
gpu: { gpu: {
systemMemory: 'Memória compartilhada do sistema',
systemMemoryUsed: 'Memória do sistema em uso',
systemMemoryTotal: 'Memória total do sistema',
systemMemoryAvailable: 'Memória disponível do sistema',
systemMemoryHelper:
'Compartilhada pela CPU e GPU. Exibe o uso de memória de todo o sistema, não apenas da GPU.',
memoryActivity: 'Atividade de leitura/escrita da memória', memoryActivity: 'Atividade de leitura/escrita da memória',
hotspotTemperature: 'Temperatura do ponto quente', hotspotTemperature: 'Temperatura do ponto quente',
fanRPM: 'Rotação da ventoinha (RPM)', fanRPM: 'Rotação da ventoinha (RPM)',
@@ -1671,7 +1695,6 @@ const message = {
historyPeak: 'Pico', historyPeak: 'Pico',
historyAggregated: historyAggregated:
'{count} amostras agregadas em intervalos de {seconds} segundos; processos exibem apenas contagens.', '{count} amostras agregadas em intervalos de {seconds} segundos; processos exibem apenas contagens.',
historyRaw: '{count} amostras originais.',
historyMetricEmpty: 'Sem dados desta métrica no período selecionado', historyMetricEmpty: 'Sem dados desta métrica no período selecionado',
historyLoadFailed: 'Falha ao carregar o histórico. Tente novamente.', historyLoadFailed: 'Falha ao carregar o histórico. Tente novamente.',
collectedAt: 'Data da coleta', collectedAt: 'Data da coleta',
@@ -1745,8 +1768,8 @@ const message = {
migModeHelper: migModeHelper:
'Usado para criar instâncias MIG, implementando isolamento físico da GPU na camada do usuário.', 'Usado para criar instâncias MIG, implementando isolamento físico da GPU na camada do usuário.',
migModeNA: 'Não Suportado', migModeNA: 'Não Suportado',
current: 'Monitoramento em Tempo Real', current: 'Status em tempo real',
history: 'Registros Históricos', history: 'Dados de monitoramento',
notSupport: 'A versão atual ou o driver não suportam exibir este parâmetro.', notSupport: 'A versão atual ou o driver não suportam exibir este parâmetro.',
processCount: 'Qtd. de processos', processCount: 'Qtd. de processos',
}, },
@@ -2058,8 +2081,6 @@ const message = {
retry: 'Repetir pontos selecionados', retry: 'Repetir pontos selecionados',
environment: 'Ambiente', environment: 'Ambiente',
plan: 'Configuração fixada', plan: 'Configuração fixada',
clientHelp:
'O contêiner cliente roda no nó principal e envia requisições a um serviço existente. Não inicia inferência nem exige GPU.',
retryHelp: 'Apenas os pontos anormais selecionados são repetidos, com os mesmos parâmetros e novas sementes.', retryHelp: 'Apenas os pontos anormais selecionados são repetidos, com os mesmos parâmetros e novas sementes.',
secretHelp: 'As cópias não incluem chaves de API nem cabeçalhos. Informe-os novamente se necessário.', secretHelp: 'As cópias não incluem chaves de API nem cabeçalhos. Informe-os novamente se necessário.',
resultHelp: resultHelp:
+33 -13
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: 'Показать секрет',
hideSecret: 'Скрыть секрет',
revealHelp: 'Храните секрет в безопасности. Просмотр не активирует отключённые или просроченные ключи.',
deleteConfirm:
'Удалить «{0}»? Секрет будет уничтожен, а ключ удалён из списка. Новые запросы будут отклонены, связанные терминалы закрыты. Журнал аудита сохранится; выполняющиеся задачи не отменяются. Действие необратимо.',
title: 'API Key', title: 'API Key',
manage: 'Управление API-ключами', manage: 'Управление API-ключами',
personalEntry: 'Управляйте своими ключами, условиями доступа и привязкой приложения в настройках.', personalEntry: 'Управляйте своими ключами, условиями доступа и привязкой приложения в настройках.',
@@ -23,11 +28,6 @@ const message = {
allowAppBinding: 'Разрешить привязку приложения по QR', allowAppBinding: 'Разрешить привязку приложения по QR',
bindingDisableHelp: bindingDisableHelp:
'Отключение привязки по QR-коду запрещает новые привязки. Уже привязанные устройства могут продолжать работу, пока ключ не будет отключён, отозван или не истечёт срок его действия.', 'Отключение привязки по QR-коду запрещает новые привязки. Уже привязанные устройства могут продолжать работу, пока ключ не будет отключён, отозван или не истечёт срок его действия.',
saveSecret:
'Скопируйте и сохраните ключ сейчас. После закрытия обычное управление не сможет показать полный ключ снова.',
saved: 'Ключ сохранён',
alreadyCreated:
'Ключ уже создан и не будет показан повторно. Если исходный ответ потерян, отзовите и создайте ключ заново либо используйте разрешённую привязку по QR.',
uncertain: uncertain:
'Результат запроса неизвестен. Повторите с теми же данными, чтобы избежать дублей, или закройте и проверьте список.', 'Результат запроса неизвестен. Повторите с теми же данными, чтобы избежать дублей, или закройте и проверьте список.',
legacyHelp: legacyHelp:
@@ -1036,6 +1036,23 @@ const message = {
'Перед сохранением создаётся одно минимальное изображение; возможна оплата', 'Перед сохранением создаётся одно минимальное изображение; возможна оплата',
}, },
model: { model: {
gpuAcceleration: 'Ускорение GPU',
gpuMode: 'Выбор ускорителей',
gpuAll: 'Все ускорители',
gpuCount: 'Указать количество',
gpuSpecific: 'Выбрать устройства',
gpuAllHelper: 'Использовать все доступные ускорители этого типа на текущем узле.',
gpuCountHelper:
'Среда выполнения выделяет указанное количество ускорителей без привязки к конкретным устройствам.',
gpuSpecificHelper: 'Выберите конкретные ускорители вручную.',
gpuCountRequired: 'Введите целое число больше 0',
gpuDevices: 'Устройства GPU',
gpuCapabilitiesHelper:
'Доступ к GPU обязателен; при необходимости включите мониторинг и управление (utility) и поддержку вычислений (compute).',
gpuSelectRequired: 'Выберите хотя бы одно доступное устройство GPU.',
gpuUnsupported: 'Не поддерживается',
gpuNotDetected: 'Поддерживаемые ускорители не обнаружены',
gpuMissing: 'Недоступно',
modelCategory: 'Категория модели', modelCategory: 'Категория модели',
modelName: 'Название модели', modelName: 'Название модели',
modelArchitecture: 'Тип модели', modelArchitecture: 'Тип модели',
@@ -1053,11 +1070,11 @@ const message = {
onlineModel: 'Онлайн-модели', onlineModel: 'Онлайн-модели',
instanceDeployment: 'Развертывание по экземплярам', instanceDeployment: 'Развертывание по экземплярам',
nvidiaDeployment: 'Развертывание с оптимизацией для NVIDIA', nvidiaDeployment: 'Развертывание с оптимизацией для NVIDIA',
engines: 'Движки инференса', engines: 'Локальные движки инференса',
modelManagement: 'Управление моделями', modelManagement: 'Управление моделями',
serviceDetails: 'Сведения о сервисе', serviceDetails: 'Сведения о сервисе',
deployments: 'Локальные развертывания', deployments: 'Локальные развертывания',
files: 'Файлы моделей', files: 'Каталог моделей',
connectExternal: 'Подключить внешний сервис', connectExternal: 'Подключить внешний сервис',
deploymentTemplates: 'Шаблоны команд запуска vLLM', deploymentTemplates: 'Шаблоны команд запуска vLLM',
engineProvider: 'Движок / Поставщик', engineProvider: 'Движок / Поставщик',
@@ -1074,7 +1091,7 @@ const message = {
container_conn_helper: 'Используйте этот адрес для доступа или подключения между контейнерами', container_conn_helper: 'Используйте этот адрес для доступа или подключения между контейнерами',
from_remote: 'Эта модель не была загружена через 1Panel, нет связанных журналов извлечения.', from_remote: 'Эта модель не была загружена через 1Panel, нет связанных журналов извлечения.',
no_logs: 'Журналы извлечения для этой модели были удалены и не могут быть просмотрены.', no_logs: 'Журналы извлечения для этой модели были удалены и не могут быть просмотрены.',
vllmVersionHelper: 'Для серверов FusionXpark GB 10 выберите версию -cu130.', vllmVersionHelper: 'Для серверов FusionXpark GB10 выберите версию -cu130.',
vllmDeployProfile: 'Модель развертывания', vllmDeployProfile: 'Модель развертывания',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1627,6 +1644,12 @@ const message = {
timePerOutputTokenChart: 'Задержка между токенами', timePerOutputTokenChart: 'Задержка между токенами',
}, },
gpu: { gpu: {
systemMemory: 'Общая системная память',
systemMemoryUsed: 'Использовано системной памяти',
systemMemoryTotal: 'Всего системной памяти',
systemMemoryAvailable: 'Доступная системная память',
systemMemoryHelper:
'Память совместно используется CPU и GPU. Показано потребление всей системы, а не только GPU.',
memoryActivity: 'Активность чтения/записи памяти', memoryActivity: 'Активность чтения/записи памяти',
hotspotTemperature: 'Температура горячей точки', hotspotTemperature: 'Температура горячей точки',
fanRPM: 'Скорость вентилятора (об/мин)', fanRPM: 'Скорость вентилятора (об/мин)',
@@ -1662,7 +1685,6 @@ const message = {
historyPeak: 'Максимум', historyPeak: 'Максимум',
historyAggregated: historyAggregated:
'{count} измерений сгруппированы по {seconds} с; для процессов показано только количество.', '{count} измерений сгруппированы по {seconds} с; для процессов показано только количество.',
historyRaw: 'Исходных измерений: {count}.',
historyMetricEmpty: 'Нет данных этой метрики за выбранный период', historyMetricEmpty: 'Нет данных этой метрики за выбранный период',
historyLoadFailed: 'Не удалось загрузить историю. Повторите попытку.', historyLoadFailed: 'Не удалось загрузить историю. Повторите попытку.',
collectedAt: 'Время сбора', collectedAt: 'Время сбора',
@@ -1735,8 +1757,8 @@ const message = {
migModeHelper: migModeHelper:
'Используется для создания экземпляров MIG, реализуя физическую изоляцию GPU на пользовательском уровне.', 'Используется для создания экземпляров MIG, реализуя физическую изоляцию GPU на пользовательском уровне.',
migModeNA: 'Не Поддерживается', migModeNA: 'Не Поддерживается',
current: 'Мониторинг в Реальном Времени', current: 'Состояние в реальном времени',
history: 'Исторические Записи', history: 'Данные мониторинга',
notSupport: 'Текущая версия или драйвер не поддерживает отображение этого параметра.', notSupport: 'Текущая версия или драйвер не поддерживает отображение этого параметра.',
processCount: 'Кол-во процессов', processCount: 'Кол-во процессов',
}, },
@@ -2052,8 +2074,6 @@ const message = {
retry: 'Повторить выбранные точки', retry: 'Повторить выбранные точки',
environment: 'Среда выполнения', environment: 'Среда выполнения',
plan: 'Зафиксированная конфигурация', plan: 'Зафиксированная конфигурация',
clientHelp:
'Клиентский контейнер работает на главном узле и отправляет запросы существующему сервису. Он не запускает модель и не требует GPU.',
retryHelp: 'Повторяются только выбранные проблемные точки с прежними параметрами и новыми начальными числами.', retryHelp: 'Повторяются только выбранные проблемные точки с прежними параметрами и новыми начальными числами.',
secretHelp: 'Копии не содержат ключи API и заголовки. При необходимости введите их заново.', secretHelp: 'Копии не содержат ключи API и заголовки. При необходимости введите их заново.',
resultHelp: resultHelp:
+33 -13
View File
@@ -2,6 +2,12 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: 'Gizli anahtarı göster',
hideSecret: 'Gizli anahtarı gizle',
revealHelp:
'Gizli anahtarı güvenli tutun. Görüntülemek, devre dışı veya süresi dolmuş anahtarı kullanılabilir yapmaz.',
deleteConfirm:
'“{0}” silinsin mi? Gizli anahtar yok edilecek ve listeden kaldırılacaktır. Yeni istekler reddedilecek ve ilgili terminaller kapatılacaktır. Denetim kayıtları korunur; çalışan işler iptal edilmez. Bu işlem geri alınamaz.',
title: 'API Key', title: 'API Key',
manage: 'API anahtarlarını yönet', manage: 'API anahtarlarını yönet',
personalEntry: 'Ayarlar bölümünde anahtarlarınızı, erişim koşullarını ve uygulama QR bağlantısını yönetin.', personalEntry: 'Ayarlar bölümünde anahtarlarınızı, erişim koşullarını ve uygulama QR bağlantısını yönetin.',
@@ -23,11 +29,6 @@ const message = {
allowAppBinding: 'Uygulama QR bağlantısına izin ver', allowAppBinding: 'Uygulama QR bağlantısına izin ver',
bindingDisableHelp: bindingDisableHelp:
'QR eşleştirmesini devre dışı bırakmak yeni eşleştirmeleri engeller. Eşleştirilmiş cihazlar, anahtar devre dışı bırakılana, iptal edilene veya süresi dolana kadar kullanılmaya devam edebilir.', 'QR eşleştirmesini devre dışı bırakmak yeni eşleştirmeleri engeller. Eşleştirilmiş cihazlar, anahtar devre dışı bırakılana, iptal edilene veya süresi dolana kadar kullanılmaya devam edebilir.',
saveSecret:
'Anahtarı şimdi kopyalayıp kaydedin. Kapattıktan sonra normal anahtar yönetimi tam anahtarı yeniden gösteremez.',
saved: 'Anahtarı kaydettim',
alreadyCreated:
'Bu anahtar zaten oluşturuldu ve yeniden gösterilmez. İlk yanıt kaybolduysa iptal edip yeniden oluşturun veya izin veriliyorsa QR bağlantısını kullanın.',
uncertain: uncertain:
'İstek sonucu belirsiz. Tekrarları önlemek için aynı bilgilerle yeniden deneyin veya kapatıp yeni anahtar oluşturmadan listeyi kontrol edin.', 'İstek sonucu belirsiz. Tekrarları önlemek için aynı bilgilerle yeniden deneyin veya kapatıp yeni anahtar oluşturmadan listeyi kontrol edin.',
legacyHelp: legacyHelp:
@@ -1046,6 +1047,22 @@ const message = {
validateImageAvailabilityHelper: 'Kaydetmeden önce bir küçük görsel üretir ve ücret doğurabilir', validateImageAvailabilityHelper: 'Kaydetmeden önce bir küçük görsel üretir ve ücret doğurabilir',
}, },
model: { model: {
gpuAcceleration: 'GPU hızlandırma',
gpuMode: 'Hızlandırıcı seçimi',
gpuAll: 'Tüm hızlandırıcılar',
gpuCount: 'Sayı belirt',
gpuSpecific: 'Cihaz seç',
gpuAllHelper: 'Geçerli düğümde bu türdeki tüm kullanılabilir hızlandırıcıları kullanın.',
gpuCountHelper: 'Çalışma zamanı, belirli cihazlara bağlamadan istenen sayıda hızlandırıcı tahsis eder.',
gpuSpecificHelper: 'Belirli hızlandırıcıları elle seçin.',
gpuCountRequired: '0’dan büyük bir tam sayı girin',
gpuDevices: 'GPU aygıtları',
gpuCapabilitiesHelper:
'GPU erişimi zorunludur; izleme ve yönetimi (utility) ve hesaplama desteğini (compute) gerektiğinde etkinleştirin.',
gpuSelectRequired: 'En az bir kullanılabilir GPU aygıtı seçin.',
gpuUnsupported: 'Desteklenmiyor',
gpuNotDetected: 'Desteklenen hızlandırıcı algılanmadı',
gpuMissing: 'Kullanılamıyor',
modelCategory: 'Model kategorisi', modelCategory: 'Model kategorisi',
modelName: 'Model adı', modelName: 'Model adı',
modelArchitecture: 'Model türü', modelArchitecture: 'Model türü',
@@ -1063,11 +1080,11 @@ const message = {
onlineModel: 'Çevrimiçi modeller', onlineModel: 'Çevrimiçi modeller',
instanceDeployment: 'Örnek bazında dağıtım', instanceDeployment: 'Örnek bazında dağıtım',
nvidiaDeployment: 'NVIDIA için optimize edilmiş dağıtım', nvidiaDeployment: 'NVIDIA için optimize edilmiş dağıtım',
engines: 'Çıkarım motorları', engines: 'Yerel çıkarım motorları',
modelManagement: 'Model yönetimi', modelManagement: 'Model yönetimi',
serviceDetails: 'Hizmet ayrıntıları', serviceDetails: 'Hizmet ayrıntıları',
deployments: 'Yerel dağıtımlar', deployments: 'Yerel dağıtımlar',
files: 'Model dosyaları', files: 'Model merkezi',
connectExternal: 'Harici hizmete bağlan', connectExternal: 'Harici hizmete bağlan',
deploymentTemplates: 'vLLM başlatma komutu şablonları', deploymentTemplates: 'vLLM başlatma komutu şablonları',
engineProvider: 'Motor / Sağlayıcı', engineProvider: 'Motor / Sağlayıcı',
@@ -1084,7 +1101,7 @@ const message = {
container_conn_helper: 'Konteynerler arası erişim veya bağlantı için bu adresi kullanın', container_conn_helper: 'Konteynerler arası erişim veya bağlantı için bu adresi kullanın',
from_remote: 'Bu model 1Panel aracılığıyla indirilmedi, ilgili çekme logları yok.', from_remote: 'Bu model 1Panel aracılığıyla indirilmedi, ilgili çekme logları yok.',
no_logs: 'Bu modelin çekme logları silindi ve görüntülenemiyor.', no_logs: 'Bu modelin çekme logları silindi ve görüntülenemiyor.',
vllmVersionHelper: 'FusionXpark GB 10 sunucuları için lütfen -cu130 sürümünü seçin.', vllmVersionHelper: 'FusionXpark GB10 sunucuları için lütfen -cu130 sürümünü seçin.',
vllmDeployProfile: 'Dağıtım modeli', vllmDeployProfile: 'Dağıtım modeli',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1635,6 +1652,12 @@ const message = {
timePerOutputTokenChart: 'Token arası gecikme', timePerOutputTokenChart: 'Token arası gecikme',
}, },
gpu: { gpu: {
systemMemory: 'Paylaşılan sistem belleği',
systemMemoryUsed: 'Kullanılan sistem belleği',
systemMemoryTotal: 'Toplam sistem belleği',
systemMemoryAvailable: 'Kullanılabilir sistem belleği',
systemMemoryHelper:
'CPU ve GPU tarafından paylaşılır. Yalnızca GPU kullanımını değil, tüm sistemin bellek kullanımını gösterir.',
memoryActivity: 'Bellek okuma/yazma etkinliği', memoryActivity: 'Bellek okuma/yazma etkinliği',
hotspotTemperature: 'Sıcak nokta sıcaklığı', hotspotTemperature: 'Sıcak nokta sıcaklığı',
fanRPM: 'Fan hızı (RPM)', fanRPM: 'Fan hızı (RPM)',
@@ -1670,7 +1693,6 @@ const message = {
historyPeak: 'En yüksek', historyPeak: 'En yüksek',
historyAggregated: historyAggregated:
'{count} örnek {seconds} saniyelik aralıklarla toplandı; işlemler için yalnızca sayılar gösterilir.', '{count} örnek {seconds} saniyelik aralıklarla toplandı; işlemler için yalnızca sayılar gösterilir.',
historyRaw: '{count} ham örnek.',
historyMetricEmpty: 'Seçilen zaman aralığında bu metriğe ait veri yok', historyMetricEmpty: 'Seçilen zaman aralığında bu metriğe ait veri yok',
historyLoadFailed: 'Geçmiş yüklenemedi. Lütfen tekrar deneyin.', historyLoadFailed: 'Geçmiş yüklenemedi. Lütfen tekrar deneyin.',
collectedAt: 'Toplanma zamanı', collectedAt: 'Toplanma zamanı',
@@ -1742,8 +1764,8 @@ const message = {
migModeHelper: migModeHelper:
"MIG örnekleri oluşturmak için kullanılır, GPU'nun kullanıcı katmanında fiziksel izolasyonunu uygular.", "MIG örnekleri oluşturmak için kullanılır, GPU'nun kullanıcı katmanında fiziksel izolasyonunu uygular.",
migModeNA: 'Desteklenmiyor', migModeNA: 'Desteklenmiyor',
current: 'Gerçek Zamanlı İzleme', current: 'Gerçek Zamanlı Durum',
history: 'Geçmiş Kayıtlar', history: 'İzleme Verileri',
notSupport: 'Mevcut sürüm veya sürücü bu parametrenin görüntülenmesini desteklemiyor.', notSupport: 'Mevcut sürüm veya sürücü bu parametrenin görüntülenmesini desteklemiyor.',
processCount: 'Süreç sayısı', processCount: 'Süreç sayısı',
}, },
@@ -2057,8 +2079,6 @@ const message = {
retry: 'Seçili ölçümleri yinele', retry: 'Seçili ölçümleri yinele',
environment: 'Çalışma ortamı', environment: 'Çalışma ortamı',
plan: 'Sabitlenmiş yapılandırma', plan: 'Sabitlenmiş yapılandırma',
clientHelp:
'İstemci konteyneri ana düğümde çalışır ve mevcut hizmete istek gönderir. Çıkarım hizmeti başlatmaz ve GPU gerektirmez.',
retryHelp: 'Yalnızca seçili sorunlu ölçümler aynı parametreler ve yeni tohumlarla yinelenir.', retryHelp: 'Yalnızca seçili sorunlu ölçümler aynı parametreler ve yeni tohumlarla yinelenir.',
secretHelp: 'Kopyalar API anahtarlarını ve başlıkları içermez. Gerekiyorsa yeniden girin.', secretHelp: 'Kopyalar API anahtarlarını ve başlıkları içermez. Gerekiyorsa yeniden girin.',
resultHelp: resultHelp:
+30 -11
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: '檢視密鑰',
hideSecret: '隱藏密鑰',
revealHelp: '請妥善保管密鑰。檢視不會恢復已停用或已過期密鑰的使用權限。',
deleteConfirm:
'確定刪除「{0}」嗎?密鑰將被銷毀並從列表移除,拒絕後續請求並關閉關聯終端。稽核記錄仍保留,執行中的工作不會取消,此操作無法復原。',
title: 'API Key', title: 'API Key',
manage: '管理 API Key', manage: '管理 API Key',
personalEntry: '前往設定管理自己的金鑰、存取條件與 APP 掃碼綁定。', personalEntry: '前往設定管理自己的金鑰、存取條件與 APP 掃碼綁定。',
@@ -22,10 +27,6 @@ const message = {
futureExpiry: '請選擇晚於目前時間的到期時間。', futureExpiry: '請選擇晚於目前時間的到期時間。',
allowAppBinding: '允許 APP 掃碼綁定', allowAppBinding: '允許 APP 掃碼綁定',
bindingDisableHelp: '關閉掃碼綁定後,將停止接受新的綁定;已綁定裝置可繼續使用,直至金鑰被停用、撤銷或到期。', bindingDisableHelp: '關閉掃碼綁定後,將停止接受新的綁定;已綁定裝置可繼續使用,直至金鑰被停用、撤銷或到期。',
saveSecret: '請立即複製並儲存金鑰。關閉後,一般金鑰管理無法再次顯示完整金鑰。',
saved: '我已儲存金鑰',
alreadyCreated:
'這把金鑰已建立,無法再次顯示明文。若原回應遺失,請撤銷後重新建立;允許掃碼綁定時,也可透過掃碼交付。',
uncertain: '請求結果尚不確定。請保持原設定重試,避免重複建立;或關閉並檢查清單後再建立其他金鑰。', uncertain: '請求結果尚不確定。請保持原設定重試,避免重複建立;或關閉並檢查清單後再建立其他金鑰。',
legacyHelp: '舊版 Key 用於相容歷史 API 連線,啟用、停用及重設操作僅對該 Key 生效。', legacyHelp: '舊版 Key 用於相容歷史 API 連線,啟用、停用及重設操作僅對該 Key 生效。',
showLegacy: '檢視舊版金鑰', showLegacy: '檢視舊版金鑰',
@@ -976,6 +977,21 @@ const message = {
validateImageAvailabilityHelper: '儲存前會實際產生一張最小圖片,可能產生費用', validateImageAvailabilityHelper: '儲存前會實際產生一張最小圖片,可能產生費用',
}, },
model: { model: {
gpuAcceleration: 'GPU 加速',
gpuMode: '加速卡選擇',
gpuAll: '全部加速卡',
gpuCount: '指定數量',
gpuSpecific: '指定裝置',
gpuAllHelper: '容器使用目前節點所有可用的同類型加速卡。',
gpuCountHelper: '由執行環境分配指定數量的加速卡,不綁定特定裝置。',
gpuSpecificHelper: '手動選擇特定加速卡。',
gpuCountRequired: '請輸入大於 0 的整數',
gpuDevices: 'GPU 裝置',
gpuCapabilitiesHelper: 'GPU 存取為必選項,可依需求啟用監控管理(utility)和運算支援(compute)。',
gpuSelectRequired: '請選擇至少一個目前可用的 GPU 裝置',
gpuUnsupported: '不支援',
gpuNotDetected: '未偵測到可用加速卡',
gpuMissing: '無法使用',
modelCategory: '模型類別', modelCategory: '模型類別',
modelName: '模型名稱', modelName: '模型名稱',
modelArchitecture: '模型類型', modelArchitecture: '模型類型',
@@ -993,11 +1009,11 @@ const message = {
onlineModel: '線上模型', onlineModel: '線上模型',
instanceDeployment: '按執行個體部署', instanceDeployment: '按執行個體部署',
nvidiaDeployment: 'NVIDIA 最佳化部署', nvidiaDeployment: 'NVIDIA 最佳化部署',
engines: '推理引擎', engines: '本機推理引擎',
modelManagement: '模型管理', modelManagement: '模型管理',
serviceDetails: '服務詳情', serviceDetails: '服務詳情',
deployments: '本機部署', deployments: '本機部署',
files: '模型檔案', files: '模型廣場',
connectExternal: '接入外部服務', connectExternal: '接入外部服務',
deploymentTemplates: 'vLLM 啟動命令範本', deploymentTemplates: 'vLLM 啟動命令範本',
engineProvider: '引擎 / 供應商', engineProvider: '引擎 / 供應商',
@@ -1014,7 +1030,7 @@ const message = {
container_conn_helper: '容器間瀏覽或連接使用此地址', container_conn_helper: '容器間瀏覽或連接使用此地址',
from_remote: '該模型並非透過 1Panel 下載,無相關拉取日誌。', from_remote: '該模型並非透過 1Panel 下載,無相關拉取日誌。',
no_logs: '該模型的拉取日誌已被刪除,無法檢視相關日誌。', no_logs: '該模型的拉取日誌已被刪除,無法檢視相關日誌。',
vllmVersionHelper: 'FusionXpark GB 10 伺服器請選擇 -cu130 版本', vllmVersionHelper: 'FusionXpark GB10 伺服器請選擇 -cu130 版本',
vllmDeployProfile: '部署模型', vllmDeployProfile: '部署模型',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1539,6 +1555,11 @@ const message = {
timePerOutputTokenChart: 'Token 間延遲', timePerOutputTokenChart: 'Token 間延遲',
}, },
gpu: { gpu: {
systemMemory: '系統共享記憶體',
systemMemoryUsed: '系統已用記憶體',
systemMemoryTotal: '系統總記憶體',
systemMemoryAvailable: '系統可用記憶體',
systemMemoryHelper: 'CPU 與 GPU 共用,顯示整機記憶體使用情況,不代表 GPU 獨占用量。',
memoryActivity: '顯示記憶體讀寫活躍率', memoryActivity: '顯示記憶體讀寫活躍率',
hotspotTemperature: '熱點溫度', hotspotTemperature: '熱點溫度',
fanRPM: '風扇轉速(RPM)', fanRPM: '風扇轉速(RPM)',
@@ -1573,7 +1594,6 @@ const message = {
historyAverage: '平均值', historyAverage: '平均值',
historyPeak: '峰值', historyPeak: '峰值',
historyAggregated: '共 {count} 筆取樣,按 {seconds} 秒彙整;程序僅顯示統計數量。', historyAggregated: '共 {count} 筆取樣,按 {seconds} 秒彙整;程序僅顯示統計數量。',
historyRaw: '共 {count} 筆原始取樣。',
historyMetricEmpty: '所選時間範圍內沒有此指標的資料', historyMetricEmpty: '所選時間範圍內沒有此指標的資料',
historyLoadFailed: '歷史記錄載入失敗,請重試', historyLoadFailed: '歷史記錄載入失敗,請重試',
collectedAt: '採集時間', collectedAt: '採集時間',
@@ -1642,8 +1662,8 @@ const message = {
prohibitedHelper: '禁止: 不允許行程同時執行', prohibitedHelper: '禁止: 不允許行程同時執行',
migModeHelper: '用於建立 MIG 實例,在使用者層實現 GPU 的物理隔離。', migModeHelper: '用於建立 MIG 實例,在使用者層實現 GPU 的物理隔離。',
migModeNA: '不支援', migModeNA: '不支援',
current: '即時監控', current: '即時狀態',
history: '歷史記錄', history: '監控資料',
notSupport: '當前版本或驅動不支援顯示該參數。', notSupport: '當前版本或驅動不支援顯示該參數。',
processCount: '行程數', processCount: '行程數',
}, },
@@ -1927,7 +1947,6 @@ const message = {
retry: '重測選取測點', retry: '重測選取測點',
environment: '執行環境', environment: '執行環境',
plan: '凍結設定', plan: '凍結設定',
clientHelp: '壓測容器在主節點執行,向既有服務傳送請求。不會啟動推論服務,也不需分配 GPU。',
retryHelp: '本輪只重測選取的異常測點,保留參數並重新分配隨機種子。', retryHelp: '本輪只重測選取的異常測點,保留參數並重新分配隨機種子。',
secretHelp: '複製設定不包含 API Key 與請求標頭,請視需要重新填寫。', secretHelp: '複製設定不包含 API Key 與請求標頭,請視需要重新填寫。',
resultHelp: resultHelp:
+30 -11
View File
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
const message = { const message = {
apiKeyManagement: { apiKeyManagement: {
reveal: '查看密钥',
hideSecret: '隐藏密钥',
revealHelp: '请妥善保管密钥。查看不会恢复已停用或已过期密钥的使用权限。',
deleteConfirm:
'确定删除“{0}”吗?密钥将被销毁并从列表移除,拒绝后续请求并关闭关联终端。审计记录仍保留,运行中的任务不会取消,此操作不可恢复。',
title: 'API Key', title: 'API Key',
manage: '管理 API Key', manage: '管理 API Key',
personalEntry: '前往设置管理自己的密钥、访问条件和 APP 扫码绑定。', personalEntry: '前往设置管理自己的密钥、访问条件和 APP 扫码绑定。',
@@ -22,10 +27,6 @@ const message = {
futureExpiry: '请选择晚于当前时间的到期时间。', futureExpiry: '请选择晚于当前时间的到期时间。',
allowAppBinding: '允许 APP 扫码绑定', allowAppBinding: '允许 APP 扫码绑定',
bindingDisableHelp: '关闭扫码绑定后,将停止接受新的绑定;已绑定设备可继续使用,直至密钥被停用、撤销或到期。', bindingDisableHelp: '关闭扫码绑定后,将停止接受新的绑定;已绑定设备可继续使用,直至密钥被停用、撤销或到期。',
saveSecret: '请立即复制并保存密钥。关闭后,普通密钥管理无法再次显示完整密钥。',
saved: '我已保存密钥',
alreadyCreated:
'这把密钥已创建,无法再次展示明文。若原响应丢失,请撤销后重新创建;允许扫码绑定时,也可通过扫码交付。',
uncertain: '请求结果尚不确定。请保持原配置重试,避免重复创建;或关闭并检查列表后再创建其他密钥。', uncertain: '请求结果尚不确定。请保持原配置重试,避免重复创建;或关闭并检查列表后再创建其他密钥。',
legacyHelp: '旧版 Key 用于兼容历史 API 连接,启用、停用和重置操作仅对该 Key 生效。', legacyHelp: '旧版 Key 用于兼容历史 API 连接,启用、停用和重置操作仅对该 Key 生效。',
showLegacy: '查看旧版密钥', showLegacy: '查看旧版密钥',
@@ -989,6 +990,21 @@ const message = {
validateImageAvailabilityHelper: '保存前会真实生成一张最小图片,可能产生费用', validateImageAvailabilityHelper: '保存前会真实生成一张最小图片,可能产生费用',
}, },
model: { model: {
gpuAcceleration: 'GPU 加速',
gpuMode: '加速卡选择',
gpuAll: '全部加速卡',
gpuCount: '指定数量',
gpuSpecific: '指定设备',
gpuAllHelper: '容器使用当前节点所有可用的同类型加速卡。',
gpuCountHelper: '由运行时分配指定数量的加速卡,不绑定具体设备。',
gpuSpecificHelper: '手动选择具体加速卡。',
gpuCountRequired: '请输入大于 0 的整数',
gpuDevices: 'GPU 设备',
gpuCapabilitiesHelper: 'GPU 访问为必选项,可按需启用监控管理(utility)和计算支持(compute)。',
gpuSelectRequired: '请选择至少一个当前可用的 GPU 设备',
gpuUnsupported: '不支持',
gpuNotDetected: '未检测到可用加速卡',
gpuMissing: '不可用',
modelCategory: '模型类别', modelCategory: '模型类别',
modelName: '模型名称', modelName: '模型名称',
modelArchitecture: '模型类型', modelArchitecture: '模型类型',
@@ -1006,11 +1022,11 @@ const message = {
onlineModel: '在线模型', onlineModel: '在线模型',
instanceDeployment: '按实例部署', instanceDeployment: '按实例部署',
nvidiaDeployment: 'NVIDIA 优化部署', nvidiaDeployment: 'NVIDIA 优化部署',
engines: '推理引擎', engines: '本地推理引擎',
modelManagement: '模型管理', modelManagement: '模型管理',
serviceDetails: '服务详情', serviceDetails: '服务详情',
deployments: '本地部署', deployments: '本地部署',
files: '模型文件', files: '模型广场',
connectExternal: '接入外部服务', connectExternal: '接入外部服务',
deploymentTemplates: 'vLLM 启动命令模板', deploymentTemplates: 'vLLM 启动命令模板',
engineProvider: '引擎 / 供应商', engineProvider: '引擎 / 供应商',
@@ -1027,7 +1043,7 @@ const message = {
container_conn_helper: '容器间访问或连接使用此地址', container_conn_helper: '容器间访问或连接使用此地址',
from_remote: '该模型并非通过 1Panel 下载,无相关拉取日志。', from_remote: '该模型并非通过 1Panel 下载,无相关拉取日志。',
no_logs: '该模型的拉取日志已被删除,无法查看相关日志。', no_logs: '该模型的拉取日志已被删除,无法查看相关日志。',
vllmVersionHelper: 'FusionXpark GB 10 服务器请选择 -cu130 版本', vllmVersionHelper: 'FusionXpark GB10 服务器请选择 -cu130 版本',
vllmDeployProfile: '部署模型', vllmDeployProfile: '部署模型',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731', vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp', vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
@@ -1552,6 +1568,11 @@ const message = {
timePerOutputTokenChart: 'Token 间延迟', timePerOutputTokenChart: 'Token 间延迟',
}, },
gpu: { gpu: {
systemMemory: '系统共享内存',
systemMemoryUsed: '系统已用内存',
systemMemoryTotal: '系统总内存',
systemMemoryAvailable: '系统可用内存',
systemMemoryHelper: 'CPU 与 GPU 共用,显示整机内存使用情况,不代表 GPU 独占用量。',
memoryActivity: '显存读写活跃率', memoryActivity: '显存读写活跃率',
hotspotTemperature: '热点温度', hotspotTemperature: '热点温度',
fanRPM: '风扇转速(RPM)', fanRPM: '风扇转速(RPM)',
@@ -1586,7 +1607,6 @@ const message = {
historyAverage: '平均值', historyAverage: '平均值',
historyPeak: '峰值', historyPeak: '峰值',
historyAggregated: '共 {count} 条采样,按 {seconds} 秒聚合;进程仅展示统计数量。', historyAggregated: '共 {count} 条采样,按 {seconds} 秒聚合;进程仅展示统计数量。',
historyRaw: '共 {count} 条原始采样。',
historyMetricEmpty: '所选时间范围内没有该指标的数据', historyMetricEmpty: '所选时间范围内没有该指标的数据',
historyLoadFailed: '历史记录加载失败,请重试', historyLoadFailed: '历史记录加载失败,请重试',
collectedAt: '采集时间', collectedAt: '采集时间',
@@ -1657,8 +1677,8 @@ const message = {
prohibitedHelper: '禁止: 不允许进程同时执行', prohibitedHelper: '禁止: 不允许进程同时执行',
migModeHelper: '用于创建 MIG 实例,在用户层实现 GPU 的物理隔离。', migModeHelper: '用于创建 MIG 实例,在用户层实现 GPU 的物理隔离。',
migModeNA: '不支持', migModeNA: '不支持',
current: '实时监控', current: '实时状态',
history: '历史记录', history: '监控数据',
notSupport: '当前版本或驱动不支持显示该参数。', notSupport: '当前版本或驱动不支持显示该参数。',
}, },
mcp: { mcp: {
@@ -1935,7 +1955,6 @@ const message = {
retry: '补测选中测点', retry: '补测选中测点',
environment: '执行环境', environment: '执行环境',
plan: '冻结配置', plan: '冻结配置',
clientHelp: '压测容器在主节点运行,向已有服务发送请求。不会启动推理服务,也不需要分配 GPU。',
retryHelp: '本轮只补测选中的异常测点,保留参数并重新分配随机种子。', retryHelp: '本轮只补测选中的异常测点,保留参数并重新分配随机种子。',
secretHelp: '复制配置不包含 API Key 和请求头,请按需重新填写。', secretHelp: '复制配置不包含 API Key 和请求头,请按需重新填写。',
resultHelp: resultHelp:
+2 -3
View File
@@ -358,9 +358,8 @@ html {
background: var(--panel-main-bg-color-10); background: var(--panel-main-bg-color-10);
pre { pre {
margin: 0; margin: 0;
width: 350px; white-space: pre-wrap;
overflow: hidden; overflow-wrap: anywhere;
text-overflow: ellipsis;
} }
} }
+24
View File
@@ -286,3 +286,27 @@ html {
border: 1px solid var(--panel-main-bg-color-10); border: 1px solid var(--panel-main-bg-color-10);
background-color: var(--panel-main-bg-color-10) !important; background-color: var(--panel-main-bg-color-10) !important;
} }
// Allow long object names in dialog titles and explanatory text to wrap.
.el-dialog__header,
.el-dialog__body,
.el-message-box__title,
.el-message-box__message {
overflow-wrap: anywhere;
}
.el-dialog__title,
.el-message-box__title {
white-space: normal;
}
.el-message-box__message,
.el-dialog .el-alert__content {
min-width: 0;
}
.dialog-object-name {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
+26
View File
@@ -0,0 +1,26 @@
export function isRemoteAppIcon(icon: string): boolean {
if (
!/^https?:\/\/[^/?#]/i.test(icon) ||
/^https?:\/\/[^/?#]*@/i.test(icon) ||
Array.from(icon).some(
(char) => char <= ' ' || (char >= '\u007f' && char <= '\u009f') || /\s/.test(char) || char === '\\',
) ||
/%(?![\da-f]{2})/i.test(icon)
) {
return false;
}
try {
const url = new URL(icon);
return !!url.hostname && !url.username && !url.password;
} catch {
return false;
}
}
export function resolveAppIconUrl(appKey: string, node: string | undefined, icon: string, baseURL: string): string {
if (isRemoteAppIcon(icon)) {
return icon;
}
const params = node ? `?operateNode=${encodeURIComponent(node)}` : '';
return `${baseURL}/apps/icon/${encodeURIComponent(appKey)}${params}`;
}
+50 -3
View File
@@ -79,6 +79,9 @@
> >
{{ $t('commons.button.view') }} {{ $t('commons.button.view') }}
</el-button> </el-button>
<el-button type="primary" link @click="openHistory(item)">
{{ $t('aiTools.gpu.history') }}
</el-button>
</div> </div>
</div> </div>
</template> </template>
@@ -97,7 +100,21 @@
/> />
</div> </div>
<div class="metric-item metric-primary metric-memory"> <div class="metric-item metric-primary metric-memory">
<span>{{ $t('aiTools.gpu.memoryUsed') }}</span> <span>
{{
$t(
item.type !== 'ascend' && item.sharedMemory
? 'aiTools.gpu.systemMemory'
: 'aiTools.gpu.memoryUsed',
)
}}
<el-tooltip
v-if="item.type !== 'ascend' && item.sharedMemory"
:content="$t('aiTools.gpu.systemMemoryHelper')"
>
<el-icon><InfoFilled /></el-icon>
</el-tooltip>
</span>
<strong>{{ formatMemory(item.memUsed, item.memTotal) }}</strong> <strong>{{ formatMemory(item.memUsed, item.memTotal) }}</strong>
<el-progress <el-progress
v-if="memoryPercentage(item) !== null" v-if="memoryPercentage(item) !== null"
@@ -191,6 +208,9 @@
> >
{{ $t('commons.button.view') }} {{ $t('commons.button.view') }}
</el-button> </el-button>
<el-button type="primary" link @click="openHistory(item)">
{{ $t('aiTools.gpu.history') }}
</el-button>
</div> </div>
</div> </div>
</template> </template>
@@ -321,6 +341,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { InfoFilled } from '@element-plus/icons-vue';
import { loadGPUInfo } from '@/api/modules/ai'; import { loadGPUInfo } from '@/api/modules/ai';
import RouterMenu from '@/views/ai/gpu/index.vue'; import RouterMenu from '@/views/ai/gpu/index.vue';
import { AI } from '@/api/interface/ai'; import { AI } from '@/api/interface/ai';
@@ -328,6 +350,7 @@ import i18n from '@/lang';
import { MsgWarning } from '@/utils/message'; import { MsgWarning } from '@/utils/message';
import DeviceDetails, { type DetailSection } from './components/DeviceDetails.vue'; import DeviceDetails, { type DetailSection } from './components/DeviceDetails.vue';
const router = useRouter();
const loading = ref(); const loading = ref();
const processDrawerVisible = ref(false); const processDrawerVisible = ref(false);
const processDeviceKey = ref(''); const processDeviceKey = ref('');
@@ -574,10 +597,14 @@ const deviceDetailSections = (item: AcceleratorDevice): DetailSection[] => {
}, },
{ {
id: 'memory', id: 'memory',
title: t('aiTools.gpu.memory'), title: t(item.sharedMemory ? 'aiTools.gpu.systemMemory' : 'aiTools.gpu.memory'),
items: [ items: [
{ label: t('aiTools.gpu.memoryActivity'), value: item.memoryActivity }, { label: t('aiTools.gpu.memoryActivity'), value: item.memoryActivity },
{ label: t('aiTools.gpu.freeMemory'), value: item.memoryFree }, {
label: t(item.sharedMemory ? 'aiTools.gpu.systemMemoryAvailable' : 'aiTools.gpu.freeMemory'),
value: item.memoryFree,
help: item.sharedMemory ? t('aiTools.gpu.systemMemoryHelper') : undefined,
},
{ label: t('aiTools.gpu.memoryReserved'), value: item.memoryReserved }, { label: t('aiTools.gpu.memoryReserved'), value: item.memoryReserved },
], ],
}, },
@@ -733,6 +760,26 @@ const openXPUProcesses = (deviceID: number) => {
processDrawerVisible.value = true; processDrawerVisible.value = true;
}; };
const stableDeviceID = (vendor: string, identifier: string | undefined, fallback: string) => {
return `${vendor}:${identifier && identifier.toUpperCase() !== 'N/A' ? identifier : fallback}`;
};
const openHistory = (item: AcceleratorDevice | AI.XpuInfo['xpu'][number]) => {
let deviceID: string;
if ('basic' in item) {
deviceID = stableDeviceID(
'xpu',
item.basic.uuid,
stableDeviceID('pci', item.basic.pciBdfAddress, String(item.basic.deviceID)),
);
} else if (item.type === 'ascend') {
deviceID = `${stableDeviceID('ascend', item.busID, String(item.npuIndex))}:${item.chipIndex}`;
} else {
deviceID = stableDeviceID(item.type, item.uuid, stableDeviceID('pci', item.busID, String(item.index)));
}
void router.push({ path: '/ai/gpu/history', query: { deviceID } });
};
const deviceTitle = (item: AcceleratorDevice) => { const deviceTitle = (item: AcceleratorDevice) => {
return item.type === 'ascend' ? `Chip ${item.chipIndex}` : `GPU ${item.index}`; return item.type === 'ascend' ? `Chip ${item.chipIndex}` : `GPU ${item.index}`;
}; };
+75 -40
View File
@@ -4,45 +4,40 @@
<template v-if="supported"> <template v-if="supported">
<el-card class="history-toolbar"> <el-card class="history-toolbar">
<div class="flex flex-wrap gap-3 items-center"> <div class="flex flex-wrap gap-3 items-center">
<el-date-picker <el-select style="max-width: 100%" class="p-w-400" v-model="selectedDevice" @change="search">
v-model="timeRangeGlobal" <template #prefix>{{ $t('aiTools.gpu.deviceInfo') }}</template>
type="datetimerange"
style="max-width: 100%; width: 360px; flex-grow: 0"
range-separator="-"
:start-placeholder="$t('commons.search.timeStart')"
:end-placeholder="$t('commons.search.timeEnd')"
:shortcuts="shortcuts"
:clearable="false"
:size="isMobile ? 'small' : 'default'"
@change="changeTimeRange"
/>
<el-select style="max-width: 100%" class="p-w-300" v-model="selectedDevice" @change="search">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" /> <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
<el-radio-group v-if="history && history.bucketSeconds > 0" v-model="aggregation" @change="search"> <el-radio-group v-if="history && history.bucketSeconds > 0" v-model="aggregation" @change="search">
<el-radio-button value="avg">{{ $t('aiTools.gpu.historyAverage') }}</el-radio-button> <el-radio-button value="avg">{{ $t('aiTools.gpu.historyAverage') }}</el-radio-button>
<el-radio-button value="max">{{ $t('aiTools.gpu.historyPeak') }}</el-radio-button> <el-radio-button value="max">{{ $t('aiTools.gpu.historyPeak') }}</el-radio-button>
</el-radio-group> </el-radio-group>
<TableRefresh @search="refresh" /> <div class="ml-auto flex flex-wrap gap-3 items-center">
<el-date-picker
v-model="timeRangeGlobal"
type="datetimerange"
class="history-time-range"
style="max-width: 100%; width: 400px; flex-grow: 0"
:prefix-icon="timeRangePrefix"
range-separator="-"
:start-placeholder="$t('commons.search.timeStart')"
:end-placeholder="$t('commons.search.timeEnd')"
:shortcuts="shortcuts"
:clearable="false"
@change="changeTimeRange"
/>
<TableRefresh @search="refresh" />
</div>
</div> </div>
<div v-if="history?.sampleCount" class="history-summary"> <div v-if="history?.sampleCount && history.bucketSeconds > 0" class="history-summary">
{{ {{
history.bucketSeconds > 0 $t('aiTools.gpu.historyAggregated', {
? $t('aiTools.gpu.historyAggregated', { seconds: history.bucketSeconds,
seconds: history.bucketSeconds, count: history.sampleCount,
count: history.sampleCount, })
})
: $t('aiTools.gpu.historyRaw', { count: history.sampleCount })
}} }}
</div> </div>
</el-card> </el-card>
<el-alert
v-if="currentDevice?.legacy"
:title="$t('aiTools.gpu.legacyHistory')"
type="info"
:closable="false"
show-icon
/>
<el-alert <el-alert
v-if="failed" v-if="failed"
:title="$t('aiTools.gpu.historyLoadFailed')" :title="$t('aiTools.gpu.historyLoadFailed')"
@@ -58,7 +53,15 @@
<el-row v-else-if="history?.sampleCount" :gutter="12"> <el-row v-else-if="history?.sampleCount" :gutter="12">
<el-col v-for="chart in charts" :key="chart.id" :xs="24" :md="12"> <el-col v-for="chart in charts" :key="chart.id" :xs="24" :md="12">
<el-card class="card-interval"> <el-card class="card-interval">
<template #header>{{ chart.title }}</template> <template #header>
{{ chart.title }}
<el-tooltip
v-if="chart.id === 'gpu-memory' && currentDevice?.sharedMemory"
:content="$t('aiTools.gpu.systemMemoryHelper')"
>
<el-icon><InfoFilled /></el-icon>
</el-tooltip>
</template>
<v-charts :id="chart.id" height="320px" type="line" :option="chart.option" :dataZoom="true" /> <v-charts :id="chart.id" height="320px" type="line" :option="chart.option" :dataZoom="true" />
</el-card> </el-card>
</el-col> </el-col>
@@ -90,7 +93,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, watch, onBeforeUnmount, h } from 'vue';
import { useRoute } from 'vue-router';
import { InfoFilled } from '@element-plus/icons-vue';
import { loadGPUMonitor, getGPUOptions } from '@/api/modules/ai'; import { loadGPUMonitor, getGPUOptions } from '@/api/modules/ai';
import RouterMenu from '@/views/ai/gpu/index.vue'; import RouterMenu from '@/views/ai/gpu/index.vue';
import { shortcuts } from '@/utils/shortcuts'; import { shortcuts } from '@/utils/shortcuts';
@@ -98,6 +103,7 @@ import i18n from '@/lang';
import { AI } from '@/api/interface/ai'; import { AI } from '@/api/interface/ai';
import { useGlobalStore } from '@/composables/useGlobalStore'; import { useGlobalStore } from '@/composables/useGlobalStore';
const route = useRoute();
const { isMobile } = useGlobalStore(); const { isMobile } = useGlobalStore();
const loading = ref(false); const loading = ref(false);
const failed = ref(false); const failed = ref(false);
@@ -108,6 +114,7 @@ const aggregation = ref<'avg' | 'max'>('avg');
const history = ref<AI.MonitorGPUData>(); const history = ref<AI.MonitorGPUData>();
const currentDevice = computed(() => options.value.find((item) => item.value === selectedDevice.value)); const currentDevice = computed(() => options.value.find((item) => item.value === selectedDevice.value));
const timeRangeGlobal = ref<[Date, Date]>([new Date(new Date().setHours(0, 0, 0, 0)), new Date()]); const timeRangeGlobal = ref<[Date, Date]>([new Date(new Date().setHours(0, 0, 0, 0)), new Date()]);
const timeRangePrefix = () => h('span', i18n.global.t('commons.table.date'));
let followNow = true; let followNow = true;
let requestID = 0; let requestID = 0;
@@ -124,10 +131,18 @@ const charts = computed(() => {
}, },
{ {
id: 'gpu-memory', id: 'gpu-memory',
title: 'memory', title: currentDevice.value?.sharedMemory ? 'systemMemory' : 'memory',
series: [ series: [
{ key: 'memoryUsed', label: 'memoryUsed', unit: 'MiB' }, {
{ key: 'memoryTotal', label: 'memoryTotal', unit: 'MiB' }, key: 'memoryUsed',
label: currentDevice.value?.sharedMemory ? 'systemMemoryUsed' : 'memoryUsed',
unit: 'MiB',
},
{
key: 'memoryTotal',
label: currentDevice.value?.sharedMemory ? 'systemMemoryTotal' : 'memoryTotal',
unit: 'MiB',
},
{ key: 'memoryPercent', label: 'percent', unit: '%', percent: true }, { key: 'memoryPercent', label: 'percent', unit: '%', percent: true },
], ],
}, },
@@ -338,12 +353,16 @@ const loadOptions = async () => {
const response = await getGPUOptions(); const response = await getGPUOptions();
if (id !== requestID) return; if (id !== requestID) return;
supported.value = response.data.supported; supported.value = response.data.supported;
options.value = (supported.value ? response.data.chartHide || [] : []).map((item) => ({ options.value = (supported.value ? response.data.chartHide || [] : [])
...item, .filter((item) => item.deviceID && !item.legacy)
value: item.deviceID || `legacy:${item.productName}`, .map((item) => ({
label: `${item.productName} · ${item.deviceID || i18n.global.t('aiTools.gpu.legacyDevice')}`, ...item,
})); value: item.deviceID!,
if (!currentDevice.value) selectedDevice.value = options.value[0]?.value || ''; label: item.productName
.replace(/^(\d+) - /, `${item.type === 'xpu' ? 'XPU' : 'GPU'} $1 · `)
.replace(' - ', ' · '),
}));
if (!currentDevice.value && !route.query.deviceID) selectedDevice.value = options.value[0]?.value || '';
await search(); await search();
} catch { } catch {
if (id === requestID) failed.value = true; if (id === requestID) failed.value = true;
@@ -401,7 +420,15 @@ const loadProcessType = (val: string) => {
return val; return val;
}; };
onMounted(loadOptions); watch(
() => [route.path, route.query.deviceID],
() => {
if (route.path !== '/ai/gpu/history') return;
if (typeof route.query.deviceID === 'string') selectedDevice.value = route.query.deviceID;
void loadOptions();
},
{ immediate: true },
);
onBeforeUnmount(() => { onBeforeUnmount(() => {
requestID++; requestID++;
}); });
@@ -412,6 +439,14 @@ onBeforeUnmount(() => {
margin: 7px 0 12px; margin: 7px 0 12px;
--el-card-padding: 12px; --el-card-padding: 12px;
} }
.history-toolbar :deep(.history-time-range .el-range__icon) {
width: auto;
flex-shrink: 0;
margin-left: 0;
margin-right: 6px;
font-style: normal;
white-space: nowrap;
}
.history-summary { .history-summary {
margin-top: 12px; margin-top: 12px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
@@ -1,5 +1,5 @@
<template> <template>
<el-form-item v-if="showSync"> <el-form-item v-if="showSync && !hideSyncToggle">
<el-checkbox v-model="form.syncModelAccount">{{ $t('aiTools.model.syncModelAccount') }}</el-checkbox> <el-checkbox v-model="form.syncModelAccount">{{ $t('aiTools.model.syncModelAccount') }}</el-checkbox>
</el-form-item> </el-form-item>
<template v-if="!showSync || form.syncModelAccount"> <template v-if="!showSync || form.syncModelAccount">
@@ -52,6 +52,7 @@ const props = defineProps<{
containerPort: string; containerPort: string;
hostMode?: boolean; hostMode?: boolean;
showSync?: boolean; showSync?: boolean;
hideSyncToggle?: boolean;
}>(); }>();
const systemIP = ref(''); const systemIP = ref('');
const addressTypes = computed(() => [ const addressTypes = computed(() => [
@@ -57,13 +57,17 @@
v-if="visible && view && view !== VllmPlaceholder && !failed && !missing" v-if="visible && view && view !== VllmPlaceholder && !failed && !missing"
ref="contentRef" ref="contentRef"
v-bind=" v-bind="
view === EngineConnection || view === AppParams view === AppParams
? {} ? { modelGPU: true }
: view === ContainerLogs : view === EngineConnection
? { highlightDiff: 210 } ? {}
: view === TaskLog : view === ContainerLogs
? { width: '70%' } ? { highlightDiff: 210 }
: { dialog: true } : view === TaskLog
? { width: '70%' }
: ['config', 'monitor'].includes(actionName)
? {}
: { dialog: true }
" "
@close="returnToManagement" @close="returnToManagement"
@search="emit('search')" @search="emit('search')"
@@ -357,7 +361,9 @@ const deploymentButtons = [
{ {
label: i18n.global.t('commons.button.delete'), label: i18n.global.t('commons.button.delete'),
permission: true, permission: true,
disabled: (row: Deployment) => operationDisabled(row) && row.status !== 'Uninstalling', disabled: (row: Deployment) =>
deleteLoading.value ||
(operationDisabled(row) && !['Uninstalling', 'ReStarting', 'Restarting'].includes(row.status)),
click: (row: Deployment) => { click: (row: Deployment) => {
deleting.value = row; deleting.value = row;
forceDelete.value = false; forceDelete.value = false;
@@ -0,0 +1,41 @@
.form-section {
margin-bottom: 12px;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
background: var(--el-bg-color-overlay);
}
.form-section-header {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 6px 12px;
color: var(--el-text-color-regular);
font-size: 13px;
font-weight: 600;
line-height: 1.5;
cursor: pointer;
list-style: none;
&::-webkit-details-marker {
display: none;
}
&::after {
content: '';
width: 6px;
height: 6px;
margin-left: auto;
flex-shrink: 0;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: rotate(-45deg);
}
}
.form-section[open] > .form-section-header::after {
transform: rotate(45deg);
}
.form-section-body {
padding: 12px;
border-top: 1px solid var(--el-border-color-light);
}
@@ -0,0 +1,7 @@
import type { FormInstance } from 'element-plus';
export const expandInvalidFormSection = (form: FormInstance, prop: string | string[], valid: boolean) => {
if (valid) return;
const section = form?.getField(prop)?.$el?.closest<HTMLDetailsElement>('details.form-section');
if (section) section.open = true;
};
@@ -0,0 +1,246 @@
<template>
<details open class="form-section">
<summary class="form-section-header">
<span>{{ $t('aiTools.model.gpuAcceleration') }}</span>
<el-switch
@click.stop
@keydown.stop
v-model="model.enabled"
:disabled="loading"
:aria-label="$t('aiTools.model.gpuAcceleration')"
@change="changeEnabled"
/>
<el-text v-if="!loading && !hasSupportedGPU" type="info" size="small">
{{ $t(gpuDevices ? 'aiTools.model.gpuNotDetected' : 'aiTools.model.loadFailed') }}
</el-text>
</summary>
<div v-if="model.enabled || model.error" v-show="!loading" class="form-section-body">
<el-form-item
prop="gpu.mode"
:rules="gpuModeRules"
:error="model.error"
:label="model.enabled ? $t('aiTools.model.gpuMode') : undefined"
>
<el-radio-group v-if="model.enabled" v-model="model.mode" @change="changeMode">
<el-radio-button value="all">
{{ $t('aiTools.model.gpuAll') }}
</el-radio-button>
<el-radio-button v-if="imageType === 'nvidia'" value="count">
{{ $t('aiTools.model.gpuCount') }}
</el-radio-button>
<el-radio-button value="devices">
{{ $t('aiTools.model.gpuSpecific') }}
</el-radio-button>
</el-radio-group>
<span v-if="model.enabled && model.mode === 'all'" class="input-help">
{{ $t('aiTools.model.gpuAllHelper') }}
</span>
<span v-if="model.enabled && model.mode === 'count'" class="input-help">
{{ $t('aiTools.model.gpuCountHelper') }}
</span>
<span v-if="model.enabled && model.mode === 'devices'" class="input-help">
{{ $t('aiTools.model.gpuSpecificHelper') }}
</span>
</el-form-item>
<template v-if="model.enabled">
<el-form-item
v-if="model.mode === 'count'"
:label="$t('aiTools.model.gpuCount')"
prop="gpu.count"
:rules="gpuCountRules"
>
<el-input-number
v-model="model.count"
:min="1"
:precision="0"
:step="1"
step-strictly
@change="model.error = undefined"
/>
</el-form-item>
<el-form-item
v-if="model.mode === 'devices'"
:label="$t('aiTools.model.gpuDevices')"
prop="gpu.deviceIDs"
:rules="gpuDeviceRules"
>
<el-select
v-model="model.deviceIDs"
@change="model.error = undefined"
popper-class="model-gpu-device-select"
multiple
filterable
class="w-full"
>
<el-option
v-for="device in gpuDevices || []"
:key="device.id"
:value="device.id"
:label="device.name"
:disabled="device.type !== imageType || !device.available"
>
<div class="flex items-center justify-between gap-2">
<span class="truncate">{{ device.name }}</span>
<span class="flex shrink-0 items-center gap-1">
<el-tag v-if="device.type !== imageType" size="small" type="warning">
{{ $t('aiTools.model.gpuUnsupported') }}
</el-tag>
<el-tag v-else-if="!device.available" size="small" type="danger">
{{ $t('aiTools.model.gpuMissing') }}
</el-tag>
<el-tag size="small" type="info">
{{ gpuTypeLabels[device.type] || device.type.toUpperCase() }}
</el-tag>
</span>
</div>
</el-option>
<el-option
v-for="id in missingGPUDevices"
:key="id"
:value="id"
:label="`${id} (${$t('aiTools.model.gpuMissing')})`"
disabled
/>
</el-select>
</el-form-item>
<el-form-item v-if="imageType === 'nvidia'" label="Capabilities" prop="gpu.capabilities">
<el-checkbox-group v-model="model.capabilities">
<el-checkbox value="gpu" disabled>gpu</el-checkbox>
<el-checkbox value="utility">utility</el-checkbox>
<el-checkbox value="compute">compute</el-checkbox>
</el-checkbox-group>
<span class="input-help">{{ $t('aiTools.model.gpuCapabilitiesHelper') }}</span>
</el-form-item>
</template>
<span v-if="selectingDevices && gpuWarnings.length" class="input-help">{{ gpuWarnings.join('; ') }}</span>
</div>
</details>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { AI } from '@/api/interface/ai';
import { loadModelGPUDevices } from '@/api/modules/ai';
import i18n from '@/lang';
const gpuTypeLabels: Record<string, string> = { nvidia: 'NVIDIA', intel: 'Intel', ascend: 'Ascend', amd: 'AMD' };
const props = withDefaults(defineProps<{ imageType?: string }>(), { imageType: 'nvidia' });
const model = defineModel<AI.ModelGPUConfig>({ required: true });
const loading = ref(false);
const gpuWarnings = ref<string[]>([]);
const gpuDevices = ref<AI.ModelGPUDevice[]>();
const hasSupportedGPU = computed(() =>
Boolean(gpuDevices.value?.some((item) => item.type === props.imageType && item.available)),
);
const selectingDevices = computed(() => model.value.enabled && model.value.mode === 'devices');
const missingGPUDevices = computed(() =>
model.value.deviceIDs.filter((id) => !(gpuDevices.value || []).some((item) => item.id === id)),
);
const gpuModeRules = [
{
validator: (_rule: unknown, _value: string, callback: (error?: Error) => void) => {
if (model.value.error) {
callback(new Error(model.value.error));
} else if (model.value.enabled && !gpuDevices.value) {
callback(new Error(i18n.global.t('aiTools.model.loadFailed')));
} else if (model.value.enabled && !hasSupportedGPU.value) {
callback(new Error(i18n.global.t('aiTools.model.gpuNotDetected')));
} else if (
model.value.enabled &&
(!['all', 'count', 'devices'].includes(model.value.mode) ||
(model.value.mode === 'count' && props.imageType !== 'nvidia'))
) {
callback(new Error(i18n.global.t('aiTools.model.gpuSelectRequired')));
} else {
callback();
}
},
trigger: 'change',
},
];
const gpuCountRules = [
{
validator: (_rule: unknown, value: number, callback: (error?: Error) => void) => {
const availableCount =
gpuDevices.value?.filter((item) => item.type === props.imageType && item.available).length || 0;
if (model.value.enabled && model.value.mode === 'count' && (!Number.isInteger(value) || value < 1)) {
callback(new Error(i18n.global.t('aiTools.model.gpuCountRequired')));
} else if (
model.value.enabled &&
model.value.mode === 'count' &&
availableCount > 0 &&
value > availableCount
) {
callback(new Error(i18n.global.t('commons.rule.numberRange', [1, availableCount])));
} else {
callback();
}
},
trigger: 'change',
},
];
const gpuDeviceRules = [
{
validator: (_rule: unknown, value: string[], callback: (error?: Error) => void) => {
if (selectingDevices.value && !value.length) {
callback(new Error(i18n.global.t('aiTools.model.gpuSelectRequired')));
} else {
callback();
}
},
trigger: 'change',
},
];
const changeMode = () => {
model.value.error = undefined;
model.value.deviceIDs = [];
model.value.count = model.value.mode === 'count' ? 1 : undefined;
};
const changeEnabled = () => {
model.value.error = undefined;
if (!model.value.enabled) {
model.value.mode = 'all';
model.value.count = undefined;
model.value.deviceIDs = [];
}
};
let requestVersion = 0;
const init = async (create: boolean) => {
const version = ++requestVersion;
loading.value = true;
gpuDevices.value = undefined;
gpuWarnings.value = [];
if (create) {
model.value.enabled = false;
model.value.mode = 'all';
model.value.count = undefined;
model.value.deviceIDs = [];
model.value.error = undefined;
}
try {
const { data } = await loadModelGPUDevices();
if (version !== requestVersion) return;
gpuDevices.value = data.devices || [];
gpuWarnings.value = data.warnings || [];
if (create) model.value.enabled = hasSupportedGPU.value;
} catch {
} finally {
if (version === requestVersion) {
loading.value = false;
}
}
};
defineExpose({ init, loading });
</script>
<style lang="scss" scoped>
@use '@/views/ai/model/components/form-section.scss';
:global(.model-gpu-device-select .el-select-dropdown__item.is-selected::after) {
display: none;
}
</style>
+21 -25
View File
@@ -80,13 +80,6 @@
/> />
</template> </template>
<template #main> <template #main>
<el-alert
v-if="loadFailed"
type="error"
:closable="false"
:title="$t('aiTools.model.loadFailed')"
class="mb-4"
/>
<ComplexTable <ComplexTable
v-if="!isLocal" v-if="!isLocal"
:data="connections" :data="connections"
@@ -230,8 +223,13 @@
</el-table-column> </el-table-column>
<el-table-column :label="$t('commons.table.status')" min-width="150"> <el-table-column :label="$t('commons.table.status')" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
<el-dropdown placement="bottom" :disabled="statusDisabled(row)"> <el-dropdown placement="bottom" :disabled="statusDisabled(row, 'stop')">
<Status v-permission :status="row.status" operate :disabled="statusDisabled(row)" /> <Status
v-permission
:status="row.status"
operate
:disabled="statusDisabled(row, 'stop')"
/>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item <el-dropdown-item
@@ -242,7 +240,8 @@
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item <el-dropdown-item
:disabled=" :disabled="
statusDisabled(row) || ['Stopped', 'Exited'].includes(row.status) statusDisabled(row, 'stop') ||
['Stopped', 'Exited'].includes(row.status)
" "
@click="operate(row, 'stop')" @click="operate(row, 'stop')"
> >
@@ -358,8 +357,8 @@ const isManagement = computed(
() => isCurrentPage.value && isLocal && ['vllm', 'tensorrt'].includes(String(route.query.tab)), () => isCurrentPage.value && isLocal && ['vllm', 'tensorrt'].includes(String(route.query.tab)),
); );
const tabs = computed(() => [ const tabs = computed(() => [
{ label: i18n.global.t('aiTools.model.services'), path: '/ai/model/account' },
{ label: i18n.global.t('aiTools.model.files'), path: '/ai/model/files' }, { label: i18n.global.t('aiTools.model.files'), path: '/ai/model/files' },
{ label: i18n.global.t('aiTools.model.services'), path: '/ai/model/account' },
{ label: i18n.global.t('aiTools.model.engines'), path: '/ai/model/local' }, { label: i18n.global.t('aiTools.model.engines'), path: '/ai/model/local' },
]); ]);
const downloaderModules = import.meta.glob('/src/xpack/views/model-downloader/index.vue'); const downloaderModules = import.meta.glob('/src/xpack/views/model-downloader/index.vue');
@@ -375,7 +374,6 @@ const poolRef = ref();
const terminalRef = ref<InstanceType<typeof TerminalDialog>>(); const terminalRef = ref<InstanceType<typeof TerminalDialog>>();
const syncRef = ref<InstanceType<typeof SyncService>>(); const syncRef = ref<InstanceType<typeof SyncService>>();
const loading = ref(false); const loading = ref(false);
const loadFailed = ref(false);
const connections = ref<AI.AgentAccountItem[]>([]); const connections = ref<AI.AgentAccountItem[]>([]);
const accountColumns = ref<FuTableColumnConfig[]>([]); const accountColumns = ref<FuTableColumnConfig[]>([]);
const providers = ref<AI.ProviderInfo[]>([]); const providers = ref<AI.ProviderInfo[]>([]);
@@ -501,7 +499,6 @@ const search = async () => {
const version = ++searchVersion; const version = ++searchVersion;
if (!isCurrentPage.value || isFiles || isManagement.value) return; if (!isCurrentPage.value || isFiles || isManagement.value) return;
loading.value = true; loading.value = true;
loadFailed.value = false;
try { try {
if (isLocal) { if (isLocal) {
const data = await loadModelOverview(isProductPro.value, isFxplay.value); const data = await loadModelOverview(isProductPro.value, isFxplay.value);
@@ -527,7 +524,6 @@ const search = async () => {
connections.value = []; connections.value = [];
pagination.total = 0; pagination.total = 0;
deployments.value = []; deployments.value = [];
loadFailed.value = true;
} finally { } finally {
if (version === searchVersion) loading.value = false; if (version === searchVersion) loading.value = false;
} }
@@ -556,7 +552,6 @@ const accountButtons = [
{ {
label: i18n.global.t('commons.button.delete'), label: i18n.global.t('commons.button.delete'),
permission: true, permission: true,
show: (row: AI.AgentAccountItem) => !row.appInstallId || row.provider === 'ollama',
click: async (row: AI.AgentAccountItem) => { click: async (row: AI.AgentAccountItem) => {
await ElMessageBox.confirm( await ElMessageBox.confirm(
i18n.global.t('commons.msg.delete', [row.name]), i18n.global.t('commons.msg.delete', [row.name]),
@@ -570,22 +565,23 @@ const accountButtons = [
]; ];
const taskRef = ref(); const taskRef = ref();
const pending = ref(''); const pending = ref('');
const operationDisabled = (row: Deployment) => const operationDisabled = (row: Deployment, operation = '') =>
Boolean(pending.value) || Boolean(pending.value) ||
['Installing', 'Starting', 'ReStarting', 'Restarting', 'Rebuilding', 'Waiting', 'Uninstalling'].includes( ['Installing', 'Starting', 'Rebuilding', 'Waiting', 'Uninstalling'].includes(row.status) ||
row.status, (['ReStarting', 'Restarting'].includes(row.status) && !['stop', 'delete'].includes(operation));
); const statusDisabled = (row: Deployment, operation = '') =>
const statusDisabled = (row: Deployment) => operationDisabled(row, operation) ||
operationDisabled(row) || !hasManagePermissionAccess() || (row.engine === 'ollama' && !hasPermission('app_manage')); !hasManagePermissionAccess() ||
(row.engine === 'ollama' && !hasPermission('app_manage'));
const operate = async (row: Deployment, operation: 'start' | 'stop' | 'restart') => { const operate = async (row: Deployment, operation: 'start' | 'stop' | 'restart') => {
if (statusDisabled(row)) return; if (statusDisabled(row, operation)) return;
const node = currentNode.value; const node = currentNode.value;
await ElMessageBox.confirm( await ElMessageBox.confirm(
i18n.global.t('app.operatorHelper', [i18n.global.t('commons.operate.' + operation)]), i18n.global.t('app.operatorHelper', [i18n.global.t('commons.operate.' + operation)]),
i18n.global.t('commons.operate.' + operation), i18n.global.t('commons.operate.' + operation),
{ type: 'warning' }, { type: 'warning' },
); );
if (node !== currentNode.value || statusDisabled(row)) return; if (node !== currentNode.value || statusDisabled(row, operation)) return;
pending.value = `${row.engine}:${row.id}`; pending.value = `${row.engine}:${row.id}`;
try { try {
if (row.engine === 'ollama') { if (row.engine === 'ollama') {
@@ -653,7 +649,7 @@ const deploymentButtons = [
label: i18n.global.t('commons.button.delete'), label: i18n.global.t('commons.button.delete'),
permission: true, permission: true,
show: (row: Deployment) => row.engine === 'ollama' && hasPermission('app_manage'), show: (row: Deployment) => row.engine === 'ollama' && hasPermission('app_manage'),
disabled: operationDisabled, disabled: (row: Deployment) => operationDisabled(row, 'delete'),
click: async (row: Deployment) => { click: async (row: Deployment) => {
const node = currentNode.value; const node = currentNode.value;
const path = route.fullPath; const path = route.fullPath;
@@ -683,7 +679,7 @@ const deploymentButtons = [
label: i18n.global.t('commons.button.delete'), label: i18n.global.t('commons.button.delete'),
permission: true, permission: true,
show: (row: Deployment) => row.engine === 'vllm', show: (row: Deployment) => row.engine === 'vllm',
disabled: (row: Deployment) => operationDisabled(row) && row.status !== 'Uninstalling', disabled: (row: Deployment) => operationDisabled(row, 'delete') && row.status !== 'Uninstalling',
click: (row: Deployment) => manageRef.value.open(row, 'delete', true), click: (row: Deployment) => manageRef.value.open(row, 'delete', true),
}, },
]; ];
@@ -5,15 +5,30 @@
@update:model-value="open = $event" @update:model-value="open = $event"
@close="handleClose" @close="handleClose"
> >
<AppInstallForm ref="formRef" v-model="form" :loading="loading"> <AppInstallForm ref="formRef" v-model="form" :loading="loading" sectioned hide-gpu-config>
<template #after-params> <template #after-params>
<AccountAddress <GPUConfig ref="gpuRef" v-model="form.gpu" />
ref="accountRef" <details open class="form-section">
show-sync <summary class="form-section-header">
:form="form" <span>{{ $t('aiTools.model.syncModelAccount') }}</span>
:host-port="Number(form.params.PANEL_APP_PORT_HTTP) || 11434" <el-switch
container-port="11434" @click.stop
/> @keydown.stop
v-model="form.syncModelAccount"
:aria-label="$t('aiTools.model.syncModelAccount')"
/>
</summary>
<div v-show="form.syncModelAccount" class="form-section-body">
<AccountAddress
ref="accountRef"
show-sync
hide-sync-toggle
:form="form"
:host-port="Number(form.params.PANEL_APP_PORT_HTTP) || 11434"
container-port="11434"
/>
</div>
</details>
</template> </template>
</AppInstallForm> </AppInstallForm>
<template v-if="!inline" #footer> <template v-if="!inline" #footer>
@@ -22,7 +37,7 @@
v-permission v-permission
type="primary" type="primary"
:loading="loading" :loading="loading"
:disabled="loading || !form.appDetailId" :disabled="loading || gpuRef?.loading || !form.appDetailId"
@click="submit" @click="submit"
> >
{{ $t('commons.button.confirm') }} {{ $t('commons.button.confirm') }}
@@ -40,6 +55,8 @@ import i18n from '@/lang';
import DrawerPro from '@/components/drawer-pro/index.vue'; import DrawerPro from '@/components/drawer-pro/index.vue';
import AppInstallForm from '@/views/app-store/detail/form/index.vue'; import AppInstallForm from '@/views/app-store/detail/form/index.vue';
import AccountAddress from '../../components/account-address/index.vue'; import AccountAddress from '../../components/account-address/index.vue';
import { AI } from '@/api/interface/ai';
import GPUConfig from '../../components/gpu-config/index.vue';
const props = defineProps({ inline: Boolean }); const props = defineProps({ inline: Boolean });
const emit = defineEmits(['search', 'task', 'close']); const emit = defineEmits(['search', 'task', 'close']);
@@ -47,7 +64,14 @@ const open = ref(false);
const loading = ref(false); const loading = ref(false);
const formRef = ref<InstanceType<typeof AppInstallForm>>(); const formRef = ref<InstanceType<typeof AppInstallForm>>();
const accountRef = ref(); const accountRef = ref();
const gpuRef = ref<InstanceType<typeof GPUConfig>>();
const form = ref({ const form = ref({
gpu: {
enabled: true,
mode: 'all',
deviceIDs: [],
capabilities: ['gpu', 'utility', 'compute'],
} as AI.ModelGPUConfig,
appDetailId: 0, appDetailId: 0,
name: '', name: '',
params: {} as Record<string, any>, params: {} as Record<string, any>,
@@ -69,12 +93,15 @@ const openCreate = async () => {
if (!open.value) return; if (!open.value) return;
formRef.value.resetForm(); formRef.value.resetForm();
formRef.value.setFormData({ formRef.value.setFormData({
gpu: { enabled: true, mode: 'all', deviceIDs: [], capabilities: ['gpu', 'utility', 'compute'] },
restartPolicy: 'unless-stopped', restartPolicy: 'unless-stopped',
syncModelAccount: true, syncModelAccount: true,
modelAccountBaseURLType: 'container', modelAccountBaseURLType: 'container',
modelAccountBaseURL: '', modelAccountBaseURL: '',
}); });
await formRef.value.initForm('ollama', true); await formRef.value.initForm('ollama', true);
if (!open.value) return;
await gpuRef.value?.init(true);
await nextTick(); await nextTick();
formRef.value?.clearValidate(); formRef.value?.clearValidate();
} finally { } finally {
@@ -82,7 +109,7 @@ const openCreate = async () => {
} }
}; };
const submit = async () => { const submit = async () => {
if (!form.value.appDetailId || !(await formRef.value?.validate())) return; if (loading.value || gpuRef.value?.loading || !form.value.appDetailId || !(await formRef.value?.validate())) return;
const data = formRef.value.getFormData(); const data = formRef.value.getFormData();
if (data.editCompose && !data.dockerCompose.trim()) { if (data.editCompose && !data.dockerCompose.trim()) {
MsgError(i18n.global.t('app.composeNullErr')); MsgError(i18n.global.t('app.composeNullErr'));
@@ -112,5 +139,14 @@ watch(
watch(open, (value) => { watch(open, (value) => {
if (props.inline && !value) handleClose(); if (props.inline && !value) handleClose();
}); });
defineExpose({ openCreate, submit, loading, disabled: computed(() => !form.value.appDetailId) }); defineExpose({
openCreate,
submit,
loading,
disabled: computed(() => !form.value.appDetailId || gpuRef.value?.loading),
});
</script> </script>
<style lang="scss" scoped>
@use '@/views/ai/model/components/form-section.scss';
</style>
@@ -211,7 +211,7 @@ const buttons = [
label: i18n.global.t('commons.button.stop'), label: i18n.global.t('commons.button.stop'),
permission: true, permission: true,
disabled: (row: AI.TensorRTLLM) => { disabled: (row: AI.TensorRTLLM) => {
return row.status !== 'Running'; return !['Running', 'ReStarting', 'Restarting'].includes(row.status);
}, },
click: (row: AI.TensorRTLLM) => { click: (row: AI.TensorRTLLM) => {
operate(row, 'stop'); operate(row, 'stop');
@@ -1,66 +1,89 @@
<template> <template>
<component <component
:is="inline ? 'div' : dialog ? DialogPro : DrawerPro" :is="inline ? 'div' : DrawerPro"
v-bind=" v-bind="
inline inline
? {} ? {}
: { : {
modelValue: drawerVisiable, modelValue: drawerVisiable,
header: $t('commons.button.' + mode), header: $t('commons.button.' + mode),
title: $t('commons.button.' + mode), size: isMobile ? 'full' : 'large',
size: 'large',
} }
" "
@update:model-value="drawerVisiable = $event" @update:model-value="drawerVisiable = $event"
@close="handleClose" @close="handleClose"
> >
<el-alert :title="$t('aiTools.tensorRT.imageAlert')" class="common-prompt" :closable="false" type="warning" /> <el-alert :title="$t('aiTools.tensorRT.imageAlert')" class="common-prompt" :closable="false" type="warning" />
<el-form ref="formRef" label-position="top" :model="tensorRTLLM" :rules="rules" v-loading="loading"> <el-form
<el-form-item :label="$t('commons.table.name')" prop="name"> @validate="(prop, valid) => expandInvalidFormSection(formRef, prop, valid)"
<el-input clearable v-model.trim="tensorRTLLM.name" :disabled="mode == 'edit'" /> scroll-to-error
</el-form-item> ref="formRef"
<el-form-item :label="$t('app.containerName')" prop="containerName"> label-position="top"
<el-input v-model.trim="tensorRTLLM.containerName"></el-input> :model="tensorRTLLM"
</el-form-item> :rules="rules"
<el-form-item :label="$t('container.image')" prop="image"> v-loading="loading"
<el-input v-model.trim="tensorRTLLM.image" /> >
</el-form-item> <details open class="form-section">
<el-form-item :label="$t('app.version')" prop="version"> <summary class="form-section-header">{{ $t('home.baseInfo') }}</summary>
<el-input v-model.trim="tensorRTLLM.version" /> <div class="form-section-body">
</el-form-item> <el-form-item :label="$t('commons.table.name')" prop="name">
<div v-if="isFxplay"> <el-input clearable v-model.trim="tensorRTLLM.name" :disabled="mode == 'edit'" />
<el-form-item :label="$t('aiTools.tensorRT.modelSpeedup')" prop="modelSpeedup"> </el-form-item>
<el-switch v-model="tensorRTLLM.modelSpeedup" @change="changeModelSpeedup"></el-switch> <el-form-item :label="$t('app.containerName')" prop="containerName">
</el-form-item> <el-input v-model.trim="tensorRTLLM.containerName"></el-input>
<el-form-item </el-form-item>
:label="$t('aiTools.tensorRT.modelType')" <el-form-item :label="$t('container.image')" prop="image">
prop="modelType" <el-input v-model.trim="tensorRTLLM.image" />
v-if="tensorRTLLM.modelSpeedup" </el-form-item>
> <el-form-item :label="$t('app.version')" prop="version">
<el-select v-model="tensorRTLLM.modelType"> <el-input v-model.trim="tensorRTLLM.version" />
<el-option label="Qwen3" value="Qwen3" /> </el-form-item>
</el-select> <div v-if="isFxplay">
</el-form-item> <el-form-item :label="$t('aiTools.tensorRT.modelSpeedup')" prop="modelSpeedup">
</div> <el-switch v-model="tensorRTLLM.modelSpeedup" @change="changeModelSpeedup"></el-switch>
<el-form-item :label="$t('aiTools.tensorRT.modelDir')" prop="modelDir"> </el-form-item>
<el-input v-model="tensorRTLLM.modelDir"> <el-form-item
<template #prepend> :label="$t('aiTools.tensorRT.modelType')"
<el-button icon="Folder" @click="modelDirRef.acceptParams({ dir: true })" /> prop="modelType"
</template> v-if="tensorRTLLM.modelSpeedup"
</el-input> >
</el-form-item> <el-select v-model="tensorRTLLM.modelType">
<el-form-item :label="$t('runtime.runScript')" prop="command"> <el-option label="Qwen3" value="Qwen3" />
<el-input v-model="tensorRTLLM.command"></el-input> </el-select>
<span class="input-help"> </el-form-item>
{{ $t('aiTools.tensorRT.commandHelper') }} </div>
</span> <el-form-item :label="$t('aiTools.tensorRT.modelDir')" prop="modelDir">
</el-form-item> <el-input v-model="tensorRTLLM.modelDir">
<NodeConfig v-model="tensorRTLLM" /> <template #prepend>
<el-button icon="Folder" @click="selectModelDir" />
</template>
</el-input>
</el-form-item>
</div>
</details>
<GPUConfig ref="gpuRef" v-model="tensorRTLLM.gpu" />
<details open class="form-section">
<summary class="form-section-header">{{ $t('runtime.runScript') }}</summary>
<div class="form-section-body">
<el-form-item :label="$t('runtime.runScript')" prop="command">
<el-input v-model="tensorRTLLM.command"></el-input>
<span class="input-help">
{{ $t('aiTools.tensorRT.commandHelper') }}
</span>
</el-form-item>
</div>
</details>
<details open class="form-section">
<summary class="form-section-header">{{ $t('app.advanced') }}</summary>
<div class="form-section-body">
<NodeConfig v-model="tensorRTLLM" />
</div>
</details>
</el-form> </el-form>
<template v-if="!inline" #footer> <template v-if="!inline" #footer>
<span class="dialog-footer"> <span class="dialog-footer">
<el-button @click="drawerVisiable = false">{{ $t('commons.button.cancel') }}</el-button> <el-button @click="drawerVisiable = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button v-permission :disabled="loading" type="primary" @click="onSubmit"> <el-button v-permission :disabled="loading || gpuRef?.loading" type="primary" @click="onSubmit">
{{ $t('commons.button.confirm') }} {{ $t('commons.button.confirm') }}
</el-button> </el-button>
</span> </span>
@@ -70,26 +93,35 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import DialogPro from '@/components/dialog-pro/index.vue'; import { expandInvalidFormSection } from '@/views/ai/model/components/form-section';
const props = defineProps({ dialog: Boolean, inline: Boolean }); const props = defineProps({ inline: Boolean });
import NodeConfig from '@/views/website/runtime//components/node-config.vue'; import NodeConfig from '@/views/website/runtime//components/node-config.vue';
import DrawerPro from '@/components/drawer-pro/index.vue'; import DrawerPro from '@/components/drawer-pro/index.vue';
import FileList from '@/components/file-list/index.vue'; import FileList from '@/components/file-list/index.vue';
import { reactive, ref } from 'vue'; import { computed, nextTick, reactive, ref } from 'vue';
import { AI } from '@/api/interface/ai';
import GPUConfig from '../../components/gpu-config/index.vue';
import { Rules } from '@/global/form-rules'; import { Rules } from '@/global/form-rules';
import i18n from '@/lang'; import i18n from '@/lang';
import { ElForm, FormInstance } from 'element-plus'; import { ElForm, FormInstance } from 'element-plus';
import { createTensorRTLLM, updateTensorRTLLM } from '@/api/modules/ai'; import { createTensorRTLLM, updateTensorRTLLM } from '@/api/modules/ai';
import { MsgSuccess } from '@/utils/message'; import { MsgSuccess } from '@/utils/message';
import { useGlobalStore } from '@/composables/useGlobalStore'; import { useGlobalStore } from '@/composables/useGlobalStore';
const { isFxplay } = useGlobalStore(); const { isFxplay, isMobile, isProductPro } = useGlobalStore();
const loading = ref(false); const loading = ref(false);
const gpuRef = ref<InstanceType<typeof GPUConfig>>();
const mode = ref('create'); const mode = ref('create');
const drawerVisiable = ref(false); const drawerVisiable = ref(false);
const newTensorRTLLM = () => { const newTensorRTLLM = () => {
return { return {
gpu: {
enabled: true,
mode: 'all',
deviceIDs: [],
capabilities: ['gpu', 'utility', 'compute'],
} as AI.ModelGPUConfig,
name: '', name: '',
containerName: '', containerName: '',
version: '1.2.0rc0', version: '1.2.0rc0',
@@ -108,15 +140,32 @@ const modelDirRef = ref();
const tensorRTLLM = ref(newTensorRTLLM()); const tensorRTLLM = ref(newTensorRTLLM());
const emit = defineEmits(['search', 'close']); const emit = defineEmits(['search', 'close']);
const openCreate = (): void => { const openCreate = async () => {
mode.value = 'create'; mode.value = 'create';
drawerVisiable.value = true; drawerVisiable.value = true;
tensorRTLLM.value = newTensorRTLLM(); tensorRTLLM.value = newTensorRTLLM();
loading.value = true;
try {
await nextTick();
await gpuRef.value?.init(true);
} finally {
loading.value = false;
}
}; };
const openEdit = (rowData: any): void => { const openEdit = async (rowData: any) => {
mode.value = 'edit'; mode.value = 'edit';
tensorRTLLM.value = { ...rowData }; tensorRTLLM.value = {
...rowData,
gpu: {
...rowData.gpu,
mode: rowData.gpu?.mode || 'devices',
error: rowData.gpu ? rowData.gpu.error : i18n.global.t('aiTools.model.loadFailed'),
enabled: rowData.gpu?.enabled ?? false,
deviceIDs: [...(rowData.gpu?.deviceIDs || [])],
capabilities: [...(rowData.gpu?.capabilities || ['gpu', 'utility', 'compute'])],
},
};
if (tensorRTLLM.value.environments == null) { if (tensorRTLLM.value.environments == null) {
tensorRTLLM.value.environments = []; tensorRTLLM.value.environments = [];
} }
@@ -131,6 +180,13 @@ const openEdit = (rowData: any): void => {
tensorRTLLM.value.extraHosts = []; tensorRTLLM.value.extraHosts = [];
} }
drawerVisiable.value = true; drawerVisiable.value = true;
loading.value = true;
try {
await nextTick();
await gpuRef.value?.init(false);
} finally {
loading.value = false;
}
}; };
const handleClose = () => { const handleClose = () => {
@@ -138,6 +194,24 @@ const handleClose = () => {
emit('close'); emit('close');
}; };
const downloaderModules = import.meta.glob<{
getModelDownloaderSettings: () => Promise<{ data: { modelDir: string } }>;
}>('/src/xpack/api/modules/model-downloader.ts');
const selectModelDir = async () => {
let path = tensorRTLLM.value.modelDir;
const loadDownloader = Object.values(downloaderModules)[0];
if (isProductPro.value && loadDownloader) {
try {
const { getModelDownloaderSettings } = await loadDownloader();
const { data } = await getModelDownloaderSettings();
path = data.modelDir;
} catch {
path = '/';
}
}
modelDirRef.value.acceptParams({ dir: true, path });
};
const getModelDir = (path: string) => { const getModelDir = (path: string) => {
tensorRTLLM.value.modelDir = path; tensorRTLLM.value.modelDir = path;
}; };
@@ -164,6 +238,7 @@ const rules = reactive({
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const onSubmit = async () => { const onSubmit = async () => {
if (loading.value || gpuRef.value?.loading) return;
formRef.value?.validate(async (valid) => { formRef.value?.validate(async (valid) => {
if (!valid) return; if (!valid) return;
loading.value = true; loading.value = true;
@@ -212,5 +287,10 @@ defineExpose({
submit: onSubmit, submit: onSubmit,
loading, loading,
openEdit, openEdit,
disabled: computed(() => gpuRef.value?.loading),
}); });
</script> </script>
<style lang="scss" scoped>
@use '@/views/ai/model/components/form-section.scss';
</style>
@@ -3,7 +3,9 @@
<el-card> <el-card>
<div class="app-wrapper" @click="openDetail(app.key)"> <div class="app-wrapper" @click="openDetail(app.key)">
<div class="app-image"> <div class="app-image">
<el-avatar shape="square" :size="60" :src="getAppIconUrl(app.key, currentNode)" /> <el-avatar shape="square" :size="60" :src="getAppIconUrl(app.key, currentNode, app.icon)">
<SvgIcon iconName="p-appstore" />
</el-avatar>
</div> </div>
<div class="app-content"> <div class="app-content">
<div class="content-top"> <div class="content-top">
+119 -101
View File
@@ -16,6 +16,8 @@
/> />
<el-form <el-form
@validate="(prop, valid) => expandInvalidFormSection(formRef, prop, valid)"
scroll-to-error
v-loading="loading" v-loading="loading"
@submit.prevent @submit.prevent
ref="formRef" ref="formRef"
@@ -25,128 +27,137 @@
:rules="formRules" :rules="formRules"
:validate-on-rule-change="false" :validate-on-rule-change="false"
> >
<el-form-item :label="$t('commons.table.name')" prop="name"> <component :is="sectioned ? 'details' : 'section'" :open="sectioned" :class="{ 'form-section': sectioned }">
<el-input v-model.trim="formData.name"></el-input> <summary v-if="sectioned" class="form-section-header">{{ $t('home.baseInfo') }}</summary>
</el-form-item> <div :class="{ 'form-section-body': sectioned }">
<el-form-item :label="$t('app.version')" prop="version"> <el-form-item :label="$t('commons.table.name')" prop="name">
<el-select v-model="formData.version" @change="handleVersionChange"> <el-input v-model.trim="formData.name"></el-input>
<el-option </el-form-item>
v-for="(version, index) in appVersions" <el-form-item :label="$t('app.version')" prop="version">
:key="index" <el-select v-model="formData.version" @change="handleVersionChange">
:label="version" <el-option
:value="version" v-for="(version, index) in appVersions"
></el-option> :key="index"
</el-select> :label="version"
</el-form-item> :value="version"
></el-option>
</el-select>
</el-form-item>
<Params <Params
:key="paramKey" :key="paramKey"
v-if="showParams" v-if="showParams"
v-model:form="formData.params" v-model:form="formData.params"
v-model:params="installParams" v-model:params="installParams"
v-model:rules="formRules.params" v-model:rules="formRules.params"
:propStart="'params.'" :propStart="'params.'"
/> />
</div>
</component>
<slot name="after-params" /> <slot name="after-params" />
<el-form-item prop="advanced"> <component :is="sectioned ? 'details' : 'section'" :open="sectioned" :class="{ 'form-section': sectioned }">
<el-checkbox v-model="formData.advanced" :label="$t('app.advanced')" size="large" /> <summary v-if="sectioned" class="form-section-header">{{ $t('app.advanced') }}</summary>
</el-form-item> <el-form-item v-if="!sectioned" prop="advanced">
<el-checkbox v-model="formData.advanced" :label="$t('app.advanced')" size="large" />
<div v-if="formData.advanced">
<el-form-item :label="$t('app.containerName')" prop="containerName">
<el-input v-model.trim="formData.containerName" :placeholder="$t('app.containerNameHelper')" />
</el-form-item> </el-form-item>
<el-form-item prop="allowPort" v-if="!isHostMode"> <div v-if="formData.advanced" :class="{ 'form-section-body': sectioned }">
<el-checkbox v-model="formData.allowPort" :label="$t('app.allowPort')" size="large" /> <el-form-item :label="$t('app.containerName')" prop="containerName">
<span class="input-help">{{ $t('app.allowPortHelper') }}</span> <el-input v-model.trim="formData.containerName" :placeholder="$t('app.containerNameHelper')" />
</el-form-item> </el-form-item>
<el-form-item :label="$t('app.specifyIP')" v-if="formData.allowPort" prop="specifyIP"> <el-form-item prop="allowPort" v-if="!isHostMode">
<el-input v-model="formData.specifyIP" /> <el-checkbox v-model="formData.allowPort" :label="$t('app.allowPort')" size="large" />
<span class="input-help">{{ $t('app.specifyIPHelper') }}</span> <span class="input-help">{{ $t('app.allowPortHelper') }}</span>
</el-form-item> </el-form-item>
<el-form-item :label="$t('container.restartPolicy')" prop="restartPolicy"> <el-form-item :label="$t('app.specifyIP')" v-if="formData.allowPort" prop="specifyIP">
<el-select v-model="formData.restartPolicy" class="p-w-300"> <el-input v-model="formData.specifyIP" />
<el-option :label="$t('container.no')" value="no"></el-option> <span class="input-help">{{ $t('app.specifyIPHelper') }}</span>
<el-option :label="$t('container.always')" value="always"></el-option> </el-form-item>
<el-option :label="$t('container.onFailure')" value="on-failure"></el-option>
<el-option :label="$t('container.unlessStopped')" value="unless-stopped"></el-option>
</el-select>
</el-form-item>
<el-form-item <el-form-item :label="$t('container.restartPolicy')" prop="restartPolicy">
:label="$t('container.cpuQuota')" <el-select v-model="formData.restartPolicy" class="p-w-300">
prop="cpuQuota" <el-option :label="$t('container.no')" value="no"></el-option>
:rules="checkNumberRange(0, limits.cpu)" <el-option :label="$t('container.always')" value="always"></el-option>
> <el-option :label="$t('container.onFailure')" value="on-failure"></el-option>
<el-input type="number" class="!w-2/5" v-model.number="formData.cpuQuota" maxlength="5"> <el-option :label="$t('container.unlessStopped')" value="unless-stopped"></el-option>
<template #append>{{ $t('app.cpuCore') }}</template> </el-select>
</el-input> </el-form-item>
<span class="input-help">
{{ $t('container.limitHelper', [limits.cpu]) }}{{ $t('commons.units.core') }}
</span>
</el-form-item>
<el-form-item <el-form-item
:label="$t('container.memoryLimit')" :label="$t('container.cpuQuota')"
prop="memoryLimit" prop="cpuQuota"
:rules="checkNumberRange(0, limits.memory)" :rules="checkNumberRange(0, limits.cpu)"
> >
<el-input class="!w-2/5" v-model.number="formData.memoryLimit" maxlength="10"> <el-input type="number" class="!w-2/5" v-model.number="formData.cpuQuota" maxlength="5">
<template #append> <template #append>{{ $t('app.cpuCore') }}</template>
<el-select </el-input>
v-model="formData.memoryUnit" <span class="input-help">
placeholder="Select" {{ $t('container.limitHelper', [limits.cpu]) }}{{ $t('commons.units.core') }}
class="p-w-100" </span>
@change="changeUnit" </el-form-item>
>
<el-option label="MB" value="M" />
<el-option label="GB" value="G" />
</el-select>
</template>
</el-input>
<span class="input-help">
{{ $t('container.limitHelper', [limits.memory]) }}{{ formData.memoryUnit }}B
</span>
</el-form-item>
<el-form-item pro="gpuConfig" v-if="gpuSupport"> <el-form-item
<el-checkbox v-model="formData.gpuConfig" :label="$t('app.gpuConfig')" size="large" /> :label="$t('container.memoryLimit')"
<span class="input-help">{{ $t('app.gpuConfigHelper') }}</span> prop="memoryLimit"
</el-form-item> :rules="checkNumberRange(0, limits.memory)"
>
<el-input class="!w-2/5" v-model.number="formData.memoryLimit" maxlength="10">
<template #append>
<el-select
v-model="formData.memoryUnit"
placeholder="Select"
class="p-w-100"
@change="changeUnit"
>
<el-option label="MB" value="M" />
<el-option label="GB" value="G" />
</el-select>
</template>
</el-input>
<span class="input-help">
{{ $t('container.limitHelper', [limits.memory]) }}{{ formData.memoryUnit }}B
</span>
</el-form-item>
<el-form-item pro="pullImage"> <el-form-item pro="gpuConfig" v-if="gpuSupport && !hideGpuConfig">
<el-checkbox v-model="formData.pullImage" :label="$t('app.pullImage')" size="large" /> <el-checkbox v-model="formData.gpuConfig" :label="$t('app.gpuConfig')" size="large" />
<span class="input-help">{{ $t('app.pullImageHelper') }}</span> <span class="input-help">{{ $t('app.gpuConfigHelper') }}</span>
</el-form-item> </el-form-item>
<PushToNode <el-form-item pro="pullImage">
v-if="isMaster && isXpackOrEE && batchInstallSupport" <el-checkbox v-model="formData.pullImage" :label="$t('app.pullImage')" size="large" />
:push-node="formData.pushNode" <span class="input-help">{{ $t('app.pullImageHelper') }}</span>
:nodes="formData.nodes" </el-form-item>
type="app"
@update:push-node="formData.pushNode = $event"
@update:nodes="formData.nodes = $event"
/>
<el-form-item prop="editCompose"> <PushToNode
<el-checkbox v-model="formData.editCompose" :label="$t('app.editCompose')" size="large" /> v-if="isMaster && isXpackOrEE && batchInstallSupport"
<span class="input-help">{{ $t('app.editComposeHelper') }}</span> :push-node="formData.pushNode"
</el-form-item> :nodes="formData.nodes"
type="app"
@update:push-node="formData.pushNode = $event"
@update:nodes="formData.nodes = $event"
/>
<div v-if="formData.editCompose"> <el-form-item prop="editCompose">
<CodemirrorPro v-model="formData.dockerCompose" mode="yaml" /> <el-checkbox v-model="formData.editCompose" :label="$t('app.editCompose')" size="large" />
<span class="input-help">{{ $t('app.editComposeHelper') }}</span>
</el-form-item>
<div v-if="formData.editCompose">
<CodemirrorPro v-model="formData.dockerCompose" mode="yaml" />
</div>
</div> </div>
</div> </component>
</el-form> </el-form>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { expandInvalidFormSection } from '@/views/ai/model/components/form-section';
import { App } from '@/api/interface/app'; import { App } from '@/api/interface/app';
import { getAppByKey, getAppDetail, getAppInstalledByID } from '@/api/modules/app'; import { getAppByKey, getAppDetail, getAppInstalledByID } from '@/api/modules/app';
import { getAppStoreConfig } from '@/api/modules/setting'; import { getAppStoreConfig } from '@/api/modules/setting';
@@ -183,6 +194,8 @@ interface Props {
loading?: boolean; loading?: boolean;
modelValue?: any; modelValue?: any;
batchInstallSupport?: boolean; batchInstallSupport?: boolean;
sectioned?: boolean;
hideGpuConfig?: boolean;
} }
const limits = ref<Container.ResourceLimit>({ const limits = ref<Container.ResourceLimit>({
@@ -193,6 +206,7 @@ const limits = ref<Container.ResourceLimit>({
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
batchInstallSupport: false, batchInstallSupport: false,
sectioned: false,
}); });
interface Emits { interface Emits {
@@ -460,3 +474,7 @@ defineExpose({
initClusterForm, initClusterForm,
}); });
</script> </script>
<style lang="scss" scoped>
@use '@/views/ai/model/components/form-section.scss';
</style>
@@ -4,12 +4,15 @@
<div class="brief" v-loading="loadingApp"> <div class="brief" v-loading="loadingApp">
<div class="detail flex"> <div class="detail flex">
<div class="w-12 h-12 rounded p-1 shadow-md icon"> <div class="w-12 h-12 rounded p-1 shadow-md icon">
<img <el-avatar
:src="getAppIconUrl(app.key, currentNode)" :src="getAppIconUrl(app.key, currentNode, app.icon)"
alt="App Icon" alt="App Icon"
class="w-full h-full rounded" shape="square"
style="object-fit: contain" fit="contain"
/> class="!w-full !h-full rounded"
>
<SvgIcon iconName="p-appstore" />
</el-avatar>
</div> </div>
<div class="ml-4"> <div class="ml-4">
<div class="name mb-2"> <div class="name mb-2">
@@ -6,6 +6,7 @@
<AppIcon <AppIcon
@open-detail="$emit('openDetail')" @open-detail="$emit('openDetail')"
:appKey="installed.appKey" :appKey="installed.appKey"
:icon="installed.icon"
:currentNode="currentNode" :currentNode="currentNode"
></AppIcon> ></AppIcon>
</el-col> </el-col>
@@ -1,6 +1,13 @@
<template> <template>
<div class="icon"> <div class="icon">
<el-avatar @click="$emit('openDetail')" shape="square" :size="77" :src="getAppIconUrl(appKey, currentNode)" /> <el-avatar
@click="$emit('openDetail')"
shape="square"
:size="77"
:src="getAppIconUrl(appKey, currentNode, icon)"
>
<SvgIcon iconName="p-appstore" />
</el-avatar>
</div> </div>
</template> </template>
@@ -10,6 +17,7 @@ import { getAppIconUrl } from '@/api/modules/app';
interface Props { interface Props {
currentNode: string; currentNode: string;
appKey: string; appKey: string;
icon?: string;
} }
defineProps<Props>(); defineProps<Props>();
@@ -1,5 +1,10 @@
<template> <template>
<DrawerPro v-model="open" :header="$t('app.param')" @close="handleClose" size="normal"> <DrawerPro
v-model="open"
:header="$t('app.param')"
@close="handleClose"
:size="isMobile ? 'full' : appKey === 'ollama' ? 'large' : 'normal'"
>
<template #buttons> <template #buttons>
<el-button type="primary" plain @click="editParam" :disabled="loading" :icon="edit ? '' : 'Edit'"> <el-button type="primary" plain @click="editParam" :disabled="loading" :icon="edit ? '' : 'Edit'">
{{ edit ? $t('app.detail') : $t('commons.button.edit') }} {{ edit ? $t('app.detail') : $t('commons.button.edit') }}
@@ -60,127 +65,168 @@
</div> </div>
<div v-else v-loading="loading"> <div v-else v-loading="loading">
<el-alert :title="$t('app.updateHelper')" type="warning" :closable="false" class="common-prompt" /> <el-alert :title="$t('app.updateHelper')" type="warning" :closable="false" class="common-prompt" />
<el-form @submit.prevent ref="paramForm" :model="paramModel" label-position="top" :rules="rules"> <el-form
<div v-for="(p, index) in params" :key="index"> @validate="(prop, valid) => expandInvalidFormSection(paramForm, prop, valid)"
<el-form-item :prop="'params.' + p.key" :label="getLabel(p)"> scroll-to-error
<el-input @submit.prevent
v-if="p.type == 'number'" ref="paramForm"
type="number" :model="paramModel"
v-model.number="paramModel.params[p.key]" label-position="top"
:disabled="!p.edit" :rules="rules"
></el-input> >
<el-input <component
v-else-if="p.type == 'password'" :is="appKey === 'ollama' ? 'details' : 'section'"
:type="p.showPassword ? 'text' : 'password'" :open="appKey === 'ollama'"
v-model="paramModel.params[p.key]" :class="{ 'form-section': appKey === 'ollama' }"
:disabled="!p.edit" >
> <summary v-if="appKey === 'ollama'" class="form-section-header">{{ $t('home.baseInfo') }}</summary>
<template #append> <div :class="{ 'form-section-body': appKey === 'ollama' }">
<el-button <div v-for="(p, index) in params" :key="index">
:icon="p.showPassword ? 'Hide' : 'View'" <el-form-item :prop="'params.' + p.key" :label="getLabel(p)">
@click="p.showPassword = !p.showPassword" <el-input
/> v-if="p.type == 'number'"
</template> type="number"
</el-input> v-model.number="paramModel.params[p.key]"
<el-select :disabled="!p.edit"
v-model="paramModel.params[p.key]" ></el-input>
v-else-if="p.type == 'select'" <el-input
:multiple="p.multiple" v-else-if="p.type == 'password'"
> :type="p.showPassword ? 'text' : 'password'"
<el-option v-model="paramModel.params[p.key]"
v-for="value in p.values" :disabled="!p.edit"
:key="value.label"
:value="value.value"
:label="value.label"
:disabled="!p.edit"
></el-option>
</el-select>
<el-input v-else v-model.trim="paramModel.params[p.key]" :disabled="!p.edit"></el-input>
</el-form-item>
</div>
<AccountAddress
v-if="appKey === 'ollama'"
ref="accountRef"
show-sync
:form="paramModel"
:host-port="Number(paramModel.params.PANEL_APP_PORT_HTTP) || paramModel.httpPort"
container-port="11434"
:host-mode="paramModel.isHostMode"
/>
<el-form-item prop="advanced">
<el-checkbox v-model="paramModel.advanced" :label="$t('app.advanced')" size="large" />
</el-form-item>
<div v-if="paramModel.advanced">
<el-form-item :label="$t('app.containerName')" prop="containerName">
<el-input
v-model.trim="paramModel.containerName"
:placeholder="$t('app.containerNameHelper')"
></el-input>
</el-form-item>
<el-form-item prop="allowPort" v-if="!paramModel.isHostMode">
<el-checkbox
v-model="paramModel.allowPort"
:label="$t('app.allowPort')"
size="large"
@change="changeAllowPort"
/>
<span class="input-help">{{ $t('app.allowPortHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('app.specifyIP')" v-if="paramModel.allowPort" prop="specifyIP">
<el-input v-model="paramModel.specifyIP"></el-input>
<span class="input-help">{{ $t('app.specifyIPHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('container.restartPolicy')" prop="restartPolicy">
<el-select v-model="paramModel.restartPolicy" class="p-w-300">
<el-option :label="$t('container.no')" value="no"></el-option>
<el-option :label="$t('container.always')" value="always"></el-option>
<el-option :label="$t('container.onFailure')" value="on-failure"></el-option>
<el-option :label="$t('container.unlessStopped')" value="unless-stopped"></el-option>
</el-select>
</el-form-item>
<el-form-item :label="$t('container.cpuQuota')" prop="cpuQuota">
<el-input type="number" class="!w-2/5" v-model.number="paramModel.cpuQuota" maxlength="5">
<template #append>{{ $t('app.cpuCore') }}</template>
</el-input>
<span class="input-help">
{{ $t('container.limitHelper', [limits.cpu]) }}{{ $t('commons.units.core') }}
</span>
</el-form-item>
<el-form-item :label="$t('container.memoryLimit')" prop="memoryLimit">
<el-input class="!w-2/5" v-model.number="paramModel.memoryLimit" maxlength="10">
<template #append>
<el-select
v-model="paramModel.memoryUnit"
placeholder="Select"
class="!w-[85px]"
@change="changeUnit"
> >
<el-option label="MB" value="M" /> <template #append>
<el-option label="GB" value="G" /> <el-button
:icon="p.showPassword ? 'Hide' : 'View'"
@click="p.showPassword = !p.showPassword"
/>
</template>
</el-input>
<el-select
v-model="paramModel.params[p.key]"
v-else-if="p.type == 'select'"
:multiple="p.multiple"
>
<el-option
v-for="value in p.values"
:key="value.label"
:value="value.value"
:label="value.label"
:disabled="!p.edit"
></el-option>
</el-select> </el-select>
</template> <el-input v-else v-model.trim="paramModel.params[p.key]" :disabled="!p.edit"></el-input>
</el-input> </el-form-item>
<span class="input-help"> </div>
{{ $t('container.limitHelper', [limits.memory]) }}{{ paramModel.memoryUnit }}B
</span>
</el-form-item>
<el-form-item prop="editCompose">
<el-checkbox v-model="paramModel.editCompose" :label="$t('app.editCompose')" size="large" />
<span class="input-help">{{ $t('app.editComposeHelper') }}</span>
</el-form-item>
<div v-if="paramModel.editCompose">
<el-button @click="openDiff()" type="primary" class="!mb-2">
{{ $t('app.showDiff') }}
</el-button>
<CodemirrorPro v-model="paramModel.dockerCompose" mode="yaml"></CodemirrorPro>
</div> </div>
</div> </component>
<GPUConfig v-if="showModelGPU" ref="gpuRef" v-model="paramModel.gpu" />
<details v-if="appKey === 'ollama'" open class="form-section">
<summary class="form-section-header">
<span>{{ $t('aiTools.model.syncModelAccount') }}</span>
<el-switch
@click.stop
@keydown.stop
v-model="paramModel.syncModelAccount"
:aria-label="$t('aiTools.model.syncModelAccount')"
/>
</summary>
<div v-show="paramModel.syncModelAccount" class="form-section-body">
<AccountAddress
ref="accountRef"
show-sync
hide-sync-toggle
:form="paramModel"
:host-port="Number(paramModel.params.PANEL_APP_PORT_HTTP) || paramModel.httpPort"
container-port="11434"
:host-mode="paramModel.isHostMode"
/>
</div>
</details>
<component
:is="appKey === 'ollama' ? 'details' : 'section'"
:open="appKey === 'ollama'"
:class="{ 'form-section': appKey === 'ollama' }"
>
<summary v-if="appKey === 'ollama'" class="form-section-header">{{ $t('app.advanced') }}</summary>
<el-form-item v-if="appKey !== 'ollama'" prop="advanced">
<el-checkbox v-model="paramModel.advanced" :label="$t('app.advanced')" size="large" />
</el-form-item>
<div
v-if="appKey === 'ollama' || paramModel.advanced"
:class="{ 'form-section-body': appKey === 'ollama' }"
>
<el-form-item :label="$t('app.containerName')" prop="containerName">
<el-input
v-model.trim="paramModel.containerName"
:placeholder="$t('app.containerNameHelper')"
></el-input>
</el-form-item>
<el-form-item prop="allowPort" v-if="!paramModel.isHostMode">
<el-checkbox
v-model="paramModel.allowPort"
:label="$t('app.allowPort')"
size="large"
@change="changeAllowPort"
/>
<span class="input-help">{{ $t('app.allowPortHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('app.specifyIP')" v-if="paramModel.allowPort" prop="specifyIP">
<el-input v-model="paramModel.specifyIP"></el-input>
<span class="input-help">{{ $t('app.specifyIPHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('container.restartPolicy')" prop="restartPolicy">
<el-select v-model="paramModel.restartPolicy" class="p-w-300">
<el-option :label="$t('container.no')" value="no"></el-option>
<el-option :label="$t('container.always')" value="always"></el-option>
<el-option :label="$t('container.onFailure')" value="on-failure"></el-option>
<el-option :label="$t('container.unlessStopped')" value="unless-stopped"></el-option>
</el-select>
</el-form-item>
<el-form-item :label="$t('container.cpuQuota')" prop="cpuQuota">
<el-input type="number" class="!w-2/5" v-model.number="paramModel.cpuQuota" maxlength="5">
<template #append>{{ $t('app.cpuCore') }}</template>
</el-input>
<span class="input-help">
{{ $t('container.limitHelper', [limits.cpu]) }}{{ $t('commons.units.core') }}
</span>
</el-form-item>
<el-form-item :label="$t('container.memoryLimit')" prop="memoryLimit">
<el-input class="!w-2/5" v-model.number="paramModel.memoryLimit" maxlength="10">
<template #append>
<el-select
v-model="paramModel.memoryUnit"
placeholder="Select"
class="!w-[85px]"
@change="changeUnit"
>
<el-option label="MB" value="M" />
<el-option label="GB" value="G" />
</el-select>
</template>
</el-input>
<span class="input-help">
{{ $t('container.limitHelper', [limits.memory]) }}{{ paramModel.memoryUnit }}B
</span>
</el-form-item>
<el-form-item prop="editCompose">
<el-checkbox v-model="paramModel.editCompose" :label="$t('app.editCompose')" size="large" />
<span class="input-help">{{ $t('app.editComposeHelper') }}</span>
</el-form-item>
<div v-if="paramModel.editCompose">
<el-button @click="openDiff()" type="primary" class="!mb-2">
{{ $t('app.showDiff') }}
</el-button>
<CodemirrorPro v-model="paramModel.dockerCompose" mode="yaml"></CodemirrorPro>
</div>
</div>
</component>
</el-form> </el-form>
</div> </div>
<template #footer v-if="edit"> <template #footer v-if="edit">
<el-button @click="handleClose" :disabled="loading">{{ $t('commons.button.cancel') }}</el-button> <el-button @click="handleClose" :disabled="loading">{{ $t('commons.button.cancel') }}</el-button>
<el-button type="primary" :disabled="loading" @click="submit(paramForm)"> <el-button type="primary" :disabled="loading || gpuRef?.loading" @click="submit(paramForm)">
{{ $t('commons.button.confirm') }} {{ $t('commons.button.confirm') }}
</el-button> </el-button>
</template> </template>
@@ -188,12 +234,14 @@
</DrawerPro> </DrawerPro>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { expandInvalidFormSection } from '@/views/ai/model/components/form-section';
import AccountAddress from '@/views/ai/model/components/account-address/index.vue'; import AccountAddress from '@/views/ai/model/components/account-address/index.vue';
import GPUConfig from '@/views/ai/model/components/gpu-config/index.vue';
import Diff from '@/views/app-store/installed/upgrade/diff/index.vue'; import Diff from '@/views/app-store/installed/upgrade/diff/index.vue';
import { App } from '@/api/interface/app'; import { App } from '@/api/interface/app';
import { getAppInstallParams, updateAppInstallParams, updateInstallConfig } from '@/api/modules/app'; import { getAppInstallParams, updateAppInstallParams, updateInstallConfig } from '@/api/modules/app';
import { onBeforeUnmount, reactive, ref } from 'vue'; import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue';
import { FormInstance } from 'element-plus'; import { FormInstance } from 'element-plus';
import { Rules, checkNumberRange } from '@/global/form-rules'; import { Rules, checkNumberRange } from '@/global/form-rules';
import { MsgError, MsgSuccess } from '@/utils/message'; import { MsgError, MsgSuccess } from '@/utils/message';
@@ -218,8 +266,9 @@ interface EditForm extends App.InstallParams {
default: any; default: any;
} }
const props = defineProps({ modelGPU: Boolean });
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const { currentNode } = useGlobalStore(); const { currentNode, isMobile } = useGlobalStore();
const open = ref(false); const open = ref(false);
const loading = ref(false); const loading = ref(false);
const params = ref<EditForm[]>(); const params = ref<EditForm[]>();
@@ -228,6 +277,16 @@ const paramForm = ref<FormInstance>();
const paramModel = reactive<any>({ const paramModel = reactive<any>({
params: {}, params: {},
}); });
const advancedFields = [
'memoryLimit',
'cpuQuota',
'memoryUnit',
'allowPort',
'specifyIP',
'containerName',
'restartPolicy',
];
let initialAdvancedConfig: Record<string, unknown> = {};
const rules = reactive({ const rules = reactive({
params: {}, params: {},
cpuQuota: [Rules.requiredInput, checkNumberRange(0, 999)], cpuQuota: [Rules.requiredInput, checkNumberRange(0, 999)],
@@ -237,7 +296,9 @@ const rules = reactive({
}); });
const appType = ref(''); const appType = ref('');
const appKey = ref(''); const appKey = ref('');
const showModelGPU = computed(() => props.modelGPU && appKey.value === 'ollama');
const accountRef = ref<InstanceType<typeof AccountAddress>>(); const accountRef = ref<InstanceType<typeof AccountAddress>>();
const gpuRef = ref<InstanceType<typeof GPUConfig>>();
const appConfigUpdate = ref<App.AppConfigUpdate>({ const appConfigUpdate = ref<App.AppConfigUpdate>({
installID: 0, installID: 0,
webUI: '', webUI: '',
@@ -305,19 +366,24 @@ const acceptParams = async (props: ParamProps) => {
if (!(await get())) return; if (!(await get())) return;
open.value = true; open.value = true;
openConfig.value = false; openConfig.value = false;
if (props.edit) editParam(); if (props.edit) await editParam();
}; };
const handleClose = () => { const handleClose = () => {
emit('close'); emit('close');
open.value = false; open.value = false;
}; };
const editParam = () => { const editParam = async () => {
params.value.forEach((param: EditForm) => { params.value.forEach((param: EditForm) => {
paramModel.params[param.key] = param.value; paramModel.params[param.key] = param.value;
}); });
edit.value = !edit.value; edit.value = !edit.value;
loadLimit(); loadLimit();
if (edit.value && showModelGPU.value) {
await nextTick();
await gpuRef.value?.init(false);
}
}; };
const changeAllowPort = () => { const changeAllowPort = () => {
@@ -386,11 +452,21 @@ const get = async () => {
} }
appType.value = res.data.type; appType.value = res.data.type;
appKey.value = res.data.appKey; appKey.value = res.data.appKey;
if (showModelGPU.value) {
paramModel.gpu = res.data.gpu || {
enabled: false,
mode: 'devices',
deviceIDs: [],
capabilities: ['gpu', 'utility', 'compute'],
error: i18n.global.t('aiTools.model.loadFailed'),
};
}
paramModel.name = res.data.name; paramModel.name = res.data.name;
paramModel.httpPort = res.data.httpPort; paramModel.httpPort = res.data.httpPort;
paramModel.syncModelAccount = res.data.syncModelAccount; paramModel.syncModelAccount = res.data.syncModelAccount;
paramModel.modelAccountBaseURLType = res.data.modelAccountBaseURLType || 'container'; paramModel.modelAccountBaseURLType = res.data.modelAccountBaseURLType || 'container';
paramModel.modelAccountBaseURL = res.data.modelAccountBaseURL; paramModel.modelAccountBaseURL = res.data.modelAccountBaseURL;
initialAdvancedConfig = Object.fromEntries(advancedFields.map((key) => [key, paramModel[key]]));
return true; return true;
} catch (error) { } catch (error) {
return false; return false;
@@ -400,13 +476,11 @@ const get = async () => {
}; };
const submit = async (formEl: FormInstance) => { const submit = async (formEl: FormInstance) => {
if (!formEl) return; if (!formEl || loading.value || gpuRef.value?.loading) return;
const node = currentNode.value; const node = currentNode.value;
const installId = paramData.value.id; const installId = paramData.value.id;
await formEl.validate((valid) => { await formEl.validate((valid) => {
if (!valid) { if (!valid) return;
return;
}
ElMessageBox.confirm(i18n.global.t('app.updateWarn'), i18n.global.t('commons.button.update'), { ElMessageBox.confirm(i18n.global.t('app.updateWarn'), i18n.global.t('commons.button.update'), {
confirmButtonText: i18n.global.t('commons.button.confirm'), confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'), cancelButtonText: i18n.global.t('commons.button.cancel'),
@@ -414,24 +488,26 @@ const submit = async (formEl: FormInstance) => {
}).then(async () => { }).then(async () => {
if (!open.value || node !== currentNode.value || installId !== paramData.value.id) return; if (!open.value || node !== currentNode.value || installId !== paramData.value.id) return;
const submitModel: Record<string, any> = { installId, params: paramModel.params }; const submitModel: Record<string, any> = { installId, params: paramModel.params };
if (showModelGPU.value) submitModel.gpu = paramModel.gpu;
if (appKey.value === 'ollama') { if (appKey.value === 'ollama') {
submitModel.syncModelAccount = paramModel.syncModelAccount; submitModel.syncModelAccount = paramModel.syncModelAccount;
submitModel.modelAccountBaseURLType = paramModel.modelAccountBaseURLType; submitModel.modelAccountBaseURLType = paramModel.modelAccountBaseURLType;
submitModel.modelAccountBaseURL = accountRef.value.baseURL; submitModel.modelAccountBaseURL = accountRef.value.baseURL;
} }
if (paramModel.advanced) { const updateAdvanced =
submitModel.advanced = paramModel.advanced; appKey.value === 'ollama'
submitModel.memoryLimit = paramModel.memoryLimit; ? paramModel.editCompose ||
submitModel.cpuQuota = paramModel.cpuQuota; advancedFields.some((key) => paramModel[key] !== initialAdvancedConfig[key])
submitModel.memoryUnit = paramModel.memoryUnit; : paramModel.advanced;
submitModel.allowPort = paramModel.allowPort; if (updateAdvanced) {
submitModel.specifyIP = paramModel.specifyIP; submitModel.advanced = true;
submitModel.containerName = paramModel.containerName; for (const key of advancedFields) {
submitModel[key] = paramModel[key];
}
if (paramModel.editCompose) { if (paramModel.editCompose) {
submitModel.editCompose = paramModel.editCompose; submitModel.editCompose = paramModel.editCompose;
submitModel.dockerCompose = paramModel.dockerCompose; submitModel.dockerCompose = paramModel.dockerCompose;
} }
submitModel.restartPolicy = paramModel.restartPolicy;
} }
try { try {
loading.value = true; loading.value = true;
@@ -505,6 +581,7 @@ onBeforeUnmount(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use '@/views/ai/model/components/form-section.scss';
.change-button { .change-button {
margin-top: 5px; margin-top: 5px;
} }
@@ -65,7 +65,7 @@ const acceptParams = (apppInstall: App.AppInstalled) => {
const getVersions = async () => { const getVersions = async () => {
try { try {
const res = await getAppUpdateVersions({ appInstallID: appInstallID.value }); const res = await getAppUpdateVersions({ appInstallID: appInstallID.value, loadCompose: false });
versions.value = res.data || []; versions.value = res.data || [];
if (versions.value.length > 0) { if (versions.value.length > 0) {
req.appDetailID = versions.value[0].detailId; req.appDetailID = versions.value[0].detailId;

Some files were not shown because too many files have changed in this diff Show More