mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-31 00:04:44 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
832d367428 | ||
|
|
e6c50b89ba | ||
|
|
f2aa51066d | ||
|
|
3f594bd7a2 | ||
|
|
02031e4d7c | ||
|
|
2f7bab67b3 | ||
|
|
2d473042fc | ||
|
|
8885463a75 | ||
|
|
01feaa6059 | ||
|
|
ecb264f087 | ||
|
|
8bae2f6779 |
@@ -207,6 +207,7 @@ func (b *BaseApi) GetAppListUpdate(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Param key path string true "app key"
|
||||
// @Success 200 {file} file "app icon"
|
||||
// @Success 302 "redirect to the official icon URL"
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /apps/icon/:key [get]
|
||||
@@ -216,12 +217,18 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
|
||||
helper.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
iconBytes, filename, etag, err := appService.GetAppIcon(appKey)
|
||||
iconBytes, source, etag, err := appService.GetAppIcon(appKey)
|
||||
if err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if appicon.IsRemoteURL(source) {
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Redirect(http.StatusFound, source)
|
||||
return
|
||||
}
|
||||
|
||||
if len(iconBytes) == 0 {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
@@ -229,7 +236,7 @@ func (b *BaseApi) GetAppIcon(c *gin.Context) {
|
||||
|
||||
c.Header("Cache-Control", "public, max-age=2592000")
|
||||
|
||||
if etag != "" && filename != "" {
|
||||
if etag != "" && source != "" {
|
||||
c.Header("ETag", etag)
|
||||
if c.GetHeader("If-None-Match") == etag {
|
||||
c.Status(http.StatusNotModified)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,7 @@ func (b *BaseApi) GetServices(c *gin.Context) {
|
||||
// @Tags App
|
||||
// @Summary Search app update version by install id
|
||||
// @Accept json
|
||||
// @Param request body request.AppUpdateVersion true "request"
|
||||
// @Success 200 {array} dto.AppVersion
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
|
||||
@@ -3,11 +3,16 @@ package v2
|
||||
import (
|
||||
"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/service"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/ai_tools/accelerator"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (b *BaseApi) LoadModelGPUDevices(c *gin.Context) {
|
||||
helper.SuccessWithData(c, service.LoadModelGPUDevices())
|
||||
}
|
||||
|
||||
// @Tags AI
|
||||
// @Summary Load gpu / xpu info
|
||||
// @Accept json
|
||||
|
||||
@@ -2,6 +2,28 @@ package dto
|
||||
|
||||
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 {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -177,6 +177,7 @@ type DiskInfo struct {
|
||||
}
|
||||
|
||||
type GPUInfo struct {
|
||||
SharedMemory bool `json:"sharedMemory"`
|
||||
Type string `json:"type"`
|
||||
Index uint `json:"index"`
|
||||
NPUIndex uint `json:"npuIndex"`
|
||||
|
||||
@@ -56,6 +56,7 @@ type MonitorGPUOptions struct {
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
type GPUChartHide struct {
|
||||
SharedMemory bool `json:"sharedMemory"`
|
||||
DeviceID string `json:"deviceID"`
|
||||
Legacy bool `json:"legacy"`
|
||||
ProductName string `json:"productName"`
|
||||
|
||||
@@ -22,6 +22,9 @@ type AppInstallCreate struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Services map[string]string `json:"services"`
|
||||
TaskID string `json:"taskID"`
|
||||
GPU *dto.ModelGPUConfig `json:"gpu"`
|
||||
|
||||
GPUImageType string `json:"-"`
|
||||
|
||||
SyncModelAccount *bool `json:"syncModelAccount"`
|
||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
|
||||
@@ -128,6 +131,7 @@ type AppInstallDelete struct {
|
||||
}
|
||||
|
||||
type AppInstalledUpdate struct {
|
||||
GPU *dto.ModelGPUConfig `json:"gpu"`
|
||||
SyncModelAccount *bool `json:"syncModelAccount"`
|
||||
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
|
||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
||||
@@ -156,6 +160,7 @@ type PortUpdate struct {
|
||||
type AppUpdateVersion struct {
|
||||
AppInstallID uint `json:"appInstallID" validate:"required"`
|
||||
UpdateVersion string `json:"updateVersion"`
|
||||
LoadCompose *bool `json:"loadCompose,omitempty"`
|
||||
}
|
||||
|
||||
type AppInstallSortItem struct {
|
||||
|
||||
@@ -16,6 +16,7 @@ type TensorRTLLMCreate struct {
|
||||
Command string `json:"command" validate:"required"`
|
||||
ModelType string `json:"modelType"`
|
||||
ModelSpeedup bool `json:"modelSpeedup"`
|
||||
GPU *dto.ModelGPUConfig `json:"gpu"`
|
||||
DockerConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ type AppDTO struct {
|
||||
type AppItem struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
ID uint `json:"id"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
@@ -186,6 +187,7 @@ type AppParam struct {
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
GPU *dto.ModelGPUConfig `json:"gpu,omitempty"`
|
||||
AppKey string `json:"appKey"`
|
||||
Name string `json:"name"`
|
||||
HttpPort int `json:"httpPort"`
|
||||
@@ -194,5 +196,6 @@ type AppConfig struct {
|
||||
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
|
||||
Params []AppParam `json:"params"`
|
||||
RawCompose string `json:"rawCompose"`
|
||||
IsEdit bool `json:"isEdit"`
|
||||
request.AppContainerConfig
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package response
|
||||
|
||||
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/model"
|
||||
)
|
||||
@@ -18,6 +19,7 @@ type TensorRTLLMDTO struct {
|
||||
ModelDir string `json:"modelDir"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
GPU *dto.ModelGPUConfig `json:"gpu"`
|
||||
|
||||
ExposedPorts []request.ExposedPort `json:"exposedPorts"`
|
||||
Environments []request.Environment `json:"environments"`
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -30,6 +31,7 @@ type IAppRepo interface {
|
||||
BatchCreate(ctx context.Context, apps []model.App) error
|
||||
Create(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
|
||||
DeleteByIDs(ctx context.Context, ids []uint) error
|
||||
DeleteBy(opts ...DBOption) error
|
||||
@@ -41,6 +43,10 @@ func NewIAppRepo() IAppRepo {
|
||||
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 {
|
||||
return func(g *gorm.DB) *gorm.DB {
|
||||
if len(name) == 0 {
|
||||
|
||||
@@ -17,6 +17,8 @@ type IAppDetailRepo interface {
|
||||
WithIgnored() DBOption
|
||||
GetFirst(opts ...DBOption) (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
|
||||
DeleteByAppIds(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
|
||||
}
|
||||
|
||||
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 {
|
||||
return getTx(ctx).Model(&model.AppDetail{}).Create(&details).Error
|
||||
}
|
||||
|
||||
@@ -1394,19 +1394,10 @@ func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
|
||||
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
|
||||
agentAccountMutationMu.Lock()
|
||||
defer agentAccountMutationMu.Unlock()
|
||||
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
|
||||
_, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
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 {
|
||||
var agentCount int64
|
||||
|
||||
+63
-71
@@ -135,6 +135,7 @@ func (a AppService) PageApp(ctx *gin.Context, req request.AppSearch) (*response.
|
||||
ID: ap.ID,
|
||||
Name: ap.Name,
|
||||
Key: ap.Key,
|
||||
Icon: appicon.RemoteURL(ap.Icon),
|
||||
Limit: ap.Limit,
|
||||
GpuSupport: ap.GpuSupport,
|
||||
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) {
|
||||
return appDetailDTO, err
|
||||
}
|
||||
if err == nil && app.Resource == constant.AppResourceRemote {
|
||||
packageReady := err == nil
|
||||
if packageReady && app.Resource == constant.AppResourceRemote {
|
||||
detail.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 {
|
||||
case constant.RuntimePHP:
|
||||
paramMap := make(map[string]interface{})
|
||||
@@ -296,17 +307,19 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
|
||||
if err != nil {
|
||||
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 {
|
||||
return appDetailDTO, err
|
||||
}
|
||||
if service, ok := composeMap["services"]; ok {
|
||||
servicesMap := service.(map[string]interface{})
|
||||
for k := range servicesMap {
|
||||
for k := range composeMap.Services {
|
||||
appDetailDTO.Image = k
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
paramMap := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(detail.Params), ¶mMap); err != nil {
|
||||
@@ -315,29 +328,10 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
|
||||
appDetailDTO.Params = paramMap
|
||||
}
|
||||
|
||||
if appDetailDTO.DockerCompose == "" {
|
||||
filename := filepath.Base(appDetailDTO.DownloadUrl)
|
||||
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 {
|
||||
if err := ensureAppDetailCompose(app, &detail); 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)
|
||||
|
||||
@@ -355,6 +349,13 @@ func (a AppService) GetAppDetailByID(id uint) (*response.AppDetailDTO, error) {
|
||||
if err != nil {
|
||||
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
|
||||
paramMap := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(appDetail.Params), ¶mMap); err != nil {
|
||||
@@ -442,32 +443,14 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
||||
App: app,
|
||||
}
|
||||
composeMap := make(map[string]interface{})
|
||||
var composeRes []byte
|
||||
if req.EditCompose {
|
||||
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if appDetail.DockerCompose == "" {
|
||||
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version)
|
||||
if app.Resource == constant.AppResourceRemote {
|
||||
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
|
||||
if err != nil {
|
||||
if err = ensureAppDetailCompose(app, &appDetail); 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 {
|
||||
return
|
||||
}
|
||||
@@ -542,6 +525,26 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
|
||||
composeByte []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)
|
||||
if err != nil {
|
||||
@@ -922,22 +925,10 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
|
||||
res.CanUpdate = true
|
||||
return res, err
|
||||
}
|
||||
apps, _ := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
|
||||
for _, app := range apps {
|
||||
if app.Icon == "" {
|
||||
res.CanUpdate = true
|
||||
return res, err
|
||||
list, err := readAppList()
|
||||
if err != nil || list.LastModified != lastModified {
|
||||
list, err = getAppList()
|
||||
}
|
||||
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 {
|
||||
return res, err
|
||||
}
|
||||
@@ -968,29 +959,26 @@ func getAppFromRepo(downloadPath string) 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 {
|
||||
return nil, err
|
||||
}
|
||||
return readAppList()
|
||||
}
|
||||
|
||||
func readAppList() (*dto.AppList, error) {
|
||||
listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json")
|
||||
file, err := os.Open(listFile)
|
||||
data, err := os.ReadFile(listFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
if err = json.NewDecoder(file).Decode(list); err != nil {
|
||||
list := &dto.AppList{}
|
||||
if err = json.Unmarshal(data, list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
var InitTypes = map[string]struct{}{
|
||||
"runtime": {},
|
||||
"php": {},
|
||||
"node": {},
|
||||
}
|
||||
|
||||
func deleteCustomApp() {
|
||||
installs, err := appInstallRepo.ListBy(context.Background())
|
||||
if err != nil {
|
||||
@@ -1088,6 +1076,10 @@ func (a AppService) GetAppIcon(key string) ([]byte, string, string, error) {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
if appicon.IsRemoteURL(app.Icon) {
|
||||
return nil, app.Icon, "", nil
|
||||
}
|
||||
|
||||
if appicon.IsIconFile(app.Icon) {
|
||||
fileName, etag := appicon.ParseIconField(app.Icon)
|
||||
iconBytes, err := appicon.ReadIconFile(fileName)
|
||||
|
||||
@@ -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®ion=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®ion=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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,8 @@ import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -31,7 +29,6 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/env"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
||||
"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/joho/godotenv"
|
||||
"github.com/pkg/errors"
|
||||
@@ -259,6 +256,15 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
|
||||
if err != nil {
|
||||
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()) {
|
||||
return buserr.New("ErrInstallDirNotFound")
|
||||
}
|
||||
@@ -342,9 +348,6 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
||||
return errors.New("lifecycle script operation not supported")
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -355,10 +358,17 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
||||
}
|
||||
return err
|
||||
}
|
||||
ready := make(chan error, 1)
|
||||
started := make(chan struct{})
|
||||
operation := string(req.Operate)
|
||||
operationTask.AddSubTaskWithOps(
|
||||
task.GetTaskName(install.Name, taskType, task.TaskScopeApp),
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -385,6 +395,12 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
||||
)
|
||||
go func() {
|
||||
if taskErr := operationTask.Execute(); taskErr != nil {
|
||||
select {
|
||||
case <-started:
|
||||
default:
|
||||
ready <- taskErr
|
||||
return
|
||||
}
|
||||
if onFailure != nil {
|
||||
onFailure(taskErr)
|
||||
return
|
||||
@@ -394,7 +410,7 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
|
||||
_ = appInstallRepo.Save(context.Background(), &install)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
return <-ready
|
||||
}
|
||||
|
||||
func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
|
||||
@@ -474,9 +490,10 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
|
||||
}
|
||||
|
||||
backupDockerCompose := installed.DockerCompose
|
||||
if req.Advanced {
|
||||
updateGPU := installed.App.Key == "ollama" && req.GPU != nil
|
||||
if req.Advanced || updateGPU {
|
||||
composeMap := make(map[string]interface{})
|
||||
if req.EditCompose {
|
||||
if req.Advanced && req.EditCompose {
|
||||
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -485,16 +502,35 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !req.SkipComposeCommonConfig {
|
||||
if req.Advanced && !req.SkipComposeCommonConfig {
|
||||
if err = addDockerComposeCommonParam(composeMap, installed.ServiceName, req.AppContainerConfig, req.Params); err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
installed.DockerCompose = string(composeByte)
|
||||
}
|
||||
if req.Advanced {
|
||||
if req.ContainerName == "" {
|
||||
req.Params[constant.ContainerName] = installed.ContainerName
|
||||
} else {
|
||||
@@ -743,7 +779,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
||||
}
|
||||
if canUpgrade {
|
||||
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"))
|
||||
if readErr != nil {
|
||||
return versions, readErr
|
||||
@@ -754,33 +790,6 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
||||
}
|
||||
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 {
|
||||
majorVersion := getMajorVersion(install.Version)
|
||||
if !strings.HasPrefix(detail.Version, majorVersion) {
|
||||
@@ -792,13 +801,46 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
|
||||
FeishuConfigured: feishuTarget && feishuConfigured,
|
||||
Version: detail.Version,
|
||||
DetailId: detail.ID,
|
||||
DockerCompose: newCompose,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(versions, func(i, j int) bool {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -994,6 +1036,16 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
|
||||
res.ModelAccountBaseURLType = install.ModelAccountBaseURLType
|
||||
res.ModelAccountBaseURL = install.ModelAccountBaseURL
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1007,9 +1059,22 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
|
||||
res.RestartPolicy = getRestartPolicy(install.DockerCompose)
|
||||
res.WebUI = install.WebUI
|
||||
res.Type = install.App.Type
|
||||
if rawCompose, err := getUpgradeCompose(install, detail); err == nil {
|
||||
res.RawCompose = rawCompose
|
||||
rawCompose := install.DockerCompose
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1036,8 +1101,16 @@ func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
|
||||
}
|
||||
switch appInstall.Status {
|
||||
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
|
||||
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
|
||||
constant.StatusStarting, constant.StatusWaiting:
|
||||
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:
|
||||
if !force {
|
||||
return nil
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"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/global"
|
||||
"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/req_helper"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
|
||||
)
|
||||
|
||||
type appSyncContext struct {
|
||||
task *task.Task
|
||||
httpClient http.Client
|
||||
baseRemoteUrl string
|
||||
systemVersion string
|
||||
appsMap map[string]model.App
|
||||
@@ -34,7 +25,6 @@ type appSyncContext struct {
|
||||
oldAppIds []uint
|
||||
appTags []*model.AppTag
|
||||
skipMetaSync bool
|
||||
pendingIcons map[string]string
|
||||
}
|
||||
|
||||
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"))
|
||||
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")
|
||||
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
|
||||
*sharedCtx = &appSyncContext{skipMetaSync: true}
|
||||
@@ -82,13 +76,11 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
|
||||
|
||||
ctx := &appSyncContext{
|
||||
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),
|
||||
systemVersion: setting.SystemVersion,
|
||||
settingService: settingService,
|
||||
list: list,
|
||||
appTags: make([]*model.AppTag, 0),
|
||||
pendingIcons: make(map[string]string),
|
||||
}
|
||||
|
||||
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.appsMap, ctx.pendingIcons = getApps(oldApps, list.Apps, setting.SystemVersion, t)
|
||||
ctx.appsMap = getApps(oldApps, list.Apps, setting.SystemVersion, t)
|
||||
|
||||
var addCount, updateCount, deleteCount int
|
||||
if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil {
|
||||
@@ -129,40 +121,39 @@ func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func refreshOfficialAppIcons(list *dto.AppList, t *task.Task) error {
|
||||
if list == nil {
|
||||
return nil
|
||||
}
|
||||
apps, err := appRepo.GetBy(appRepo.WithResource(constant.AppResourceRemote))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
systemVersion, err := settingRepo.GetValueByKey("SystemVersion")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated := getApps(apps, list.Apps, systemVersion, t)
|
||||
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
|
||||
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 {
|
||||
func (c *appSyncContext) processOneApp(item appWorkItem) model.App {
|
||||
app := item.app
|
||||
l := item.appDef
|
||||
result := appWorkResult{
|
||||
appKey: l.AppProperty.Key,
|
||||
hadIcon: item.hadIcon,
|
||||
}
|
||||
|
||||
if item.hadIcon {
|
||||
status, iconField := c.downloadAppIcon(item.iconUrl, l.AppProperty.Key, app.Icon)
|
||||
result.iconStatus = status
|
||||
switch status {
|
||||
case http.StatusOK:
|
||||
app.Icon = iconField
|
||||
case http.StatusNotModified:
|
||||
default:
|
||||
result.httpFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
app.TagsKey = l.AppProperty.Tags
|
||||
if l.AppProperty.Recommend > 0 {
|
||||
@@ -189,24 +180,12 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := InitTypes[app.Type]; ok {
|
||||
dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml")
|
||||
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 {
|
||||
downloadURL := fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
|
||||
if v.LastModified <= 0 || detail.LastModified != v.LastModified || detail.Params != string(paramByte) || detail.DownloadUrl != downloadURL {
|
||||
detail.DockerCompose = ""
|
||||
}
|
||||
|
||||
detail.Params = string(paramByte)
|
||||
detail.DownloadUrl = fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
|
||||
detail.DownloadUrl = downloadURL
|
||||
detail.DownloadCallBackUrl = v.DownloadCallBackUrl
|
||||
detail.Update = true
|
||||
detail.LastModified = v.LastModified
|
||||
@@ -219,168 +198,23 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
|
||||
}
|
||||
app.Details = newDetails
|
||||
|
||||
result.app = app
|
||||
return result
|
||||
return app
|
||||
}
|
||||
|
||||
func (c *appSyncContext) syncAppIconsAndDetails() error {
|
||||
func (c *appSyncContext) syncAppDetails() error {
|
||||
total := len(c.list.Apps)
|
||||
global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total)
|
||||
|
||||
workItems := make([]appWorkItem, 0, total)
|
||||
for _, l := range c.list.Apps {
|
||||
app, ok := c.appsMap[l.AppProperty.Key]
|
||||
if !ok {
|
||||
continue
|
||||
for i, definition := range c.list.Apps {
|
||||
app, ok := c.appsMap[definition.AppProperty.Key]
|
||||
if ok {
|
||||
c.appsMap[app.Key] = c.processOneApp(appWorkItem{appDef: definition, app: app})
|
||||
}
|
||||
iconUrl, hasPending := c.pendingIcons[l.AppProperty.Key]
|
||||
workItems = append(workItems, appWorkItem{
|
||||
appDef: l,
|
||||
app: app,
|
||||
iconUrl: iconUrl,
|
||||
hadIcon: hasPending,
|
||||
})
|
||||
if (i+1)%max(1, total/4) == 0 || i+1 == total {
|
||||
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), i+1, total)
|
||||
}
|
||||
|
||||
totalWork := len(workItems)
|
||||
if totalWork == 0 {
|
||||
}
|
||||
global.LOG.Infof("[AppStore] app detail sync completed, total: %d", total)
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -411,7 +245,7 @@ func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(
|
||||
ctx.task = t
|
||||
ctx.appTags = make([]*model.AppTag, 0)
|
||||
|
||||
if err = ctx.syncAppIconsAndDetails(); err != nil {
|
||||
if err = ctx.syncAppDetails(); err != nil {
|
||||
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -216,8 +216,9 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.detail.DockerCompose = string(composeContent)
|
||||
_ = appDetailRepo.Update(context.Background(), u.detail)
|
||||
if err := cacheAppDetailCompose(&u.detail, composeContent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(u.detail.DockerCompose) == "" && strings.TrimSpace(u.req.DockerCompose) == "" {
|
||||
return buserr.WithName("ErrFileNotFound", "docker-compose.yml")
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"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/common"
|
||||
"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 {
|
||||
return err
|
||||
}
|
||||
appDetail.Update = false
|
||||
_ = appDetailRepo.Update(context.Background(), appDetail)
|
||||
marked, err := appDetailRepo.MarkDownloaded(appDetail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !marked {
|
||||
return buserr.WithDetail("ErrGetCompose", "application resources changed; retry the operation", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1268,9 +1274,8 @@ func getAppDetails(details []model.AppDetail, versions []dto.AppConfigVersion) m
|
||||
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))
|
||||
pendingIcons := make(map[string]string, len(items))
|
||||
for _, old := range oldApps {
|
||||
old.Status = constant.AppTakeDown
|
||||
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.GpuSupport = config.GpuSupport
|
||||
app.BatchInstallSupport = config.BatchInstallSupport
|
||||
if item.Icon != "" {
|
||||
pendingIcons[key] = item.Icon
|
||||
if appicon.IsRemoteURL(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
|
||||
}
|
||||
return apps, pendingIcons
|
||||
return apps
|
||||
}
|
||||
|
||||
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {
|
||||
@@ -1579,6 +1586,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
|
||||
HttpPort: installed.HttpPort,
|
||||
HttpsPort: installed.HttpsPort,
|
||||
AppName: installed.App.Name,
|
||||
Icon: appicon.RemoteURL(installed.App.Icon),
|
||||
AppKey: installed.App.Key,
|
||||
AppType: installed.App.Type,
|
||||
Path: installed.GetPath(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"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/gpu"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/psutil"
|
||||
"github.com/robfig/cron/v3"
|
||||
@@ -205,7 +207,14 @@ func (m *MonitorService) LoadGPUOptions() dto.MonitorGPUOptions {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
return data
|
||||
@@ -800,6 +809,7 @@ func loadGPUOptions(snapshot *accelerator.Snapshot) dto.MonitorGPUOptions {
|
||||
})
|
||||
for _, item := range snapshot.Devices {
|
||||
chartHide := dto.GPUChartHide{
|
||||
SharedMemory: item.GPU != nil && item.GPU.SharedMemory,
|
||||
DeviceID: item.ID,
|
||||
ProductName: item.Label,
|
||||
Type: string(item.Kind),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -57,12 +57,31 @@ func (t TensorRTLLMService) Page(req request.TensorRTLLMSearch) response.TensorR
|
||||
serverDTO.Dir = path.Join(global.Dir.TensorRTLLMDir, item.Name)
|
||||
serverDTO.Image = envs["IMAGE"]
|
||||
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)
|
||||
|
||||
composeByte, err := files.NewFileOp().GetContent(path.Join(global.Dir.TensorRTLLMDir, item.Name, "docker-compose.yml"))
|
||||
if err == nil {
|
||||
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)
|
||||
if err == nil {
|
||||
@@ -133,6 +152,9 @@ func handleLLMParams(llm *model.TensorRTLLM, create request.TensorRTLLMCreate) e
|
||||
var environments []interface{}
|
||||
environments = append(environments, fmt.Sprintf("MODEL_PATH=%s", create.ModelDir))
|
||||
for _, e := range create.Environments {
|
||||
if e.Key == "MODEL_PATH" {
|
||||
continue
|
||||
}
|
||||
environments = append(environments, fmt.Sprintf("%s=%s", e.Key, e.Value))
|
||||
}
|
||||
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))
|
||||
}
|
||||
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
|
||||
composeByte, err := yaml.Marshal(composeMap)
|
||||
|
||||
@@ -316,6 +316,8 @@ func (t *Task) ExecuteQueued(slots chan struct{}, waitingStep string, onFinish f
|
||||
|
||||
func (t *Task) Execute() error {
|
||||
if err := t.taskRepo.Save(context.Background(), t.Task); err != nil {
|
||||
_ = t.logFile.Close()
|
||||
global.RemoveTaskCancel(t.TaskID)
|
||||
return err
|
||||
}
|
||||
return t.executeRegistered()
|
||||
|
||||
@@ -26,6 +26,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
aiToolsRouter.POST("/domain/update", baseApi.UpdateBindDomain)
|
||||
|
||||
aiToolsRouter.GET("/gpu/load", baseApi.LoadGpuInfo)
|
||||
aiToolsRouter.GET("/model/gpu/devices", baseApi.LoadModelGPUDevices)
|
||||
aiToolsRouter.POST("/gpu/search", baseApi.LoadGPUMonitor)
|
||||
aiToolsRouter.GET("/gpu/options", baseApi.GetCPUOptions)
|
||||
aiToolsRouter.POST("/vllm/monitor/search", baseApi.LoadVLLMMonitor)
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
)
|
||||
|
||||
const nvidiaSMICommand = "nvidia-smi"
|
||||
@@ -20,7 +22,31 @@ func (n nvidiaSMI) LoadInfo(ctx context.Context) (*Info, error) {
|
||||
if err != nil {
|
||||
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) {
|
||||
@@ -38,6 +64,7 @@ func parseNvidiaSMI(buf []byte) (*Info, error) {
|
||||
for i := range s.Gpu {
|
||||
gpuItem := Device{
|
||||
Type: "nvidia",
|
||||
SharedMemory: UsesSystemMemory(s.Gpu[i].ProductName),
|
||||
MemoryActivity: s.Gpu[i].Utilization.MemoryUtil,
|
||||
EncoderUtil: s.Gpu[i].Utilization.EncoderUtil,
|
||||
DecoderUtil: s.Gpu[i].Utilization.DecoderUtil,
|
||||
@@ -72,6 +99,12 @@ func parseNvidiaSMI(buf []byte) (*Info, error) {
|
||||
ComputeMode: s.Gpu[i].ComputeMode,
|
||||
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.ECCErrors = []ECCError{
|
||||
{Scope: "Volatile Total", Correctable: s.Gpu[i].EccErrors.Volatile.SingleBit.Total, Uncorrectable: s.Gpu[i].EccErrors.Volatile.DoubleBit.Total},
|
||||
|
||||
@@ -17,6 +17,7 @@ type ECCError struct {
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
SharedMemory bool `json:"sharedMemory"`
|
||||
ECCPending string `json:"eccPending"`
|
||||
ECCErrors []ECCError `json:"eccErrors"`
|
||||
MemoryActivity string `json:"memoryActivity"`
|
||||
|
||||
@@ -6,13 +6,42 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
)
|
||||
|
||||
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 {
|
||||
return strings.HasPrefix(icon, IconPrefix)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,13 @@ const (
|
||||
AcceleratorMetricValuePattern = `^\s*([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\s*(.*?)\s*$`
|
||||
AscendVersionPattern = `(?i)\bVersion:\s*([^\s|]+)`
|
||||
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)
|
||||
@@ -122,6 +129,13 @@ func Init() {
|
||||
AcceleratorMetricValuePattern,
|
||||
AscendVersionPattern,
|
||||
AscendMemoryPattern,
|
||||
NvidiaGPUUUIDPattern,
|
||||
NvidiaGPUDeviceIDPattern,
|
||||
IntelGPURenderNamePattern,
|
||||
IntelGPUDevicePathPattern,
|
||||
AscendGPUDeviceNamePattern,
|
||||
AscendGPUDevicePathPattern,
|
||||
AscendGPUDeviceIDPattern,
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
|
||||
+144
-2
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
return false
|
||||
}
|
||||
@@ -41,6 +41,24 @@ func apiKeyError(c *gin.Context, err error) {
|
||||
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) {
|
||||
var req dto.APIKeySearch
|
||||
if !bindAPIKeyRequest(c, &req) {
|
||||
@@ -55,6 +73,25 @@ func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
|
||||
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) {
|
||||
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
|
||||
if !bindAPIKeyRequest(c, &req) {
|
||||
@@ -69,6 +106,22 @@ func (b *BaseApi) CreateAPIKey(c *gin.Context) {
|
||||
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) {
|
||||
var req dto.APIKeyUpdate
|
||||
if !bindAPIKeyRequest(c, &req) {
|
||||
@@ -81,6 +134,21 @@ func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
|
||||
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) {
|
||||
var req dto.APIKeyStatus
|
||||
if !bindAPIKeyRequest(c, &req) {
|
||||
@@ -93,6 +161,22 @@ func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
|
||||
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) {
|
||||
var req dto.APIKeyMutation
|
||||
if !bindAPIKeyRequest(c, &req) {
|
||||
@@ -106,5 +190,63 @@ func (b *BaseApi) RevokeAPIKey(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
@@ -417,11 +417,13 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
|
||||
}
|
||||
|
||||
// @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
|
||||
// @Success 200 {string} key
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.Response{data=string} "code=200 with the new secret; authentication and business errors use envelope code 400, 401 or 500"
|
||||
// @Header 200 {string} Cache-Control "no-store"
|
||||
// @Security PanelSession || CSRFToken
|
||||
// @Router /core/auth/api/generate [post]
|
||||
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
|
||||
func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
||||
@@ -440,12 +442,13 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
// @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
|
||||
// @Produce json
|
||||
// @Param request body dto.ApiInterfaceConfig true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Success 200 {object} dto.Response "code=200 on success; authentication and business errors use envelope code 400, 401 or 500"
|
||||
// @Security PanelSession || CSRFToken
|
||||
// @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]"}
|
||||
func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -42,7 +43,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
|
||||
ownerProvider = ownerProviders[0]
|
||||
}
|
||||
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()
|
||||
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) {
|
||||
settingRepo := repo.NewISettingRepo()
|
||||
config := APIAuthConfig{}
|
||||
|
||||
@@ -66,6 +66,26 @@ func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
|
||||
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 {
|
||||
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) {
|
||||
if key.SecretVersion != 1 || key.SecretCiphertext == "" {
|
||||
if key.DeletedAt != nil || key.Status == "Revoked" || key.SecretVersion != 1 || key.SecretCiphertext == "" {
|
||||
return "", buserr.New("ErrApiConfigKeyInvalid")
|
||||
}
|
||||
master, err := encrypt.APIKeyEncryptionKey()
|
||||
@@ -98,7 +118,7 @@ func APIKeySecret(key model.APIKey) (string, 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")
|
||||
}
|
||||
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) {
|
||||
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 != "" {
|
||||
q = q.Where("id = ?", keyID)
|
||||
} else {
|
||||
|
||||
+35
-27
@@ -6,41 +6,42 @@ import (
|
||||
)
|
||||
|
||||
type APIKeyItem struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
KeyHint string `json:"keyHint"`
|
||||
Status string `json:"status"`
|
||||
IPWhiteList string `json:"ipWhiteList"`
|
||||
APITrustedProxies string `json:"apiTrustedProxies"`
|
||||
APIKeyValidityTime int `json:"apiKeyValidityTime"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
AllowAppBinding bool `json:"allowAppBinding"`
|
||||
Revision uint64 `json:"revision"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
|
||||
Kind string `json:"kind" enums:"apiKey,legacy" example:"apiKey"`
|
||||
Name string `json:"name" example:"Deployment automation"`
|
||||
Description string `json:"description" example:"Used by the deployment service"`
|
||||
KeyHint string `json:"keyHint" example:"abcd••••1234"`
|
||||
Status string `json:"status" enums:"Enable,Disable,Revoked,Expired" example:"Enable"`
|
||||
IPWhiteList string `json:"ipWhiteList" example:"192.0.2.10"`
|
||||
APITrustedProxies string `json:"apiTrustedProxies" example:"192.0.2.1"`
|
||||
APIKeyValidityTime int `json:"apiKeyValidityTime" example:"120"`
|
||||
ExpiresAt *time.Time `json:"expiresAt" format:"date-time" extensions:"x-nullable"`
|
||||
AllowAppBinding bool `json:"allowAppBinding" example:"false"`
|
||||
Revision uint64 `json:"revision" minimum:"1" example:"1"`
|
||||
CreatedAt *time.Time `json:"createdAt" format:"date-time" extensions:"x-nullable"`
|
||||
}
|
||||
|
||||
type APIKeySearch struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
ExcludeRevoked bool `json:"excludeRevoked"`
|
||||
Page int `json:"page" minimum:"1" example:"1"`
|
||||
PageSize int `json:"pageSize" minimum:"1" maximum:"100" example:"20"`
|
||||
ExcludeRevoked bool `json:"excludeRevoked" default:"false"`
|
||||
}
|
||||
type APIKeyPage struct {
|
||||
Items []APIKeyItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
type APIKeyFields struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IPWhiteList string `json:"ipWhiteList"`
|
||||
APITrustedProxies string `json:"apiTrustedProxies"`
|
||||
APIKeyValidityTime int `json:"apiKeyValidityTime"`
|
||||
ExpiresAt json.RawMessage `json:"expiresAt"`
|
||||
AllowAppBinding bool `json:"allowAppBinding"`
|
||||
Name string `json:"name" minLength:"1" maxLength:"64" example:"Deployment automation"`
|
||||
Description string `json:"description" maxLength:"256" example:"Used by the deployment service"`
|
||||
IPWhiteList string `json:"ipWhiteList" maxLength:"4096" example:"192.0.2.10"`
|
||||
APITrustedProxies string `json:"apiTrustedProxies" maxLength:"4096" example:"192.0.2.1"`
|
||||
APIKeyValidityTime int `json:"apiKeyValidityTime" minimum:"0" maximum:"1440" example:"120"`
|
||||
ExpiresAt json.RawMessage `json:"expiresAt" swaggertype:"string" format:"date-time" extensions:"x-nullable" example:"2030-01-01T00:00:00Z"`
|
||||
AllowAppBinding bool `json:"allowAppBinding" default:"false"`
|
||||
}
|
||||
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
|
||||
}
|
||||
type APIKeyCreated struct {
|
||||
@@ -49,8 +50,15 @@ type APIKeyCreated struct {
|
||||
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
|
||||
}
|
||||
type APIKeyMutation struct {
|
||||
ID string `json:"id"`
|
||||
Revision uint64 `json:"revision"`
|
||||
ID string `json:"id" example:"8a8f0f77-1ec8-4d46-8f5a-0ea8807e2d8a"`
|
||||
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 {
|
||||
APIKeyMutation
|
||||
@@ -58,5 +66,5 @@ type APIKeyUpdate struct {
|
||||
}
|
||||
type APIKeyStatus struct {
|
||||
APIKeyMutation
|
||||
Status string `json:"status"`
|
||||
Status string `json:"status" enums:"Enable,Disable" example:"Disable"`
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type APIKey struct {
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
RevokedAt *time.Time `json:"-"`
|
||||
DeletedAt *time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type LegacyAPIKeyPolicy struct {
|
||||
|
||||
@@ -14,12 +14,12 @@ func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
|
||||
}
|
||||
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
|
||||
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
|
||||
}
|
||||
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
|
||||
items := make([]model.APIKey, 0)
|
||||
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID)
|
||||
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID).Where("deleted_at IS NULL")
|
||||
if excludeRevoked {
|
||||
q = q.Where("status <> ?", "Revoked")
|
||||
}
|
||||
|
||||
@@ -52,6 +52,27 @@ func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) {
|
||||
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 {
|
||||
status := key.Status
|
||||
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) {
|
||||
owner, err := s.owner(c)
|
||||
owner, err := s.managementOwner(c)
|
||||
if err != nil {
|
||||
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) {
|
||||
owner, err := s.owner(c)
|
||||
owner, err := s.managementOwner(c)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
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)
|
||||
err := q.Where("request_id = ?", req.RequestID).First(&key).Error
|
||||
if err == nil {
|
||||
if key.DeletedAt != nil {
|
||||
return buserr.New("ErrAPIKeyNotFound")
|
||||
}
|
||||
if key.RequestHash != requestHash {
|
||||
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 {
|
||||
owner, err := s.owner(c)
|
||||
owner, err := s.managementOwner(c)
|
||||
if err != nil {
|
||||
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 {
|
||||
owner, err := s.owner(c)
|
||||
owner, err := s.managementOwner(c)
|
||||
if err != nil {
|
||||
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 {
|
||||
owner, err := s.owner(c)
|
||||
owner, err := s.managementOwner(c)
|
||||
if err != nil {
|
||||
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 {
|
||||
if req.ID == "" || req.Revision == 0 {
|
||||
return buserr.New("ErrInvalidParams")
|
||||
}
|
||||
apiKeyMutationMu.Lock()
|
||||
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 {
|
||||
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) {
|
||||
return buserr.New("ErrAPIKeyNotFound")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if key.Revision != req.Revision || key.Status == "Revoked" {
|
||||
if key.Revision != req.Revision || (key.Status == "Revoked" && action != "delete") {
|
||||
return buserr.New("ErrAPIKeyConflict")
|
||||
}
|
||||
before := key
|
||||
|
||||
+2177
-264
File diff suppressed because it is too large
Load Diff
@@ -4542,6 +4542,9 @@
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
},
|
||||
"302": {
|
||||
"description": "redirect to the official icon URL"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
@@ -5339,6 +5342,17 @@
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"description": "request",
|
||||
"in": "body",
|
||||
"name": "request",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/request.AppUpdateVersion"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -47477,6 +47491,23 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"request.AppUpdateVersion": {
|
||||
"properties": {
|
||||
"appInstallID": {
|
||||
"type": "integer"
|
||||
},
|
||||
"loadCompose": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"updateVersion": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appInstallID"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"response.AppConfig": {
|
||||
"properties": {
|
||||
"advanced": {
|
||||
@@ -47503,6 +47534,9 @@
|
||||
"hostMode": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"isEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"memoryLimit": {
|
||||
"type": "number"
|
||||
},
|
||||
@@ -47763,6 +47797,9 @@
|
||||
"gpuSupport": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
// @schemes http https
|
||||
|
||||
// @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 eg:
|
||||
// @description curl -X GET "http://{host}:{port}/api/v2/toolbox/device/base" \
|
||||
@@ -27,7 +30,7 @@ import (
|
||||
// @description ```
|
||||
// @description - `1Panel-Token` is the key for the panel API Key.
|
||||
// @type apiKey
|
||||
// @in Header
|
||||
// @in header
|
||||
// @name 1Panel-Token
|
||||
// @securityDefinitions.apikey Timestamp
|
||||
// @type apiKey
|
||||
|
||||
@@ -159,6 +159,9 @@ var WebUrlMap = map[string]struct{}{
|
||||
"/websites": {},
|
||||
"/websites/templates": {},
|
||||
"/websites/ssl": {},
|
||||
"/websites/ssl/ca": {},
|
||||
"/websites/ssl/acme-account": {},
|
||||
"/websites/ssl/dns-account": {},
|
||||
"/websites/runtimes/php": {},
|
||||
"/websites/runtimes/node": {},
|
||||
"/websites/runtimes/java": {},
|
||||
|
||||
@@ -72,5 +72,6 @@ func coreMigrations() []*gormigrate.Migration {
|
||||
migrations.UpdateAiMenuOrder,
|
||||
migrations.UpdateAiMenuOrderV2,
|
||||
migrations.AddPanelMCP,
|
||||
migrations.AddAPIKeyDeletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,3 +15,8 @@ var AddOperationLogAPIKey = &gormigrate.Migration{
|
||||
ID: "20260915-add-operation-log-api-key",
|
||||
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{}) },
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ func OperationLog() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
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":
|
||||
c.Next()
|
||||
return
|
||||
|
||||
@@ -40,6 +40,8 @@ func (s *BaseRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey)
|
||||
authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus)
|
||||
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.POST("/current/update", baseApi.UpdateCurrentUser)
|
||||
|
||||
@@ -45,6 +45,8 @@ func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
|
||||
"enable": {"启用 API Key", "Enable API key"},
|
||||
"disable": {"停用 API Key", "Disable 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"},
|
||||
}
|
||||
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.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 != "" {
|
||||
record.IP = event.ClientIP
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { ReqPage } from '.';
|
||||
|
||||
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 {
|
||||
id: number;
|
||||
modelAccountBaseURLType: 'container' | 'localhost' | 'systemIP' | 'custom';
|
||||
@@ -34,6 +49,7 @@ export namespace AI {
|
||||
xpu: XpuInfo['xpu'];
|
||||
}
|
||||
export interface GPU {
|
||||
sharedMemory?: boolean;
|
||||
eccPending?: string;
|
||||
eccErrors?: { scope: string; correctable: string; uncorrectable: string }[];
|
||||
memoryActivity?: string;
|
||||
@@ -124,6 +140,7 @@ export namespace AI {
|
||||
chartHide: Array<ChartHide>;
|
||||
}
|
||||
export interface ChartHide {
|
||||
sharedMemory?: boolean;
|
||||
deviceID?: string;
|
||||
legacy?: boolean;
|
||||
productName: string;
|
||||
@@ -392,6 +409,7 @@ export namespace AI {
|
||||
}
|
||||
|
||||
export interface TensorRTLLM {
|
||||
gpu?: ModelGPUConfig;
|
||||
id?: number;
|
||||
name: string;
|
||||
containerName: string;
|
||||
|
||||
@@ -30,4 +30,7 @@ export namespace APIKey {
|
||||
id: string;
|
||||
revision: number;
|
||||
}
|
||||
export interface Revealed extends Reference {
|
||||
apiKey: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export namespace App {
|
||||
|
||||
export interface AppItem {
|
||||
name: string;
|
||||
icon?: string;
|
||||
key: string;
|
||||
id: number;
|
||||
description: string;
|
||||
@@ -137,6 +138,7 @@ export namespace App {
|
||||
}
|
||||
|
||||
export interface AppInstall {
|
||||
gpu?: AI.ModelGPUConfig;
|
||||
syncModelAccount?: boolean;
|
||||
modelAccountBaseURLType?: string;
|
||||
modelAccountBaseURL?: string;
|
||||
@@ -291,6 +293,7 @@ export namespace App {
|
||||
feishuConfigured?: boolean;
|
||||
version: string;
|
||||
detailId: number;
|
||||
dockerCompose?: string;
|
||||
}
|
||||
|
||||
export interface InstallParams {
|
||||
@@ -310,6 +313,7 @@ export namespace App {
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
gpu?: AI.ModelGPUConfig;
|
||||
appKey: string;
|
||||
name: string;
|
||||
httpPort: number;
|
||||
@@ -318,6 +322,7 @@ export namespace App {
|
||||
modelAccountBaseURL: string;
|
||||
params: InstallParams[];
|
||||
rawCompose?: string;
|
||||
isEdit?: boolean;
|
||||
cpuQuota: number;
|
||||
memoryLimit: number;
|
||||
memoryUnit: string;
|
||||
@@ -341,6 +346,7 @@ export namespace App {
|
||||
export interface AppUpdateVersionReq {
|
||||
appInstallID: number;
|
||||
updateVersion?: string;
|
||||
loadCompose?: boolean;
|
||||
}
|
||||
|
||||
export interface AppIgnoreReq {
|
||||
|
||||
@@ -151,6 +151,7 @@ export namespace Dashboard {
|
||||
inodesUsedPercent: number;
|
||||
}
|
||||
export interface GPUInfo {
|
||||
sharedMemory?: boolean;
|
||||
type: string;
|
||||
index: number;
|
||||
npuIndex: number;
|
||||
|
||||
@@ -43,6 +43,10 @@ export const updateBindDomain = (req: AI.BindDomain) => {
|
||||
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 = () => {
|
||||
return http.get<AI.Info>(`/ai/gpu/load`);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ const prefix = '/core/auth/api/keys';
|
||||
export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => {
|
||||
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);
|
||||
};
|
||||
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) => {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import http from '@/api';
|
||||
import { ResPage } from '../interface';
|
||||
import { App } from '../interface/app';
|
||||
import { TimeoutEnum } from '@/enums/http-enum';
|
||||
import { resolveAppIconUrl } from '@/utils/app-icon';
|
||||
|
||||
export const syncApp = (req: App.AppStoreSync) => {
|
||||
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 });
|
||||
};
|
||||
|
||||
export const getAppInstallParams = (id: number) => {
|
||||
return http.get<App.AppConfig>(`apps/installed/params/${id}`);
|
||||
export const getAppInstallParams = (id: number, node?: string) => {
|
||||
const params = node ? `?operateNode=${encodeURIComponent(node)}` : '';
|
||||
return http.get<App.AppConfig>(`apps/installed/params/${id}${params}`);
|
||||
};
|
||||
|
||||
export const updateAppInstallParams = (req: any) => {
|
||||
@@ -135,10 +137,9 @@ export const getCurrentNodeCustomAppConfig = () => {
|
||||
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 params = node ? `?operateNode=${node}` : '';
|
||||
return `${baseURL}/apps/icon/${appKey}${params}`;
|
||||
return resolveAppIconUrl(appKey, node, icon, baseURL);
|
||||
}
|
||||
|
||||
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>
|
||||
<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-form-item>
|
||||
<div v-if="form.advanced">
|
||||
@@ -83,6 +83,7 @@ const props = withDefaults(
|
||||
editCompose: boolean;
|
||||
dockerCompose: string;
|
||||
};
|
||||
showToggle?: boolean;
|
||||
showAllowPort?: boolean;
|
||||
showSpecifyIP?: boolean;
|
||||
showContainerName?: boolean;
|
||||
@@ -93,6 +94,7 @@ const props = withDefaults(
|
||||
autoLoadLimit?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showToggle: true,
|
||||
showAllowPort: true,
|
||||
showSpecifyIP: true,
|
||||
showContainerName: true,
|
||||
|
||||
@@ -6,27 +6,14 @@
|
||||
:confirm-before-close="true"
|
||||
@before-close="beforeClose"
|
||||
>
|
||||
<template v-if="created">
|
||||
<el-alert
|
||||
:title="$t(secret ? 'apiKeyManagement.saveSecret' : 'apiKeyManagement.alreadyCreated')"
|
||||
:type="secret ? 'warning' : 'info'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<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-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="text-[var(--el-text-color-regular)]"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<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">
|
||||
<li>
|
||||
@@ -139,13 +126,10 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<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') }}
|
||||
</el-button>
|
||||
<el-button v-if="created" type="primary" :disabled="!!secret && !saved" @click="finish">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
<el-button v-else type="primary" :loading="busy" @click="submit">
|
||||
<el-button type="primary" :loading="busy" @click="submit">
|
||||
{{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }}
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -154,7 +138,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { v4 as uuidv4 } from 'uuid';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
@@ -174,9 +158,6 @@ const visible = ref(false);
|
||||
const busy = ref(false);
|
||||
const uncertain = ref(false);
|
||||
const item = ref<APIKey.Item>();
|
||||
const created = ref<APIKey.Item>();
|
||||
const secret = ref('');
|
||||
const saved = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const ipMode = ref('restricted');
|
||||
const neverExpires = ref(false);
|
||||
@@ -196,8 +177,6 @@ let editorVersion = 0;
|
||||
|
||||
const discardEditor = () => {
|
||||
editorVersion++;
|
||||
secret.value = '';
|
||||
created.value = undefined;
|
||||
visible.value = false;
|
||||
busy.value = false;
|
||||
};
|
||||
@@ -240,9 +219,6 @@ const rules = {
|
||||
const open = (existing?: APIKey.Item, fromApp = false) => {
|
||||
editorVersion++;
|
||||
item.value = existing;
|
||||
created.value = undefined;
|
||||
secret.value = '';
|
||||
saved.value = false;
|
||||
uncertain.value = false;
|
||||
pendingCreate = undefined;
|
||||
requestID = uuidv4();
|
||||
@@ -269,19 +245,9 @@ const open = (existing?: APIKey.Item, fromApp = false) => {
|
||||
nextTick(() => formRef.value?.clearValidate());
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
secret.value = '';
|
||||
visible.value = false;
|
||||
if (created.value) emit('created', created.value);
|
||||
};
|
||||
const beforeClose = async (done: () => void) => {
|
||||
const beforeClose = (done: () => void) => {
|
||||
if (busy.value) return;
|
||||
if (secret.value && !saved.value) {
|
||||
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
|
||||
return;
|
||||
}
|
||||
if (created.value) finish();
|
||||
else if (uncertain.value) emit('changed');
|
||||
if (uncertain.value) emit('changed');
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -310,9 +276,13 @@ const submit = async () => {
|
||||
pendingCreate ??= { ...payload, requestID };
|
||||
const response = await createAPIKey(pendingCreate);
|
||||
if (ticket !== editorVersion) return;
|
||||
created.value = response.data.item;
|
||||
secret.value = response.data.apiKey;
|
||||
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');
|
||||
} catch (error) {
|
||||
@@ -341,16 +311,12 @@ watch(
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
onBeforeUnmount(discardEditor);
|
||||
onBeforeRouteLeave(async (to) => {
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) {
|
||||
discardEditor();
|
||||
return true;
|
||||
}
|
||||
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();
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
<el-col :span="22">
|
||||
<el-alert class="mt-2" :show-icon="true" type="warning" :closable="false">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</el-alert>
|
||||
<slot name="content"></slot>
|
||||
<ul v-for="(item, index) in form.names" :key="index">
|
||||
<div style="word-wrap: break-word">
|
||||
<div class="dialog-object-name">
|
||||
<li>{{ item }}</li>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
class="mr-2 flex-shrink-0"
|
||||
></svg-icon>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
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',
|
||||
manage: 'Manage API Keys',
|
||||
personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.',
|
||||
@@ -23,10 +28,6 @@ const message = {
|
||||
allowAppBinding: 'Allow APP QR binding',
|
||||
bindingDisableHelp:
|
||||
'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:
|
||||
'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.',
|
||||
legacyHelp:
|
||||
@@ -1036,6 +1037,23 @@ const message = {
|
||||
validateImageAvailabilityHelper: 'Generates one minimal image before saving and may incur charges',
|
||||
},
|
||||
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',
|
||||
modelName: 'Model name',
|
||||
modelArchitecture: 'Model type',
|
||||
@@ -1053,11 +1071,11 @@ const message = {
|
||||
onlineModel: 'Online models',
|
||||
instanceDeployment: 'Deploy by instance',
|
||||
nvidiaDeployment: 'NVIDIA-optimized deployment',
|
||||
engines: 'Inference engines',
|
||||
engines: 'Local inference engines',
|
||||
modelManagement: 'Model management',
|
||||
serviceDetails: 'Service details',
|
||||
deployments: 'Local deployments',
|
||||
files: 'Model files',
|
||||
files: 'Model hub',
|
||||
connectExternal: 'Connect external service',
|
||||
deploymentTemplates: 'vLLM startup command templates',
|
||||
engineProvider: 'Engine / Provider',
|
||||
@@ -1074,7 +1092,7 @@ const message = {
|
||||
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.',
|
||||
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',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1625,6 +1643,11 @@ const message = {
|
||||
timePerOutputTokenChart: 'Inter-token latency',
|
||||
},
|
||||
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',
|
||||
hotspotTemperature: 'Hotspot temperature',
|
||||
fanRPM: 'Fan speed (RPM)',
|
||||
@@ -1660,7 +1683,6 @@ const message = {
|
||||
historyPeak: 'Peak',
|
||||
historyAggregated:
|
||||
'{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',
|
||||
historyLoadFailed: 'Failed to load history. Please retry.',
|
||||
collectedAt: 'Collected at',
|
||||
@@ -1733,8 +1755,8 @@ const message = {
|
||||
prohibitedHelper: 'Prohibited: Concurrent process execution is not allowed',
|
||||
migModeHelper: 'Used to create MIG instances, implementing physical GPU isolation at the user layer.',
|
||||
migModeNA: 'Not Supported',
|
||||
current: 'Real-time Monitoring',
|
||||
history: 'Historical Records',
|
||||
current: 'Real-time Status',
|
||||
history: 'Monitoring Data',
|
||||
notSupport: 'The current version or driver does not support displaying this parameter.',
|
||||
},
|
||||
mcp: {
|
||||
@@ -2042,8 +2064,6 @@ const message = {
|
||||
retry: 'Retry selected points',
|
||||
environment: 'Environment',
|
||||
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.',
|
||||
secretHelp: 'API keys and headers are excluded from copies. Enter them again if needed.',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
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',
|
||||
manage: 'Gestionar claves API',
|
||||
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',
|
||||
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.',
|
||||
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:
|
||||
'El resultado es incierto. Reintenta con los mismos datos para evitar duplicados, o cierra y revisa la lista antes de crear otra.',
|
||||
legacyHelp:
|
||||
@@ -1038,6 +1039,23 @@ const message = {
|
||||
validateImageAvailabilityHelper: 'Genera una imagen mínima antes de guardar y puede generar costes',
|
||||
},
|
||||
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',
|
||||
modelName: 'Nombre del modelo',
|
||||
modelArchitecture: 'Tipo de modelo',
|
||||
@@ -1055,11 +1073,11 @@ const message = {
|
||||
onlineModel: 'Modelos en línea',
|
||||
instanceDeployment: 'Despliegue por instancia',
|
||||
nvidiaDeployment: 'Despliegue optimizado para NVIDIA',
|
||||
engines: 'Motores de inferencia',
|
||||
engines: 'Motores de inferencia locales',
|
||||
modelManagement: 'Gestión de modelos',
|
||||
serviceDetails: 'Detalles del servicio',
|
||||
deployments: 'Despliegues locales',
|
||||
files: 'Archivos de modelos',
|
||||
files: 'Catálogo de modelos',
|
||||
connectExternal: 'Conectar servicio externo',
|
||||
deploymentTemplates: 'Plantillas de comandos de inicio de vLLM',
|
||||
engineProvider: 'Motor / Proveedor',
|
||||
@@ -1076,7 +1094,7 @@ const message = {
|
||||
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.',
|
||||
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',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1635,6 +1653,12 @@ const message = {
|
||||
timePerOutputTokenChart: 'Latencia entre tokens',
|
||||
},
|
||||
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',
|
||||
hotspotTemperature: 'Temperatura del punto caliente',
|
||||
fanRPM: 'Velocidad del ventilador (RPM)',
|
||||
@@ -1670,7 +1694,6 @@ const message = {
|
||||
historyPeak: 'Máximo',
|
||||
historyAggregated:
|
||||
'{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',
|
||||
historyLoadFailed: 'No se pudo cargar el historial. Inténtelo de nuevo.',
|
||||
collectedAt: 'Fecha de recopilación',
|
||||
@@ -1744,8 +1767,8 @@ const message = {
|
||||
migModeHelper:
|
||||
'Se utiliza para crear instancias MIG, implementando aislamiento físico de GPU en la capa de usuario.',
|
||||
migModeNA: 'No Compatible',
|
||||
current: 'Monitoreo en Tiempo Real',
|
||||
history: 'Registros Históricos',
|
||||
current: 'Estado en tiempo real',
|
||||
history: 'Datos de monitoreo',
|
||||
notSupport: 'La versión actual o el controlador no admiten mostrar este parámetro.',
|
||||
processCount: 'Procesos',
|
||||
},
|
||||
@@ -2060,8 +2083,6 @@ const message = {
|
||||
retry: 'Repetir puntos seleccionados',
|
||||
environment: 'Entorno',
|
||||
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.',
|
||||
secretHelp: 'Las copias no incluyen claves API ni cabeceras. Vuelva a introducirlas si es necesario.',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
apiKeyManagement: {
|
||||
reveal: 'نمایش کلید محرمانه',
|
||||
hideSecret: 'پنهان کردن کلید محرمانه',
|
||||
revealHelp: 'کلید را در جای امن نگه دارید. نمایش آن، کلید غیرفعال یا منقضیشده را قابل استفاده نمیکند.',
|
||||
deleteConfirm:
|
||||
'«{0}» حذف شود؟ کلید محرمانه نابود و از فهرست حذف میشود. درخواستهای جدید رد و اتصالهای ترمینال مرتبط بسته میشوند. سوابق حسابرسی حفظ و کارهای در حال اجرا لغو نمیشوند. این عملیات برگشتپذیر نیست.',
|
||||
title: 'API Key',
|
||||
manage: 'مدیریت کلیدهای API',
|
||||
personalEntry: 'کلیدها، شرایط دسترسی و اتصال برنامه را در تنظیمات مدیریت کنید.',
|
||||
@@ -23,10 +28,6 @@ const message = {
|
||||
allowAppBinding: 'اجازه اتصال برنامه با QR',
|
||||
bindingDisableHelp:
|
||||
'با غیرفعال کردن اتصال از طریق QR، اتصال دستگاههای جدید متوقف میشود. دستگاههای متصل میتوانند تا زمان غیرفعالسازی، لغو یا انقضای کلید به استفاده از آن ادامه دهند.',
|
||||
saveSecret: 'اکنون کلید را کپی و ذخیره کنید. پس از بستن، مدیریت عادی نمیتواند کلید کامل را دوباره نشان دهد.',
|
||||
saved: 'کلید را ذخیره کردم',
|
||||
alreadyCreated:
|
||||
'این کلید قبلاً ایجاد شده و دوباره نمایش داده نمیشود. اگر پاسخ اصلی گم شده است، کلید را لغو و دوباره ایجاد کنید یا در صورت اجازه از QR استفاده کنید.',
|
||||
uncertain:
|
||||
'نتیجه درخواست نامشخص است. برای جلوگیری از تکرار، با همان اطلاعات دوباره تلاش کنید یا ببندید و پیش از ایجاد کلید دیگر فهرست را بررسی کنید.',
|
||||
legacyHelp:
|
||||
@@ -1021,6 +1022,22 @@ const message = {
|
||||
validateImageAvailabilityHelper: 'پیش از ذخیره یک تصویر حداقلی تولید میشود و ممکن است هزینه داشته باشد',
|
||||
},
|
||||
model: {
|
||||
gpuAcceleration: 'شتابدهی GPU',
|
||||
gpuMode: 'انتخاب شتابدهنده',
|
||||
gpuAll: 'همه شتابدهندهها',
|
||||
gpuCount: 'تعیین تعداد',
|
||||
gpuSpecific: 'انتخاب دستگاهها',
|
||||
gpuAllHelper: 'از همه شتابدهندههای موجود از این نوع در گره فعلی استفاده شود.',
|
||||
gpuCountHelper: 'محیط اجرا تعداد درخواستی شتابدهندهها را بدون اتصال به دستگاههای مشخص تخصیص میدهد.',
|
||||
gpuSpecificHelper: 'شتابدهندههای مشخص را بهصورت دستی انتخاب کنید.',
|
||||
gpuCountRequired: 'یک عدد صحیح بزرگتر از صفر وارد کنید',
|
||||
gpuDevices: 'دستگاههای GPU',
|
||||
gpuCapabilitiesHelper:
|
||||
'دسترسی به GPU الزامی است؛ نظارت و مدیریت (utility) و پشتیبانی محاسباتی (compute) را در صورت نیاز فعال کنید.',
|
||||
gpuSelectRequired: 'حداقل یک دستگاه GPU موجود را انتخاب کنید.',
|
||||
gpuUnsupported: 'پشتیبانی نمیشود',
|
||||
gpuNotDetected: 'هیچ شتابدهنده پشتیبانیشدهای شناسایی نشد',
|
||||
gpuMissing: 'در دسترس نیست',
|
||||
modelCategory: 'دستهبندی مدل',
|
||||
modelName: 'نام مدل',
|
||||
modelArchitecture: 'نوع مدل',
|
||||
@@ -1038,11 +1055,11 @@ const message = {
|
||||
onlineModel: 'مدلهای آنلاین',
|
||||
instanceDeployment: 'استقرار بر اساس نمونه',
|
||||
nvidiaDeployment: 'استقرار بهینهشده برای NVIDIA',
|
||||
engines: 'موتورهای استنتاج',
|
||||
engines: 'موتورهای استنتاج محلی',
|
||||
modelManagement: 'مدیریت مدلها',
|
||||
serviceDetails: 'جزئیات سرویس',
|
||||
deployments: 'استقرارهای محلی',
|
||||
files: 'فایلهای مدل',
|
||||
files: 'مرکز مدلها',
|
||||
connectExternal: 'اتصال به سرویس خارجی',
|
||||
deploymentTemplates: 'الگوهای فرمان راهاندازی vLLM',
|
||||
engineProvider: 'موتور / ارائهدهنده',
|
||||
@@ -1059,7 +1076,7 @@ const message = {
|
||||
container_conn_helper: 'از این آدرس برای دسترسی بین کانتینرها یا اتصال استفاده کنید',
|
||||
from_remote: 'این مدل از طریق 1Panel دانلود نشده است، لاگ مربوط به دریافت وجود ندارد.',
|
||||
no_logs: 'لاگ دریافت این مدل حذف شده است و قابل مشاهده نیست.',
|
||||
vllmVersionHelper: 'برای سرورهای FusionXpark GB 10، لطفاً نسخه -cu130 را انتخاب کنید.',
|
||||
vllmVersionHelper: 'برای سرورهای FusionXpark GB10، لطفاً نسخه -cu130 را انتخاب کنید.',
|
||||
vllmDeployProfile: 'مدل استقرار',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1608,6 +1625,11 @@ const message = {
|
||||
timePerOutputTokenChart: 'تأخیر بین توکنها',
|
||||
},
|
||||
gpu: {
|
||||
systemMemory: 'حافظهٔ مشترک سیستم',
|
||||
systemMemoryUsed: 'حافظهٔ مصرفشدهٔ سیستم',
|
||||
systemMemoryTotal: 'کل حافظهٔ سیستم',
|
||||
systemMemoryAvailable: 'حافظهٔ در دسترس سیستم',
|
||||
systemMemoryHelper: 'بین CPU و GPU مشترک است. مصرف حافظهٔ کل سیستم را نشان میدهد، نه فقط مصرف GPU.',
|
||||
memoryActivity: 'فعالیت خواندن/نوشتن حافظه',
|
||||
hotspotTemperature: 'دمای نقطه داغ',
|
||||
fanRPM: 'سرعت فن (RPM)',
|
||||
@@ -1643,7 +1665,6 @@ const message = {
|
||||
historyPeak: 'بیشینه',
|
||||
historyAggregated:
|
||||
'{count} نمونه در بازههای {seconds} ثانیهای تجمیع شدهاند؛ فقط تعداد فرایندها نمایش داده میشود.',
|
||||
historyRaw: '{count} نمونه اصلی.',
|
||||
historyMetricEmpty: 'در بازه زمانی انتخابشده دادهای برای این شاخص وجود ندارد',
|
||||
historyLoadFailed: 'بارگیری سابقه ناموفق بود. دوباره تلاش کنید.',
|
||||
collectedAt: 'زمان گردآوری',
|
||||
@@ -1716,8 +1737,8 @@ const message = {
|
||||
migModeHelper:
|
||||
'برای ایجاد نمونههای MIG استفاده میشود و ایزولهسازی فیزیکی GPU را در لایه کاربر پیادهسازی میکند.',
|
||||
migModeNA: 'پشتیبانی نمیشود',
|
||||
current: 'نظارت بلادرنگ',
|
||||
history: 'سوابق تاریخی',
|
||||
current: 'وضعیت بلادرنگ',
|
||||
history: 'دادههای نظارتی',
|
||||
notSupport: 'نسخه فعلی یا درایور از نمایش این پارامتر پشتیبانی نمیکند.',
|
||||
},
|
||||
mcp: {
|
||||
@@ -2020,8 +2041,6 @@ const message = {
|
||||
retry: 'تکرار نقاط انتخابشده',
|
||||
environment: 'محیط اجرا',
|
||||
plan: 'پیکربندی ثابت',
|
||||
clientHelp:
|
||||
'کانتینر کلاینت در گره اصلی به سرویس موجود درخواست میفرستد. سرویس استنتاج راهاندازی نمیکند و به GPU نیاز ندارد.',
|
||||
retryHelp: 'فقط نقاط غیرعادی انتخابشده با همان پارامترها و بذرهای جدید تکرار میشوند.',
|
||||
secretHelp: 'کپیها شامل کلید API و سرآیندها نیستند. در صورت نیاز دوباره وارد کنید.',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
apiKeyManagement: {
|
||||
reveal: 'シークレットを表示',
|
||||
hideSecret: 'シークレットを隠す',
|
||||
revealHelp: 'シークレットを安全に保管してください。表示しても無効または期限切れのキーは使用できません。',
|
||||
deleteConfirm:
|
||||
'「{0}」を削除しますか?シークレットは破棄され、キーは一覧から削除されます。新しいリクエストは拒否され、関連ターミナルは切断されます。監査記録は保持され、実行中のタスクは中止されません。この操作は元に戻せません。',
|
||||
title: 'API Key',
|
||||
manage: 'API Key を管理',
|
||||
personalEntry: '設定で自分のキー、アクセス条件、APP の QR 連携を管理します。',
|
||||
@@ -23,10 +28,6 @@ const message = {
|
||||
allowAppBinding: 'APP の QR 連携を許可',
|
||||
bindingDisableHelp:
|
||||
'QR 連携を無効にすると、新規の連携を受け付けなくなります。連携済みの端末は、キーが無効化、失効、または期限切れになるまで引き続き利用できます。',
|
||||
saveSecret: '今すぐキーをコピーして保存してください。閉じると通常のキー管理から完全なキーを再表示できません。',
|
||||
saved: 'キーを保存しました',
|
||||
alreadyCreated:
|
||||
'このキーは作成済みで、再表示できません。最初の応答を受け取れなかった場合は失効させて再作成するか、許可されていれば QR を使用してください。',
|
||||
uncertain: 'リクエスト結果が不明です。重複を避けるため同じ設定で再試行するか、閉じて一覧を確認してください。',
|
||||
legacyHelp:
|
||||
'旧版キーは従来の API 接続との互換性を維持します。有効化、無効化、リセットはこのキーにのみ適用されます。',
|
||||
@@ -1025,6 +1026,22 @@ const message = {
|
||||
validateImageAvailabilityHelper: '保存前に最小画像を1枚生成するため、料金が発生する場合があります',
|
||||
},
|
||||
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: 'モデルカテゴリ',
|
||||
modelName: 'モデル名',
|
||||
modelArchitecture: 'モデルの種類',
|
||||
@@ -1042,11 +1059,11 @@ const message = {
|
||||
onlineModel: 'オンラインモデル',
|
||||
instanceDeployment: 'インスタンス単位でデプロイ',
|
||||
nvidiaDeployment: 'NVIDIA 向け最適化デプロイ',
|
||||
engines: '推論エンジン',
|
||||
engines: 'ローカル推論エンジン',
|
||||
modelManagement: 'モデル管理',
|
||||
serviceDetails: 'サービス詳細',
|
||||
deployments: 'ローカルデプロイ',
|
||||
files: 'モデルファイル',
|
||||
files: 'モデル広場',
|
||||
connectExternal: '外部サービスに接続',
|
||||
deploymentTemplates: 'vLLM 起動コマンドテンプレート',
|
||||
engineProvider: 'エンジン / プロバイダー',
|
||||
@@ -1063,7 +1080,7 @@ const message = {
|
||||
container_conn_helper: 'コンテナ間のアクセスまたは接続にこのアドレスを使用',
|
||||
from_remote: 'このモデルは1Panelを介してダウンロードされておらず、関連するプルログはありません。',
|
||||
no_logs: 'このモデルのプルログは削除されており、関連するログを表示できません。',
|
||||
vllmVersionHelper: 'FusionXpark GB 10 サーバーでは -cu130 バージョンを選択してください。',
|
||||
vllmVersionHelper: 'FusionXpark GB10 サーバーでは -cu130 バージョンを選択してください。',
|
||||
vllmDeployProfile: 'デプロイモデル',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1612,6 +1629,11 @@ const message = {
|
||||
timePerOutputTokenChart: 'トークン間遅延',
|
||||
},
|
||||
gpu: {
|
||||
systemMemory: '共有システムメモリ',
|
||||
systemMemoryUsed: 'システムメモリ使用量',
|
||||
systemMemoryTotal: 'システムメモリ合計',
|
||||
systemMemoryAvailable: '利用可能なシステムメモリ',
|
||||
systemMemoryHelper: 'CPU と GPU で共有します。GPU 単体ではなく、システム全体のメモリ使用量を表示します。',
|
||||
memoryActivity: 'メモリ読み書き稼働率',
|
||||
hotspotTemperature: 'ホットスポット温度',
|
||||
fanRPM: 'ファン回転数(RPM)',
|
||||
@@ -1646,7 +1668,6 @@ const message = {
|
||||
historyAverage: '平均値',
|
||||
historyPeak: '最大値',
|
||||
historyAggregated: '{count} 件のサンプルを {seconds} 秒間隔で集計。プロセスは件数のみ表示します。',
|
||||
historyRaw: '元のサンプル {count} 件。',
|
||||
historyMetricEmpty: '選択した期間にこの指標のデータはありません',
|
||||
historyLoadFailed: '履歴の読み込みに失敗しました。再試行してください。',
|
||||
collectedAt: '収集日時',
|
||||
@@ -1716,8 +1737,8 @@ const message = {
|
||||
prohibitedHelper: '禁止: プロセスの同時実行は許可されません',
|
||||
migModeHelper: 'MIGインスタンスを作成するために使用され、ユーザーレイヤーでGPUの物理的隔離を実装します。',
|
||||
migModeNA: 'サポートされていません',
|
||||
current: 'リアルタイム監視',
|
||||
history: '履歴記録',
|
||||
current: 'リアルタイム状態',
|
||||
history: '監視データ',
|
||||
notSupport: '現在のバージョンまたはドライバーはこのパラメータの表示をサポートしていません。',
|
||||
processCount: 'プロセス数',
|
||||
},
|
||||
@@ -2025,8 +2046,6 @@ const message = {
|
||||
retry: '選択した測定点を再試行',
|
||||
environment: '実行環境',
|
||||
plan: '固定された設定',
|
||||
clientHelp:
|
||||
'クライアントコンテナはメインノードで既存サービスに要求を送信します。推論サービスの起動や GPU の割り当ては不要です。',
|
||||
retryHelp: '選択した異常な測定点のみ、同じパラメータと新しいシードで再試行します。',
|
||||
secretHelp: 'コピーに API キーとヘッダーは含まれません。必要に応じて再入力してください。',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
apiKeyManagement: {
|
||||
reveal: '비밀 키 표시',
|
||||
hideSecret: '비밀 키 숨기기',
|
||||
revealHelp: '비밀 키를 안전하게 보관하세요. 표시해도 비활성화되거나 만료된 키는 사용할 수 없습니다.',
|
||||
deleteConfirm:
|
||||
'“{0}”을(를) 삭제하시겠습니까? 비밀 키가 파기되고 목록에서 제거됩니다. 새 요청은 거부되고 연결된 터미널은 종료됩니다. 감사 기록은 유지되며 실행 중인 작업은 취소되지 않습니다. 이 작업은 되돌릴 수 없습니다.',
|
||||
title: 'API Key',
|
||||
manage: 'API Key 관리',
|
||||
personalEntry: '설정에서 내 키, 접근 조건 및 APP QR 연결을 관리합니다.',
|
||||
@@ -23,10 +28,6 @@ const message = {
|
||||
allowAppBinding: 'APP QR 연결 허용',
|
||||
bindingDisableHelp:
|
||||
'QR 연결을 비활성화하면 새로운 기기를 연결할 수 없습니다. 이미 연결된 기기는 키가 비활성화, 폐기 또는 만료될 때까지 계속 사용할 수 있습니다.',
|
||||
saveSecret: '지금 키를 복사하여 저장하세요. 닫으면 일반 키 관리에서 전체 키를 다시 표시할 수 없습니다.',
|
||||
saved: '키를 저장했습니다',
|
||||
alreadyCreated:
|
||||
'이 키는 이미 생성되어 다시 표시할 수 없습니다. 원래 응답을 잃었다면 폐기 후 다시 만들거나 허용된 QR 연결을 사용하세요.',
|
||||
uncertain:
|
||||
'요청 결과를 확인할 수 없습니다. 중복 생성을 피하려면 같은 설정으로 재시도하거나 닫고 목록을 확인하세요.',
|
||||
legacyHelp:
|
||||
@@ -1015,6 +1016,22 @@ const message = {
|
||||
validateImageAvailabilityHelper: '저장 전에 최소 이미지 한 장을 생성하며 비용이 발생할 수 있습니다',
|
||||
},
|
||||
model: {
|
||||
gpuAcceleration: 'GPU 가속',
|
||||
gpuMode: '가속기 선택',
|
||||
gpuAll: '모든 가속기',
|
||||
gpuCount: '수량 지정',
|
||||
gpuSpecific: '장치 선택',
|
||||
gpuAllHelper: '현재 노드에서 사용 가능한 같은 유형의 가속기를 모두 사용합니다.',
|
||||
gpuCountHelper: '특정 장치에 고정하지 않고 런타임이 지정한 수량의 가속기를 할당합니다.',
|
||||
gpuSpecificHelper: '사용할 가속기를 직접 선택합니다.',
|
||||
gpuCountRequired: '0보다 큰 정수를 입력하세요',
|
||||
gpuDevices: 'GPU 장치',
|
||||
gpuCapabilitiesHelper:
|
||||
'GPU 접근은 필수이며, 필요에 따라 모니터링 및 관리(utility)와 연산 지원(compute)을 활성화할 수 있습니다.',
|
||||
gpuSelectRequired: '사용 가능한 GPU 장치를 하나 이상 선택하세요.',
|
||||
gpuUnsupported: '지원 안 됨',
|
||||
gpuNotDetected: '지원되는 가속기가 감지되지 않았습니다',
|
||||
gpuMissing: '사용 불가',
|
||||
modelCategory: '모델 카테고리',
|
||||
modelName: '모델 이름',
|
||||
modelArchitecture: '모델 유형',
|
||||
@@ -1032,11 +1049,11 @@ const message = {
|
||||
onlineModel: '온라인 모델',
|
||||
instanceDeployment: '인스턴스별 배포',
|
||||
nvidiaDeployment: 'NVIDIA 최적화 배포',
|
||||
engines: '추론 엔진',
|
||||
engines: '로컬 추론 엔진',
|
||||
modelManagement: '모델 관리',
|
||||
serviceDetails: '서비스 상세 정보',
|
||||
deployments: '로컬 배포',
|
||||
files: '모델 파일',
|
||||
files: '모델 허브',
|
||||
connectExternal: '외부 서비스 연결',
|
||||
deploymentTemplates: 'vLLM 시작 명령 템플릿',
|
||||
engineProvider: '엔진 / 공급자',
|
||||
@@ -1053,7 +1070,7 @@ const message = {
|
||||
container_conn_helper: '컨테이너 간 접근 또는 연결에 이 주소를 사용',
|
||||
from_remote: '이 모델은 1Panel을 통해 다운로드되지 않았으며 관련 풀 로그가 없습니다.',
|
||||
no_logs: '이 모델의 풀 로그가 삭제되어 관련 로그를 볼 수 없습니다.',
|
||||
vllmVersionHelper: 'FusionXpark GB 10 서버는 -cu130 버전을 선택하세요.',
|
||||
vllmVersionHelper: 'FusionXpark GB10 서버는 -cu130 버전을 선택하세요.',
|
||||
vllmDeployProfile: '배포 모델',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1597,6 +1614,11 @@ const message = {
|
||||
timePerOutputTokenChart: '토큰 간 지연',
|
||||
},
|
||||
gpu: {
|
||||
systemMemory: '공유 시스템 메모리',
|
||||
systemMemoryUsed: '사용 중인 시스템 메모리',
|
||||
systemMemoryTotal: '전체 시스템 메모리',
|
||||
systemMemoryAvailable: '사용 가능한 시스템 메모리',
|
||||
systemMemoryHelper: 'CPU와 GPU가 공유합니다. GPU만의 사용량이 아닌 시스템 전체 메모리 사용량을 표시합니다.',
|
||||
memoryActivity: '메모리 읽기/쓰기 활성률',
|
||||
hotspotTemperature: '핫스폿 온도',
|
||||
fanRPM: '팬 회전 속도 (RPM)',
|
||||
@@ -1631,7 +1653,6 @@ const message = {
|
||||
historyAverage: '평균',
|
||||
historyPeak: '최댓값',
|
||||
historyAggregated: '샘플 {count}개를 {seconds}초 간격으로 집계합니다. 프로세스는 개수만 표시합니다.',
|
||||
historyRaw: '원본 샘플 {count}개.',
|
||||
historyMetricEmpty: '선택한 기간에 이 지표의 데이터가 없습니다',
|
||||
historyLoadFailed: '기록을 불러오지 못했습니다. 다시 시도하세요.',
|
||||
collectedAt: '수집 시간',
|
||||
@@ -1701,8 +1722,8 @@ const message = {
|
||||
prohibitedHelper: '금지됨: 프로세스 동시 실행이 허용되지 않음',
|
||||
migModeHelper: 'MIG 인스턴스를 생성하는 데 사용되며 사용자 레이어에서 GPU의 물리적 격리를 구현합니다.',
|
||||
migModeNA: '지원되지 않음',
|
||||
current: '실시간 모니터링',
|
||||
history: '기록',
|
||||
current: '실시간 상태',
|
||||
history: '모니터링 데이터',
|
||||
notSupport: '현재 버전 또는 드라이버는 이 매개변수 표시를 지원하지 않습니다.',
|
||||
processCount: '프로세스 수',
|
||||
},
|
||||
@@ -2001,8 +2022,6 @@ const message = {
|
||||
retry: '선택한 측정점 재시도',
|
||||
environment: '실행 환경',
|
||||
plan: '고정 설정',
|
||||
clientHelp:
|
||||
'클라이언트 컨테이너는 주 노드에서 기존 서비스에 요청을 보냅니다. 추론 서비스를 시작하거나 GPU를 할당하지 않습니다.',
|
||||
retryHelp: '선택한 비정상 측정점만 같은 매개변수와 새 시드로 재시도합니다.',
|
||||
secretHelp: '복사본에는 API 키와 헤더가 없습니다. 필요한 경우 다시 입력하세요.',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
apiKeyManagement: {
|
||||
reveal: 'ສະແດງຄີລັບ',
|
||||
hideSecret: 'ເຊື່ອງຄີລັບ',
|
||||
revealHelp: 'ກະລຸນາເກັບຄີລັບໃຫ້ປອດໄພ. ການສະແດງບໍ່ເຮັດໃຫ້ຄີທີ່ປິດໃຊ້ງານ ຫຼືໝົດອາຍຸກັບມາໃຊ້ໄດ້.',
|
||||
deleteConfirm:
|
||||
'ລຶບ “{0}” ຫຼືບໍ່? ຄີລັບຈະຖືກທຳລາຍ ແລະລຶບອອກຈາກລາຍການ. ຄຳຮ້ອງຂໍໃໝ່ຈະຖືກປະຕິເສດ ແລະປິດເທີມິນັລທີ່ກ່ຽວຂ້ອງ. ບັນທຶກການກວດສອບຈະຍັງຄົງຢູ່; ວຽກທີ່ກຳລັງເຮັດຈະບໍ່ຖືກຍົກເລີກ. ບໍ່ສາມາດກູ້ຄືນໄດ້.',
|
||||
title: 'API Key',
|
||||
manage: 'ຈັດການ API Key',
|
||||
personalEntry: 'ຈັດການກະແຈ, ເງື່ອນໄຂເຂົ້າໃຊ້ ແລະ ການຜູກ APP ໃນການຕັ້ງຄ່າ.',
|
||||
@@ -23,9 +28,6 @@ const message = {
|
||||
allowAppBinding: 'ອະນຸຍາດຜູກ APP ຜ່ານ QR',
|
||||
bindingDisableHelp:
|
||||
'ເມື່ອປິດການຜູກມັດຜ່ານ QR, ລະບົບຈະບໍ່ຮັບການຜູກມັດໃໝ່. ອຸປະກອນທີ່ຜູກມັດແລ້ວຍັງສາມາດໃຊ້ງານໄດ້ ຈົນກວ່າກະແຈຈະຖືກປິດໃຊ້, ຖອນ ຫຼື ໝົດອາຍຸ.',
|
||||
saveSecret: 'ຄັດລອກ ແລະ ບັນທຶກກະແຈດຽວນີ້. ຫຼັງປິດແລ້ວ ການຈັດການປົກກະຕິຈະບໍ່ສະແດງກະແຈເຕັມອີກ.',
|
||||
saved: 'ຂ້ອຍບັນທຶກກະແຈແລ້ວ',
|
||||
alreadyCreated: 'ກະແຈນີ້ສ້າງແລ້ວ ແລະ ບໍ່ສະແດງອີກ. ຖ້າຄຳຕອບເກົ່າຫາຍ ໃຫ້ຖອນແລ້ວສ້າງໃໝ່ ຫຼື ໃຊ້ QR ຖ້າອະນຸຍາດ.',
|
||||
uncertain: 'ຍັງບໍ່ແນ່ໃຈຜົນຄຳຮ້ອງ. ລອງໃໝ່ດ້ວຍຂໍ້ມູນເດີມເພື່ອບໍ່ສ້າງຊ້ຳ ຫຼື ປິດແລ້ວກວດລາຍການກ່ອນສ້າງອີກ.',
|
||||
legacyHelp:
|
||||
'ກະແຈເກົ່າໃຊ້ເພື່ອຮອງຮັບການເຊື່ອມຕໍ່ API ເກົ່າ. ການເປີດໃຊ້, ປິດໃຊ້ ແລະ ຣີເຊັດ ມີຜົນສະເພາະກະແຈນີ້ເທົ່ານັ້ນ.',
|
||||
@@ -1011,6 +1013,22 @@ const message = {
|
||||
validateImageAvailabilityHelper: 'ສ້າງຮູບຂະໜາດນ້ອຍໜຶ່ງຮູບກ່ອນບັນທຶກ ແລະ ອາດມີຄ່າໃຊ້ຈ່າຍ',
|
||||
},
|
||||
model: {
|
||||
gpuAcceleration: 'ການເລັ່ງ GPU',
|
||||
gpuMode: 'ເລືອກຕົວເລັ່ງ',
|
||||
gpuAll: 'ຕົວເລັ່ງທັງໝົດ',
|
||||
gpuCount: 'ລະບຸຈຳນວນ',
|
||||
gpuSpecific: 'ເລືອກອຸປະກອນ',
|
||||
gpuAllHelper: 'ໃຊ້ຕົວເລັ່ງປະເພດດຽວກັນທັງໝົດທີ່ມີໃນໂນດປັດຈຸບັນ.',
|
||||
gpuCountHelper: 'ລະບົບຈະຈັດສັນຕົວເລັ່ງຕາມຈຳນວນທີ່ລະບຸ ໂດຍບໍ່ຜູກກັບອຸປະກອນສະເພາະ.',
|
||||
gpuSpecificHelper: 'ເລືອກຕົວເລັ່ງທີ່ຕ້ອງການດ້ວຍຕົນເອງ.',
|
||||
gpuCountRequired: 'ກະລຸນາປ້ອນຈຳນວນເຕັມທີ່ຫຼາຍກວ່າ 0',
|
||||
gpuDevices: 'ອຸປະກອນ GPU',
|
||||
gpuCapabilitiesHelper:
|
||||
'ຕ້ອງເປີດການເຂົ້າເຖິງ GPU ແລະ ສາມາດເປີດການຕິດຕາມແລະຈັດການ (utility) ແລະ ການຄຳນວນ (compute) ໄດ້ຕາມຕ້ອງການ.',
|
||||
gpuSelectRequired: 'ກະລຸນາເລືອກ GPU ທີ່ພ້ອມໃຊ້ຢ່າງໜ້ອຍໜຶ່ງອຸປະກອນ',
|
||||
gpuUnsupported: 'ບໍ່ຮອງຮັບ',
|
||||
gpuNotDetected: 'ບໍ່ພົບຕົວເລັ່ງທີ່ຮອງຮັບ',
|
||||
gpuMissing: 'ບໍ່ພ້ອມໃຊ້',
|
||||
modelCategory: 'ໝວດໝູ່ໂມເດວ',
|
||||
modelName: 'ຊື່ໂມເດວ',
|
||||
modelArchitecture: 'ປະເພດໂມເດວ',
|
||||
@@ -1028,11 +1046,11 @@ const message = {
|
||||
onlineModel: 'ໂມເດວອອນລາຍ',
|
||||
instanceDeployment: 'ນຳໃຊ້ຕາມອິນສະແຕນ',
|
||||
nvidiaDeployment: 'ການນຳໃຊ້ທີ່ປັບໃຫ້ເໝາະກັບ NVIDIA',
|
||||
engines: 'ເຄື່ອງຈັກອະນຸມານ',
|
||||
engines: 'ເຄື່ອງຈັກອະນຸມານພາຍໃນ',
|
||||
modelManagement: 'ຈັດການໂມເດວ',
|
||||
serviceDetails: 'ລາຍລະອຽດບໍລິການ',
|
||||
deployments: 'ການນຳໃຊ້ພາຍໃນເຄື່ອງ',
|
||||
files: 'ໄຟລ໌ໂມເດວ',
|
||||
files: 'ສູນລວມໂມເດວ',
|
||||
connectExternal: 'ເຊື່ອມຕໍ່ບໍລິການພາຍນອກ',
|
||||
deploymentTemplates: 'ແມ່ແບບຄຳສັ່ງເລີ່ມຕົ້ນ vLLM',
|
||||
engineProvider: 'ເຄື່ອງຈັກ / ຜູ້ໃຫ້ບໍລິການ',
|
||||
@@ -1049,7 +1067,7 @@ const message = {
|
||||
container_conn_helper: 'ໃຊ້ທີ່ຢູ່ນີ້ສຳລັບການເຂົ້າເຖິງ ຫຼື ເຊື່ອມຕໍ່ລະຫວ່າງຄອນເທນເນີ',
|
||||
from_remote: 'ໂມເດວນີ້ບໍ່ໄດ້ດາວໂຫຼດຜ່ານ 1Panel, ບໍ່ມີລັອກການດຶງຂໍ້ມູນທີ່ກ່ຽວຂ້ອງ.',
|
||||
no_logs: 'ລັອກການດຶງຂໍ້ມູນຂອງໂມເດວນີ້ຖືກລຶບແລ້ວ ແລະ ບໍ່ສາມາດເບິ່ງໄດ້.',
|
||||
vllmVersionHelper: 'ສຳລັບເຊີເວີ FusionXpark GB 10, ກະລຸນາເລືອກເວີຊັນ -cu130.',
|
||||
vllmVersionHelper: 'ສຳລັບເຊີເວີ FusionXpark GB10, ກະລຸນາເລືອກເວີຊັນ -cu130.',
|
||||
vllmDeployProfile: 'ໂມເດວສຳລັບຕິດຕັ້ງ',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1588,6 +1606,11 @@ const message = {
|
||||
timePerOutputTokenChart: 'ເວລາລໍຖ້າລະຫວ່າງ Token',
|
||||
},
|
||||
gpu: {
|
||||
systemMemory: 'ໜ່ວຍຄວາມຈຳລະບົບທີ່ໃຊ້ຮ່ວມກັນ',
|
||||
systemMemoryUsed: 'ໜ່ວຍຄວາມຈຳລະບົບທີ່ໃຊ້ແລ້ວ',
|
||||
systemMemoryTotal: 'ໜ່ວຍຄວາມຈຳລະບົບທັງໝົດ',
|
||||
systemMemoryAvailable: 'ໜ່ວຍຄວາມຈຳລະບົບທີ່ພ້ອມໃຊ້',
|
||||
systemMemoryHelper: 'CPU ແລະ GPU ໃຊ້ຮ່ວມກັນ. ສະແດງການໃຊ້ໜ່ວຍຄວາມຈຳຂອງທັງລະບົບ ບໍ່ແມ່ນສະເພາະ GPU.',
|
||||
memoryActivity: 'ກິດຈະກຳອ່ານ/ຂຽນໜ່ວຍຄວາມຈຳ',
|
||||
hotspotTemperature: 'ອຸນຫະພູມຈຸດຮ້ອນ',
|
||||
fanRPM: 'ຄວາມໄວພັດລົມ (RPM)',
|
||||
@@ -1622,7 +1645,6 @@ const message = {
|
||||
historyAverage: 'ຄ່າສະເລ່ຍ',
|
||||
historyPeak: 'ຄ່າສູງສຸດ',
|
||||
historyAggregated: 'ລວມ {count} ຕົວຢ່າງເປັນຊ່ວງ {seconds} ວິນາທີ; ສະແດງພຽງຈຳນວນໂປຣເຊສ.',
|
||||
historyRaw: '{count} ຕົວຢ່າງຕົ້ນສະບັບ.',
|
||||
historyMetricEmpty: 'ບໍ່ມີຂໍ້ມູນຕົວຊີ້ວັດນີ້ໃນຊ່ວງເວລາທີ່ເລືອກ',
|
||||
historyLoadFailed: 'ໂຫຼດປະຫວັດບໍ່ສຳເລັດ. ກະລຸນາລອງໃໝ່.',
|
||||
collectedAt: 'ເວລາເກັບຂໍ້ມູນ',
|
||||
@@ -1692,8 +1714,8 @@ const message = {
|
||||
prohibitedHelper: 'ຫ້າມ: ບໍ່ອະນຸຍາດໃຫ້ລັນໂປຣເຊສພ້ອມກັນ',
|
||||
migModeHelper: 'ໃຊ້ສຳລັບສ້າງ MIG instances ເພື່ອແຍກສ່ວນ GPU ທາງກາຍະພາບ.',
|
||||
migModeNA: 'ບໍ່ຮອງຮັບ',
|
||||
current: 'ຕິດຕາມແບບ Real-time',
|
||||
history: 'ບັນທຶກຍ້ອນຫຼັງ',
|
||||
current: 'ສະຖານະແບບທັນທີ',
|
||||
history: 'ຂໍ້ມູນການຕິດຕາມ',
|
||||
notSupport: 'ເວີຊັນ ຫຼື driver ປັດຈຸບັນບໍ່ຮອງຮັບການສະແດງຄ່ານີ້.',
|
||||
},
|
||||
mcp: {
|
||||
@@ -1989,8 +2011,6 @@ const message = {
|
||||
retry: 'ທົດສອບຈຸດທີ່ເລືອກຄືນ',
|
||||
environment: 'ສະພາບແວດລ້ອມ',
|
||||
plan: 'ການຕັ້ງຄ່າຄົງທີ່',
|
||||
clientHelp:
|
||||
'ຄອນເທນເນີລູກຂ່າຍເຮັດວຽກຢູ່ໂນດຫຼັກ ແລະ ສົ່ງຄຳຮ້ອງຂໍໄປບໍລິການທີ່ມີແລ້ວ. ບໍ່ເລີ່ມບໍລິການອະນຸມານ ແລະ ບໍ່ຕ້ອງການ GPU.',
|
||||
retryHelp: 'ທົດສອບຄືນສະເພາະຈຸດຜິດປົກກະຕິທີ່ເລືອກ ດ້ວຍພາລາມິເຕີເດີມ ແລະ ຄ່າສຸ່ມໃໝ່.',
|
||||
secretHelp: 'ສຳເນົາບໍ່ມີລະຫັດ API ແລະ ສ່ວນຫົວ. ກະລຸນາປ້ອນໃໝ່ຖ້າຈຳເປັນ.',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,12 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
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',
|
||||
manage: 'Urus kunci API',
|
||||
personalEntry: 'Urus kunci, syarat akses dan pautan APP anda dalam Tetapan.',
|
||||
@@ -23,11 +29,6 @@ const message = {
|
||||
allowAppBinding: 'Benarkan pautan APP melalui QR',
|
||||
bindingDisableHelp:
|
||||
'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:
|
||||
'Hasil permintaan belum pasti. Cuba semula dengan butiran sama untuk mengelakkan pendua, atau tutup dan semak senarai sebelum mencipta lagi.',
|
||||
legacyHelp:
|
||||
@@ -1047,6 +1048,22 @@ const message = {
|
||||
validateImageAvailabilityHelper: 'Menjana satu imej minimum sebelum menyimpan dan mungkin dikenakan caj',
|
||||
},
|
||||
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',
|
||||
modelName: 'Nama model',
|
||||
modelArchitecture: 'Jenis model',
|
||||
@@ -1064,11 +1081,11 @@ const message = {
|
||||
onlineModel: 'Model dalam talian',
|
||||
instanceDeployment: 'Pengerahan mengikut instans',
|
||||
nvidiaDeployment: 'Pengerahan dioptimumkan untuk NVIDIA',
|
||||
engines: 'Enjin inferens',
|
||||
engines: 'Enjin inferens setempat',
|
||||
modelManagement: 'Pengurusan model',
|
||||
serviceDetails: 'Butiran perkhidmatan',
|
||||
deployments: 'Pengerahan setempat',
|
||||
files: 'Fail model',
|
||||
files: 'Hab model',
|
||||
connectExternal: 'Sambungkan perkhidmatan luaran',
|
||||
deploymentTemplates: 'Templat arahan permulaan vLLM',
|
||||
engineProvider: 'Enjin / Pembekal',
|
||||
@@ -1085,7 +1102,7 @@ const message = {
|
||||
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.',
|
||||
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',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1638,6 +1655,12 @@ const message = {
|
||||
timePerOutputTokenChart: 'Kependaman antara token',
|
||||
},
|
||||
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',
|
||||
hotspotTemperature: 'Suhu titik panas',
|
||||
fanRPM: 'Kelajuan kipas (RPM)',
|
||||
@@ -1673,7 +1696,6 @@ const message = {
|
||||
historyPeak: 'Puncak',
|
||||
historyAggregated:
|
||||
'{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',
|
||||
historyLoadFailed: 'Gagal memuatkan sejarah. Sila cuba lagi.',
|
||||
collectedAt: 'Masa pengumpulan',
|
||||
@@ -1746,8 +1768,8 @@ const message = {
|
||||
migModeHelper:
|
||||
'Digunakan untuk mencipta contoh MIG, melaksanakan pengasingan fizikal GPU pada lapisan pengguna.',
|
||||
migModeNA: 'Tidak Disokong',
|
||||
current: 'Pemantauan Masa Nyata',
|
||||
history: 'Rekod Sejarah',
|
||||
current: 'Status Masa Nyata',
|
||||
history: 'Data Pemantauan',
|
||||
notSupport: 'Versi atau pemacu semasa tidak menyokong paparan parameter ini.',
|
||||
processCount: 'Bilangan proses',
|
||||
},
|
||||
@@ -2057,8 +2079,6 @@ const message = {
|
||||
retry: 'Ulang titik terpilih',
|
||||
environment: 'Persekitaran',
|
||||
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.',
|
||||
secretHelp: 'Salinan tidak mengandungi kunci API atau pengepala. Masukkan semula jika perlu.',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
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',
|
||||
manage: 'Gerenciar chaves API',
|
||||
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',
|
||||
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.',
|
||||
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:
|
||||
'O resultado é incerto. Tente novamente com os mesmos dados para evitar duplicações ou feche e verifique a lista antes de criar outra.',
|
||||
legacyHelp:
|
||||
@@ -1042,6 +1043,23 @@ const message = {
|
||||
validateImageAvailabilityHelper: 'Gera uma imagem mínima antes de salvar e pode gerar custos',
|
||||
},
|
||||
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',
|
||||
modelName: 'Nome do modelo',
|
||||
modelArchitecture: 'Tipo de modelo',
|
||||
@@ -1059,11 +1077,11 @@ const message = {
|
||||
onlineModel: 'Modelos online',
|
||||
instanceDeployment: 'Implantação por instância',
|
||||
nvidiaDeployment: 'Implantação otimizada para NVIDIA',
|
||||
engines: 'Mecanismos de inferência',
|
||||
engines: 'Mecanismos de inferência locais',
|
||||
modelManagement: 'Gerenciamento de modelos',
|
||||
serviceDetails: 'Detalhes do serviço',
|
||||
deployments: 'Implantações locais',
|
||||
files: 'Arquivos de modelos',
|
||||
files: 'Central de modelos',
|
||||
connectExternal: 'Conectar serviço externo',
|
||||
deploymentTemplates: 'Modelos de comandos de inicialização do vLLM',
|
||||
engineProvider: 'Mecanismo / Provedor',
|
||||
@@ -1080,7 +1098,7 @@ const message = {
|
||||
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.',
|
||||
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',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1636,6 +1654,12 @@ const message = {
|
||||
timePerOutputTokenChart: 'Latência entre tokens',
|
||||
},
|
||||
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',
|
||||
hotspotTemperature: 'Temperatura do ponto quente',
|
||||
fanRPM: 'Rotação da ventoinha (RPM)',
|
||||
@@ -1671,7 +1695,6 @@ const message = {
|
||||
historyPeak: 'Pico',
|
||||
historyAggregated:
|
||||
'{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',
|
||||
historyLoadFailed: 'Falha ao carregar o histórico. Tente novamente.',
|
||||
collectedAt: 'Data da coleta',
|
||||
@@ -1745,8 +1768,8 @@ const message = {
|
||||
migModeHelper:
|
||||
'Usado para criar instâncias MIG, implementando isolamento físico da GPU na camada do usuário.',
|
||||
migModeNA: 'Não Suportado',
|
||||
current: 'Monitoramento em Tempo Real',
|
||||
history: 'Registros Históricos',
|
||||
current: 'Status em tempo real',
|
||||
history: 'Dados de monitoramento',
|
||||
notSupport: 'A versão atual ou o driver não suportam exibir este parâmetro.',
|
||||
processCount: 'Qtd. de processos',
|
||||
},
|
||||
@@ -2058,8 +2081,6 @@ const message = {
|
||||
retry: 'Repetir pontos selecionados',
|
||||
environment: 'Ambiente',
|
||||
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.',
|
||||
secretHelp: 'As cópias não incluem chaves de API nem cabeçalhos. Informe-os novamente se necessário.',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
apiKeyManagement: {
|
||||
reveal: 'Показать секрет',
|
||||
hideSecret: 'Скрыть секрет',
|
||||
revealHelp: 'Храните секрет в безопасности. Просмотр не активирует отключённые или просроченные ключи.',
|
||||
deleteConfirm:
|
||||
'Удалить «{0}»? Секрет будет уничтожен, а ключ удалён из списка. Новые запросы будут отклонены, связанные терминалы закрыты. Журнал аудита сохранится; выполняющиеся задачи не отменяются. Действие необратимо.',
|
||||
title: 'API Key',
|
||||
manage: 'Управление API-ключами',
|
||||
personalEntry: 'Управляйте своими ключами, условиями доступа и привязкой приложения в настройках.',
|
||||
@@ -23,11 +28,6 @@ const message = {
|
||||
allowAppBinding: 'Разрешить привязку приложения по QR',
|
||||
bindingDisableHelp:
|
||||
'Отключение привязки по QR-коду запрещает новые привязки. Уже привязанные устройства могут продолжать работу, пока ключ не будет отключён, отозван или не истечёт срок его действия.',
|
||||
saveSecret:
|
||||
'Скопируйте и сохраните ключ сейчас. После закрытия обычное управление не сможет показать полный ключ снова.',
|
||||
saved: 'Ключ сохранён',
|
||||
alreadyCreated:
|
||||
'Ключ уже создан и не будет показан повторно. Если исходный ответ потерян, отзовите и создайте ключ заново либо используйте разрешённую привязку по QR.',
|
||||
uncertain:
|
||||
'Результат запроса неизвестен. Повторите с теми же данными, чтобы избежать дублей, или закройте и проверьте список.',
|
||||
legacyHelp:
|
||||
@@ -1036,6 +1036,23 @@ const message = {
|
||||
'Перед сохранением создаётся одно минимальное изображение; возможна оплата',
|
||||
},
|
||||
model: {
|
||||
gpuAcceleration: 'Ускорение GPU',
|
||||
gpuMode: 'Выбор ускорителей',
|
||||
gpuAll: 'Все ускорители',
|
||||
gpuCount: 'Указать количество',
|
||||
gpuSpecific: 'Выбрать устройства',
|
||||
gpuAllHelper: 'Использовать все доступные ускорители этого типа на текущем узле.',
|
||||
gpuCountHelper:
|
||||
'Среда выполнения выделяет указанное количество ускорителей без привязки к конкретным устройствам.',
|
||||
gpuSpecificHelper: 'Выберите конкретные ускорители вручную.',
|
||||
gpuCountRequired: 'Введите целое число больше 0',
|
||||
gpuDevices: 'Устройства GPU',
|
||||
gpuCapabilitiesHelper:
|
||||
'Доступ к GPU обязателен; при необходимости включите мониторинг и управление (utility) и поддержку вычислений (compute).',
|
||||
gpuSelectRequired: 'Выберите хотя бы одно доступное устройство GPU.',
|
||||
gpuUnsupported: 'Не поддерживается',
|
||||
gpuNotDetected: 'Поддерживаемые ускорители не обнаружены',
|
||||
gpuMissing: 'Недоступно',
|
||||
modelCategory: 'Категория модели',
|
||||
modelName: 'Название модели',
|
||||
modelArchitecture: 'Тип модели',
|
||||
@@ -1053,11 +1070,11 @@ const message = {
|
||||
onlineModel: 'Онлайн-модели',
|
||||
instanceDeployment: 'Развертывание по экземплярам',
|
||||
nvidiaDeployment: 'Развертывание с оптимизацией для NVIDIA',
|
||||
engines: 'Движки инференса',
|
||||
engines: 'Локальные движки инференса',
|
||||
modelManagement: 'Управление моделями',
|
||||
serviceDetails: 'Сведения о сервисе',
|
||||
deployments: 'Локальные развертывания',
|
||||
files: 'Файлы моделей',
|
||||
files: 'Каталог моделей',
|
||||
connectExternal: 'Подключить внешний сервис',
|
||||
deploymentTemplates: 'Шаблоны команд запуска vLLM',
|
||||
engineProvider: 'Движок / Поставщик',
|
||||
@@ -1074,7 +1091,7 @@ const message = {
|
||||
container_conn_helper: 'Используйте этот адрес для доступа или подключения между контейнерами',
|
||||
from_remote: 'Эта модель не была загружена через 1Panel, нет связанных журналов извлечения.',
|
||||
no_logs: 'Журналы извлечения для этой модели были удалены и не могут быть просмотрены.',
|
||||
vllmVersionHelper: 'Для серверов FusionXpark GB 10 выберите версию -cu130.',
|
||||
vllmVersionHelper: 'Для серверов FusionXpark GB10 выберите версию -cu130.',
|
||||
vllmDeployProfile: 'Модель развертывания',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1627,6 +1644,12 @@ const message = {
|
||||
timePerOutputTokenChart: 'Задержка между токенами',
|
||||
},
|
||||
gpu: {
|
||||
systemMemory: 'Общая системная память',
|
||||
systemMemoryUsed: 'Использовано системной памяти',
|
||||
systemMemoryTotal: 'Всего системной памяти',
|
||||
systemMemoryAvailable: 'Доступная системная память',
|
||||
systemMemoryHelper:
|
||||
'Память совместно используется CPU и GPU. Показано потребление всей системы, а не только GPU.',
|
||||
memoryActivity: 'Активность чтения/записи памяти',
|
||||
hotspotTemperature: 'Температура горячей точки',
|
||||
fanRPM: 'Скорость вентилятора (об/мин)',
|
||||
@@ -1662,7 +1685,6 @@ const message = {
|
||||
historyPeak: 'Максимум',
|
||||
historyAggregated:
|
||||
'{count} измерений сгруппированы по {seconds} с; для процессов показано только количество.',
|
||||
historyRaw: 'Исходных измерений: {count}.',
|
||||
historyMetricEmpty: 'Нет данных этой метрики за выбранный период',
|
||||
historyLoadFailed: 'Не удалось загрузить историю. Повторите попытку.',
|
||||
collectedAt: 'Время сбора',
|
||||
@@ -1735,8 +1757,8 @@ const message = {
|
||||
migModeHelper:
|
||||
'Используется для создания экземпляров MIG, реализуя физическую изоляцию GPU на пользовательском уровне.',
|
||||
migModeNA: 'Не Поддерживается',
|
||||
current: 'Мониторинг в Реальном Времени',
|
||||
history: 'Исторические Записи',
|
||||
current: 'Состояние в реальном времени',
|
||||
history: 'Данные мониторинга',
|
||||
notSupport: 'Текущая версия или драйвер не поддерживает отображение этого параметра.',
|
||||
processCount: 'Кол-во процессов',
|
||||
},
|
||||
@@ -2052,8 +2074,6 @@ const message = {
|
||||
retry: 'Повторить выбранные точки',
|
||||
environment: 'Среда выполнения',
|
||||
plan: 'Зафиксированная конфигурация',
|
||||
clientHelp:
|
||||
'Клиентский контейнер работает на главном узле и отправляет запросы существующему сервису. Он не запускает модель и не требует GPU.',
|
||||
retryHelp: 'Повторяются только выбранные проблемные точки с прежними параметрами и новыми начальными числами.',
|
||||
secretHelp: 'Копии не содержат ключи API и заголовки. При необходимости введите их заново.',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,12 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
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',
|
||||
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.',
|
||||
@@ -23,11 +29,6 @@ const message = {
|
||||
allowAppBinding: 'Uygulama QR bağlantısına izin ver',
|
||||
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.',
|
||||
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:
|
||||
'İstek sonucu belirsiz. Tekrarları önlemek için aynı bilgilerle yeniden deneyin veya kapatıp yeni anahtar oluşturmadan listeyi kontrol edin.',
|
||||
legacyHelp:
|
||||
@@ -1046,6 +1047,22 @@ const message = {
|
||||
validateImageAvailabilityHelper: 'Kaydetmeden önce bir küçük görsel üretir ve ücret doğurabilir',
|
||||
},
|
||||
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',
|
||||
modelName: 'Model adı',
|
||||
modelArchitecture: 'Model türü',
|
||||
@@ -1063,11 +1080,11 @@ const message = {
|
||||
onlineModel: 'Çevrimiçi modeller',
|
||||
instanceDeployment: 'Örnek bazında 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',
|
||||
serviceDetails: 'Hizmet ayrıntıları',
|
||||
deployments: 'Yerel dağıtımlar',
|
||||
files: 'Model dosyaları',
|
||||
files: 'Model merkezi',
|
||||
connectExternal: 'Harici hizmete bağlan',
|
||||
deploymentTemplates: 'vLLM başlatma komutu şablonları',
|
||||
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',
|
||||
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.',
|
||||
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',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1635,6 +1652,12 @@ const message = {
|
||||
timePerOutputTokenChart: 'Token arası gecikme',
|
||||
},
|
||||
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',
|
||||
hotspotTemperature: 'Sıcak nokta sıcaklığı',
|
||||
fanRPM: 'Fan hızı (RPM)',
|
||||
@@ -1670,7 +1693,6 @@ const message = {
|
||||
historyPeak: 'En yüksek',
|
||||
historyAggregated:
|
||||
'{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',
|
||||
historyLoadFailed: 'Geçmiş yüklenemedi. Lütfen tekrar deneyin.',
|
||||
collectedAt: 'Toplanma zamanı',
|
||||
@@ -1742,8 +1764,8 @@ const message = {
|
||||
migModeHelper:
|
||||
"MIG örnekleri oluşturmak için kullanılır, GPU'nun kullanıcı katmanında fiziksel izolasyonunu uygular.",
|
||||
migModeNA: 'Desteklenmiyor',
|
||||
current: 'Gerçek Zamanlı İzleme',
|
||||
history: 'Geçmiş Kayıtlar',
|
||||
current: 'Gerçek Zamanlı Durum',
|
||||
history: 'İzleme Verileri',
|
||||
notSupport: 'Mevcut sürüm veya sürücü bu parametrenin görüntülenmesini desteklemiyor.',
|
||||
processCount: 'Süreç sayısı',
|
||||
},
|
||||
@@ -2057,8 +2079,6 @@ const message = {
|
||||
retry: 'Seçili ölçümleri yinele',
|
||||
environment: 'Çalışma ortamı',
|
||||
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.',
|
||||
secretHelp: 'Kopyalar API anahtarlarını ve başlıkları içermez. Gerekiyorsa yeniden girin.',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
apiKeyManagement: {
|
||||
reveal: '檢視密鑰',
|
||||
hideSecret: '隱藏密鑰',
|
||||
revealHelp: '請妥善保管密鑰。檢視不會恢復已停用或已過期密鑰的使用權限。',
|
||||
deleteConfirm:
|
||||
'確定刪除「{0}」嗎?密鑰將被銷毀並從列表移除,拒絕後續請求並關閉關聯終端。稽核記錄仍保留,執行中的工作不會取消,此操作無法復原。',
|
||||
title: 'API Key',
|
||||
manage: '管理 API Key',
|
||||
personalEntry: '前往設定管理自己的金鑰、存取條件與 APP 掃碼綁定。',
|
||||
@@ -22,10 +27,6 @@ const message = {
|
||||
futureExpiry: '請選擇晚於目前時間的到期時間。',
|
||||
allowAppBinding: '允許 APP 掃碼綁定',
|
||||
bindingDisableHelp: '關閉掃碼綁定後,將停止接受新的綁定;已綁定裝置可繼續使用,直至金鑰被停用、撤銷或到期。',
|
||||
saveSecret: '請立即複製並儲存金鑰。關閉後,一般金鑰管理無法再次顯示完整金鑰。',
|
||||
saved: '我已儲存金鑰',
|
||||
alreadyCreated:
|
||||
'這把金鑰已建立,無法再次顯示明文。若原回應遺失,請撤銷後重新建立;允許掃碼綁定時,也可透過掃碼交付。',
|
||||
uncertain: '請求結果尚不確定。請保持原設定重試,避免重複建立;或關閉並檢查清單後再建立其他金鑰。',
|
||||
legacyHelp: '舊版 Key 用於相容歷史 API 連線,啟用、停用及重設操作僅對該 Key 生效。',
|
||||
showLegacy: '檢視舊版金鑰',
|
||||
@@ -976,6 +977,21 @@ const message = {
|
||||
validateImageAvailabilityHelper: '儲存前會實際產生一張最小圖片,可能產生費用',
|
||||
},
|
||||
model: {
|
||||
gpuAcceleration: 'GPU 加速',
|
||||
gpuMode: '加速卡選擇',
|
||||
gpuAll: '全部加速卡',
|
||||
gpuCount: '指定數量',
|
||||
gpuSpecific: '指定裝置',
|
||||
gpuAllHelper: '容器使用目前節點所有可用的同類型加速卡。',
|
||||
gpuCountHelper: '由執行環境分配指定數量的加速卡,不綁定特定裝置。',
|
||||
gpuSpecificHelper: '手動選擇特定加速卡。',
|
||||
gpuCountRequired: '請輸入大於 0 的整數',
|
||||
gpuDevices: 'GPU 裝置',
|
||||
gpuCapabilitiesHelper: 'GPU 存取為必選項,可依需求啟用監控管理(utility)和運算支援(compute)。',
|
||||
gpuSelectRequired: '請選擇至少一個目前可用的 GPU 裝置',
|
||||
gpuUnsupported: '不支援',
|
||||
gpuNotDetected: '未偵測到可用加速卡',
|
||||
gpuMissing: '無法使用',
|
||||
modelCategory: '模型類別',
|
||||
modelName: '模型名稱',
|
||||
modelArchitecture: '模型類型',
|
||||
@@ -993,11 +1009,11 @@ const message = {
|
||||
onlineModel: '線上模型',
|
||||
instanceDeployment: '按執行個體部署',
|
||||
nvidiaDeployment: 'NVIDIA 最佳化部署',
|
||||
engines: '推理引擎',
|
||||
engines: '本機推理引擎',
|
||||
modelManagement: '模型管理',
|
||||
serviceDetails: '服務詳情',
|
||||
deployments: '本機部署',
|
||||
files: '模型檔案',
|
||||
files: '模型廣場',
|
||||
connectExternal: '接入外部服務',
|
||||
deploymentTemplates: 'vLLM 啟動命令範本',
|
||||
engineProvider: '引擎 / 供應商',
|
||||
@@ -1014,7 +1030,7 @@ const message = {
|
||||
container_conn_helper: '容器間瀏覽或連接使用此地址',
|
||||
from_remote: '該模型並非透過 1Panel 下載,無相關拉取日誌。',
|
||||
no_logs: '該模型的拉取日誌已被刪除,無法檢視相關日誌。',
|
||||
vllmVersionHelper: 'FusionXpark GB 10 伺服器請選擇 -cu130 版本',
|
||||
vllmVersionHelper: 'FusionXpark GB10 伺服器請選擇 -cu130 版本',
|
||||
vllmDeployProfile: '部署模型',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1539,6 +1555,11 @@ const message = {
|
||||
timePerOutputTokenChart: 'Token 間延遲',
|
||||
},
|
||||
gpu: {
|
||||
systemMemory: '系統共享記憶體',
|
||||
systemMemoryUsed: '系統已用記憶體',
|
||||
systemMemoryTotal: '系統總記憶體',
|
||||
systemMemoryAvailable: '系統可用記憶體',
|
||||
systemMemoryHelper: 'CPU 與 GPU 共用,顯示整機記憶體使用情況,不代表 GPU 獨占用量。',
|
||||
memoryActivity: '顯示記憶體讀寫活躍率',
|
||||
hotspotTemperature: '熱點溫度',
|
||||
fanRPM: '風扇轉速(RPM)',
|
||||
@@ -1573,7 +1594,6 @@ const message = {
|
||||
historyAverage: '平均值',
|
||||
historyPeak: '峰值',
|
||||
historyAggregated: '共 {count} 筆取樣,按 {seconds} 秒彙整;程序僅顯示統計數量。',
|
||||
historyRaw: '共 {count} 筆原始取樣。',
|
||||
historyMetricEmpty: '所選時間範圍內沒有此指標的資料',
|
||||
historyLoadFailed: '歷史記錄載入失敗,請重試',
|
||||
collectedAt: '採集時間',
|
||||
@@ -1642,8 +1662,8 @@ const message = {
|
||||
prohibitedHelper: '禁止: 不允許行程同時執行',
|
||||
migModeHelper: '用於建立 MIG 實例,在使用者層實現 GPU 的物理隔離。',
|
||||
migModeNA: '不支援',
|
||||
current: '即時監控',
|
||||
history: '歷史記錄',
|
||||
current: '即時狀態',
|
||||
history: '監控資料',
|
||||
notSupport: '當前版本或驅動不支援顯示該參數。',
|
||||
processCount: '行程數',
|
||||
},
|
||||
@@ -1927,7 +1947,6 @@ const message = {
|
||||
retry: '重測選取測點',
|
||||
environment: '執行環境',
|
||||
plan: '凍結設定',
|
||||
clientHelp: '壓測容器在主節點執行,向既有服務傳送請求。不會啟動推論服務,也不需分配 GPU。',
|
||||
retryHelp: '本輪只重測選取的異常測點,保留參數並重新分配隨機種子。',
|
||||
secretHelp: '複製設定不包含 API Key 與請求標頭,請視需要重新填寫。',
|
||||
resultHelp:
|
||||
|
||||
@@ -2,6 +2,11 @@ import { getFuLocaleMessage } from '@/lang/fu';
|
||||
|
||||
const message = {
|
||||
apiKeyManagement: {
|
||||
reveal: '查看密钥',
|
||||
hideSecret: '隐藏密钥',
|
||||
revealHelp: '请妥善保管密钥。查看不会恢复已停用或已过期密钥的使用权限。',
|
||||
deleteConfirm:
|
||||
'确定删除“{0}”吗?密钥将被销毁并从列表移除,拒绝后续请求并关闭关联终端。审计记录仍保留,运行中的任务不会取消,此操作不可恢复。',
|
||||
title: 'API Key',
|
||||
manage: '管理 API Key',
|
||||
personalEntry: '前往设置管理自己的密钥、访问条件和 APP 扫码绑定。',
|
||||
@@ -22,10 +27,6 @@ const message = {
|
||||
futureExpiry: '请选择晚于当前时间的到期时间。',
|
||||
allowAppBinding: '允许 APP 扫码绑定',
|
||||
bindingDisableHelp: '关闭扫码绑定后,将停止接受新的绑定;已绑定设备可继续使用,直至密钥被停用、撤销或到期。',
|
||||
saveSecret: '请立即复制并保存密钥。关闭后,普通密钥管理无法再次显示完整密钥。',
|
||||
saved: '我已保存密钥',
|
||||
alreadyCreated:
|
||||
'这把密钥已创建,无法再次展示明文。若原响应丢失,请撤销后重新创建;允许扫码绑定时,也可通过扫码交付。',
|
||||
uncertain: '请求结果尚不确定。请保持原配置重试,避免重复创建;或关闭并检查列表后再创建其他密钥。',
|
||||
legacyHelp: '旧版 Key 用于兼容历史 API 连接,启用、停用和重置操作仅对该 Key 生效。',
|
||||
showLegacy: '查看旧版密钥',
|
||||
@@ -989,6 +990,21 @@ const message = {
|
||||
validateImageAvailabilityHelper: '保存前会真实生成一张最小图片,可能产生费用',
|
||||
},
|
||||
model: {
|
||||
gpuAcceleration: 'GPU 加速',
|
||||
gpuMode: '加速卡选择',
|
||||
gpuAll: '全部加速卡',
|
||||
gpuCount: '指定数量',
|
||||
gpuSpecific: '指定设备',
|
||||
gpuAllHelper: '容器使用当前节点所有可用的同类型加速卡。',
|
||||
gpuCountHelper: '由运行时分配指定数量的加速卡,不绑定具体设备。',
|
||||
gpuSpecificHelper: '手动选择具体加速卡。',
|
||||
gpuCountRequired: '请输入大于 0 的整数',
|
||||
gpuDevices: 'GPU 设备',
|
||||
gpuCapabilitiesHelper: 'GPU 访问为必选项,可按需启用监控管理(utility)和计算支持(compute)。',
|
||||
gpuSelectRequired: '请选择至少一个当前可用的 GPU 设备',
|
||||
gpuUnsupported: '不支持',
|
||||
gpuNotDetected: '未检测到可用加速卡',
|
||||
gpuMissing: '不可用',
|
||||
modelCategory: '模型类别',
|
||||
modelName: '模型名称',
|
||||
modelArchitecture: '模型类型',
|
||||
@@ -1006,11 +1022,11 @@ const message = {
|
||||
onlineModel: '在线模型',
|
||||
instanceDeployment: '按实例部署',
|
||||
nvidiaDeployment: 'NVIDIA 优化部署',
|
||||
engines: '推理引擎',
|
||||
engines: '本地推理引擎',
|
||||
modelManagement: '模型管理',
|
||||
serviceDetails: '服务详情',
|
||||
deployments: '本地部署',
|
||||
files: '模型文件',
|
||||
files: '模型广场',
|
||||
connectExternal: '接入外部服务',
|
||||
deploymentTemplates: 'vLLM 启动命令模板',
|
||||
engineProvider: '引擎 / 供应商',
|
||||
@@ -1027,7 +1043,7 @@ const message = {
|
||||
container_conn_helper: '容器间访问或连接使用此地址',
|
||||
from_remote: '该模型并非通过 1Panel 下载,无相关拉取日志。',
|
||||
no_logs: '该模型的拉取日志已被删除,无法查看相关日志。',
|
||||
vllmVersionHelper: 'FusionXpark GB 10 服务器请选择 -cu130 版本',
|
||||
vllmVersionHelper: 'FusionXpark GB10 服务器请选择 -cu130 版本',
|
||||
vllmDeployProfile: '部署模型',
|
||||
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
|
||||
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
|
||||
@@ -1552,6 +1568,11 @@ const message = {
|
||||
timePerOutputTokenChart: 'Token 间延迟',
|
||||
},
|
||||
gpu: {
|
||||
systemMemory: '系统共享内存',
|
||||
systemMemoryUsed: '系统已用内存',
|
||||
systemMemoryTotal: '系统总内存',
|
||||
systemMemoryAvailable: '系统可用内存',
|
||||
systemMemoryHelper: 'CPU 与 GPU 共用,显示整机内存使用情况,不代表 GPU 独占用量。',
|
||||
memoryActivity: '显存读写活跃率',
|
||||
hotspotTemperature: '热点温度',
|
||||
fanRPM: '风扇转速(RPM)',
|
||||
@@ -1586,7 +1607,6 @@ const message = {
|
||||
historyAverage: '平均值',
|
||||
historyPeak: '峰值',
|
||||
historyAggregated: '共 {count} 条采样,按 {seconds} 秒聚合;进程仅展示统计数量。',
|
||||
historyRaw: '共 {count} 条原始采样。',
|
||||
historyMetricEmpty: '所选时间范围内没有该指标的数据',
|
||||
historyLoadFailed: '历史记录加载失败,请重试',
|
||||
collectedAt: '采集时间',
|
||||
@@ -1657,8 +1677,8 @@ const message = {
|
||||
prohibitedHelper: '禁止: 不允许进程同时执行',
|
||||
migModeHelper: '用于创建 MIG 实例,在用户层实现 GPU 的物理隔离。',
|
||||
migModeNA: '不支持',
|
||||
current: '实时监控',
|
||||
history: '历史记录',
|
||||
current: '实时状态',
|
||||
history: '监控数据',
|
||||
notSupport: '当前版本或驱动不支持显示该参数。',
|
||||
},
|
||||
mcp: {
|
||||
@@ -1935,7 +1955,6 @@ const message = {
|
||||
retry: '补测选中测点',
|
||||
environment: '执行环境',
|
||||
plan: '冻结配置',
|
||||
clientHelp: '压测容器在主节点运行,向已有服务发送请求。不会启动推理服务,也不需要分配 GPU。',
|
||||
retryHelp: '本轮只补测选中的异常测点,保留参数并重新分配随机种子。',
|
||||
secretHelp: '复制配置不包含 API Key 和请求头,请按需重新填写。',
|
||||
resultHelp:
|
||||
|
||||
@@ -358,9 +358,8 @@ html {
|
||||
background: var(--panel-main-bg-color-10);
|
||||
pre {
|
||||
margin: 0;
|
||||
width: 350px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -286,3 +286,27 @@ html {
|
||||
border: 1px solid var(--panel-main-bg-color-10);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -79,6 +79,9 @@
|
||||
>
|
||||
{{ $t('commons.button.view') }}
|
||||
</el-button>
|
||||
<el-button type="primary" link @click="openHistory(item)">
|
||||
{{ $t('aiTools.gpu.history') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -97,7 +100,21 @@
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<el-progress
|
||||
v-if="memoryPercentage(item) !== null"
|
||||
@@ -191,6 +208,9 @@
|
||||
>
|
||||
{{ $t('commons.button.view') }}
|
||||
</el-button>
|
||||
<el-button type="primary" link @click="openHistory(item)">
|
||||
{{ $t('aiTools.gpu.history') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -321,6 +341,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
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 RouterMenu from '@/views/ai/gpu/index.vue';
|
||||
import { AI } from '@/api/interface/ai';
|
||||
@@ -328,6 +350,7 @@ import i18n from '@/lang';
|
||||
import { MsgWarning } from '@/utils/message';
|
||||
import DeviceDetails, { type DetailSection } from './components/DeviceDetails.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref();
|
||||
const processDrawerVisible = ref(false);
|
||||
const processDeviceKey = ref('');
|
||||
@@ -574,10 +597,14 @@ const deviceDetailSections = (item: AcceleratorDevice): DetailSection[] => {
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
title: t('aiTools.gpu.memory'),
|
||||
title: t(item.sharedMemory ? 'aiTools.gpu.systemMemory' : 'aiTools.gpu.memory'),
|
||||
items: [
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
@@ -733,6 +760,26 @@ const openXPUProcesses = (deviceID: number) => {
|
||||
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) => {
|
||||
return item.type === 'ascend' ? `Chip ${item.chipIndex}` : `GPU ${item.index}`;
|
||||
};
|
||||
|
||||
@@ -4,45 +4,40 @@
|
||||
<template v-if="supported">
|
||||
<el-card class="history-toolbar">
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<el-date-picker
|
||||
v-model="timeRangeGlobal"
|
||||
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-select style="max-width: 100%" class="p-w-400" v-model="selectedDevice" @change="search">
|
||||
<template #prefix>{{ $t('aiTools.gpu.deviceInfo') }}</template>
|
||||
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<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="max">{{ $t('aiTools.gpu.historyPeak') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<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 v-if="history?.sampleCount" class="history-summary">
|
||||
</div>
|
||||
<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,
|
||||
count: history.sampleCount,
|
||||
})
|
||||
: $t('aiTools.gpu.historyRaw', { count: history.sampleCount })
|
||||
}}
|
||||
</div>
|
||||
</el-card>
|
||||
<el-alert
|
||||
v-if="currentDevice?.legacy"
|
||||
:title="$t('aiTools.gpu.legacyHistory')"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert
|
||||
v-if="failed"
|
||||
:title="$t('aiTools.gpu.historyLoadFailed')"
|
||||
@@ -58,7 +53,15 @@
|
||||
<el-row v-else-if="history?.sampleCount" :gutter="12">
|
||||
<el-col v-for="chart in charts" :key="chart.id" :xs="24" :md="12">
|
||||
<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" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -90,7 +93,9 @@
|
||||
</template>
|
||||
|
||||
<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 RouterMenu from '@/views/ai/gpu/index.vue';
|
||||
import { shortcuts } from '@/utils/shortcuts';
|
||||
@@ -98,6 +103,7 @@ import i18n from '@/lang';
|
||||
import { AI } from '@/api/interface/ai';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
|
||||
const route = useRoute();
|
||||
const { isMobile } = useGlobalStore();
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
@@ -108,6 +114,7 @@ const aggregation = ref<'avg' | 'max'>('avg');
|
||||
const history = ref<AI.MonitorGPUData>();
|
||||
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 timeRangePrefix = () => h('span', i18n.global.t('commons.table.date'));
|
||||
let followNow = true;
|
||||
let requestID = 0;
|
||||
|
||||
@@ -124,10 +131,18 @@ const charts = computed(() => {
|
||||
},
|
||||
{
|
||||
id: 'gpu-memory',
|
||||
title: 'memory',
|
||||
title: currentDevice.value?.sharedMemory ? 'systemMemory' : 'memory',
|
||||
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 },
|
||||
],
|
||||
},
|
||||
@@ -338,12 +353,16 @@ const loadOptions = async () => {
|
||||
const response = await getGPUOptions();
|
||||
if (id !== requestID) return;
|
||||
supported.value = response.data.supported;
|
||||
options.value = (supported.value ? response.data.chartHide || [] : []).map((item) => ({
|
||||
options.value = (supported.value ? response.data.chartHide || [] : [])
|
||||
.filter((item) => item.deviceID && !item.legacy)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
value: item.deviceID || `legacy:${item.productName}`,
|
||||
label: `${item.productName} · ${item.deviceID || i18n.global.t('aiTools.gpu.legacyDevice')}`,
|
||||
value: item.deviceID!,
|
||||
label: item.productName
|
||||
.replace(/^(\d+) - /, `${item.type === 'xpu' ? 'XPU' : 'GPU'} $1 · `)
|
||||
.replace(' - ', ' · '),
|
||||
}));
|
||||
if (!currentDevice.value) selectedDevice.value = options.value[0]?.value || '';
|
||||
if (!currentDevice.value && !route.query.deviceID) selectedDevice.value = options.value[0]?.value || '';
|
||||
await search();
|
||||
} catch {
|
||||
if (id === requestID) failed.value = true;
|
||||
@@ -401,7 +420,15 @@ const loadProcessType = (val: string) => {
|
||||
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(() => {
|
||||
requestID++;
|
||||
});
|
||||
@@ -412,6 +439,14 @@ onBeforeUnmount(() => {
|
||||
margin: 7px 0 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 {
|
||||
margin-top: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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-form-item>
|
||||
<template v-if="!showSync || form.syncModelAccount">
|
||||
@@ -52,6 +52,7 @@ const props = defineProps<{
|
||||
containerPort: string;
|
||||
hostMode?: boolean;
|
||||
showSync?: boolean;
|
||||
hideSyncToggle?: boolean;
|
||||
}>();
|
||||
const systemIP = ref('');
|
||||
const addressTypes = computed(() => [
|
||||
|
||||
@@ -57,12 +57,16 @@
|
||||
v-if="visible && view && view !== VllmPlaceholder && !failed && !missing"
|
||||
ref="contentRef"
|
||||
v-bind="
|
||||
view === EngineConnection || view === AppParams
|
||||
view === AppParams
|
||||
? { modelGPU: true }
|
||||
: view === EngineConnection
|
||||
? {}
|
||||
: view === ContainerLogs
|
||||
? { highlightDiff: 210 }
|
||||
: view === TaskLog
|
||||
? { width: '70%' }
|
||||
: ['config', 'monitor'].includes(actionName)
|
||||
? {}
|
||||
: { dialog: true }
|
||||
"
|
||||
@close="returnToManagement"
|
||||
@@ -357,7 +361,9 @@ const deploymentButtons = [
|
||||
{
|
||||
label: i18n.global.t('commons.button.delete'),
|
||||
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) => {
|
||||
deleting.value = row;
|
||||
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>
|
||||
@@ -80,13 +80,6 @@
|
||||
/>
|
||||
</template>
|
||||
<template #main>
|
||||
<el-alert
|
||||
v-if="loadFailed"
|
||||
type="error"
|
||||
:closable="false"
|
||||
:title="$t('aiTools.model.loadFailed')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<ComplexTable
|
||||
v-if="!isLocal"
|
||||
:data="connections"
|
||||
@@ -230,8 +223,13 @@
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('commons.table.status')" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-dropdown placement="bottom" :disabled="statusDisabled(row)">
|
||||
<Status v-permission :status="row.status" operate :disabled="statusDisabled(row)" />
|
||||
<el-dropdown placement="bottom" :disabled="statusDisabled(row, 'stop')">
|
||||
<Status
|
||||
v-permission
|
||||
:status="row.status"
|
||||
operate
|
||||
:disabled="statusDisabled(row, 'stop')"
|
||||
/>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
@@ -242,7 +240,8 @@
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:disabled="
|
||||
statusDisabled(row) || ['Stopped', 'Exited'].includes(row.status)
|
||||
statusDisabled(row, 'stop') ||
|
||||
['Stopped', 'Exited'].includes(row.status)
|
||||
"
|
||||
@click="operate(row, 'stop')"
|
||||
>
|
||||
@@ -358,8 +357,8 @@ const isManagement = computed(
|
||||
() => isCurrentPage.value && isLocal && ['vllm', 'tensorrt'].includes(String(route.query.tab)),
|
||||
);
|
||||
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.services'), path: '/ai/model/account' },
|
||||
{ label: i18n.global.t('aiTools.model.engines'), path: '/ai/model/local' },
|
||||
]);
|
||||
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 syncRef = ref<InstanceType<typeof SyncService>>();
|
||||
const loading = ref(false);
|
||||
const loadFailed = ref(false);
|
||||
const connections = ref<AI.AgentAccountItem[]>([]);
|
||||
const accountColumns = ref<FuTableColumnConfig[]>([]);
|
||||
const providers = ref<AI.ProviderInfo[]>([]);
|
||||
@@ -501,7 +499,6 @@ const search = async () => {
|
||||
const version = ++searchVersion;
|
||||
if (!isCurrentPage.value || isFiles || isManagement.value) return;
|
||||
loading.value = true;
|
||||
loadFailed.value = false;
|
||||
try {
|
||||
if (isLocal) {
|
||||
const data = await loadModelOverview(isProductPro.value, isFxplay.value);
|
||||
@@ -527,7 +524,6 @@ const search = async () => {
|
||||
connections.value = [];
|
||||
pagination.total = 0;
|
||||
deployments.value = [];
|
||||
loadFailed.value = true;
|
||||
} finally {
|
||||
if (version === searchVersion) loading.value = false;
|
||||
}
|
||||
@@ -556,7 +552,6 @@ const accountButtons = [
|
||||
{
|
||||
label: i18n.global.t('commons.button.delete'),
|
||||
permission: true,
|
||||
show: (row: AI.AgentAccountItem) => !row.appInstallId || row.provider === 'ollama',
|
||||
click: async (row: AI.AgentAccountItem) => {
|
||||
await ElMessageBox.confirm(
|
||||
i18n.global.t('commons.msg.delete', [row.name]),
|
||||
@@ -570,22 +565,23 @@ const accountButtons = [
|
||||
];
|
||||
const taskRef = ref();
|
||||
const pending = ref('');
|
||||
const operationDisabled = (row: Deployment) =>
|
||||
const operationDisabled = (row: Deployment, operation = '') =>
|
||||
Boolean(pending.value) ||
|
||||
['Installing', 'Starting', 'ReStarting', 'Restarting', 'Rebuilding', 'Waiting', 'Uninstalling'].includes(
|
||||
row.status,
|
||||
);
|
||||
const statusDisabled = (row: Deployment) =>
|
||||
operationDisabled(row) || !hasManagePermissionAccess() || (row.engine === 'ollama' && !hasPermission('app_manage'));
|
||||
['Installing', 'Starting', 'Rebuilding', 'Waiting', 'Uninstalling'].includes(row.status) ||
|
||||
(['ReStarting', 'Restarting'].includes(row.status) && !['stop', 'delete'].includes(operation));
|
||||
const statusDisabled = (row: Deployment, operation = '') =>
|
||||
operationDisabled(row, operation) ||
|
||||
!hasManagePermissionAccess() ||
|
||||
(row.engine === 'ollama' && !hasPermission('app_manage'));
|
||||
const operate = async (row: Deployment, operation: 'start' | 'stop' | 'restart') => {
|
||||
if (statusDisabled(row)) return;
|
||||
if (statusDisabled(row, operation)) return;
|
||||
const node = currentNode.value;
|
||||
await ElMessageBox.confirm(
|
||||
i18n.global.t('app.operatorHelper', [i18n.global.t('commons.operate.' + operation)]),
|
||||
i18n.global.t('commons.operate.' + operation),
|
||||
{ type: 'warning' },
|
||||
);
|
||||
if (node !== currentNode.value || statusDisabled(row)) return;
|
||||
if (node !== currentNode.value || statusDisabled(row, operation)) return;
|
||||
pending.value = `${row.engine}:${row.id}`;
|
||||
try {
|
||||
if (row.engine === 'ollama') {
|
||||
@@ -653,7 +649,7 @@ const deploymentButtons = [
|
||||
label: i18n.global.t('commons.button.delete'),
|
||||
permission: true,
|
||||
show: (row: Deployment) => row.engine === 'ollama' && hasPermission('app_manage'),
|
||||
disabled: operationDisabled,
|
||||
disabled: (row: Deployment) => operationDisabled(row, 'delete'),
|
||||
click: async (row: Deployment) => {
|
||||
const node = currentNode.value;
|
||||
const path = route.fullPath;
|
||||
@@ -683,7 +679,7 @@ const deploymentButtons = [
|
||||
label: i18n.global.t('commons.button.delete'),
|
||||
permission: true,
|
||||
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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,15 +5,30 @@
|
||||
@update:model-value="open = $event"
|
||||
@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>
|
||||
<GPUConfig ref="gpuRef" v-model="form.gpu" />
|
||||
<details open class="form-section">
|
||||
<summary class="form-section-header">
|
||||
<span>{{ $t('aiTools.model.syncModelAccount') }}</span>
|
||||
<el-switch
|
||||
@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>
|
||||
</AppInstallForm>
|
||||
<template v-if="!inline" #footer>
|
||||
@@ -22,7 +37,7 @@
|
||||
v-permission
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="loading || !form.appDetailId"
|
||||
:disabled="loading || gpuRef?.loading || !form.appDetailId"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('commons.button.confirm') }}
|
||||
@@ -40,6 +55,8 @@ import i18n from '@/lang';
|
||||
import DrawerPro from '@/components/drawer-pro/index.vue';
|
||||
import AppInstallForm from '@/views/app-store/detail/form/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 emit = defineEmits(['search', 'task', 'close']);
|
||||
@@ -47,7 +64,14 @@ const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const formRef = ref<InstanceType<typeof AppInstallForm>>();
|
||||
const accountRef = ref();
|
||||
const gpuRef = ref<InstanceType<typeof GPUConfig>>();
|
||||
const form = ref({
|
||||
gpu: {
|
||||
enabled: true,
|
||||
mode: 'all',
|
||||
deviceIDs: [],
|
||||
capabilities: ['gpu', 'utility', 'compute'],
|
||||
} as AI.ModelGPUConfig,
|
||||
appDetailId: 0,
|
||||
name: '',
|
||||
params: {} as Record<string, any>,
|
||||
@@ -69,12 +93,15 @@ const openCreate = async () => {
|
||||
if (!open.value) return;
|
||||
formRef.value.resetForm();
|
||||
formRef.value.setFormData({
|
||||
gpu: { enabled: true, mode: 'all', deviceIDs: [], capabilities: ['gpu', 'utility', 'compute'] },
|
||||
restartPolicy: 'unless-stopped',
|
||||
syncModelAccount: true,
|
||||
modelAccountBaseURLType: 'container',
|
||||
modelAccountBaseURL: '',
|
||||
});
|
||||
await formRef.value.initForm('ollama', true);
|
||||
if (!open.value) return;
|
||||
await gpuRef.value?.init(true);
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
} finally {
|
||||
@@ -82,7 +109,7 @@ const openCreate = 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();
|
||||
if (data.editCompose && !data.dockerCompose.trim()) {
|
||||
MsgError(i18n.global.t('app.composeNullErr'));
|
||||
@@ -112,5 +139,14 @@ watch(
|
||||
watch(open, (value) => {
|
||||
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>
|
||||
|
||||
<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'),
|
||||
permission: true,
|
||||
disabled: (row: AI.TensorRTLLM) => {
|
||||
return row.status !== 'Running';
|
||||
return !['Running', 'ReStarting', 'Restarting'].includes(row.status);
|
||||
},
|
||||
click: (row: AI.TensorRTLLM) => {
|
||||
operate(row, 'stop');
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
<template>
|
||||
<component
|
||||
:is="inline ? 'div' : dialog ? DialogPro : DrawerPro"
|
||||
:is="inline ? 'div' : DrawerPro"
|
||||
v-bind="
|
||||
inline
|
||||
? {}
|
||||
: {
|
||||
modelValue: drawerVisiable,
|
||||
header: $t('commons.button.' + mode),
|
||||
title: $t('commons.button.' + mode),
|
||||
size: 'large',
|
||||
size: isMobile ? 'full' : 'large',
|
||||
}
|
||||
"
|
||||
@update:model-value="drawerVisiable = $event"
|
||||
@close="handleClose"
|
||||
>
|
||||
<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
|
||||
@validate="(prop, valid) => expandInvalidFormSection(formRef, prop, valid)"
|
||||
scroll-to-error
|
||||
ref="formRef"
|
||||
label-position="top"
|
||||
:model="tensorRTLLM"
|
||||
:rules="rules"
|
||||
v-loading="loading"
|
||||
>
|
||||
<details open class="form-section">
|
||||
<summary class="form-section-header">{{ $t('home.baseInfo') }}</summary>
|
||||
<div class="form-section-body">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input clearable v-model.trim="tensorRTLLM.name" :disabled="mode == 'edit'" />
|
||||
</el-form-item>
|
||||
@@ -45,22 +55,35 @@
|
||||
<el-form-item :label="$t('aiTools.tensorRT.modelDir')" prop="modelDir">
|
||||
<el-input v-model="tensorRTLLM.modelDir">
|
||||
<template #prepend>
|
||||
<el-button icon="Folder" @click="modelDirRef.acceptParams({ dir: true })" />
|
||||
<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>
|
||||
<template v-if="!inline" #footer>
|
||||
<span class="dialog-footer">
|
||||
<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') }}
|
||||
</el-button>
|
||||
</span>
|
||||
@@ -70,26 +93,35 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import DialogPro from '@/components/dialog-pro/index.vue';
|
||||
const props = defineProps({ dialog: Boolean, inline: Boolean });
|
||||
import { expandInvalidFormSection } from '@/views/ai/model/components/form-section';
|
||||
const props = defineProps({ inline: Boolean });
|
||||
import NodeConfig from '@/views/website/runtime//components/node-config.vue';
|
||||
import DrawerPro from '@/components/drawer-pro/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 i18n from '@/lang';
|
||||
import { ElForm, FormInstance } from 'element-plus';
|
||||
import { createTensorRTLLM, updateTensorRTLLM } from '@/api/modules/ai';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
const { isFxplay } = useGlobalStore();
|
||||
const { isFxplay, isMobile, isProductPro } = useGlobalStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const gpuRef = ref<InstanceType<typeof GPUConfig>>();
|
||||
const mode = ref('create');
|
||||
const drawerVisiable = ref(false);
|
||||
const newTensorRTLLM = () => {
|
||||
return {
|
||||
gpu: {
|
||||
enabled: true,
|
||||
mode: 'all',
|
||||
deviceIDs: [],
|
||||
capabilities: ['gpu', 'utility', 'compute'],
|
||||
} as AI.ModelGPUConfig,
|
||||
name: '',
|
||||
containerName: '',
|
||||
version: '1.2.0rc0',
|
||||
@@ -108,15 +140,32 @@ const modelDirRef = ref();
|
||||
const tensorRTLLM = ref(newTensorRTLLM());
|
||||
const emit = defineEmits(['search', 'close']);
|
||||
|
||||
const openCreate = (): void => {
|
||||
const openCreate = async () => {
|
||||
mode.value = 'create';
|
||||
drawerVisiable.value = true;
|
||||
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';
|
||||
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) {
|
||||
tensorRTLLM.value.environments = [];
|
||||
}
|
||||
@@ -131,6 +180,13 @@ const openEdit = (rowData: any): void => {
|
||||
tensorRTLLM.value.extraHosts = [];
|
||||
}
|
||||
drawerVisiable.value = true;
|
||||
loading.value = true;
|
||||
try {
|
||||
await nextTick();
|
||||
await gpuRef.value?.init(false);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -138,6 +194,24 @@ const handleClose = () => {
|
||||
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) => {
|
||||
tensorRTLLM.value.modelDir = path;
|
||||
};
|
||||
@@ -164,6 +238,7 @@ const rules = reactive({
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (loading.value || gpuRef.value?.loading) return;
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
loading.value = true;
|
||||
@@ -212,5 +287,10 @@ defineExpose({
|
||||
submit: onSubmit,
|
||||
loading,
|
||||
openEdit,
|
||||
disabled: computed(() => gpuRef.value?.loading),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/views/ai/model/components/form-section.scss';
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<el-card>
|
||||
<div class="app-wrapper" @click="openDetail(app.key)">
|
||||
<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 class="app-content">
|
||||
<div class="content-top">
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
/>
|
||||
|
||||
<el-form
|
||||
@validate="(prop, valid) => expandInvalidFormSection(formRef, prop, valid)"
|
||||
scroll-to-error
|
||||
v-loading="loading"
|
||||
@submit.prevent
|
||||
ref="formRef"
|
||||
@@ -25,6 +27,9 @@
|
||||
:rules="formRules"
|
||||
:validate-on-rule-change="false"
|
||||
>
|
||||
<component :is="sectioned ? 'details' : 'section'" :open="sectioned" :class="{ 'form-section': sectioned }">
|
||||
<summary v-if="sectioned" class="form-section-header">{{ $t('home.baseInfo') }}</summary>
|
||||
<div :class="{ 'form-section-body': sectioned }">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input v-model.trim="formData.name"></el-input>
|
||||
</el-form-item>
|
||||
@@ -47,14 +52,18 @@
|
||||
v-model:rules="formRules.params"
|
||||
:propStart="'params.'"
|
||||
/>
|
||||
</div>
|
||||
</component>
|
||||
|
||||
<slot name="after-params" />
|
||||
|
||||
<el-form-item prop="advanced">
|
||||
<component :is="sectioned ? 'details' : 'section'" :open="sectioned" :class="{ 'form-section': sectioned }">
|
||||
<summary v-if="sectioned" class="form-section-header">{{ $t('app.advanced') }}</summary>
|
||||
<el-form-item v-if="!sectioned" prop="advanced">
|
||||
<el-checkbox v-model="formData.advanced" :label="$t('app.advanced')" size="large" />
|
||||
</el-form-item>
|
||||
|
||||
<div v-if="formData.advanced">
|
||||
<div v-if="formData.advanced" :class="{ 'form-section-body': sectioned }">
|
||||
<el-form-item :label="$t('app.containerName')" prop="containerName">
|
||||
<el-input v-model.trim="formData.containerName" :placeholder="$t('app.containerNameHelper')" />
|
||||
</el-form-item>
|
||||
@@ -114,7 +123,7 @@
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item pro="gpuConfig" v-if="gpuSupport">
|
||||
<el-form-item pro="gpuConfig" v-if="gpuSupport && !hideGpuConfig">
|
||||
<el-checkbox v-model="formData.gpuConfig" :label="$t('app.gpuConfig')" size="large" />
|
||||
<span class="input-help">{{ $t('app.gpuConfigHelper') }}</span>
|
||||
</el-form-item>
|
||||
@@ -142,11 +151,13 @@
|
||||
<CodemirrorPro v-model="formData.dockerCompose" mode="yaml" />
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { expandInvalidFormSection } from '@/views/ai/model/components/form-section';
|
||||
import { App } from '@/api/interface/app';
|
||||
import { getAppByKey, getAppDetail, getAppInstalledByID } from '@/api/modules/app';
|
||||
import { getAppStoreConfig } from '@/api/modules/setting';
|
||||
@@ -183,6 +194,8 @@ interface Props {
|
||||
loading?: boolean;
|
||||
modelValue?: any;
|
||||
batchInstallSupport?: boolean;
|
||||
sectioned?: boolean;
|
||||
hideGpuConfig?: boolean;
|
||||
}
|
||||
|
||||
const limits = ref<Container.ResourceLimit>({
|
||||
@@ -193,6 +206,7 @@ const limits = ref<Container.ResourceLimit>({
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
batchInstallSupport: false,
|
||||
sectioned: false,
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
@@ -460,3 +474,7 @@ defineExpose({
|
||||
initClusterForm,
|
||||
});
|
||||
</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="detail flex">
|
||||
<div class="w-12 h-12 rounded p-1 shadow-md icon">
|
||||
<img
|
||||
:src="getAppIconUrl(app.key, currentNode)"
|
||||
<el-avatar
|
||||
:src="getAppIconUrl(app.key, currentNode, app.icon)"
|
||||
alt="App Icon"
|
||||
class="w-full h-full rounded"
|
||||
style="object-fit: contain"
|
||||
/>
|
||||
shape="square"
|
||||
fit="contain"
|
||||
class="!w-full !h-full rounded"
|
||||
>
|
||||
<SvgIcon iconName="p-appstore" />
|
||||
</el-avatar>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="name mb-2">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<AppIcon
|
||||
@open-detail="$emit('openDetail')"
|
||||
:appKey="installed.appKey"
|
||||
:icon="installed.icon"
|
||||
:currentNode="currentNode"
|
||||
></AppIcon>
|
||||
</el-col>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +17,7 @@ import { getAppIconUrl } from '@/api/modules/app';
|
||||
interface Props {
|
||||
currentNode: string;
|
||||
appKey: string;
|
||||
icon?: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<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>
|
||||
<el-button type="primary" plain @click="editParam" :disabled="loading" :icon="edit ? '' : 'Edit'">
|
||||
{{ edit ? $t('app.detail') : $t('commons.button.edit') }}
|
||||
@@ -60,7 +65,22 @@
|
||||
</div>
|
||||
<div v-else v-loading="loading">
|
||||
<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
|
||||
@validate="(prop, valid) => expandInvalidFormSection(paramForm, prop, valid)"
|
||||
scroll-to-error
|
||||
@submit.prevent
|
||||
ref="paramForm"
|
||||
:model="paramModel"
|
||||
label-position="top"
|
||||
:rules="rules"
|
||||
>
|
||||
<component
|
||||
:is="appKey === 'ollama' ? 'details' : 'section'"
|
||||
:open="appKey === 'ollama'"
|
||||
:class="{ 'form-section': appKey === 'ollama' }"
|
||||
>
|
||||
<summary v-if="appKey === 'ollama'" class="form-section-header">{{ $t('home.baseInfo') }}</summary>
|
||||
<div :class="{ 'form-section-body': appKey === 'ollama' }">
|
||||
<div v-for="(p, index) in params" :key="index">
|
||||
<el-form-item :prop="'params.' + p.key" :label="getLabel(p)">
|
||||
<el-input
|
||||
@@ -98,19 +118,44 @@
|
||||
<el-input v-else v-model.trim="paramModel.params[p.key]" :disabled="!p.edit"></el-input>
|
||||
</el-form-item>
|
||||
</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
|
||||
v-if="appKey === 'ollama'"
|
||||
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"
|
||||
/>
|
||||
<el-form-item prop="advanced">
|
||||
</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="paramModel.advanced">
|
||||
<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"
|
||||
@@ -176,11 +221,12 @@
|
||||
<CodemirrorPro v-model="paramModel.dockerCompose" mode="yaml"></CodemirrorPro>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer v-if="edit">
|
||||
<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') }}
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -188,12 +234,14 @@
|
||||
</DrawerPro>
|
||||
</template>
|
||||
<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 GPUConfig from '@/views/ai/model/components/gpu-config/index.vue';
|
||||
import Diff from '@/views/app-store/installed/upgrade/diff/index.vue';
|
||||
|
||||
import { App } from '@/api/interface/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 { Rules, checkNumberRange } from '@/global/form-rules';
|
||||
import { MsgError, MsgSuccess } from '@/utils/message';
|
||||
@@ -218,8 +266,9 @@ interface EditForm extends App.InstallParams {
|
||||
default: any;
|
||||
}
|
||||
|
||||
const props = defineProps({ modelGPU: Boolean });
|
||||
const emit = defineEmits(['close']);
|
||||
const { currentNode } = useGlobalStore();
|
||||
const { currentNode, isMobile } = useGlobalStore();
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const params = ref<EditForm[]>();
|
||||
@@ -228,6 +277,16 @@ const paramForm = ref<FormInstance>();
|
||||
const paramModel = reactive<any>({
|
||||
params: {},
|
||||
});
|
||||
const advancedFields = [
|
||||
'memoryLimit',
|
||||
'cpuQuota',
|
||||
'memoryUnit',
|
||||
'allowPort',
|
||||
'specifyIP',
|
||||
'containerName',
|
||||
'restartPolicy',
|
||||
];
|
||||
let initialAdvancedConfig: Record<string, unknown> = {};
|
||||
const rules = reactive({
|
||||
params: {},
|
||||
cpuQuota: [Rules.requiredInput, checkNumberRange(0, 999)],
|
||||
@@ -237,7 +296,9 @@ const rules = reactive({
|
||||
});
|
||||
const appType = ref('');
|
||||
const appKey = ref('');
|
||||
const showModelGPU = computed(() => props.modelGPU && appKey.value === 'ollama');
|
||||
const accountRef = ref<InstanceType<typeof AccountAddress>>();
|
||||
const gpuRef = ref<InstanceType<typeof GPUConfig>>();
|
||||
const appConfigUpdate = ref<App.AppConfigUpdate>({
|
||||
installID: 0,
|
||||
webUI: '',
|
||||
@@ -305,19 +366,24 @@ const acceptParams = async (props: ParamProps) => {
|
||||
if (!(await get())) return;
|
||||
open.value = true;
|
||||
openConfig.value = false;
|
||||
if (props.edit) editParam();
|
||||
if (props.edit) await editParam();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
open.value = false;
|
||||
};
|
||||
const editParam = () => {
|
||||
const editParam = async () => {
|
||||
params.value.forEach((param: EditForm) => {
|
||||
paramModel.params[param.key] = param.value;
|
||||
});
|
||||
|
||||
edit.value = !edit.value;
|
||||
loadLimit();
|
||||
if (edit.value && showModelGPU.value) {
|
||||
await nextTick();
|
||||
await gpuRef.value?.init(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changeAllowPort = () => {
|
||||
@@ -386,11 +452,21 @@ const get = async () => {
|
||||
}
|
||||
appType.value = res.data.type;
|
||||
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.httpPort = res.data.httpPort;
|
||||
paramModel.syncModelAccount = res.data.syncModelAccount;
|
||||
paramModel.modelAccountBaseURLType = res.data.modelAccountBaseURLType || 'container';
|
||||
paramModel.modelAccountBaseURL = res.data.modelAccountBaseURL;
|
||||
initialAdvancedConfig = Object.fromEntries(advancedFields.map((key) => [key, paramModel[key]]));
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
@@ -400,13 +476,11 @@ const get = async () => {
|
||||
};
|
||||
|
||||
const submit = async (formEl: FormInstance) => {
|
||||
if (!formEl) return;
|
||||
if (!formEl || loading.value || gpuRef.value?.loading) return;
|
||||
const node = currentNode.value;
|
||||
const installId = paramData.value.id;
|
||||
await formEl.validate((valid) => {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
if (!valid) return;
|
||||
ElMessageBox.confirm(i18n.global.t('app.updateWarn'), i18n.global.t('commons.button.update'), {
|
||||
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
||||
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||
@@ -414,24 +488,26 @@ const submit = async (formEl: FormInstance) => {
|
||||
}).then(async () => {
|
||||
if (!open.value || node !== currentNode.value || installId !== paramData.value.id) return;
|
||||
const submitModel: Record<string, any> = { installId, params: paramModel.params };
|
||||
if (showModelGPU.value) submitModel.gpu = paramModel.gpu;
|
||||
if (appKey.value === 'ollama') {
|
||||
submitModel.syncModelAccount = paramModel.syncModelAccount;
|
||||
submitModel.modelAccountBaseURLType = paramModel.modelAccountBaseURLType;
|
||||
submitModel.modelAccountBaseURL = accountRef.value.baseURL;
|
||||
}
|
||||
if (paramModel.advanced) {
|
||||
submitModel.advanced = paramModel.advanced;
|
||||
submitModel.memoryLimit = paramModel.memoryLimit;
|
||||
submitModel.cpuQuota = paramModel.cpuQuota;
|
||||
submitModel.memoryUnit = paramModel.memoryUnit;
|
||||
submitModel.allowPort = paramModel.allowPort;
|
||||
submitModel.specifyIP = paramModel.specifyIP;
|
||||
submitModel.containerName = paramModel.containerName;
|
||||
const updateAdvanced =
|
||||
appKey.value === 'ollama'
|
||||
? paramModel.editCompose ||
|
||||
advancedFields.some((key) => paramModel[key] !== initialAdvancedConfig[key])
|
||||
: paramModel.advanced;
|
||||
if (updateAdvanced) {
|
||||
submitModel.advanced = true;
|
||||
for (const key of advancedFields) {
|
||||
submitModel[key] = paramModel[key];
|
||||
}
|
||||
if (paramModel.editCompose) {
|
||||
submitModel.editCompose = paramModel.editCompose;
|
||||
submitModel.dockerCompose = paramModel.dockerCompose;
|
||||
}
|
||||
submitModel.restartPolicy = paramModel.restartPolicy;
|
||||
}
|
||||
try {
|
||||
loading.value = true;
|
||||
@@ -505,6 +581,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/views/ai/model/components/form-section.scss';
|
||||
.change-button {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ const acceptParams = (apppInstall: App.AppInstalled) => {
|
||||
|
||||
const getVersions = async () => {
|
||||
try {
|
||||
const res = await getAppUpdateVersions({ appInstallID: appInstallID.value });
|
||||
const res = await getAppUpdateVersions({ appInstallID: appInstallID.value, loadCompose: false });
|
||||
versions.value = res.data || [];
|
||||
if (versions.value.length > 0) {
|
||||
req.appDetailID = versions.value[0].detailId;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user