Compare commits

..
29 Commits
Author SHA1 Message Date
CityFun 142c9b9a71 fix: Fixed issue with build backend failed (#12199) 2026-03-17 14:50:45 +08:00
ssongliu f3c7603f6e fix: respect region-based release doc source (#12198) 2026-03-17 14:33:11 +08:00
ssongliuandssongliu db26da1700 chore: format english proxy helper text (#12197)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-17 14:06:20 +08:00
ssongliuandssongliu d5ff59f4e7 fix: remove unused compose backup status param (#12196)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-17 13:56:34 +08:00
王贺 85b88db99e docs: refine Docker proxy desc (#12195) 2026-03-17 11:15:27 +08:00
ssongliuandssongliu f7053934b5 fix: improve compose backup and container restore handling (#12192)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-16 22:39:34 +08:00
wangdage12andfanbook-wangdage bf1339bb40 fix: show loading state when confirming deletion of multiple folders and reduce unnecessary basedir requests
Co-authored-by: fanbook-wangdage <124357765+fanbook-wangdage@users.noreply.github.com>
2026-03-16 19:07:19 +08:00
CityFun 43c5e62c23 feat: change allowedOrigin param for openclaw upgrade (#12188) 2026-03-16 10:53:50 +00:00
ssongliuandssongliu 28634ce631 fix(middleware): preserve numeric format in operation logs (#12173) (#12185)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-16 17:33:07 +08:00
ssongliuandssongliu 72263dfdda fix(container): allow backup for stopped containers (#12184)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-16 16:39:16 +08:00
ssongliuandssongliu 479d36e25f fix(log): localize upgrade and rebuild operation labels (#12183)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-16 16:25:34 +08:00
ssongliu 2f3ad3537e feat: add docs source config and runtime env linkage (#12182) 2026-03-16 08:13:51 +00:00
CityFun 2dfe5096f0 feat: Optimize reverse proxy handling for applications with HTTPS ports. (#12181) 2026-03-16 08:07:51 +00:00
CityFun 4375722aef feat: add checkOnStart config for openclaw (#12178) 2026-03-16 06:45:49 +00:00
CityFun bde4d06842 fix: Fixed issue with build source code failed (#12177)
Refs https://github.com/1Panel-dev/1Panel/issues/12176
2026-03-16 05:51:50 +00:00
CityFun 17a486b2c4 feat: Modify the OpenClaw upgrade logic. (#12175) 2026-03-16 04:11:50 +00:00
CityFun 8499c09571 feat: Add OpenClaw security adaptation (#12168) 2026-03-13 18:41:44 +08:00
CityFun 7cb70f785a feat: change openclaw port jump logic (#12164) 2026-03-12 14:27:34 +00:00
CityFun f3781320de feat: add icon for agent (#12163) 2026-03-12 18:52:04 +08:00
CityFun 869ee75020 feat: Add allowedOrigins config for openclaw (#12162) 2026-03-12 09:37:33 +00:00
ssongliu 1d5a26373d chore: optimize lint and cleanup warnings (#12161) 2026-03-12 09:35:33 +00:00
ssongliu 176fd0f8ad feat: support file operations inside containers (#12160)
Refs #523
2026-03-12 09:33:33 +00:00
ssongliu 341a55ae63 feat: add Agent quick jump with priority show strategy (#12159)
Refs #12158
2026-03-12 09:31:33 +00:00
CityFun fb87cf545f feat: Modify the Gemini account verification logic (#12155)
Refs https://github.com/1Panel-dev/1Panel/issues/12149
2026-03-12 09:07:33 +00:00
ssongliu 6db2a9352e feat: refactor image update flow and align i18n messages (#12154)
Refs #10786
2026-03-12 09:05:34 +00:00
ssongliu 21826bcc1a feat: complete backup and recovery for containers and compose (#12153)
Refs #9656
2026-03-12 09:03:34 +00:00
ssongliu eedbc2e95c feat: load registry auth from docker cli config when pulling image (#12152)
Refs #10460
2026-03-12 09:01:33 +00:00
CityFun 46763df7bb fix: Fixed issue with ai menu change failed (#12147) 2026-03-11 10:55:27 +00:00
CityFun 175162753e feat: openclaw channel support wecom (#12146) 2026-03-11 10:53:27 +00:00
113 changed files with 7409 additions and 1279 deletions
+3 -1
View File
@@ -73,4 +73,6 @@ agent/.golangci.yml
openspec
CLAUDE.md
AGENTS.md
opencode.json
opencode.json
superpowers
.worktrees/
+54 -33
View File
@@ -353,6 +353,47 @@ func (b *BaseApi) UpdateAgentDiscordConfig(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Get Agent QQ Bot channel config
// @Accept json
// @Param request body dto.AgentWecomConfigReq true "request"
// @Success 200 {object} dto.AgentWecomConfig
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/channel/wecom/get [post]
func (b *BaseApi) GetAgentWecomConfig(c *gin.Context) {
var req dto.AgentWecomConfigReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.GetWecomConfig(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Update Agent WeCom channel config
// @Accept json
// @Param request body dto.AgentWecomConfigUpdateReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/channel/wecom/update [post]
func (b *BaseApi) UpdateAgentWecomConfig(c *gin.Context) {
var req dto.AgentWecomConfigUpdateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UpdateWecomConfig(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Get Agent QQ Bot channel config
// @Accept json
@@ -436,19 +477,19 @@ func (b *BaseApi) CheckAgentPlugin(c *gin.Context) {
}
// @Tags AI
// @Summary Get Agent Browser config
// @Summary Get Agent Security config
// @Accept json
// @Param request body dto.AgentBrowserConfigReq true "request"
// @Success 200 {object} dto.AgentBrowserConfig
// @Param request body dto.AgentSecurityConfigReq true "request"
// @Success 200 {object} dto.AgentSecurityConfig
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/browser/get [post]
func (b *BaseApi) GetAgentBrowserConfig(c *gin.Context) {
var req dto.AgentBrowserConfigReq
// @Router /ai/agents/security/get [post]
func (b *BaseApi) GetAgentSecurityConfig(c *gin.Context) {
var req dto.AgentSecurityConfigReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.GetBrowserConfig(req)
data, err := agentService.GetSecurityConfig(req)
if err != nil {
helper.BadRequest(c, err)
return
@@ -457,19 +498,19 @@ func (b *BaseApi) GetAgentBrowserConfig(c *gin.Context) {
}
// @Tags AI
// @Summary Update Agent Browser config
// @Summary Update Agent Security config
// @Accept json
// @Param request body dto.AgentBrowserConfigUpdateReq true "request"
// @Param request body dto.AgentSecurityConfigUpdateReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/browser/update [post]
func (b *BaseApi) UpdateAgentBrowserConfig(c *gin.Context) {
var req dto.AgentBrowserConfigUpdateReq
// @Router /ai/agents/security/update [post]
func (b *BaseApi) UpdateAgentSecurityConfig(c *gin.Context) {
var req dto.AgentSecurityConfigUpdateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UpdateBrowserConfig(req); err != nil {
if err := agentService.UpdateSecurityConfig(req); err != nil {
helper.BadRequest(c, err)
return
}
@@ -517,26 +558,6 @@ func (b *BaseApi) UpdateAgentOtherConfig(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Approve Agent Feishu pairing code
// @Accept json
// @Param request body dto.AgentFeishuPairingApproveReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/channel/feishu/approve [post]
func (b *BaseApi) ApproveAgentFeishuPairing(c *gin.Context) {
var req dto.AgentFeishuPairingApproveReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.ApproveFeishuPairing(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Approve Agent channel pairing code
// @Accept json
+30
View File
@@ -430,6 +430,16 @@ func (b *BaseApi) Backup(c *gin.Context) {
helper.InternalServer(c, err)
return
}
case "container":
if err := backupService.ContainerBackup(req); err != nil {
helper.InternalServer(c, err)
return
}
case "compose":
if err := backupService.ComposeBackup(req); err != nil {
helper.InternalServer(c, err)
return
}
}
helper.Success(c)
}
@@ -485,6 +495,16 @@ func (b *BaseApi) Recover(c *gin.Context) {
helper.InternalServer(c, err)
return
}
case "container":
if err := backupService.ContainerRecover(req); err != nil {
helper.InternalServer(c, err)
return
}
case "compose":
if err := backupService.ComposeRecover(req); err != nil {
helper.InternalServer(c, err)
return
}
}
helper.Success(c)
}
@@ -525,6 +545,16 @@ func (b *BaseApi) RecoverByUpload(c *gin.Context) {
helper.InternalServer(c, err)
return
}
case "container":
if err := backupService.ContainerRecover(req); err != nil {
helper.InternalServer(c, err)
return
}
case "compose":
if err := backupService.ComposeRecover(req); err != nil {
helper.InternalServer(c, err)
return
}
}
helper.Success(c)
}
+159
View File
@@ -1,6 +1,9 @@
package v2
import (
"net/http"
"net/url"
"path"
"strconv"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
@@ -53,6 +56,162 @@ func (b *BaseApi) LoadContainerUsers(c *gin.Context) {
helper.SuccessWithData(c, containerService.LoadUsers(req))
}
// @Tags Container
// @Summary List container files
// @Accept json
// @Param request body dto.ContainerFileReq true "request"
// @Success 200 {array} dto.ContainerFileInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/search [post]
func (b *BaseApi) ListContainerFiles(c *gin.Context) {
var req dto.ContainerFileReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
files, err := containerService.ListContainerFiles(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, files)
}
// @Tags Container
// @Summary Upload container file
// @Accept multipart/form-data
// @Param containerID formData string true "containerID"
// @Param path formData string true "path"
// @Param file formData file true "file"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/upload [post]
// @x-panel-log {"bodyKeys":["containerID","path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"容器 [containerID] 上传文件到 [path]","formatEN":"Upload file to [path] in container [containerID]"}
func (b *BaseApi) UploadContainerFile(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
helper.BadRequest(c, err)
return
}
containerIDs := form.Value["containerID"]
paths := form.Value["path"]
uploadFiles := form.File["file"]
if len(containerIDs) == 0 || len(paths) == 0 || len(uploadFiles) == 0 {
helper.BadRequest(c, errors.New("invalid container file upload params"))
return
}
req := dto.ContainerFileReq{
ContainerID: containerIDs[0],
Path: paths[0],
}
for _, uploadFile := range uploadFiles {
file, err := uploadFile.Open()
if err != nil {
helper.InternalServer(c, err)
return
}
err = containerService.UploadContainerFile(req, path.Base(uploadFile.Filename), uploadFile.Size, file)
_ = file.Close()
if err != nil {
helper.InternalServer(c, err)
return
}
}
helper.Success(c)
}
// @Tags Container
// @Summary Get container file content
// @Accept json
// @Param request body dto.ContainerFileReq true "request"
// @Success 200 {object} dto.ContainerFileContent
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/content [post]
func (b *BaseApi) GetContainerFileContent(c *gin.Context) {
var req dto.ContainerFileReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
content, err := containerService.GetContainerFileContent(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, content)
}
// @Tags Container
// @Summary Get container file size
// @Accept json
// @Param request body dto.ContainerFileReq true "request"
// @Success 200 {int} size
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/size [post]
func (b *BaseApi) GetContainerFileSize(c *gin.Context) {
var req dto.ContainerFileReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
size, err := containerService.GetContainerFileSize(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, size)
}
// @Tags Container
// @Summary Delete container file
// @Accept json
// @Param request body dto.ContainerFileBatchDeleteReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/del [post]
// @x-panel-log {"bodyKeys":["containerID","paths"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除容器 [containerID] 文件 [paths]","formatEN":"Delete files [paths] in container [containerID]"}
func (b *BaseApi) DeleteContainerFile(c *gin.Context) {
var req dto.ContainerFileBatchDeleteReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := containerService.DeleteContainerFile(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Container
// @Summary Download container file
// @Accept json
// @Param request body dto.ContainerFileReq true "request"
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/download [post]
// @x-panel-log {"bodyKeys":["containerID","path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"下载容器 [containerID] 文件 [path]","formatEN":"Download file [path] from container [containerID]"}
func (b *BaseApi) DownloadContainerFile(c *gin.Context) {
var req dto.ContainerFileReq
if err := c.ShouldBindJSON(&req); err != nil {
helper.BadRequest(c, err)
return
}
if req.ContainerID == "" || req.Path == "" {
helper.BadRequest(c, errors.New("invalid container file download params"))
return
}
reader, fileName, contentType, err := containerService.DownloadContainerFile(req)
if err != nil {
helper.InternalServer(c, err)
return
}
defer reader.Close()
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName))
c.DataFromReader(http.StatusOK, -1, contentType, reader, nil)
}
// @Tags Container
// @Summary List containers
// @Accept json
+61 -45
View File
@@ -3,32 +3,33 @@ package dto
import "time"
type AgentCreateReq struct {
Name string `json:"name" validate:"required"`
AppVersion string `json:"appVersion" validate:"required"`
WebUIPort int `json:"webUIPort" validate:"required"`
BridgePort int `json:"bridgePort"`
AgentType string `json:"agentType"`
Provider string `json:"provider"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
AccountID uint `json:"accountId"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseURL"`
Token string `json:"token"`
TaskID string `json:"taskID"`
Advanced bool `json:"advanced"`
ContainerName string `json:"containerName"`
AllowPort bool `json:"allowPort"`
SpecifyIP string `json:"specifyIP"`
RestartPolicy string `json:"restartPolicy"`
CpuQuota float64 `json:"cpuQuota"`
MemoryLimit float64 `json:"memoryLimit"`
MemoryUnit string `json:"memoryUnit"`
PullImage bool `json:"pullImage"`
EditCompose bool `json:"editCompose"`
DockerCompose string `json:"dockerCompose"`
Name string `json:"name" validate:"required"`
AppVersion string `json:"appVersion" validate:"required"`
WebUIPort int `json:"webUIPort" validate:"required"`
BridgePort int `json:"bridgePort"`
AllowedOrigins []string `json:"allowedOrigins"`
AgentType string `json:"agentType"`
Provider string `json:"provider"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
AccountID uint `json:"accountId"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseURL"`
Token string `json:"token"`
TaskID string `json:"taskID"`
Advanced bool `json:"advanced"`
ContainerName string `json:"containerName"`
AllowPort bool `json:"allowPort"`
SpecifyIP string `json:"specifyIP"`
RestartPolicy string `json:"restartPolicy"`
CpuQuota float64 `json:"cpuQuota"`
MemoryLimit float64 `json:"memoryLimit"`
MemoryUnit string `json:"memoryUnit"`
PullImage bool `json:"pullImage"`
EditCompose bool `json:"editCompose"`
DockerCompose string `json:"dockerCompose"`
}
type AgentItem struct {
@@ -194,10 +195,30 @@ type AgentTelegramConfig struct {
type AgentChannelPairingApproveReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu telegram discord"`
Type string `json:"type" validate:"required,oneof=feishu telegram discord wecom"`
PairingCode string `json:"pairingCode" validate:"required"`
}
type AgentWecomConfigReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentWecomConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required,oneof=pairing open"`
BotID string `json:"botId" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
type AgentWecomConfig struct {
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
BotID string `json:"botId"`
Secret string `json:"secret"`
Installed bool `json:"installed"`
}
type AgentQQBotConfigReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
@@ -218,13 +239,13 @@ type AgentQQBotConfig struct {
type AgentPluginInstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=qqbot"`
Type string `json:"type" validate:"required,oneof=qqbot wecom"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginCheckReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=qqbot"`
Type string `json:"type" validate:"required,oneof=qqbot wecom"`
}
type AgentPluginStatus struct {
@@ -252,24 +273,17 @@ type AgentDiscordConfig struct {
Proxy string `json:"proxy"`
}
type AgentBrowserConfigReq struct {
type AgentSecurityConfigReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentBrowserConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
Headless bool `json:"headless"`
NoSandbox bool `json:"noSandbox"`
DefaultProfile string `json:"defaultProfile" validate:"required"`
type AgentSecurityConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
AllowedOrigins []string `json:"allowedOrigins"`
}
type AgentBrowserConfig struct {
Enabled bool `json:"enabled"`
ExecutablePath string `json:"executablePath"`
Headless bool `json:"headless"`
NoSandbox bool `json:"noSandbox"`
DefaultProfile string `json:"defaultProfile"`
type AgentSecurityConfig struct {
AllowedOrigins []string `json:"allowedOrigins"`
}
type AgentOtherConfigReq struct {
@@ -277,10 +291,12 @@ type AgentOtherConfigReq struct {
}
type AgentOtherConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
UserTimezone string `json:"userTimezone" validate:"required"`
AgentID uint `json:"agentId" validate:"required"`
UserTimezone string `json:"userTimezone" validate:"required"`
BrowserEnabled bool `json:"browserEnabled"`
}
type AgentOtherConfig struct {
UserTimezone string `json:"userTimezone"`
UserTimezone string `json:"userTimezone"`
BrowserEnabled bool `json:"browserEnabled"`
}
+3 -2
View File
@@ -65,10 +65,11 @@ type UploadForRecover struct {
}
type CommonBackup struct {
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster container compose"`
Name string `json:"name"`
DetailName string `json:"detailName"`
Secret string `json:"secret"`
StopBefore bool `json:"stopBefore"`
TaskID string `json:"taskID"`
FileName string `json:"fileName"`
Args []string `json:"args"`
@@ -77,7 +78,7 @@ type CommonBackup struct {
}
type CommonRecover struct {
DownloadAccountID uint `json:"downloadAccountID" validate:"required"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster container compose"`
Name string `json:"name"`
DetailName string `json:"detailName"`
File string `json:"file"`
+28
View File
@@ -48,6 +48,34 @@ type ContainerOptions struct {
State string `json:"state"`
}
type ContainerFileReq struct {
ContainerID string `json:"containerID" validate:"required"`
Path string `json:"path" validate:"required"`
}
type ContainerFileBatchDeleteReq struct {
ContainerID string `json:"containerID" validate:"required"`
Paths []string `json:"paths" validate:"required,min=1,dive,required"`
}
type ContainerFileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
IsLink bool `json:"isLink"`
LinkTo string `json:"linkTo"`
Size int64 `json:"size"`
Mode string `json:"mode"`
ModTime string `json:"modTime"`
}
type ContainerFileContent struct {
Content string `json:"content"`
Size int64 `json:"size"`
Truncated bool `json:"truncated"`
IsBinary bool `json:"isBinary"`
}
type ContainerStatus struct {
Created int `json:"created"`
Running int `json:"running"`
+1
View File
@@ -4,6 +4,7 @@ import "time"
type DashboardBase struct {
WebsiteNumber int `json:"websiteNumber"`
AgentNumber int `json:"agentNumber"`
DatabaseNumber int `json:"databaseNumber"`
CronjobNumber int `json:"cronjobNumber"`
AppInstalledNumber int `json:"appInstalledNumber"`
+4 -7
View File
@@ -113,8 +113,8 @@ var catalog = map[string]Meta{
EnvKey: "MINIMAX_API_KEY",
Enabled: true,
Models: []Model{
{ID: "minimax/MiniMax-M2.1", Name: "MiniMax M2.1"},
{ID: "minimax/MiniMax-M2.1-lightning", Name: "MiniMax M2.1 Lightning"},
{ID: "minimax/MiniMax-M2.5", Name: "MiniMax M2.5"},
{ID: "minimax/MiniMax-M2.5-highspeed", Name: "MiniMax M2.5 highspeed"},
},
},
"kimi": {
@@ -192,12 +192,9 @@ var catalog = map[string]Meta{
EnvKey: "GEMINI_API_KEY",
Enabled: true,
Models: []Model{
{ID: "google/gemini-1.5-flash", Name: "Gemini 1.5 Flash"},
{ID: "google/gemini-1.5-pro", Name: "Gemini 1.5 Pro"},
{ID: "google/gemini-2.0-flash", Name: "Gemini 2.0 Flash"},
{ID: "google/gemini-2.5-flash", Name: "Gemini 2.5 Flash"},
{ID: "google/gemini-2.5-pro", Name: "Gemini 2.5 Pro"},
{ID: "google/gemini-3-flash-preview", Name: "Gemini 3 Flash Preview"},
{ID: "google/gemini-flash-latest", Name: "Gemini Flash Latest"},
{ID: "google/gemini-3-pro-preview", Name: "Gemini 3 Pro Preview"},
},
},
"moonshot": {
+339
View File
@@ -0,0 +1,339 @@
package provider
import (
"fmt"
"strings"
)
type OpenClawPatch struct {
PrimaryModel string
Models map[string]interface{}
}
func BuildOpenClawPatch(provider, modelName, apiType string, maxTokens, contextWindow int, baseURL, apiKey string) (*OpenClawPatch, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
modelName = strings.TrimSpace(modelName)
if modelName == "" {
return nil, fmt.Errorf("model is required")
}
modelID := modelName
if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 {
modelID = parts[1]
}
switch provider {
case "deepseek":
return buildDeepseekPatch(modelName, baseURL, apiKey), nil
case "gemini":
return buildGeminiPatch(modelName), nil
case "moonshot", "kimi":
return buildMoonshotPatch(provider, modelName, modelID, baseURL, apiKey), nil
case "bailian-coding-plan":
return buildBailianPatch(modelID, maxTokens, contextWindow, baseURL, apiKey), nil
case "ark-coding-plan":
return buildArkPatch(modelID, maxTokens, contextWindow, baseURL, apiKey), nil
case "minimax":
return buildMiniMaxPatch(modelID, baseURL, apiKey), nil
case "custom", "vllm":
return buildCustomPatch(provider, modelName, apiType, maxTokens, contextWindow, baseURL, apiKey), nil
case "ollama":
return buildOllamaPatch(modelName, modelID, apiType, baseURL), nil
case "kimi-coding":
return buildKimiCodingPatch(modelName, modelID, baseURL, apiKey), nil
case "zai":
return buildZaiPatch(modelID, maxTokens, contextWindow, baseURL, apiKey), nil
default:
return buildGenericPatch(provider, modelName, modelID, apiType, maxTokens, contextWindow, baseURL, apiKey), nil
}
}
func buildGeminiPatch(modelName string) *OpenClawPatch {
return &OpenClawPatch{PrimaryModel: modelName}
}
func buildDeepseekPatch(modelName, baseURL, apiKey string) *OpenClawPatch {
return &OpenClawPatch{
PrimaryModel: modelName,
Models: providerModels("deepseek", strings.TrimSpace(apiKey), firstNonEmpty(strings.TrimSpace(baseURL), "https://api.deepseek.com/v1"), "openai-completions", map[string]interface{}{
"id": "deepseek-chat",
"name": "DeepSeek Chat",
"reasoning": false,
"input": []string{"text"},
"contextWindow": 128000,
"maxTokens": 8192,
"cost": map[string]interface{}{},
}),
}
}
func buildMoonshotPatch(provider, modelName, modelID, baseURL, apiKey string) *OpenClawPatch {
configProvider := provider
primaryModel := modelName
if provider == "kimi" {
configProvider = "moonshot"
primaryModel = "moonshot/" + modelID
}
return &OpenClawPatch{
PrimaryModel: primaryModel,
Models: providerModels(configProvider, strings.TrimSpace(apiKey), withCatalogDefault(provider, baseURL), "openai-completions", map[string]interface{}{
"id": modelID,
"name": modelID,
"reasoning": strings.Contains(strings.ToLower(modelID), "thinking"),
"input": []string{"text"},
"contextWindow": 256000,
"maxTokens": 8192,
"cost": map[string]interface{}{},
}),
}
}
func buildBailianPatch(modelID string, maxTokens, contextWindow int, baseURL, apiKey string) *OpenClawPatch {
normalizedID := normalizeBailianCodingPlanModelID(modelID)
return &OpenClawPatch{
PrimaryModel: "bailian-coding-plan/" + bailianPrimaryModelID(normalizedID),
Models: providerModels("bailian-coding-plan", strings.TrimSpace(apiKey), withCatalogDefault("bailian-coding-plan", baseURL), "openai-completions", map[string]interface{}{
"id": normalizedID,
"name": normalizedID,
"reasoning": isReasoningModel(normalizedID),
"input": []string{"text"},
"contextWindow": fallbackInt(contextWindow, 256000),
"maxTokens": fallbackInt(maxTokens, 8192),
"cost": map[string]interface{}{},
}),
}
}
func buildArkPatch(modelID string, maxTokens, contextWindow int, baseURL, apiKey string) *OpenClawPatch {
normalizedID := normalizeArkCodingPlanModelID(modelID)
return &OpenClawPatch{
PrimaryModel: "ark-coding-plan/" + normalizedID,
Models: providerModels("ark-coding-plan", strings.TrimSpace(apiKey), withCatalogDefault("ark-coding-plan", baseURL), "openai-completions", map[string]interface{}{
"id": normalizedID,
"name": normalizedID,
"reasoning": isReasoningModel(normalizedID),
"input": []string{"text"},
"contextWindow": fallbackInt(contextWindow, 256000),
"maxTokens": fallbackInt(maxTokens, 8192),
"cost": map[string]interface{}{},
}),
}
}
func buildMiniMaxPatch(modelID, baseURL, apiKey string) *OpenClawPatch {
normalizedID := normalizeMiniMaxModelID(modelID)
return &OpenClawPatch{
PrimaryModel: "minimax-portal/" + normalizedID,
Models: providerModels("minimax-portal", strings.TrimSpace(apiKey), firstNonEmpty(strings.TrimSpace(baseURL), "https://api.minimaxi.com/anthropic"), "anthropic-messages", map[string]interface{}{
"id": normalizedID,
"name": strings.ReplaceAll(normalizedID, "-", " "),
"reasoning": false,
"input": []string{"text"},
"contextWindow": 200000,
"maxTokens": 8192,
"cost": map[string]interface{}{},
}),
}
}
func buildCustomPatch(provider, modelName, apiType string, maxTokens, contextWindow int, baseURL, apiKey string) *OpenClawPatch {
customModelID := normalizeCustomModel(modelName)
return &OpenClawPatch{
PrimaryModel: provider + "/" + customModelID,
Models: providerModels(provider, strings.TrimSpace(apiKey), strings.TrimSpace(baseURL), apiType, map[string]interface{}{
"id": customModelID,
"name": customModelID,
"reasoning": isReasoningModel(customModelID),
"input": []string{"text"},
"contextWindow": fallbackInt(contextWindow, 128000),
"maxTokens": fallbackInt(maxTokens, 8192),
"cost": map[string]interface{}{},
}),
}
}
func buildOllamaPatch(modelName, modelID, apiType, baseURL string) *OpenClawPatch {
api := normalizeAPIType(apiType)
if api != "openai-completions" && api != "openai-responses" {
api = "openai-responses"
}
return &OpenClawPatch{
PrimaryModel: modelName,
Models: providerModels("ollama", "ollama", strings.TrimSpace(baseURL), api, map[string]interface{}{
"id": modelID,
"name": modelID,
"reasoning": api != "openai-completions",
"input": []string{"text"},
"contextWindow": 160000,
"maxTokens": 8192,
"cost": map[string]interface{}{},
}),
}
}
func buildKimiCodingPatch(modelName, modelID, baseURL, apiKey string) *OpenClawPatch {
return &OpenClawPatch{
PrimaryModel: modelName,
Models: providerModels("kimi-coding", strings.TrimSpace(apiKey), withCatalogDefault("kimi-coding", baseURL), "anthropic-messages", map[string]interface{}{
"id": modelID,
"name": "Kimi for Coding",
"reasoning": true,
"input": []string{"text", "image"},
"contextWindow": 262144,
"maxTokens": 32768,
"cost": map[string]interface{}{},
}),
}
}
func buildZaiPatch(modelID string, maxTokens, contextWindow int, baseURL, apiKey string) *OpenClawPatch {
return &OpenClawPatch{
PrimaryModel: "zai/" + modelID,
Models: providerModels("zai", strings.TrimSpace(apiKey), withCatalogDefault("zai", baseURL), "openai-completions", map[string]interface{}{
"id": modelID,
"name": zaiModelDisplayName(modelID),
"reasoning": modelID == "glm-5",
"input": []string{"text"},
"contextWindow": fallbackInt(contextWindow, 204800),
"maxTokens": fallbackInt(maxTokens, 131072),
"cost": map[string]interface{}{},
}),
}
}
func buildGenericPatch(provider, modelName, modelID, apiType string, maxTokens, contextWindow int, baseURL, apiKey string) *OpenClawPatch {
providerName := provider
primaryModel := modelName
if provider == "gemini" {
providerName = "google"
primaryModel = "google/" + modelID
}
return &OpenClawPatch{
PrimaryModel: primaryModel,
Models: providerModels(providerName, strings.TrimSpace(apiKey), withCatalogDefault(provider, baseURL), normalizeAPIType(apiType), map[string]interface{}{
"id": modelID,
"name": modelID,
"reasoning": isReasoningModel(modelID),
"input": []string{"text"},
"contextWindow": fallbackInt(contextWindow, 256000),
"maxTokens": fallbackInt(maxTokens, 8192),
"cost": map[string]interface{}{},
}),
}
}
func providerModels(provider, apiKey, baseURL, api string, model map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"mode": "merge",
"providers": map[string]interface{}{
provider: map[string]interface{}{
"apiKey": apiKey,
"baseUrl": baseURL,
"api": api,
"models": []map[string]interface{}{model},
},
},
}
}
func withCatalogDefault(provider, baseURL string) string {
if strings.TrimSpace(baseURL) != "" {
return strings.TrimSpace(baseURL)
}
if defaultURL, ok := DefaultBaseURL(provider); ok {
return defaultURL
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func fallbackInt(value, fallback int) int {
if value > 0 {
return value
}
return fallback
}
func normalizeAPIType(apiType string) string {
trim := strings.ToLower(strings.TrimSpace(apiType))
if trim == "" {
return "openai-completions"
}
return trim
}
func normalizeCustomModel(modelName string) string {
trim := strings.TrimSpace(modelName)
trim = strings.TrimLeft(trim, "/")
if parts := strings.SplitN(trim, "/", 2); len(parts) == 2 && strings.EqualFold(parts[0], "custom") {
return strings.TrimLeft(strings.TrimSpace(parts[1]), "/")
}
return trim
}
func normalizeBailianCodingPlanModelID(modelID string) string {
trim := strings.TrimSpace(modelID)
switch strings.ToLower(trim) {
case "minimax-m2.5", "minimax m2.5", "minimax/minimax-m2.5", "minimax/minimax m2.5":
return "MiniMax/MiniMax-M2.5"
default:
return trim
}
}
func normalizeArkCodingPlanModelID(modelID string) string {
return strings.ToLower(strings.TrimSpace(modelID))
}
func normalizeMiniMaxModelID(modelID string) string {
switch strings.ToLower(strings.TrimSpace(modelID)) {
case "minimax-m2.1", "minimax m2.1", "minimax-m2.1-preview", "minimax-m2.1-latest":
return "MiniMax-M2.1"
case "minimax-m2.1-lightning", "minimax m2.1 lightning":
return "MiniMax-M2.1-lightning"
default:
return modelID
}
}
func zaiModelDisplayName(modelID string) string {
switch strings.ToLower(strings.TrimSpace(modelID)) {
case "glm-5":
return "GLM-5"
case "glm-4.7":
return "GLM-4.7"
case "glm-4.7-flash":
return "GLM-4.7-Flash"
case "glm-4.7-flashx":
return "GLM-4.7-FlashX"
default:
return strings.TrimSpace(modelID)
}
}
func bailianPrimaryModelID(modelID string) string {
trim := strings.TrimSpace(modelID)
if trim == "" {
return ""
}
parts := strings.Split(trim, "/")
for i := len(parts) - 1; i >= 0; i-- {
part := strings.TrimSpace(parts[i])
if part != "" {
return part
}
}
return trim
}
func isReasoningModel(modelID string) bool {
trim := strings.ToLower(strings.TrimSpace(modelID))
return strings.Contains(trim, "reason") || strings.Contains(trim, "thinking")
}
+146
View File
@@ -0,0 +1,146 @@
package provider
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/buserr"
)
type VerifyRequest struct {
Method string
URL string
Headers map[string]string
Body []byte
}
func SkipVerification(key string) bool {
switch strings.ToLower(strings.TrimSpace(key)) {
case "custom", "vllm", "ollama", "kimi-coding":
return true
default:
return false
}
}
func VerifyAccount(provider, baseURL, apiKey string) error {
req := BuildVerifyRequest(provider, baseURL, apiKey)
var body *bytes.Buffer
if len(req.Body) > 0 {
body = bytes.NewBuffer(req.Body)
} else {
body = bytes.NewBuffer(nil)
}
httpReq, err := http.NewRequest(req.Method, req.URL, body)
if err != nil {
return err
}
for key, value := range req.Headers {
httpReq.Header.Set(key, value)
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(httpReq)
if err != nil {
return buserr.WithErr("ErrAgentAccountUnavailable", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return buserr.WithErr("ErrAgentAccountUnavailable", fmt.Errorf("verify failed: %s", resp.Status))
}
return nil
}
func BuildVerifyRequest(provider, baseURL, apiKey string) VerifyRequest {
provider = strings.ToLower(strings.TrimSpace(provider))
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
headers := map[string]string{}
request := VerifyRequest{Method: http.MethodGet, Headers: headers}
switch provider {
case "anthropic", "kimi-coding":
headers["x-api-key"] = apiKey
headers["anthropic-version"] = "2023-06-01"
if strings.Contains(base, "/v1") {
request.URL = base + "/models"
} else {
request.URL = base + "/v1/models"
}
case "gemini":
request.Method = http.MethodPost
if strings.Contains(base, "/v1beta") {
request.URL = base + "/models/gemini-3-flash-preview:generateContent"
} else {
request.URL = base + "/v1beta/models/gemini-3-flash-preview:generateContent"
}
headers["x-goog-api-key"] = apiKey
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"contents": []map[string]interface{}{{
"parts": []map[string]string{{
"text": "Explain how AI works in a few words",
}},
}},
})
case "zai":
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
request.URL = base + "/models"
case "bailian-coding-plan":
request.Method = http.MethodPost
if !strings.Contains(base, "/v1") {
base = base + "/v1"
}
request.URL = base + "/chat/completions"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"model": "qwen3.5-plus",
"messages": []map[string]string{{"role": "user", "content": "test"}},
"max_tokens": 1,
})
case "ark-coding-plan":
request.Method = http.MethodPost
if !strings.Contains(base, "/api/coding/v3") {
base = "https://ark.cn-beijing.volces.com/api/coding/v3"
}
request.URL = base + "/chat/completions"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"model": "doubao-seed-2.0-code",
"messages": []map[string]string{{"role": "user", "content": "test"}},
"max_tokens": 1,
})
case "minimax":
request.Method = http.MethodPost
if !strings.Contains(base, "/v1") {
base = base + "/v1"
}
request.URL = base + "/chat/completions"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"model": "MiniMax-M2.1",
"messages": []map[string]string{{"role": "user", "content": "test"}},
"max_tokens": 1,
})
default:
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
if strings.Contains(base, "/v1") {
request.URL = base + "/models"
} else {
request.URL = base + "/v1/models"
}
}
return request
}
func mustJSON(value interface{}) []byte {
payload, err := json.Marshal(value)
if err != nil {
return []byte("{}")
}
return payload
}
+2 -1
View File
@@ -80,9 +80,10 @@ func (u *LauncherRepo) ListQuickJump(withAll bool) []model.QuickJump {
}
if !withAll && len(quicks) == 0 {
return []model.QuickJump{
{Name: "Agent", Title: "aiTools.agents.agents", Recommend: 1, IsShow: true, Router: "/ai/agents/agent"},
{Name: "Website", Title: "menu.website", Recommend: 10, IsShow: true, Router: "/websites"},
{Name: "Database", Title: "menu.database", Recommend: 30, IsShow: true, Router: "/databases"},
{Name: "Cronjob", Title: "menu.cronjob", Recommend: 50, IsShow: true, Router: "/cronjobs"},
{Name: "Cronjob", Title: "menu.cronjob", Recommend: 50, IsShow: false, Router: "/cronjobs"},
{Name: "AppInstalled", Title: "home.appInstalled", Recommend: 70, IsShow: true, Router: "/apps/installed"},
}
}
File diff suppressed because it is too large Load Diff
+16 -31
View File
@@ -3,6 +3,12 @@ package service
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
@@ -18,11 +24,6 @@ import (
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
"github.com/shirou/gopsutil/v4/net"
"math"
"sort"
"strconv"
"strings"
"time"
)
const (
@@ -423,25 +424,19 @@ func loadDiskUsage(alert dto.AlertDTO) {
}
if isAlertDue(newDate) {
if strings.Contains(alert.Project, "all") {
err = processAllDisks(alert)
_ = processAllDisks(alert)
} else {
err = processSingleDisk(alert)
_ = processSingleDisk(alert)
}
}
}
func loadPanelLogin(alert dto.AlertDTO) {
count, isAlert, err := alertUtil.CountRecentFailedLoginLogs(alert.Cycle, alert.Count)
alertType := alert.Type
quota := strconv.Itoa(count)
quotaType := strconv.Itoa(int(alert.Cycle))
if err != nil {
global.LOG.Errorf("Failed to count recent failed login logs: %v", err)
}
if isAlert {
alertType = "panelLogin"
quota = strconv.Itoa(count)
quotaType = "panelLogin"
params := []dto.Param{
{
Index: "1",
@@ -454,7 +449,7 @@ func loadPanelLogin(alert dto.AlertDTO) {
Value: "",
},
}
sendAlerts(alert, alertType, quota, quotaType, params)
sendAlerts(alert, "panelLogin", strconv.Itoa(count), "panelLogin", params)
}
whitelist := strings.Split(strings.TrimSpace(alert.AdvancedParams), "\n")
@@ -463,15 +458,13 @@ func loadPanelLogin(alert dto.AlertDTO) {
global.LOG.Errorf("Failed to check recent failed ip login logs: %v", err)
}
if len(records) > 0 {
quota = strings.Join(func() []string {
quota := strings.Join(func() []string {
var ips []string
for _, r := range records {
ips = append(ips, r.IP)
}
return ips
}(), "\n")
alertType = "panelIpLogin"
quotaType = "panelIpLogin"
params := []dto.Param{
{
Index: "1",
@@ -484,22 +477,16 @@ func loadPanelLogin(alert dto.AlertDTO) {
Value: " IP ",
},
}
sendAlerts(alert, alertType, quota, quotaType, params)
sendAlerts(alert, "panelIpLogin", quota, "panelIpLogin", params)
}
}
func loadSSHLogin(alert dto.AlertDTO) {
count, isAlert, err := alertUtil.CountRecentFailedSSHLog(alert.Cycle, alert.Count)
alertType := alert.Type
quota := strconv.Itoa(count)
quotaType := strconv.Itoa(int(alert.Cycle))
if err != nil {
global.LOG.Errorf("Failed to count recent failed ssh login logs: %v", err)
}
if isAlert {
alertType = "sshLogin"
quota = strconv.Itoa(count)
quotaType = "sshLogin"
params := []dto.Param{
{
Index: "1",
@@ -512,7 +499,7 @@ func loadSSHLogin(alert dto.AlertDTO) {
Value: "",
},
}
sendAlerts(alert, alertType, quota, quotaType, params)
sendAlerts(alert, "sshLogin", strconv.Itoa(count), "sshLogin", params)
}
whitelist := strings.Split(strings.TrimSpace(alert.AdvancedParams), "\n")
records, err := alertUtil.FindRecentSuccessLoginNotInWhitelist(30, whitelist)
@@ -520,9 +507,7 @@ func loadSSHLogin(alert dto.AlertDTO) {
global.LOG.Errorf("Failed to check recent failed ip ssh login logs: %v", err)
}
if len(records) > 0 {
quota = strings.Join(records, "\n")
alertType = "sshIpLogin"
quotaType = "sshIpLogin"
quota := strings.Join(records, "\n")
params := []dto.Param{
{
Index: "1",
@@ -535,7 +520,7 @@ func loadSSHLogin(alert dto.AlertDTO) {
Value: " IP ",
},
}
sendAlerts(alert, alertType, quota, quotaType, params)
sendAlerts(alert, "sshIpLogin", quota, "sshIpLogin", params)
}
}
@@ -894,7 +879,7 @@ func processAllDisks(alert dto.AlertDTO) error {
if err != nil {
errMsg := fmt.Sprintf("disk path %s process failed: %v", item.Path, err)
errMsgs = append(errMsgs, errMsg)
global.LOG.Errorf(errMsg)
global.LOG.Errorf("%s", errMsg)
continue
}
}
@@ -907,7 +892,7 @@ func processAllDisks(alert dto.AlertDTO) error {
func processSingleDisk(alert dto.AlertDTO) error {
err := checkAndCreateDiskAlert(alert, alert.Project)
if err != nil {
global.LOG.Errorf(err.Error())
global.LOG.Errorf("%s", err.Error())
return err
}
return nil
+9 -6
View File
@@ -33,7 +33,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
composeV2 "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
@@ -363,7 +362,7 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err != nil {
return err
}
images, err := composeV2.GetImagesFromDockerCompose(content, []byte(install.DockerCompose))
images, err := docker.GetImagesFromDockerCompose(content, []byte(install.DockerCompose))
if err != nil {
return err
}
@@ -676,6 +675,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
if err != nil {
return err
}
oldVersion := install.Version
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil {
return err
@@ -752,7 +752,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
if req.DockerCompose != "" {
composeContent = []byte(req.DockerCompose)
}
images, err := composeV2.GetImagesFromDockerCompose(content, composeContent)
images, err := docker.GetImagesFromDockerCompose(content, composeContent)
if err != nil {
return err
}
@@ -802,6 +802,9 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
}
var newCompose string
if err = migrateOpenclawHTTPSUpgrade(&install, oldVersion, detail.Version); err != nil {
return err
}
if req.DockerCompose == "" {
newCompose, err = getUpgradeCompose(install, detail)
if err != nil {
@@ -911,7 +914,7 @@ func getContainerNames(install model.AppInstall) ([]string, error) {
if err != nil {
return nil, err
}
project, err := composeV2.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), []byte(envStr), true)
project, err := docker.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), []byte(envStr), true)
if err != nil {
return nil, err
}
@@ -1133,7 +1136,7 @@ func runScript(task *task.Task, appInstall *model.AppInstall, operate string) er
}
func checkContainerNameIsExist(containerName, appDir string) (bool, error) {
client, err := composeV2.NewDockerClient()
client, err := docker.NewDockerClient()
if err != nil {
return false, err
}
@@ -1169,7 +1172,7 @@ func upApp(task *task.Task, appInstall *model.AppInstall, pullImages bool) error
if err != nil {
return err
}
images, err := composeV2.GetImagesFromDockerCompose(envByte, []byte(appInstall.DockerCompose))
images, err := docker.GetImagesFromDockerCompose(envByte, []byte(appInstall.DockerCompose))
if err != nil {
return err
}
+5
View File
@@ -57,6 +57,11 @@ type IBackupService interface {
AppBackup(db dto.CommonBackup) (*model.BackupRecord, error)
AppRecover(req dto.CommonRecover) error
ContainerBackup(req dto.CommonBackup) error
ContainerRecover(req dto.CommonRecover) error
ComposeBackup(req dto.CommonBackup) error
ComposeRecover(req dto.CommonRecover) error
}
func NewIBackupService() IBackupService {
+707
View File
@@ -0,0 +1,707 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/task"
"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/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
dockerUtils "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
)
type composeBackupFile struct {
OriginalPath string `json:"originalPath"`
FileName string `json:"fileName"`
RelativePath string `json:"relativePath,omitempty"`
BackupPath string `json:"backupPath"`
}
type composeBackupMeta struct {
ComposeName string `json:"composeName"`
ComposePath string `json:"composePath"`
CreatedAt string `json:"createdAt"`
Files []composeBackupFile `json:"files"`
Containers []string `json:"containers"`
}
type composeBackupContext struct {
req dto.CommonBackup
composeName string
composePath string
composeFiles []string
composeDir string
fileOp files.FileOp
dockerClient *client.Client
stopped bool
backupDir string
fileName string
filePath string
tmpDir string
meta composeBackupMeta
}
type composeRecoverContext struct {
req dto.CommonRecover
fileOp files.FileOp
tmpDir string
meta composeBackupMeta
composeName string
targetDir string
composePath string
enqueued bool
}
func (u *BackupService) ComposeBackup(req dto.CommonBackup) error {
timeNow := time.Now().Format(constant.DateTimeSlimLayout) + common.RandStrAndNum(5)
fileName := req.FileName
if fileName == "" {
fileName = fmt.Sprintf("%s_%s.tar.gz", req.Name, timeNow)
}
if !strings.HasSuffix(fileName, ".tar.gz") {
fileName += ".tar.gz"
}
itemDir := fmt.Sprintf("compose/%s", req.Name)
backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)
record := &model.BackupRecord{
Type: req.Type,
Name: req.Name,
SourceAccountIDs: "1",
DownloadAccountID: 1,
FileDir: itemDir,
FileName: fileName,
TaskID: req.TaskID,
Status: constant.StatusWaiting,
Description: req.Description,
}
if err := backupRepo.CreateRecord(record); err != nil {
global.LOG.Errorf("save compose backup record failed, err: %v", err)
return err
}
if err := handleComposeBackup(req, nil, record.ID, backupDir, fileName); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return err
}
return nil
}
func (u *BackupService) ComposeRecover(req dto.CommonRecover) error {
return handleComposeRecover(req, nil)
}
func handleComposeBackup(req dto.CommonBackup, parentTask *task.Task, recordID uint, backupDir, fileName string) error {
composeCtx, err := newComposeBackupContext(req, backupDir, fileName)
if err != nil {
return err
}
containerNames, err := loadComposeContainerNames(composeCtx)
if err != nil {
composeCtx.close()
return err
}
backupTask := parentTask
if backupTask == nil {
backupTask, err = task.NewTaskWithOps(composeCtx.composeName, task.TaskBackup, task.TaskScopeBackup, req.TaskID, 1)
if err != nil {
return err
}
}
if req.StopBefore {
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupStop"), func(t *task.Task) error {
return stepStopComposeForBackup(composeCtx)
}, func(t *task.Task) {
_ = stepStartComposeAfterBackup(composeCtx)
}, 3, time.Hour)
}
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupPrepare"), func(t *task.Task) error { return stepPrepareComposeBackup(composeCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupFiles"), func(t *task.Task) error { return stepBackupComposeFiles(composeCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupContainers"), func(t *task.Task) error { return nil }, nil, 3, time.Hour)
for _, containerName := range containerNames {
backupFileName := fmt.Sprintf("%s.tar.gz", sanitizeComposeFileName(containerName))
backupFile := path.Join(composeCtx.tmpDir, "containers", backupFileName)
if err := handleContainerBackup(containerName, backupTask, 0, path.Dir(backupFile), path.Base(backupFile), "", "", false); err != nil {
return err
}
composeCtx.meta.Containers = append(composeCtx.meta.Containers, path.Join("containers", backupFileName))
}
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupMeta"), func(t *task.Task) error { return stepWriteComposeBackupMeta(composeCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(task.GetTaskName(composeCtx.composeName, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error {
return stepPackComposeBackup(composeCtx)
}, nil, 3, time.Hour)
if req.StopBefore {
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupStart"), func(t *task.Task) error {
return stepStartComposeAfterBackup(composeCtx)
}, nil, 3, time.Hour)
}
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupCleanup"), func(t *task.Task) error {
composeCtx.close()
return nil
}, nil, 0, time.Hour)
if parentTask != nil {
return nil
}
go func() {
defer composeCtx.close()
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
}()
return nil
}
func loadComposeContainerNames(composeCtx *composeBackupContext) ([]string, error) {
options := container.ListOptions{All: true}
options.Filters = filters.NewArgs(filters.Arg("label", composeProjectLabel+"="+composeCtx.composeName))
containers, err := composeCtx.dockerClient.ContainerList(context.Background(), options)
if err != nil {
return nil, err
}
names := make([]string, 0, len(containers))
for _, item := range containers {
if len(item.Names) == 0 {
continue
}
names = append(names, strings.TrimPrefix(item.Names[0], "/"))
}
sort.Strings(names)
return names, nil
}
func handleComposeRecover(req dto.CommonRecover, parentTask *task.Task) error {
var recoverCtx *composeRecoverContext
recoverTask := parentTask
var err error
if recoverTask == nil {
if isImportRecover(req) {
taskName := i18n.GetMsgByKey("TaskImport") + i18n.GetMsgByKey("Compose")
recoverTask, err = task.NewTask(taskName, task.TaskImport, task.TaskScopeBackup, req.TaskID, 1)
if err != nil {
return err
}
} else {
taskName := req.Name
if taskName == "" {
taskName = "compose"
}
recoverTask, err = task.NewTaskWithOps(taskName, task.TaskRecover, task.TaskScopeBackup, req.TaskID, 1)
if err != nil {
return err
}
}
}
timeout := loadRecoverTimeout(req.Timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverPrepare"), func(t *task.Task) error {
ctx, err := newComposeRecoverContext(req)
if err != nil {
return err
}
recoverCtx = ctx
if err := stepPrepareComposeRecover(recoverCtx); err != nil {
recoverCtx.close()
recoverCtx = nil
return err
}
return nil
}, func(t *task.Task) {
if recoverCtx != nil {
recoverCtx.close()
recoverCtx = nil
}
}, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverExtract"), func(t *task.Task) error { return stepExtractComposeRecover(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverMeta"), func(t *task.Task) error {
if err := stepLoadComposeRecoverMeta(recoverCtx); err != nil {
return err
}
t.Log(i18n.GetMsgWithMap("ComposeRecoverMetaLogName", map[string]interface{}{
"name": recoverCtx.composeName,
}))
t.Log(i18n.GetMsgWithMap("ComposeRecoverMetaLogPath", map[string]interface{}{
"backupPath": recoverCtx.meta.ComposePath,
"targetDir": recoverCtx.targetDir,
}))
t.Log(i18n.GetMsgWithMap("ComposeRecoverMetaLogCount", map[string]interface{}{
"files": len(recoverCtx.meta.Files),
"containers": len(recoverCtx.meta.Containers),
}))
return nil
}, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverFiles"), func(t *task.Task) error { return stepRestoreComposeFiles(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverContainers"), func(t *task.Task) error {
if recoverCtx.enqueued {
return nil
}
containerItems := make([]string, 0, len(recoverCtx.meta.Containers))
for _, item := range recoverCtx.meta.Containers {
backupItem := item
filePath, err := safeJoinWithinBase(recoverCtx.tmpDir, backupItem)
if err != nil {
return fmt.Errorf("invalid container backup path %q, err: %v", backupItem, err)
}
if !recoverCtx.fileOp.Stat(filePath) {
return fmt.Errorf("container backup file not found: %s", backupItem)
}
containerItems = append(containerItems, backupItem)
}
for _, backupItem := range containerItems {
filePath, err := safeJoinWithinBase(recoverCtx.tmpDir, backupItem)
if err != nil {
return fmt.Errorf("invalid container backup path %q, err: %v", backupItem, err)
}
containerLabel := strings.TrimSuffix(path.Base(backupItem), ".tar.gz")
containerReq := recoverCtx.req
containerReq.Type = "container"
containerReq.Name = containerLabel
containerReq.DetailName = ""
containerReq.File = filePath
containerReq.Secret = ""
if err := handleContainerRecover(containerReq, recoverTask); err != nil {
return err
}
}
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverRecord"), func(t *task.Task) error {
return stepSaveComposeRecord(recoverCtx)
}, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverCleanup"), func(t *task.Task) error {
if recoverCtx != nil {
recoverCtx.close()
recoverCtx = nil
}
return nil
}, nil, 0, timeout)
recoverCtx.enqueued = true
return nil
}, nil, 3, timeout)
if parentTask != nil {
return nil
}
go func() {
_ = recoverTask.Execute()
}()
return nil
}
func newComposeBackupContext(req dto.CommonBackup, backupDir, fileName string) (*composeBackupContext, error) {
if req.Name == "" {
return nil, fmt.Errorf("compose name is required")
}
dockerClient, err := dockerUtils.NewDockerClient()
if err != nil {
return nil, err
}
composePath, composeFiles, err := loadComposePathAndFiles(req.Name, dockerClient)
if err != nil {
_ = dockerClient.Close()
return nil, err
}
filePath := path.Join(backupDir, fileName)
tmpDir := path.Join(path.Dir(filePath), strings.TrimSuffix(path.Base(filePath), ".tar.gz"))
ctx := &composeBackupContext{
req: req,
composeName: req.Name,
composePath: composePath,
composeFiles: composeFiles,
composeDir: path.Dir(composeFiles[0]),
fileOp: files.NewFileOp(),
dockerClient: dockerClient,
backupDir: backupDir,
fileName: fileName,
filePath: filePath,
tmpDir: tmpDir,
meta: composeBackupMeta{
ComposeName: req.Name,
ComposePath: composePath,
CreatedAt: time.Now().Format(constant.DateTimeLayout),
Files: make([]composeBackupFile, 0),
Containers: make([]string, 0),
},
}
return ctx, nil
}
func loadComposePathAndFiles(composeName string, dockerClient *client.Client) (string, []string, error) {
composeRecord, _ := composeRepo.GetRecord(repo.WithByName(composeName))
if composeRecord.ID == 0 {
composeRecord, _ = composeRepo.GetRecord(repo.WithByName(strings.ToLower(composeName)))
}
composePath := composeRecord.Path
if composePath == "" {
options := container.ListOptions{All: true}
options.Filters = filters.NewArgs(filters.Arg("label", composeProjectLabel))
list, err := dockerClient.ContainerList(context.Background(), options)
if err != nil {
return "", nil, err
}
if len(list) == 0 {
return "", nil, fmt.Errorf("compose %s not found", composeName)
}
var targetContainer *container.Summary
for i := range list {
if strings.EqualFold(list[i].Labels[composeProjectLabel], composeName) {
targetContainer = &list[i]
break
}
}
if targetContainer == nil {
return "", nil, fmt.Errorf("compose %s not found", composeName)
}
config := targetContainer.Labels[composeConfigLabel]
workdir := targetContainer.Labels[composeWorkdirLabel]
if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) {
composePath = config
} else {
composePath = workdir
}
}
composeFiles := normalizeComposeFiles(composePath)
if len(composeFiles) == 0 {
return "", nil, fmt.Errorf("compose file not found for %s", composeName)
}
return composePath, composeFiles, nil
}
func normalizeComposeFiles(composePath string) []string {
items := strings.Split(composePath, ",")
result := make([]string, 0)
seen := make(map[string]struct{})
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
stat, err := os.Stat(item)
if err == nil && stat.IsDir() {
item = path.Join(item, "docker-compose.yml")
}
if _, err := os.Stat(item); err != nil {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
sort.Strings(result)
return result
}
func (c *composeBackupContext) close() {
if c.dockerClient != nil {
_ = c.dockerClient.Close()
c.dockerClient = nil
}
if c.tmpDir != "" {
_ = os.RemoveAll(c.tmpDir)
c.tmpDir = ""
}
}
func stepPrepareComposeBackup(composeCtx *composeBackupContext) error {
if err := os.MkdirAll(composeCtx.backupDir, os.ModePerm); err != nil {
return fmt.Errorf("mkdir %s failed, err: %v", composeCtx.backupDir, err)
}
_ = os.RemoveAll(composeCtx.tmpDir)
if err := os.MkdirAll(path.Join(composeCtx.tmpDir, "compose_files"), os.ModePerm); err != nil {
return err
}
if err := os.MkdirAll(path.Join(composeCtx.tmpDir, "containers"), os.ModePerm); err != nil {
return err
}
return nil
}
func stepStopComposeForBackup(composeCtx *composeBackupContext) error {
if composeCtx.stopped {
return nil
}
options := container.ListOptions{All: false}
options.Filters = filters.NewArgs(filters.Arg("label", composeProjectLabel+"="+composeCtx.composeName))
runningList, err := composeCtx.dockerClient.ContainerList(context.Background(), options)
if err != nil {
return err
}
if len(runningList) == 0 {
return nil
}
if stdout, err := compose.Operate(composeCtx.composePath, "stop"); err != nil {
return fmt.Errorf("docker-compose stop failed, std: %s, err: %v", stdout, err)
}
composeCtx.stopped = true
return nil
}
func stepStartComposeAfterBackup(composeCtx *composeBackupContext) error {
if !composeCtx.stopped {
return nil
}
if stdout, err := compose.Up(composeCtx.composePath); err != nil {
return fmt.Errorf("docker-compose up failed, std: %s, err: %v", stdout, err)
}
composeCtx.stopped = false
return nil
}
func stepBackupComposeFiles(composeCtx *composeBackupContext) error {
for i, filePath := range composeCtx.composeFiles {
backupName := fmt.Sprintf("%02d_%s", i, path.Base(filePath))
backupPath := path.Join(composeCtx.tmpDir, "compose_files", backupName)
content, err := os.ReadFile(filePath)
if err != nil {
return err
}
if err := composeCtx.fileOp.SaveFile(backupPath, string(content), fs.ModePerm); err != nil {
return err
}
relativePath := path.Base(filePath)
if composeCtx.composeDir != "" {
rel, relErr := filepath.Rel(composeCtx.composeDir, filePath)
if relErr == nil {
rel = filepath.ToSlash(rel)
if rel != "" && rel != "." && !strings.HasPrefix(rel, "../") {
relativePath = rel
}
}
}
composeCtx.meta.Files = append(composeCtx.meta.Files, composeBackupFile{
OriginalPath: filePath,
FileName: path.Base(filePath),
RelativePath: relativePath,
BackupPath: path.Join("compose_files", backupName),
})
}
if len(composeCtx.composeFiles) != 0 {
envPath := path.Join(path.Dir(composeCtx.composeFiles[0]), ".env")
if composeCtx.fileOp.Stat(envPath) {
envContent, err := os.ReadFile(envPath)
if err != nil {
return err
}
if err := composeCtx.fileOp.SaveFile(path.Join(composeCtx.tmpDir, "compose_files", ".env"), string(envContent), fs.ModePerm); err != nil {
return err
}
}
}
return nil
}
func stepWriteComposeBackupMeta(composeCtx *composeBackupContext) error {
metaBytes, err := json.MarshalIndent(composeCtx.meta, "", " ")
if err != nil {
return err
}
return composeCtx.fileOp.SaveFile(path.Join(composeCtx.tmpDir, "compose_meta.json"), string(metaBytes), fs.ModePerm)
}
func stepPackComposeBackup(composeCtx *composeBackupContext) error {
return composeCtx.fileOp.TarGzCompressPro(true, composeCtx.tmpDir, composeCtx.filePath, composeCtx.req.Secret, "")
}
func newComposeRecoverContext(req dto.CommonRecover) (*composeRecoverContext, error) {
tmpDir := path.Join(path.Dir(req.File), strings.TrimSuffix(path.Base(req.File), ".tar.gz"))
ctx := &composeRecoverContext{
req: req,
fileOp: files.NewFileOp(),
tmpDir: tmpDir,
meta: composeBackupMeta{
Files: make([]composeBackupFile, 0),
Containers: make([]string, 0),
},
}
return ctx, nil
}
func (c *composeRecoverContext) close() {
if c.tmpDir != "" {
_ = os.RemoveAll(c.tmpDir)
}
}
func stepPrepareComposeRecover(recoverCtx *composeRecoverContext) error {
if !recoverCtx.fileOp.Stat(recoverCtx.req.File) {
return buserr.WithName("ErrFileNotFound", recoverCtx.req.File)
}
_ = os.RemoveAll(recoverCtx.tmpDir)
return nil
}
func stepExtractComposeRecover(recoverCtx *composeRecoverContext) error {
return recoverCtx.fileOp.TarGzExtractPro(recoverCtx.req.File, path.Dir(recoverCtx.req.File), recoverCtx.req.Secret)
}
func stepLoadComposeRecoverMeta(recoverCtx *composeRecoverContext) error {
metaPath := path.Join(recoverCtx.tmpDir, "compose_meta.json")
if !recoverCtx.fileOp.Stat(metaPath) {
return fmt.Errorf("compose_meta.json not found in backup file")
}
metaBytes, err := os.ReadFile(metaPath)
if err != nil {
return err
}
if err := json.Unmarshal(metaBytes, &recoverCtx.meta); err != nil {
return fmt.Errorf("unmarshal compose_meta.json failed, err: %v", err)
}
recoverCtx.composeName = strings.TrimSpace(recoverCtx.req.Name)
if recoverCtx.composeName == "" {
recoverCtx.composeName = strings.TrimSpace(recoverCtx.meta.ComposeName)
}
if recoverCtx.composeName == "" {
return fmt.Errorf("compose name not found in recover request or backup file")
}
recoverCtx.targetDir = resolveComposeRecoverTargetDir(recoverCtx.meta, recoverCtx.composeName)
return nil
}
func resolveComposeRecoverTargetDir(meta composeBackupMeta, composeName string) string {
composePath := strings.TrimSpace(meta.ComposePath)
if composePath != "" {
items := strings.Split(composePath, ",")
for _, item := range items {
p := strings.TrimSpace(item)
if p == "" {
continue
}
ext := strings.ToLower(path.Ext(p))
if ext == ".yml" || ext == ".yaml" {
return path.Dir(p)
}
return p
}
}
return path.Join(global.Dir.DataDir, "docker/compose", composeName)
}
func safeJoinWithinBase(baseDir, name string) (string, error) {
base := filepath.Clean(baseDir)
candidate := strings.TrimSpace(name)
candidate = strings.ReplaceAll(candidate, "\\", "/")
candidate = filepath.Clean(filepath.FromSlash(candidate))
if candidate == "" || candidate == "." {
return "", fmt.Errorf("invalid path: empty")
}
if filepath.IsAbs(candidate) {
return "", fmt.Errorf("invalid path %q: absolute path is not allowed", name)
}
if candidate == ".." || strings.HasPrefix(candidate, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("invalid path %q: path escapes base directory", name)
}
resolved := filepath.Clean(filepath.Join(base, candidate))
rel, err := filepath.Rel(base, resolved)
if err != nil {
return "", fmt.Errorf("resolve path %q failed, err: %v", name, err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("invalid path %q: path escapes base directory", name)
}
return resolved, nil
}
func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
if recoverCtx.targetDir != "" {
_ = os.RemoveAll(recoverCtx.targetDir)
}
if err := os.MkdirAll(recoverCtx.targetDir, os.ModePerm); err != nil {
return err
}
restored := make([]string, 0, len(recoverCtx.meta.Files))
for _, item := range recoverCtx.meta.Files {
backupPath, err := safeJoinWithinBase(recoverCtx.tmpDir, item.BackupPath)
if err != nil {
return fmt.Errorf("invalid compose backup path %q, err: %v", item.BackupPath, err)
}
if !recoverCtx.fileOp.Stat(backupPath) {
continue
}
targetName := item.FileName
if item.RelativePath != "" {
targetName = item.RelativePath
}
if targetName == "" {
targetName = path.Base(item.OriginalPath)
}
if targetName == "" {
targetName = "docker-compose.yml"
}
targetPath, err := safeJoinWithinBase(recoverCtx.targetDir, targetName)
if err != nil {
return fmt.Errorf("invalid compose target path %q, err: %v", targetName, err)
}
if err := os.MkdirAll(path.Dir(targetPath), os.ModePerm); err != nil {
return err
}
content, err := os.ReadFile(backupPath)
if err != nil {
return err
}
if err := recoverCtx.fileOp.SaveFile(targetPath, string(content), fs.ModePerm); err != nil {
return err
}
restored = append(restored, targetPath)
}
envPath := path.Join(recoverCtx.tmpDir, "compose_files", ".env")
if recoverCtx.fileOp.Stat(envPath) {
envContent, err := os.ReadFile(envPath)
if err != nil {
return err
}
if err := recoverCtx.fileOp.SaveFile(path.Join(recoverCtx.targetDir, ".env"), string(envContent), fs.ModePerm); err != nil {
return err
}
}
if len(restored) == 0 {
defaultPath := path.Join(recoverCtx.targetDir, "docker-compose.yml")
if !recoverCtx.fileOp.Stat(defaultPath) {
return fmt.Errorf("compose file not found in backup data")
}
restored = append(restored, defaultPath)
}
sort.Strings(restored)
recoverCtx.composePath = strings.Join(restored, ",")
return nil
}
func stepSaveComposeRecord(recoverCtx *composeRecoverContext) error {
if recoverCtx.composePath == "" {
recoverCtx.composePath = path.Join(recoverCtx.targetDir, "docker-compose.yml")
}
recordName := strings.ToLower(recoverCtx.composeName)
record, _ := composeRepo.GetRecord(repo.WithByName(recordName))
if record.ID == 0 {
return composeRepo.CreateRecord(&model.Compose{Name: recordName, Path: recoverCtx.composePath})
}
return composeRepo.UpdateRecord(recordName, map[string]interface{}{"path": recoverCtx.composePath})
}
func sanitizeComposeFileName(in string) string {
name := strings.TrimSpace(in)
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, ":", "_")
if name == "" {
return "container"
}
return name
}
+914
View File
@@ -0,0 +1,914 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"sort"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"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/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/common"
dockerUtils "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
)
type containerBackupMeta struct {
ContainerName string `json:"containerName"`
ContainerID string `json:"containerID"`
CreatedAt string `json:"createdAt"`
Image string `json:"image"`
HostConfig *container.HostConfig `json:"hostConfig,omitempty"`
Config *container.Config `json:"config,omitempty"`
Mounts []containerMountBackup `json:"mounts"`
}
type containerMountBackup struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
Destination string `json:"destination"`
Mode string `json:"mode,omitempty"`
RW bool `json:"rw"`
Propagation string `json:"propagation,omitempty"`
BackupPath string `json:"backupPath,omitempty"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
type containerBackupContext struct {
containerName string
backupDir string
fileName string
secret string
filePath string
tmpDir string
mountRoot string
wasRunning bool
stopped bool
fileOp files.FileOp
inspectInfo container.InspectResponse
meta containerBackupMeta
}
type containerRecoverContext struct {
req dto.CommonRecover
targetName string
fileOp files.FileOp
client *client.Client
tmpDir string
meta containerBackupMeta
inspectInfo container.InspectResponse
shouldStart bool
createdContainerID string
}
func (u *BackupService) ContainerBackup(req dto.CommonBackup) error {
timeNow := time.Now().Format(constant.DateTimeSlimLayout) + common.RandStrAndNum(5)
fileName := req.FileName
if fileName == "" {
fileName = fmt.Sprintf("%s_%s.tar.gz", req.Name, timeNow)
}
if !strings.HasSuffix(fileName, ".tar.gz") {
fileName += ".tar.gz"
}
itemDir := fmt.Sprintf("container/%s", req.Name)
backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)
record := &model.BackupRecord{
Type: req.Type,
Name: req.Name,
SourceAccountIDs: "1",
DownloadAccountID: 1,
FileDir: itemDir,
FileName: fileName,
TaskID: req.TaskID,
Status: constant.StatusWaiting,
Description: req.Description,
}
if err := backupRepo.CreateRecord(record); err != nil {
global.LOG.Errorf("save backup record failed, err: %v", err)
return err
}
if err := handleContainerBackup(req.Name, nil, record.ID, backupDir, fileName, req.TaskID, req.Secret, req.StopBefore); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return err
}
return nil
}
func (u *BackupService) ContainerRecover(req dto.CommonRecover) error {
return handleContainerRecover(req, nil)
}
func handleContainerBackup(containerName string, parentTask *task.Task, recordID uint, backupDir, fileName, taskID, secret string, stopBefore bool) error {
var (
err error
backupTask *task.Task
)
backupCtx, err := newContainerBackupContext(containerName, backupDir, fileName, secret)
if err != nil {
return err
}
backupTask = parentTask
if backupTask == nil {
backupTask, err = task.NewTaskWithOps(containerName, task.TaskBackup, task.TaskScopeBackup, taskID, 1)
if err != nil {
return err
}
}
if stopBefore {
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupStop"), func(t *task.Task) error {
return stepStopContainerForBackup(backupCtx)
}, func(t *task.Task) {
_ = stepStartContainerAfterBackup(backupCtx)
}, 3, time.Hour)
}
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupPrepare"), func(t *task.Task) error {
t.Logf("------------------ %s ------------------", containerName)
return stepPrepareContainerBackup(backupCtx)
}, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupInspect"), func(t *task.Task) error { return stepBackupContainerInspect(backupCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupMounts"), func(t *task.Task) error { return stepBackupContainerMounts(backupCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupMeta"), func(t *task.Task) error { return stepWriteContainerMeta(backupCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(task.GetTaskName(containerName, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error { return stepPackContainerBackup(backupCtx) }, nil, 3, time.Hour)
if stopBefore {
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupStart"), func(t *task.Task) error {
return stepStartContainerAfterBackup(backupCtx)
}, nil, 3, time.Hour)
}
if parentTask != nil {
return nil
}
go func() {
defer backupCtx.close()
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
}()
return nil
}
func handleContainerRecover(req dto.CommonRecover, parentTask *task.Task) error {
var (
err error
recoverTask *task.Task
recoverCtx *containerRecoverContext
)
recoverTask = parentTask
if recoverTask == nil {
if isImportRecover(req) {
taskName := i18n.GetMsgByKey("TaskImport") + i18n.GetMsgByKey("Container")
recoverTask, err = task.NewTask(taskName, task.TaskImport, task.TaskScopeBackup, req.TaskID, 1)
if err != nil {
return err
}
} else {
recoverTask, err = task.NewTaskWithOps("container", task.TaskRecover, task.TaskScopeBackup, req.TaskID, 1)
if err != nil {
return err
}
}
}
timeout := loadRecoverTimeout(req.Timeout)
logName := strings.TrimSpace(req.Name)
if logName == "" && req.File != "" {
logName = strings.TrimSuffix(path.Base(req.File), ".tar.gz")
}
if logName == "" {
logName = "container"
}
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverPrepare"), func(t *task.Task) error {
ctx, err := newContainerRecoverContext(req)
if err != nil {
return err
}
recoverCtx = ctx
t.Logf("------------------ %s ------------------", logName)
if err := stepPrepareContainerRecover(recoverCtx); err != nil {
recoverCtx.close()
recoverCtx = nil
return err
}
return nil
}, func(t *task.Task) {
if recoverCtx != nil {
recoverCtx.close()
recoverCtx = nil
}
}, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverExtract"), func(t *task.Task) error { return stepExtractContainerRecover(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverParse"), func(t *task.Task) error { return stepLoadContainerRecoverData(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverCreate"), func(t *task.Task) error { return stepRecreateContainer(recoverCtx, t) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverMounts"), func(t *task.Task) error { return stepRestoreContainerMounts(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverStart"), func(t *task.Task) error { return stepStartRecoveredContainer(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverCleanup"), func(t *task.Task) error {
if recoverCtx != nil {
recoverCtx.close()
recoverCtx = nil
}
return nil
}, nil, 0, timeout)
if parentTask != nil {
return nil
}
go func() {
_ = recoverTask.Execute()
}()
return nil
}
func loadRecoverTimeout(timeout int) time.Duration {
switch timeout {
case -1:
return 0
case 0:
return 3 * time.Hour
default:
return time.Duration(timeout) * time.Second
}
}
func isImportRecover(req dto.CommonRecover) bool {
return req.BackupRecordID == 0
}
func newContainerBackupContext(containerName, backupDir, fileName, secret string) (*containerBackupContext, error) {
dockerClient, err := dockerUtils.NewDockerClient()
if err != nil {
return nil, err
}
defer func() {
_ = dockerClient.Close()
}()
inspectInfo, err := dockerClient.ContainerInspect(context.Background(), containerName)
if err != nil {
return nil, err
}
filePath := path.Join(backupDir, fileName)
tmpDir := path.Join(path.Dir(filePath), strings.TrimSuffix(path.Base(filePath), ".tar.gz"))
backupCtx := &containerBackupContext{
containerName: containerName,
backupDir: backupDir,
fileName: fileName,
secret: secret,
filePath: filePath,
tmpDir: tmpDir,
mountRoot: path.Join(tmpDir, "mounts"),
wasRunning: inspectInfo.State != nil && inspectInfo.State.Running,
fileOp: files.NewFileOp(),
inspectInfo: inspectInfo,
meta: containerBackupMeta{
ContainerName: containerName,
ContainerID: inspectInfo.ID,
CreatedAt: time.Now().Format(constant.DateTimeLayout),
Image: inspectInfo.Config.Image,
HostConfig: inspectInfo.HostConfig,
Config: inspectInfo.Config,
Mounts: make([]containerMountBackup, 0),
},
}
return backupCtx, nil
}
func newContainerRecoverContext(req dto.CommonRecover) (*containerRecoverContext, error) {
dockerClient, err := dockerUtils.NewDockerClient()
if err != nil {
return nil, err
}
tmpDir := path.Join(path.Dir(req.File), strings.TrimSuffix(path.Base(req.File), ".tar.gz"))
ctx := &containerRecoverContext{
req: req,
targetName: req.Name,
fileOp: files.NewFileOp(),
client: dockerClient,
tmpDir: tmpDir,
meta: containerBackupMeta{
Mounts: make([]containerMountBackup, 0),
},
}
return ctx, nil
}
func (c *containerBackupContext) close() {
if c.tmpDir != "" {
_ = os.RemoveAll(c.tmpDir)
}
}
func (c *containerRecoverContext) close() {
if c.client != nil {
_ = c.client.Close()
}
if c.tmpDir != "" {
_ = os.RemoveAll(c.tmpDir)
}
}
func stepPrepareContainerBackup(backupCtx *containerBackupContext) error {
if err := os.MkdirAll(backupCtx.backupDir, os.ModePerm); err != nil {
return fmt.Errorf("mkdir %s failed, err: %v", backupCtx.backupDir, err)
}
_ = os.RemoveAll(backupCtx.tmpDir)
if err := os.MkdirAll(backupCtx.mountRoot, os.ModePerm); err != nil {
return err
}
return nil
}
func stepStopContainerForBackup(backupCtx *containerBackupContext) error {
if !backupCtx.wasRunning || backupCtx.stopped {
return nil
}
dockerClient, err := dockerUtils.NewDockerClient()
if err != nil {
return err
}
defer func() {
_ = dockerClient.Close()
}()
if err := dockerClient.ContainerStop(context.Background(), backupCtx.inspectInfo.ID, container.StopOptions{}); err != nil {
return err
}
backupCtx.stopped = true
return nil
}
func stepStartContainerAfterBackup(backupCtx *containerBackupContext) error {
if !backupCtx.stopped {
return nil
}
dockerClient, err := dockerUtils.NewDockerClient()
if err != nil {
return err
}
defer func() {
_ = dockerClient.Close()
}()
if err := dockerClient.ContainerStart(context.Background(), backupCtx.inspectInfo.ID, container.StartOptions{}); err != nil {
return err
}
backupCtx.stopped = false
return nil
}
func stepBackupContainerInspect(backupCtx *containerBackupContext) error {
inspectBytes, err := json.MarshalIndent(backupCtx.inspectInfo, "", " ")
if err != nil {
return err
}
if err := backupCtx.fileOp.SaveFile(path.Join(backupCtx.tmpDir, "inspect.json"), string(inspectBytes), fs.ModePerm); err != nil {
return err
}
if backupCtx.inspectInfo.NetworkSettings != nil {
networkBytes, err := json.MarshalIndent(backupCtx.inspectInfo.NetworkSettings, "", " ")
if err != nil {
return err
}
if err := backupCtx.fileOp.SaveFile(path.Join(backupCtx.tmpDir, "network.json"), string(networkBytes), fs.ModePerm); err != nil {
return err
}
}
return nil
}
func stepBackupContainerMounts(backupCtx *containerBackupContext) error {
var (
dockerClient *client.Client
clientErr error
)
ensureClient := func() (*client.Client, error) {
if dockerClient != nil || clientErr != nil {
return dockerClient, clientErr
}
dockerClient, clientErr = dockerUtils.NewDockerClient()
return dockerClient, clientErr
}
defer func() {
if dockerClient != nil {
_ = dockerClient.Close()
}
}()
for i, item := range backupCtx.inspectInfo.Mounts {
mountMeta := containerMountBackup{
Type: string(item.Type),
Name: item.Name,
Source: item.Source,
Destination: item.Destination,
Mode: item.Mode,
RW: item.RW,
Propagation: string(item.Propagation),
Status: "skipped",
}
mountDirName := fmt.Sprintf("%02d_%s", i, sanitizeContainerMountName(item.Destination))
mountDir := path.Join(backupCtx.mountRoot, mountDirName)
mountMeta.BackupPath = path.Join("mounts", mountDirName, "data")
switch item.Type {
case mount.TypeBind, mount.TypeVolume:
if item.Source == "" {
mountMeta.Message = "empty source"
backupCtx.meta.Mounts = append(backupCtx.meta.Mounts, mountMeta)
continue
}
sourceInfo, statErr := os.Stat(item.Source)
if statErr != nil {
mountMeta.Message = statErr.Error()
backupCtx.meta.Mounts = append(backupCtx.meta.Mounts, mountMeta)
continue
}
dataDir := path.Join(mountDir, "data")
if err := os.MkdirAll(dataDir, os.ModePerm); err != nil {
return err
}
if sourceInfo.IsDir() {
if err := backupCtx.fileOp.CopyDirWithNewName(item.Source, dataDir, "."); err != nil {
return err
}
} else {
if err := backupCtx.fileOp.CopyFile(item.Source, dataDir); err != nil {
return err
}
}
if item.Type == mount.TypeVolume && item.Name != "" {
cli, err := ensureClient()
if err != nil {
return err
}
volumeInfo, volumeErr := cli.VolumeInspect(context.Background(), item.Name)
if volumeErr == nil {
volumeBytes, volumeMarshalErr := json.MarshalIndent(volumeInfo, "", " ")
if volumeMarshalErr == nil {
_ = backupCtx.fileOp.SaveFile(path.Join(mountDir, "volume.json"), string(volumeBytes), fs.ModePerm)
}
}
}
mountMeta.Status = "backed_up"
default:
mountMeta.Message = "mount type not supported for data backup"
}
backupCtx.meta.Mounts = append(backupCtx.meta.Mounts, mountMeta)
}
return nil
}
func stepWriteContainerMeta(backupCtx *containerBackupContext) error {
metaBytes, err := json.MarshalIndent(backupCtx.meta, "", " ")
if err != nil {
return err
}
if err := backupCtx.fileOp.SaveFile(path.Join(backupCtx.tmpDir, "meta.json"), string(metaBytes), fs.ModePerm); err != nil {
return err
}
return nil
}
func stepPackContainerBackup(backupCtx *containerBackupContext) error {
if err := backupCtx.fileOp.TarGzCompressPro(true, backupCtx.tmpDir, backupCtx.filePath, backupCtx.secret, ""); err != nil {
return err
}
return nil
}
func stepPrepareContainerRecover(recoverCtx *containerRecoverContext) error {
if !recoverCtx.fileOp.Stat(recoverCtx.req.File) {
return buserr.WithName("ErrFileNotFound", recoverCtx.req.File)
}
_ = os.RemoveAll(recoverCtx.tmpDir)
return nil
}
func stepExtractContainerRecover(recoverCtx *containerRecoverContext) error {
return recoverCtx.fileOp.TarGzExtractPro(recoverCtx.req.File, path.Dir(recoverCtx.req.File), recoverCtx.req.Secret)
}
func stepLoadContainerRecoverData(recoverCtx *containerRecoverContext) error {
if err := loadContainerRecoverMeta(recoverCtx); err != nil {
return err
}
if err := loadContainerRecoverInspect(recoverCtx); err != nil {
return err
}
if recoverCtx.targetName == "" {
recoverCtx.targetName = strings.TrimPrefix(recoverCtx.inspectInfo.Name, "/")
}
if recoverCtx.targetName == "" {
recoverCtx.targetName = recoverCtx.meta.ContainerName
}
if recoverCtx.targetName == "" {
return fmt.Errorf("container name not found in recover request or backup file")
}
if recoverCtx.inspectInfo.Config == nil {
recoverCtx.inspectInfo.Config = recoverCtx.meta.Config
}
if recoverCtx.inspectInfo.HostConfig == nil {
recoverCtx.inspectInfo.HostConfig = recoverCtx.meta.HostConfig
}
if recoverCtx.inspectInfo.Config == nil {
return fmt.Errorf("container config not found in backup file")
}
if recoverCtx.inspectInfo.HostConfig == nil {
recoverCtx.inspectInfo.HostConfig = &container.HostConfig{}
}
recoverCtx.shouldStart = recoverCtx.inspectInfo.State != nil && recoverCtx.inspectInfo.State.Running
return nil
}
func loadContainerRecoverMeta(recoverCtx *containerRecoverContext) error {
metaPath := path.Join(recoverCtx.tmpDir, "meta.json")
if !recoverCtx.fileOp.Stat(metaPath) {
return nil
}
metaBytes, err := os.ReadFile(metaPath)
if err != nil {
return err
}
if err := json.Unmarshal(metaBytes, &recoverCtx.meta); err != nil {
return fmt.Errorf("unmarshal meta.json failed, err: %v", err)
}
return nil
}
func loadContainerRecoverInspect(recoverCtx *containerRecoverContext) error {
inspectPath := path.Join(recoverCtx.tmpDir, "inspect.json")
if !recoverCtx.fileOp.Stat(inspectPath) {
return fmt.Errorf("inspect.json not found in backup file")
}
inspectBytes, err := os.ReadFile(inspectPath)
if err != nil {
return err
}
if err := json.Unmarshal(inspectBytes, &recoverCtx.inspectInfo); err != nil {
return fmt.Errorf("unmarshal inspect.json failed, err: %v", err)
}
return nil
}
func stepRecreateContainer(recoverCtx *containerRecoverContext, taskItem *task.Task) error {
ctx := context.Background()
if err := ensureContainerRecoverNetworks(recoverCtx); err != nil {
return err
}
if err := ensureContainerRecoverVolumes(recoverCtx); err != nil {
return err
}
config := cloneContainerConfig(recoverCtx.inspectInfo.Config)
hostConfig := cloneContainerHostConfig(recoverCtx.inspectInfo.HostConfig)
if config.Image == "" {
config.Image = recoverCtx.meta.Image
}
if config.Image == "" {
return fmt.Errorf("container image not found in backup file")
}
if !checkImageExist(recoverCtx.client, config.Image) {
if err := pullImages(taskItem, recoverCtx.client, config.Image); err != nil {
return err
}
}
if _, err := recoverCtx.client.ContainerInspect(ctx, recoverCtx.targetName); err == nil {
if err := recoverCtx.client.ContainerRemove(ctx, recoverCtx.targetName, container.RemoveOptions{Force: true, RemoveVolumes: false}); err != nil {
return err
}
} else if !client.IsErrNotFound(err) {
return err
}
networkConfig, extraNetworks := buildContainerRecoverNetworkConfig(recoverCtx.inspectInfo.NetworkSettings, hostConfig)
removeBridgeDriverIPAM(recoverCtx.client, networkConfig, extraNetworks)
createRes, err := recoverCtx.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, recoverCtx.targetName)
if err != nil {
return err
}
recoverCtx.createdContainerID = createRes.ID
extraNames := make([]string, 0, len(extraNetworks))
for name := range extraNetworks {
extraNames = append(extraNames, name)
}
sort.Strings(extraNames)
for _, item := range extraNames {
if err := recoverCtx.client.NetworkConnect(ctx, item, recoverCtx.createdContainerID, extraNetworks[item]); err != nil {
return err
}
}
return nil
}
func removeBridgeDriverIPAM(cli *client.Client, primary *network.NetworkingConfig, extras map[string]*network.EndpointSettings) {
if primary != nil {
removeBridgeDriverIPAMFromEndpoints(cli, primary.EndpointsConfig)
}
removeBridgeDriverIPAMFromEndpoints(cli, extras)
}
func removeBridgeDriverIPAMFromEndpoints(cli *client.Client, endpoints map[string]*network.EndpointSettings) {
for netName, endpoint := range endpoints {
if endpoint == nil || endpoint.IPAMConfig == nil {
continue
}
info, err := cli.NetworkInspect(context.Background(), netName, network.InspectOptions{})
if err != nil {
continue
}
if info.Driver == "bridge" {
endpoint.IPAMConfig = nil
}
}
}
func ensureContainerRecoverNetworks(recoverCtx *containerRecoverContext) error {
if recoverCtx.inspectInfo.NetworkSettings == nil {
return nil
}
for netName := range recoverCtx.inspectInfo.NetworkSettings.Networks {
if netName == "" || netName == "bridge" || netName == "host" || netName == "none" {
continue
}
if _, err := recoverCtx.client.NetworkInspect(context.Background(), netName, network.InspectOptions{}); err != nil {
if !client.IsErrNotFound(err) {
return err
}
if _, err := recoverCtx.client.NetworkCreate(context.Background(), netName, network.CreateOptions{Driver: "bridge"}); err != nil {
return err
}
}
}
return nil
}
func ensureContainerRecoverVolumes(recoverCtx *containerRecoverContext) error {
for _, item := range recoverCtx.meta.Mounts {
if item.Type != string(mount.TypeVolume) || item.Name == "" {
continue
}
if _, err := recoverCtx.client.VolumeInspect(context.Background(), item.Name); err == nil {
continue
} else if !client.IsErrNotFound(err) {
return err
}
createOptions := volume.CreateOptions{Name: item.Name}
if item.BackupPath != "" {
volumeMetaPath := path.Join(recoverCtx.tmpDir, path.Dir(item.BackupPath), "volume.json")
if recoverCtx.fileOp.Stat(volumeMetaPath) {
volumeBytes, readErr := os.ReadFile(volumeMetaPath)
if readErr != nil {
return readErr
}
var volumeInfo volume.Volume
if unmarshalErr := json.Unmarshal(volumeBytes, &volumeInfo); unmarshalErr != nil {
return unmarshalErr
}
if volumeInfo.Driver != "" {
createOptions.Driver = volumeInfo.Driver
}
if len(volumeInfo.Options) != 0 {
createOptions.DriverOpts = volumeInfo.Options
}
if len(volumeInfo.Labels) != 0 {
createOptions.Labels = volumeInfo.Labels
}
}
}
if _, err := recoverCtx.client.VolumeCreate(context.Background(), createOptions); err != nil {
return err
}
}
return nil
}
func buildContainerRecoverNetworkConfig(networkSettings *container.NetworkSettings, hostConfig *container.HostConfig) (*network.NetworkingConfig, map[string]*network.EndpointSettings) {
extraNetworks := make(map[string]*network.EndpointSettings)
if hostConfig != nil {
networkMode := string(hostConfig.NetworkMode)
if networkMode == "host" || networkMode == "none" {
return nil, extraNetworks
}
}
if networkSettings == nil || len(networkSettings.Networks) == 0 {
return nil, extraNetworks
}
primaryName := ""
if hostConfig != nil {
networkMode := string(hostConfig.NetworkMode)
if networkMode != "" && networkMode != "default" && networkMode != "bridge" {
if _, ok := networkSettings.Networks[networkMode]; ok {
primaryName = networkMode
}
}
}
if primaryName == "" {
if _, ok := networkSettings.Networks["bridge"]; ok {
primaryName = "bridge"
} else {
names := make([]string, 0, len(networkSettings.Networks))
for name := range networkSettings.Networks {
names = append(names, name)
}
sort.Strings(names)
if len(names) > 0 {
primaryName = names[0]
}
}
}
config := &network.NetworkingConfig{EndpointsConfig: make(map[string]*network.EndpointSettings)}
for name, endpoint := range networkSettings.Networks {
if name == "host" || name == "none" {
continue
}
endpointSetting := &network.EndpointSettings{Aliases: append([]string(nil), endpoint.Aliases...), MacAddress: endpoint.MacAddress}
if endpoint.IPAMConfig != nil {
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
IPv4Address: endpoint.IPAMConfig.IPv4Address,
IPv6Address: endpoint.IPAMConfig.IPv6Address,
}
} else if endpoint.IPAddress != "" || endpoint.GlobalIPv6Address != "" {
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
IPv4Address: endpoint.IPAddress,
IPv6Address: endpoint.GlobalIPv6Address,
}
}
if name == primaryName {
config.EndpointsConfig[name] = endpointSetting
} else {
extraNetworks[name] = endpointSetting
}
}
if len(config.EndpointsConfig) == 0 {
return nil, extraNetworks
}
return config, extraNetworks
}
func cloneContainerConfig(config *container.Config) *container.Config {
if config == nil {
return &container.Config{}
}
item := *config
if len(config.Env) != 0 {
item.Env = append([]string(nil), config.Env...)
}
if len(config.Cmd) != 0 {
item.Cmd = append([]string(nil), config.Cmd...)
}
if len(config.Entrypoint) != 0 {
item.Entrypoint = append([]string(nil), config.Entrypoint...)
}
if len(config.Labels) != 0 {
labels := make(map[string]string, len(config.Labels))
for key, val := range config.Labels {
labels[key] = val
}
item.Labels = labels
}
if len(config.Volumes) != 0 {
volumes := make(map[string]struct{}, len(config.Volumes))
for key, val := range config.Volumes {
volumes[key] = val
}
item.Volumes = volumes
}
return &item
}
func cloneContainerHostConfig(hostConfig *container.HostConfig) *container.HostConfig {
if hostConfig == nil {
return &container.HostConfig{}
}
item := *hostConfig
if len(hostConfig.Binds) != 0 {
item.Binds = append([]string(nil), hostConfig.Binds...)
}
if len(hostConfig.DNS) != 0 {
item.DNS = append([]string(nil), hostConfig.DNS...)
}
if len(hostConfig.ExtraHosts) != 0 {
item.ExtraHosts = append([]string(nil), hostConfig.ExtraHosts...)
}
if len(hostConfig.Mounts) != 0 {
item.Mounts = append([]mount.Mount(nil), hostConfig.Mounts...)
}
return &item
}
func stepRestoreContainerMounts(recoverCtx *containerRecoverContext) error {
currentContainer := recoverCtx.createdContainerID
if currentContainer == "" {
currentContainer = recoverCtx.targetName
}
currentInspect, err := recoverCtx.client.ContainerInspect(context.Background(), currentContainer)
if err != nil {
return err
}
currentMounts := make(map[string]container.MountPoint, len(currentInspect.Mounts))
for _, item := range currentInspect.Mounts {
currentMounts[item.Destination] = item
}
for _, item := range recoverCtx.meta.Mounts {
if item.Status != "backed_up" || item.BackupPath == "" || !item.RW {
continue
}
backupPath := path.Join(recoverCtx.tmpDir, item.BackupPath)
if !recoverCtx.fileOp.Stat(backupPath) {
continue
}
sourcePath := item.Source
if currentMount, ok := currentMounts[item.Destination]; ok {
if currentMount.Source != "" {
sourcePath = currentMount.Source
}
if item.Type == string(mount.TypeVolume) && item.Name == "" {
item.Name = currentMount.Name
}
}
if sourcePath == "" && item.Type == string(mount.TypeVolume) && item.Name != "" {
volumeInfo, volumeErr := recoverCtx.client.VolumeInspect(context.Background(), item.Name)
if volumeErr != nil {
return volumeErr
}
sourcePath = volumeInfo.Mountpoint
}
if sourcePath == "" {
continue
}
if err := restoreContainerMountData(recoverCtx.fileOp, backupPath, sourcePath); err != nil {
return err
}
}
return nil
}
func restoreContainerMountData(fileOp files.FileOp, backupPath, sourcePath string) error {
if sourcePath == "/" {
return fmt.Errorf("invalid mount source path /")
}
entries, err := os.ReadDir(backupPath)
if err != nil {
return err
}
if len(entries) == 1 && !entries[0].IsDir() && entries[0].Name() == path.Base(sourcePath) {
if err := os.MkdirAll(path.Dir(sourcePath), os.ModePerm); err != nil {
return err
}
_ = os.RemoveAll(sourcePath)
if err := fileOp.CopyFile(path.Join(backupPath, entries[0].Name()), path.Dir(sourcePath)); err != nil {
return err
}
return nil
}
_ = os.RemoveAll(sourcePath)
if err := os.MkdirAll(sourcePath, os.ModePerm); err != nil {
return err
}
if err := fileOp.CopyDirWithNewName(backupPath, sourcePath, "."); err != nil {
return err
}
return nil
}
func stepStartRecoveredContainer(recoverCtx *containerRecoverContext) error {
if !recoverCtx.shouldStart {
return nil
}
containerID := recoverCtx.createdContainerID
if containerID == "" {
containerID = recoverCtx.targetName
}
return recoverCtx.client.ContainerStart(context.Background(), containerID, container.StartOptions{})
}
func sanitizeContainerMountName(in string) string {
name := strings.TrimSpace(in)
name = strings.Trim(name, "/")
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, ":", "_")
if name == "" {
return "root"
}
return name
}
+448
View File
@@ -1,7 +1,9 @@
package service
import (
"archive/tar"
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
@@ -11,6 +13,7 @@ import (
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
"strconv"
@@ -40,6 +43,7 @@ import (
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat"
"github.com/gin-gonic/gin"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
@@ -89,6 +93,12 @@ type IContainerService interface {
Prune(req dto.ContainerPrune) error
LoadUsers(req dto.OperationWithName) []string
ListContainerFiles(req dto.ContainerFileReq) ([]dto.ContainerFileInfo, error)
UploadContainerFile(req dto.ContainerFileReq, fileName string, fileSize int64, file io.Reader) error
GetContainerFileContent(req dto.ContainerFileReq) (*dto.ContainerFileContent, error)
GetContainerFileSize(req dto.ContainerFileReq) (int64, error)
DeleteContainerFile(req dto.ContainerFileBatchDeleteReq) error
DownloadContainerFile(req dto.ContainerFileReq) (io.ReadCloser, string, string, error)
StreamLogs(ctx *gin.Context, params dto.StreamLog)
}
@@ -1166,6 +1176,444 @@ func (u *ContainerService) LoadUsers(req dto.OperationWithName) []string {
return users
}
func (u *ContainerService) ListContainerFiles(req dto.ContainerFileReq) ([]dto.ContainerFileInfo, error) {
if len(req.Path) == 0 {
req.Path = "/"
}
cli, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer cli.Close()
ctx := context.Background()
stat, err := cli.ContainerStatPath(ctx, req.ContainerID, req.Path)
if err != nil {
return nil, err
}
isDir := stat.Mode.IsDir()
isLink := stat.Mode&os.ModeSymlink != 0
if isLink && !isDir {
linkDir, linkErr := isContainerDir(cli, req.ContainerID, req.Path)
if linkErr == nil {
isDir = linkDir
}
}
if !isDir {
return []dto.ContainerFileInfo{toContainerFileInfo(req.Path, stat, isDir)}, nil
}
output, err := runContainerCommand(cli, req.ContainerID, []string{"ls", "-1A", "--", req.Path})
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(output), "\n")
files := make([]dto.ContainerFileInfo, 0, len(lines))
for _, line := range lines {
name := strings.TrimSpace(line)
if len(name) == 0 || name == "." || name == ".." {
continue
}
childPath := req.Path
if childPath == "/" {
childPath = "/" + name
} else {
childPath = strings.TrimSuffix(childPath, "/") + "/" + name
}
childStat, statErr := cli.ContainerStatPath(ctx, req.ContainerID, childPath)
if statErr != nil {
continue
}
childIsDir := childStat.Mode.IsDir()
if childStat.Mode&os.ModeSymlink != 0 && !childIsDir {
linkDir, linkErr := isContainerDir(cli, req.ContainerID, childPath)
if linkErr == nil {
childIsDir = linkDir
}
}
files = append(files, toContainerFileInfo(childPath, childStat, childIsDir))
}
sort.Slice(files, func(i, j int) bool {
if files[i].IsDir != files[j].IsDir {
return files[i].IsDir
}
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
})
return files, nil
}
func (u *ContainerService) DeleteContainerFile(req dto.ContainerFileBatchDeleteReq) error {
for _, item := range req.Paths {
if strings.TrimSpace(item) == "/" {
return buserr.New("ErrPathNotDelete")
}
}
cli, err := docker.NewDockerClient()
if err != nil {
return err
}
defer cli.Close()
command := []string{"rm", "-rf", "--"}
command = append(command, req.Paths...)
_, err = runContainerCommand(cli, req.ContainerID, command)
return err
}
func (u *ContainerService) UploadContainerFile(req dto.ContainerFileReq, fileName string, fileSize int64, file io.Reader) error {
if len(req.Path) == 0 {
req.Path = "/"
}
safeName := path.Base(fileName)
if safeName == "." || safeName == "/" || len(safeName) == 0 {
return buserr.New("ErrInvalidChar")
}
cli, err := docker.NewDockerClient()
if err != nil {
return err
}
defer cli.Close()
ctx := context.Background()
stat, err := cli.ContainerStatPath(ctx, req.ContainerID, req.Path)
if err != nil {
if _, mkErr := runContainerCommand(cli, req.ContainerID, []string{"mkdir", "-p", "--", req.Path}); mkErr != nil {
return mkErr
}
stat, err = cli.ContainerStatPath(ctx, req.ContainerID, req.Path)
if err != nil {
return err
}
}
if !stat.Mode.IsDir() {
return fmt.Errorf("path %s is not directory", req.Path)
}
pipeReader, pipeWriter := io.Pipe()
writeErr := make(chan error, 1)
go func() {
tw := tar.NewWriter(pipeWriter)
header := &tar.Header{
Name: safeName,
Mode: 0644,
Size: fileSize,
ModTime: time.Now(),
}
if err := tw.WriteHeader(header); err != nil {
_ = tw.Close()
_ = pipeWriter.CloseWithError(err)
writeErr <- err
return
}
if _, err := io.Copy(tw, file); err != nil {
_ = tw.Close()
_ = pipeWriter.CloseWithError(err)
writeErr <- err
return
}
if err := tw.Close(); err != nil {
_ = pipeWriter.CloseWithError(err)
writeErr <- err
return
}
_ = pipeWriter.Close()
writeErr <- nil
}()
err = cli.CopyToContainer(ctx, req.ContainerID, req.Path, pipeReader, container.CopyToContainerOptions{
CopyUIDGID: true,
})
if err != nil {
_ = pipeReader.CloseWithError(err)
_ = pipeWriter.CloseWithError(err)
<-writeErr
return err
}
if err := <-writeErr; err != nil {
return err
}
return nil
}
func (u *ContainerService) GetContainerFileContent(req dto.ContainerFileReq) (*dto.ContainerFileContent, error) {
if len(req.Path) == 0 {
return nil, buserr.New("ErrInvalidChar")
}
cli, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer cli.Close()
stat, err := cli.ContainerStatPath(context.Background(), req.ContainerID, req.Path)
if err != nil {
return nil, err
}
if stat.Mode.IsDir() {
return nil, fmt.Errorf("path %s is directory", req.Path)
}
content := &dto.ContainerFileContent{Size: stat.Size}
headBytes, err := runContainerCommandRaw(cli, req.ContainerID, []string{"head", "-c", "4096", "--", req.Path})
if err != nil {
return nil, err
}
if bytes.IndexByte(headBytes, 0) >= 0 {
content.IsBinary = true
return content, nil
}
const inlinePreviewMax = 512 * 1024
if stat.Size <= inlinePreviewMax {
raw, err := runContainerCommandRaw(cli, req.ContainerID, []string{"cat", "--", req.Path})
if err != nil {
return nil, err
}
content.Content = string(raw)
return content, nil
}
raw, err := runContainerCommandRaw(cli, req.ContainerID, []string{"tail", "-n", "300", "--", req.Path})
if err != nil {
return nil, err
}
content.Content = string(raw)
content.Truncated = true
return content, nil
}
func (u *ContainerService) GetContainerFileSize(req dto.ContainerFileReq) (int64, error) {
if len(req.Path) == 0 {
return 0, buserr.New("ErrInvalidChar")
}
cli, err := docker.NewDockerClient()
if err != nil {
return 0, err
}
defer cli.Close()
stat, err := cli.ContainerStatPath(context.Background(), req.ContainerID, req.Path)
if err != nil {
return 0, err
}
if !stat.Mode.IsDir() {
return stat.Size, nil
}
output, err := runContainerCommand(cli, req.ContainerID, []string{"du", "-sb", "--", req.Path})
if err != nil {
return 0, err
}
parts := strings.Fields(output)
if len(parts) == 0 {
return 0, fmt.Errorf("invalid du output")
}
size, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, err
}
return size, nil
}
func (u *ContainerService) DownloadContainerFile(req dto.ContainerFileReq) (io.ReadCloser, string, string, error) {
if len(req.Path) == 0 {
req.Path = "/"
}
cli, err := docker.NewDockerClient()
if err != nil {
return nil, "", "", err
}
ctx := context.Background()
stat, err := cli.ContainerStatPath(ctx, req.ContainerID, req.Path)
if err != nil {
_ = cli.Close()
return nil, "", "", err
}
fileName := stat.Name
if len(fileName) == 0 {
fileName = "container-file"
}
if stat.Mode.IsDir() {
if _, err := runContainerCommand(cli, req.ContainerID, []string{"sh", "-c", "command -v tar >/dev/null 2>&1"}); err != nil {
_ = cli.Close()
return nil, "", "", fmt.Errorf("tar command not found in container")
}
targetPath := path.Clean(req.Path)
parentPath := path.Dir(targetPath)
targetName := path.Base(targetPath)
if parentPath == "." || parentPath == "" {
parentPath = "/"
}
tarStream, err := runContainerCommandStream(cli, req.ContainerID, []string{
"tar", "-czf", "-", "-C", parentPath, "--", targetName,
})
if err != nil {
_ = cli.Close()
return nil, "", "", err
}
if !strings.HasSuffix(fileName, ".tar.gz") {
fileName += ".tar.gz"
}
return &closeHookReader{
ReadCloser: tarStream,
onClose: cli.Close,
}, fileName, "application/gzip", nil
}
fileStream, err := runContainerCommandStream(cli, req.ContainerID, []string{"cat", "--", req.Path})
if err != nil {
_ = cli.Close()
return nil, "", "", err
}
return &closeHookReader{
ReadCloser: fileStream,
onClose: cli.Close,
}, fileName, "application/octet-stream", nil
}
func runContainerCommand(cli *client.Client, containerID string, command []string) (string, error) {
raw, err := runContainerCommandRaw(cli, containerID, command)
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
type closeHookReader struct {
io.ReadCloser
onClose func() error
}
func (r *closeHookReader) Close() error {
var closeErr error
if r.ReadCloser != nil {
closeErr = r.ReadCloser.Close()
}
if r.onClose != nil {
if err := r.onClose(); err != nil && closeErr == nil {
closeErr = err
}
}
return closeErr
}
func runContainerCommandRaw(cli *client.Client, containerID string, command []string) ([]byte, error) {
ctx := context.Background()
resp, err := cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: command,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return nil, err
}
hijack, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{})
if err != nil {
return nil, err
}
defer hijack.Close()
raw, err := io.ReadAll(hijack.Reader)
if err != nil {
return nil, err
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if _, err := stdcopy.StdCopy(&stdout, &stderr, bytes.NewReader(raw)); err != nil {
return nil, err
}
output := strings.TrimSpace(stdout.String())
errorOutput := strings.TrimSpace(stderr.String())
info, err := cli.ContainerExecInspect(ctx, resp.ID)
if err != nil {
return nil, err
}
if info.ExitCode != 0 {
if len(errorOutput) != 0 {
return nil, fmt.Errorf("%s", errorOutput)
}
if len(output) == 0 {
return nil, fmt.Errorf("container command failed with exit code %d", info.ExitCode)
}
return nil, fmt.Errorf("%s", output)
}
return stdout.Bytes(), nil
}
func runContainerCommandStream(cli *client.Client, containerID string, command []string) (io.ReadCloser, error) {
ctx := context.Background()
resp, err := cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: command,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return nil, err
}
hijack, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{})
if err != nil {
return nil, err
}
pipeReader, pipeWriter := io.Pipe()
go func() {
defer hijack.Close()
var stderr bytes.Buffer
_, copyErr := stdcopy.StdCopy(pipeWriter, &stderr, hijack.Reader)
if copyErr != nil {
_ = pipeWriter.CloseWithError(copyErr)
return
}
info, inspectErr := cli.ContainerExecInspect(ctx, resp.ID)
if inspectErr != nil {
_ = pipeWriter.CloseWithError(inspectErr)
return
}
if info.ExitCode != 0 {
msg := strings.TrimSpace(stderr.String())
if len(msg) == 0 {
msg = fmt.Sprintf("container command failed with exit code %d", info.ExitCode)
}
_ = pipeWriter.CloseWithError(fmt.Errorf("%s", msg))
return
}
_ = pipeWriter.Close()
}()
return pipeReader, nil
}
func toContainerFileInfo(filePath string, stat container.PathStat, isDir bool) dto.ContainerFileInfo {
name := stat.Name
if len(name) == 0 {
items := strings.Split(strings.TrimSuffix(filePath, "/"), "/")
name = items[len(items)-1]
}
isLink := stat.Mode&os.ModeSymlink != 0
return dto.ContainerFileInfo{
Name: name,
Path: filePath,
IsDir: isDir,
IsLink: isLink,
LinkTo: stat.LinkTarget,
Size: stat.Size,
Mode: stat.Mode.String(),
ModTime: stat.Mtime.Format(constant.DateTimeLayout),
}
}
func isContainerDir(cli *client.Client, containerID, targetPath string) (bool, error) {
_, err := runContainerCommand(cli, containerID, []string{
"sh", "-c", "[ -d \"$1\" ]", "sh", targetPath,
})
if err != nil {
return false, err
}
return true, nil
}
func stringsToMap(list []string) map[string]string {
var labelMap = make(map[string]string)
for _, label := range list {
+11 -3
View File
@@ -284,7 +284,7 @@ func (u *DashboardService) LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher,
return data, err
}
showList, err := launcherRepo.ListName()
showList, _ := launcherRepo.ListName()
defaultList, err := appRepo.GetTopRecommend()
if err != nil {
return data, nil
@@ -361,6 +361,9 @@ func (u *DashboardService) LoadQuickOptions() []dto.QuickJump {
_ = copier.Copy(&item, &quick)
list = append(list, item)
}
sort.Slice(list, func(i, j int) bool {
return list[i].Recommend < list[j].Recommend
})
return list
}
func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error {
@@ -612,6 +615,9 @@ func loadQuickJump(base *dto.DashboardBase) {
website, _ := websiteRepo.GetBy()
base.WebsiteNumber = len(website)
agents, _ := agentRepo.List()
base.AgentNumber = len(agents)
postgresqlDbs, _ := postgresqlRepo.List()
mysqlDbs, _ := mysqlRepo.List()
base.DatabaseNumber = len(mysqlDbs) + len(postgresqlDbs)
@@ -625,6 +631,8 @@ func loadQuickJump(base *dto.DashboardBase) {
quicks := launcherRepo.ListQuickJump(false)
for i := 0; i < len(quicks); i++ {
switch quicks[i].Name {
case "Agent":
quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber)
case "Website":
quicks[i].Detail = fmt.Sprintf("%d", base.WebsiteNumber)
case "Database":
@@ -638,7 +646,7 @@ func loadQuickJump(base *dto.DashboardBase) {
_ = copier.Copy(&item, quicks[i])
base.QuickJumps = append(base.QuickJumps, item)
}
sort.Slice(quicks, func(i, j int) bool {
return quicks[i].Recommend < quicks[j].Recommend
sort.Slice(base.QuickJumps, func(i, j int) bool {
return base.QuickJumps[i].Recommend < base.QuickJumps[j].Recommend
})
}
+1 -1
View File
@@ -691,7 +691,7 @@ func (f *FileService) ReadLogByLine(req request.FileReadByLineReq) (*response.Fi
logFileRes *dto.LogFileRes
)
if stat.Size() > files.MaxReadFileSize {
lines, err = files.TailFromEnd(logFilePath, req.PageSize)
lines, _ = files.TailFromEnd(logFilePath, req.PageSize)
isEndOfFile = true
scope = "tail"
} else {
+3 -3
View File
@@ -289,11 +289,11 @@ func (s *IptablesService) LoadChainStatus(req dto.OperationWithName) dto.Iptable
}
switch req.Name {
case iptables.Chain1PanelBasic:
data.IsBind, err = iptables.CheckChainBind(iptables.FilterTab, iptables.ChainInput, req.Name)
data.IsBind, _ = iptables.CheckChainBind(iptables.FilterTab, iptables.ChainInput, req.Name)
case iptables.Chain1PanelInput:
data.IsBind, err = iptables.CheckChainBind(iptables.FilterTab, iptables.ChainInput, req.Name)
data.IsBind, _ = iptables.CheckChainBind(iptables.FilterTab, iptables.ChainInput, req.Name)
case iptables.Chain1PanelOutput:
data.IsBind, err = iptables.CheckChainBind(iptables.FilterTab, iptables.ChainOutput, req.Name)
data.IsBind, _ = iptables.CheckChainBind(iptables.FilterTab, iptables.ChainOutput, req.Name)
}
return data
}
+1 -2
View File
@@ -18,7 +18,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
cmd2 "github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/subosito/gotenv"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
@@ -241,7 +240,7 @@ func (n NginxService) Build(req request.NginxBuildReq) error {
return err
}
buildTask.AddSubTaskWithOps("", func(t *task.Task) error {
cmdMgr := cmd2.NewCommandMgr(cmd.WithTask(*buildTask), cmd.WithTimeout(120*time.Minute))
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*buildTask), cmd.WithTimeout(120*time.Minute))
if err = cmdMgr.RunBashCf("docker compose -f %s build", nginxInstall.GetComposePath()); err != nil {
return err
}
+2 -10
View File
@@ -114,7 +114,7 @@ func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error)
err = errors.New("invalid proxy config, no location found")
return
}
location.UpdateDirective("proxy_pass", []string{req.ProxyPass})
applyLocationProxyPass(location, req.ProxyPass, &req.SNI, req.ProxySSLName)
location.UpdateDirective("proxy_set_header", []string{"Host", req.ProxyHost})
location.ChangePath(req.Modifier, req.Match)
// Server Cache Settings
@@ -143,15 +143,7 @@ func (w WebsiteService) OperateProxy(req request.WebsiteProxyConfig) (err error)
} else {
location.RemoveSubFilter()
}
// SSL Settings
if req.SNI {
location.UpdateDirective("proxy_ssl_server_name", []string{"on"})
if req.ProxySSLName != "" {
location.UpdateDirective("proxy_ssl_name", []string{req.ProxySSLName})
}
} else {
location.UpdateDirective("proxy_ssl_server_name", []string{"off"})
}
// Explicit SNI configuration still takes precedence over the automatic HTTPS upstream default.
// CORS Settings
if req.Cors {
location.UpdateDirective("add_header", []string{"Access-Control-Allow-Origin", req.AllowOrigins, "always"})
+25 -1
View File
@@ -47,6 +47,30 @@ func handleChineseDomain(domain string) (string, error) {
return domain, nil
}
func isHTTPSProxyPass(proxyPass string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(proxyPass)), "https://")
}
func applyLocationProxyPass(location *components.Location, proxyPass string, sni *bool, proxySSLName string) {
location.UpdateDirective("proxy_pass", []string{proxyPass})
enableSNI := isHTTPSProxyPass(proxyPass)
if sni != nil {
enableSNI = enableSNI && *sni
}
if enableSNI {
location.UpdateDirective("proxy_ssl_server_name", []string{"on"})
} else {
location.UpdateDirective("proxy_ssl_server_name", []string{"off"})
}
sslName := "$proxy_host"
if proxySSLName != "" {
sslName = proxySSLName
}
location.UpdateDirective("proxy_ssl_name", []string{sslName})
}
func createIndexFile(website *model.Website, runtime *model.Runtime) error {
var (
indexPath string
@@ -122,7 +146,7 @@ func createProxyFile(website *model.Website) error {
return errors.New("error")
}
location.ChangePath("^~", "/")
location.UpdateDirective("proxy_pass", []string{website.Proxy})
applyLocationProxyPass(location, website.Proxy, nil, "")
location.UpdateDirective("proxy_set_header", []string{"Host", "$host"})
if err := nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
return buserr.WithErr("ErrUpdateBuWebsite", err)
+2 -1
View File
@@ -275,7 +275,8 @@ func (t *Task) Execute() error {
}
var err error
t.Log(i18n.GetWithName("TaskStart", t.Name))
for _, subTask := range t.SubTasks {
for i := 0; i < len(t.SubTasks); i++ {
subTask := t.SubTasks[i]
t.Task.CurrentStep = subTask.StepAlias
t.updateTask(t.Task)
if err = subTask.Execute(); err == nil {
+30
View File
@@ -368,6 +368,36 @@
"formatZH": "docker 服务 [operation]",
"formatEN": "[operation] docker service"
},
"/containers/files/del": {
"bodyKeys": [
"containerID",
"paths"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除容器 [containerID] 文件 [paths]",
"formatEN": "Delete files [paths] in container [containerID]"
},
"/containers/files/download": {
"bodyKeys": [
"containerID",
"path"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "下载容器 [containerID] 文件 [path]",
"formatEN": "Download file [path] from container [containerID]"
},
"/containers/files/upload": {
"bodyKeys": [
"containerID",
"path"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "容器 [containerID] 上传文件到 [path]",
"formatEN": "Upload file to [path] in container [containerID]"
},
"/containers/image/build": {
"bodyKeys": [
"name"
@@ -2,7 +2,6 @@ package nginx_conf
import (
"embed"
_ "embed"
"io"
)
+30
View File
@@ -232,6 +232,36 @@ BuildCache: 'Build Cache'
Volume: 'Volume'
Network: "Network"
PruneStart: 'Cleaning in progress'
ContainerBackupPrepare: 'Prepare container backup workspace'
ContainerBackupStop: 'Stop container before backup'
ContainerBackupInspect: 'Backup container inspect and network'
ContainerBackupMounts: 'Backup container mounts'
ContainerBackupMeta: 'Write container backup metadata'
ContainerBackupStart: 'Start container after backup'
ContainerRecoverPrepare: 'Prepare container restore workspace'
ContainerRecoverExtract: 'Extract container backup archive'
ContainerRecoverParse: 'Load container backup metadata'
ContainerRecoverCreate: 'Recreate container'
ContainerRecoverMounts: 'Restore container mounts'
ContainerRecoverStart: 'Start restored container'
ContainerRecoverCleanup: 'Cleanup container restore workspace'
ComposeBackupPrepare: 'Prepare compose backup workspace'
ComposeBackupStop: 'Stop compose before backup'
ComposeBackupFiles: 'Backup compose files'
ComposeBackupContainers: 'Backup compose containers'
ComposeBackupMeta: 'Write compose backup metadata'
ComposeBackupStart: 'Start compose after backup'
ComposeBackupCleanup: 'Cleanup compose backup workspace'
ComposeRecoverPrepare: 'Prepare compose restore workspace'
ComposeRecoverExtract: 'Extract compose backup archive'
ComposeRecoverMeta: 'Load compose backup metadata'
ComposeRecoverMetaLogName: 'Compose restore meta: name={{ .name }}'
ComposeRecoverMetaLogPath: 'Compose restore meta: backupPath={{ .backupPath }} targetDir={{ .targetDir }}'
ComposeRecoverMetaLogCount: 'Compose restore meta: files={{ .files }} containers={{ .containers }}'
ComposeRecoverFiles: 'Restore compose files'
ComposeRecoverContainers: 'Restore compose containers'
ComposeRecoverRecord: 'Save compose record'
ComposeRecoverCleanup: 'Cleanup compose restore workspace'
#runtime
ErrFileNotExist: 'Source file not found: {{ .detail }}'
+30
View File
@@ -209,6 +209,36 @@ BuildCache: 'Caché de compilación'
Volume: 'Volumen de almacenamiento'
Network: 'Red'
PruneStart: 'Limpiando en progreso espere...'
ContainerBackupPrepare: 'Preparar espacio de respaldo del contenedor'
ContainerBackupStop: 'Detener contenedor antes del respaldo'
ContainerBackupInspect: 'Respaldar inspect y red del contenedor'
ContainerBackupMounts: 'Respaldar montajes del contenedor'
ContainerBackupMeta: 'Escribir metadatos del respaldo del contenedor'
ContainerBackupStart: 'Iniciar contenedor después del respaldo'
ContainerRecoverPrepare: 'Preparar espacio de restauración del contenedor'
ContainerRecoverExtract: 'Extraer archivo de respaldo del contenedor'
ContainerRecoverParse: 'Cargar metadatos del respaldo del contenedor'
ContainerRecoverCreate: 'Recrear contenedor'
ContainerRecoverMounts: 'Restaurar montajes del contenedor'
ContainerRecoverStart: 'Iniciar contenedor restaurado'
ContainerRecoverCleanup: 'Limpiar espacio de restauracion del contenedor'
ComposeBackupPrepare: 'Preparar espacio de respaldo del compose'
ComposeBackupStop: 'Detener compose antes del respaldo'
ComposeBackupFiles: 'Respaldar archivos de compose'
ComposeBackupContainers: 'Respaldar contenedores del compose'
ComposeBackupMeta: 'Escribir metadatos del respaldo de compose'
ComposeBackupStart: 'Iniciar compose después del respaldo'
ComposeBackupCleanup: 'Limpiar espacio de respaldo del compose'
ComposeRecoverPrepare: 'Preparar espacio de restauración del compose'
ComposeRecoverExtract: 'Extraer archivo de respaldo de compose'
ComposeRecoverMeta: 'Cargar metadatos del respaldo de compose'
ComposeRecoverMetaLogName: 'Metadatos de restauracion de compose: nombre={{ .name }}'
ComposeRecoverMetaLogPath: 'Metadatos de restauracion de compose: rutaRespaldo={{ .backupPath }} rutaDestino={{ .targetDir }}'
ComposeRecoverMetaLogCount: 'Metadatos de restauracion de compose: archivos={{ .files }} contenedores={{ .containers }}'
ComposeRecoverFiles: 'Restaurar archivos de compose'
ComposeRecoverContainers: 'Restaurar contenedores del compose'
ComposeRecoverRecord: 'Guardar registro de compose'
ComposeRecoverCleanup: 'Limpiar espacio de restauracion de compose'
ErrFileNotExist: 'El archivo {{ .detail }} no existe, verifique la integridad'
ErrImageBuildErr: 'Fallo al construir la imagen'
ErrImageExist: 'La imagen ya existe Modifique el nombre de la imagen.'
+30
View File
@@ -209,6 +209,36 @@ BuildCache: 'ビルドキャッシュ'
Volume: 'ストレージボリューム'
Network: 'ネットワーク'
PruneStart: 'クリーンアップ中、しばらくお待ちください...'
ContainerBackupPrepare: 'コンテナバックアップ作業領域を準備'
ContainerBackupStop: 'バックアップ前にコンテナを停止'
ContainerBackupInspect: 'コンテナ inspect とネットワーク情報をバックアップ'
ContainerBackupMounts: 'コンテナのマウントデータをバックアップ'
ContainerBackupMeta: 'コンテナバックアップのメタデータを書き込み'
ContainerBackupStart: 'バックアップ後にコンテナを起動'
ContainerRecoverPrepare: 'コンテナ復元作業領域を準備'
ContainerRecoverExtract: 'コンテナバックアップを展開'
ContainerRecoverParse: 'コンテナバックアップメタデータを読み込み'
ContainerRecoverCreate: 'コンテナを再作成'
ContainerRecoverMounts: 'コンテナマウントを復元'
ContainerRecoverStart: '復元済みコンテナを起動'
ContainerRecoverCleanup: 'コンテナ復元作業領域をクリーンアップ'
ComposeBackupPrepare: 'Compose バックアップ作業領域を準備'
ComposeBackupStop: 'バックアップ前に Compose を停止'
ComposeBackupFiles: 'Compose ファイルをバックアップ'
ComposeBackupContainers: 'Compose コンテナをバックアップ'
ComposeBackupMeta: 'Compose バックアップのメタデータを書き込み'
ComposeBackupStart: 'バックアップ後に Compose を起動'
ComposeBackupCleanup: 'Compose バックアップ作業領域をクリーンアップ'
ComposeRecoverPrepare: 'Compose 復元作業領域を準備'
ComposeRecoverExtract: 'Compose バックアップを展開'
ComposeRecoverMeta: 'Compose バックアップメタデータを読み込み'
ComposeRecoverMetaLogName: 'Compose 復元メタ情報: 名前={{ .name }}'
ComposeRecoverMetaLogPath: 'Compose 復元メタ情報: バックアップパス={{ .backupPath }} 復元先={{ .targetDir }}'
ComposeRecoverMetaLogCount: 'Compose 復元メタ情報: ファイル={{ .files }} コンテナ={{ .containers }}'
ComposeRecoverFiles: 'Compose ファイルを復元'
ComposeRecoverContainers: 'Compose コンテナを復元'
ComposeRecoverRecord: 'Compose 記録を保存'
ComposeRecoverCleanup: 'Compose 復元作業領域をクリーンアップ'
ErrFileNotExist: '{{ .detail }} ファイルが存在しません。ソース ファイルの整合性を確認してください。'
ErrImageBuildErr: 'イメージのビルドに失敗しました'
ErrImageExist: 'イメージはすでに存在します!イメージ名を変更してください。'
+30
View File
@@ -209,6 +209,36 @@ BuildCache: '빌드 캐시'
Volume: '스토리지 볼륨'
Network: '네트워크'
PruneStart: '정리 중입니다. 잠시만 기다려주세요...'
ContainerBackupPrepare: '컨테이너 백업 작업 공간 준비'
ContainerBackupStop: '백업 전에 컨테이너 중지'
ContainerBackupInspect: '컨테이너 inspect 및 네트워크 정보 백업'
ContainerBackupMounts: '컨테이너 마운트 데이터 백업'
ContainerBackupMeta: '컨테이너 백업 메타데이터 작성'
ContainerBackupStart: '백업 후 컨테이너 시작'
ContainerRecoverPrepare: '컨테이너 복원 작업 공간 준비'
ContainerRecoverExtract: '컨테이너 백업 파일 압축 해제'
ContainerRecoverParse: '컨테이너 백업 메타데이터 로드'
ContainerRecoverCreate: '컨테이너 재생성'
ContainerRecoverMounts: '컨테이너 마운트 데이터 복원'
ContainerRecoverStart: '복원된 컨테이너 시작'
ContainerRecoverCleanup: '컨테이너 복원 작업 공간 정리'
ComposeBackupPrepare: 'Compose 백업 작업 공간 준비'
ComposeBackupStop: '백업 전에 Compose 중지'
ComposeBackupFiles: 'Compose 파일 백업'
ComposeBackupContainers: 'Compose 컨테이너 백업'
ComposeBackupMeta: 'Compose 백업 메타데이터 작성'
ComposeBackupStart: '백업 후 Compose 시작'
ComposeBackupCleanup: 'Compose 백업 작업 공간 정리'
ComposeRecoverPrepare: 'Compose 복원 작업 공간 준비'
ComposeRecoverExtract: 'Compose 백업 파일 압축 해제'
ComposeRecoverMeta: 'Compose 백업 메타데이터 로드'
ComposeRecoverMetaLogName: 'Compose 복원 메타: 이름={{ .name }}'
ComposeRecoverMetaLogPath: 'Compose 복원 메타: 백업경로={{ .backupPath }} 대상경로={{ .targetDir }}'
ComposeRecoverMetaLogCount: 'Compose 복원 메타: 파일={{ .files }} 컨테이너={{ .containers }}'
ComposeRecoverFiles: 'Compose 파일 복원'
ComposeRecoverContainers: 'Compose 컨테이너 복원'
ComposeRecoverRecord: 'Compose 기록 저장'
ComposeRecoverCleanup: 'Compose 복원 작업 공간 정리'
ErrFileNotExist: '{{ .detail }} 파일이 존재하지 않습니다 소스 파일의 무결성을 확인하세요'
ErrImageBuildErr: '이미지 빌드 실패'
ErrImageExist: '이미지가 이미 존재합니다 이미지 이름을 수정하세요.'
+30
View File
@@ -209,6 +209,36 @@ BuildCache: 'Cache binaan'
Volume: 'Jilid storan'
Network: 'Rangkaian'
PruneStart: 'Pembersihan sedang dijalankan, sila tunggu...'
ContainerBackupPrepare: 'Sediakan ruang kerja sandaran kontena'
ContainerBackupStop: 'Hentikan kontena sebelum sandaran'
ContainerBackupInspect: 'Sandarkan inspect dan rangkaian kontena'
ContainerBackupMounts: 'Sandarkan mount kontena'
ContainerBackupMeta: 'Tulis metadata sandaran kontena'
ContainerBackupStart: 'Mulakan kontena selepas sandaran'
ContainerRecoverPrepare: 'Sediakan ruang kerja pemulihan kontena'
ContainerRecoverExtract: 'Ekstrak arkib sandaran kontena'
ContainerRecoverParse: 'Muat metadata sandaran kontena'
ContainerRecoverCreate: 'Cipta semula kontena'
ContainerRecoverMounts: 'Pulihkan mount kontena'
ContainerRecoverStart: 'Mulakan kontena dipulihkan'
ContainerRecoverCleanup: 'Bersihkan ruang kerja pemulihan kontena'
ComposeBackupPrepare: 'Sediakan ruang kerja sandaran compose'
ComposeBackupStop: 'Hentikan compose sebelum sandaran'
ComposeBackupFiles: 'Sandarkan fail compose'
ComposeBackupContainers: 'Sandarkan kontena compose'
ComposeBackupMeta: 'Tulis metadata sandaran compose'
ComposeBackupStart: 'Mulakan compose selepas sandaran'
ComposeBackupCleanup: 'Bersihkan ruang kerja sandaran compose'
ComposeRecoverPrepare: 'Sediakan ruang kerja pemulihan compose'
ComposeRecoverExtract: 'Ekstrak arkib sandaran compose'
ComposeRecoverMeta: 'Muat metadata sandaran compose'
ComposeRecoverMetaLogName: 'Metadata pemulihan compose: nama={{ .name }}'
ComposeRecoverMetaLogPath: 'Metadata pemulihan compose: laluanSandaran={{ .backupPath }} laluanSasaran={{ .targetDir }}'
ComposeRecoverMetaLogCount: 'Metadata pemulihan compose: fail={{ .files }} kontena={{ .containers }}'
ComposeRecoverFiles: 'Pulihkan fail compose'
ComposeRecoverContainers: 'Pulihkan kontena compose'
ComposeRecoverRecord: 'Simpan rekod compose'
ComposeRecoverCleanup: 'Bersihkan ruang kerja pemulihan compose'
ErrFileNotExist: 'Fail {{ .detail }} tidak wujud Sila semak integriti fail sumber'
ErrImageBuildErr: 'Pembinaan imej gagal'
ErrImageExist: 'Imej sudah wujud Sila ubah nama imej.'
+30
View File
@@ -209,6 +209,36 @@ BuildCache: 'Cache de construção'
Volume: 'Volume de armazenamento'
Network: 'Rede'
PruneStart: 'Limpando em andamento aguarde...'
ContainerBackupPrepare: 'Preparar área de backup do contêiner'
ContainerBackupStop: 'Parar contêiner antes do backup'
ContainerBackupInspect: 'Fazer backup do inspect e rede do contêiner'
ContainerBackupMounts: 'Fazer backup dos mounts do contêiner'
ContainerBackupMeta: 'Escrever metadados do backup do contêiner'
ContainerBackupStart: 'Iniciar contêiner após o backup'
ContainerRecoverPrepare: 'Preparar área de restauração do contêiner'
ContainerRecoverExtract: 'Extrair arquivo de backup do contêiner'
ContainerRecoverParse: 'Carregar metadados do backup do contêiner'
ContainerRecoverCreate: 'Recriar contêiner'
ContainerRecoverMounts: 'Restaurar mounts do contêiner'
ContainerRecoverStart: 'Iniciar contêiner restaurado'
ContainerRecoverCleanup: 'Limpar área de restauração do contêiner'
ComposeBackupPrepare: 'Preparar área de backup do compose'
ComposeBackupStop: 'Parar compose antes do backup'
ComposeBackupFiles: 'Fazer backup dos arquivos do compose'
ComposeBackupContainers: 'Fazer backup dos contêineres do compose'
ComposeBackupMeta: 'Escrever metadados do backup do compose'
ComposeBackupStart: 'Iniciar compose após o backup'
ComposeBackupCleanup: 'Limpar área de backup do compose'
ComposeRecoverPrepare: 'Preparar área de restauração do compose'
ComposeRecoverExtract: 'Extrair arquivo de backup do compose'
ComposeRecoverMeta: 'Carregar metadados do backup do compose'
ComposeRecoverMetaLogName: 'Metadados da restauracao do compose: nome={{ .name }}'
ComposeRecoverMetaLogPath: 'Metadados da restauracao do compose: caminhoBackup={{ .backupPath }} caminhoDestino={{ .targetDir }}'
ComposeRecoverMetaLogCount: 'Metadados da restauracao do compose: arquivos={{ .files }} containers={{ .containers }}'
ComposeRecoverFiles: 'Restaurar arquivos do compose'
ComposeRecoverContainers: 'Restaurar contêineres do compose'
ComposeRecoverRecord: 'Salvar registro do compose'
ComposeRecoverCleanup: 'Limpar área de restauração do compose'
ErrFileNotExist: 'O arquivo {{ .detail }} não existe Verifique a integridade do arquivo de origem'
ErrImageBuildErr: 'Falha na criação da imagem'
ErrImageExist: 'A imagem já existe, modifique o nome da imagem.'
+30
View File
@@ -209,6 +209,36 @@ BuildCache: 'Кэш сборки'
Volume: 'Том хранилища'
Network: 'Сеть'
PruneStart: 'Очистка выполняется, пожалуйста, подождите...'
ContainerBackupPrepare: 'Подготовить рабочую область резервной копии контейнера'
ContainerBackupStop: 'Остановить контейнер перед резервным копированием'
ContainerBackupInspect: 'Сделать резервную копию inspect и сети контейнера'
ContainerBackupMounts: 'Сделать резервную копию точек монтирования контейнера'
ContainerBackupMeta: 'Записать метаданные резервной копии контейнера'
ContainerBackupStart: 'Запустить контейнер после резервного копирования'
ContainerRecoverPrepare: 'Подготовить рабочую область восстановления контейнера'
ContainerRecoverExtract: 'Распаковать архив резервной копии контейнера'
ContainerRecoverParse: 'Загрузить метаданные резервной копии контейнера'
ContainerRecoverCreate: 'Пересоздать контейнер'
ContainerRecoverMounts: 'Восстановить точки монтирования контейнера'
ContainerRecoverStart: 'Запустить восстановленный контейнер'
ContainerRecoverCleanup: 'Очистить рабочую область восстановления контейнера'
ComposeBackupPrepare: 'Подготовить рабочую область резервной копии compose'
ComposeBackupStop: 'Остановить compose перед резервным копированием'
ComposeBackupFiles: 'Сделать резервную копию файлов compose'
ComposeBackupContainers: 'Сделать резервную копию контейнеров compose'
ComposeBackupMeta: 'Записать метаданные резервной копии compose'
ComposeBackupStart: 'Запустить compose после резервного копирования'
ComposeBackupCleanup: 'Очистить рабочую область резервного копирования compose'
ComposeRecoverPrepare: 'Подготовить рабочую область восстановления compose'
ComposeRecoverExtract: 'Распаковать архив резервной копии compose'
ComposeRecoverMeta: 'Загрузить метаданные резервной копии compose'
ComposeRecoverMetaLogName: 'Метаданные восстановления compose: имя={{ .name }}'
ComposeRecoverMetaLogPath: 'Метаданные восстановления compose: путьБэкапа={{ .backupPath }} путьНазначения={{ .targetDir }}'
ComposeRecoverMetaLogCount: 'Метаданные восстановления compose: файлов={{ .files }} контейнеров={{ .containers }}'
ComposeRecoverFiles: 'Восстановить файлы compose'
ComposeRecoverContainers: 'Восстановить контейнеры compose'
ComposeRecoverRecord: 'Сохранить запись compose'
ComposeRecoverCleanup: 'Очистить рабочую область восстановления compose'
ErrFileNotExist: 'Файл {{ .detail }} не существует Проверьте целостность исходного файла'
ErrImageBuildErr: 'Сборка образа не удалась'
ErrImageExist: 'Изображение уже существует Пожалуйста, измените имя изображения.'
+30
View File
@@ -209,6 +209,36 @@ BuildCache: 'Derleme önbelleği'
Volume: 'Depolama hacmi'
Network: 'Ağ'
PruneStart: 'Temizlik devam ediyor, lütfen bekleyin...'
ContainerBackupPrepare: 'Konteyner yedekleme çalışma alanını hazırla'
ContainerBackupStop: 'Yedeklemeden önce konteyneri durdur'
ContainerBackupInspect: 'Konteyner inspect ve ağ bilgisini yedekle'
ContainerBackupMounts: 'Konteyner mount verilerini yedekle'
ContainerBackupMeta: 'Konteyner yedekleme meta verisini yaz'
ContainerBackupStart: 'Yedeklemeden sonra konteyneri başlat'
ContainerRecoverPrepare: 'Konteyner geri yükleme çalışma alanını hazırla'
ContainerRecoverExtract: 'Konteyner yedek arşivini çıkar'
ContainerRecoverParse: 'Konteyner yedek meta verisini yükle'
ContainerRecoverCreate: 'Konteyneri yeniden oluştur'
ContainerRecoverMounts: 'Konteyner mount verilerini geri yükle'
ContainerRecoverStart: 'Geri yüklenen konteyneri başlat'
ContainerRecoverCleanup: 'Konteyner geri yükleme çalışma alanını temizle'
ComposeBackupPrepare: 'Compose yedekleme çalışma alanını hazırla'
ComposeBackupStop: 'Yedeklemeden önce compose’u durdur'
ComposeBackupFiles: 'Compose dosyalarını yedekle'
ComposeBackupContainers: 'Compose konteynerlerini yedekle'
ComposeBackupMeta: 'Compose yedek meta verisini yaz'
ComposeBackupStart: 'Yedeklemeden sonra compose’u başlat'
ComposeBackupCleanup: 'Compose yedekleme çalışma alanını temizle'
ComposeRecoverPrepare: 'Compose geri yükleme çalışma alanını hazırla'
ComposeRecoverExtract: 'Compose yedek arşivini çıkar'
ComposeRecoverMeta: 'Compose yedek meta verisini yükle'
ComposeRecoverMetaLogName: 'Compose geri yükleme meta: ad={{ .name }}'
ComposeRecoverMetaLogPath: 'Compose geri yükleme meta: yedekYolu={{ .backupPath }} hedefYol={{ .targetDir }}'
ComposeRecoverMetaLogCount: 'Compose geri yükleme meta: dosya={{ .files }} konteyner={{ .containers }}'
ComposeRecoverFiles: 'Compose dosyalarını geri yükle'
ComposeRecoverContainers: 'Compose konteynerlerini geri yükle'
ComposeRecoverRecord: 'Compose kaydını sakla'
ComposeRecoverCleanup: 'Compose geri yükleme çalışma alanını temizle'
ErrFileNotExist: '{{ .detail }} dosyası mevcut değil Lütfen kaynak dosyanın bütünlüğünü kontrol edin'
ErrImageBuildErr: 'Image yapı başarısız'
ErrImageExist: 'Görüntü zaten var Lütfen görüntü adını değiştirin.'
+30
View File
@@ -209,6 +209,36 @@ BuildCache: '構建快取'
Volume: '磁碟區'
Network: '網路'
PruneStart: '清理中,請稍候...'
ContainerBackupPrepare: '準備容器備份工作目錄'
ContainerBackupStop: '備份前停止容器'
ContainerBackupInspect: '備份容器 Inspect 與網路資訊'
ContainerBackupMounts: '備份容器掛載資料'
ContainerBackupMeta: '寫入容器備份中繼資訊'
ContainerBackupStart: '備份後啟動容器'
ContainerRecoverPrepare: '準備容器還原工作目錄'
ContainerRecoverExtract: '解壓容器備份檔案'
ContainerRecoverParse: '載入容器備份中繼資訊'
ContainerRecoverCreate: '重建容器'
ContainerRecoverMounts: '還原容器掛載資料'
ContainerRecoverStart: '啟動已還原容器'
ContainerRecoverCleanup: '清理容器還原工作目錄'
ComposeBackupPrepare: '準備編排備份工作目錄'
ComposeBackupStop: '備份前停止編排'
ComposeBackupFiles: '備份編排檔案'
ComposeBackupContainers: '備份編排容器'
ComposeBackupMeta: '寫入編排備份中繼資訊'
ComposeBackupStart: '備份後啟動編排'
ComposeBackupCleanup: '清理編排備份工作目錄'
ComposeRecoverPrepare: '準備編排還原工作目錄'
ComposeRecoverExtract: '解壓編排備份檔案'
ComposeRecoverMeta: '載入編排備份中繼資訊'
ComposeRecoverMetaLogName: '編排還原中繼資訊:名稱={{ .name }}'
ComposeRecoverMetaLogPath: '編排還原中繼資訊:備份路徑={{ .backupPath }} 目標路徑={{ .targetDir }}'
ComposeRecoverMetaLogCount: '編排還原中繼資訊:檔案數={{ .files }} 容器數={{ .containers }}'
ComposeRecoverFiles: '還原編排檔案'
ComposeRecoverContainers: '還原編排容器'
ComposeRecoverRecord: '保存編排記錄'
ComposeRecoverCleanup: '清理編排還原工作目錄'
ErrFileNotExist: '{{ .detail }} 檔案不存在!請檢查來源檔案完整性!'
ErrImageBuildErr: '映像build 失敗'
ErrImageExist: '映像已存在!請修改映像名稱。'
+30
View File
@@ -232,6 +232,36 @@ BuildCache: "构建缓存"
Volume: "存储卷"
Network: "网络"
PruneStart: "清理中,请稍候..."
ContainerBackupPrepare: "准备容器备份工作目录"
ContainerBackupStop: "备份前停止容器"
ContainerBackupInspect: "备份容器 Inspect 与网络信息"
ContainerBackupMounts: "备份容器挂载数据"
ContainerBackupMeta: "写入容器备份元信息"
ContainerBackupStart: "备份后启动容器"
ContainerRecoverPrepare: "准备容器恢复工作目录"
ContainerRecoverExtract: "解压容器备份文件"
ContainerRecoverParse: "加载容器备份元信息"
ContainerRecoverCreate: "重建容器"
ContainerRecoverMounts: "恢复容器挂载数据"
ContainerRecoverStart: "启动已恢复容器"
ContainerRecoverCleanup: "清理容器恢复工作目录"
ComposeBackupPrepare: "准备编排备份工作目录"
ComposeBackupStop: "备份前停止编排"
ComposeBackupFiles: "备份编排文件"
ComposeBackupContainers: "备份编排容器"
ComposeBackupMeta: "写入编排备份元信息"
ComposeBackupStart: "备份后启动编排"
ComposeBackupCleanup: "清理编排备份工作目录"
ComposeRecoverPrepare: "准备编排恢复工作目录"
ComposeRecoverExtract: "解压编排备份文件"
ComposeRecoverMeta: "加载编排备份元信息"
ComposeRecoverMetaLogName: "编排恢复元信息:名称={{ .name }}"
ComposeRecoverMetaLogPath: "编排恢复元信息:备份路径={{ .backupPath }} 目标路径={{ .targetDir }}"
ComposeRecoverMetaLogCount: "编排恢复元信息:文件数={{ .files }} 容器数={{ .containers }}"
ComposeRecoverFiles: "恢复编排文件"
ComposeRecoverContainers: "恢复编排容器"
ComposeRecoverRecord: "保存编排记录"
ComposeRecoverCleanup: "清理编排恢复工作目录"
#runtime
ErrFileNotExist: "{{ .detail }} 文件不存在!请检查源文件完整性!"
+1
View File
@@ -43,6 +43,7 @@ func InitAgentDB() {
migrations.InitLocalSSHShow,
migrations.InitRecordStatus,
migrations.AddShowNameForQuickJump,
migrations.AddAgentQuickJump,
migrations.AddTimeoutForClam,
migrations.UpdateCronjobSpec,
migrations.UpdateWebsiteSSLAddColumn,
+61
View File
@@ -589,6 +589,67 @@ var AddShowNameForQuickJump = &gormigrate.Migration{
},
}
var AddAgentQuickJump = &gormigrate.Migration{
ID: "20260312-add-agent-quick-jump",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.QuickJump{}); err != nil {
return err
}
var quicks []model.QuickJump
if err := tx.Find(&quicks).Error; err != nil {
return err
}
var (
cronjob *model.QuickJump
database *model.QuickJump
showList []*model.QuickJump
)
for i := range quicks {
switch quicks[i].Name {
case "Cronjob":
cronjob = &quicks[i]
case "Database":
database = &quicks[i]
}
if quicks[i].IsShow {
showList = append(showList, &quicks[i])
}
}
showCount := len(showList)
updatedIDs := make(map[uint]struct{})
kickedCronjob := false
if showCount >= 4 && cronjob != nil && cronjob.IsShow {
cronjob.IsShow = false
updatedIDs[cronjob.ID] = struct{}{}
kickedCronjob = true
}
if !kickedCronjob && showCount >= 4 && database != nil && database.IsShow {
database.IsShow = false
updatedIDs[database.ID] = struct{}{}
}
for _, item := range quicks {
if _, ok := updatedIDs[item.ID]; !ok {
continue
}
if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Update("is_show", item.IsShow).Error; err != nil {
return err
}
}
return tx.Create(&model.QuickJump{
Name: "Agent",
Title: "aiTools.agents.agents",
Recommend: 1,
IsShow: true,
Router: "/ai/agents/agent",
}).Error
},
}
var AddTimeoutForClam = &gormigrate.Migration{
ID: "20250922-add-timeout-for-clam",
Migrate: func(tx *gorm.DB) error {
+3 -1
View File
@@ -75,7 +75,9 @@ func OperationResolveMeta() gin.HandlerFunc {
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
bodyMap, _ = parseMultipart(body, c.Request.Header.Get("Content-Type"))
} else {
_ = json.Unmarshal(body, &bodyMap)
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
_ = decoder.Decode(&bodyMap)
}
for _, key := range meta.BodyKeys {
if value, ok := bodyMap[key]; ok {
+4 -3
View File
@@ -53,17 +53,18 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/agents/accounts/delete", baseApi.DeleteAgentAccount)
aiToolsRouter.POST("/agents/channel/feishu/get", baseApi.GetAgentFeishuConfig)
aiToolsRouter.POST("/agents/channel/feishu/update", baseApi.UpdateAgentFeishuConfig)
aiToolsRouter.POST("/agents/channel/feishu/approve", baseApi.ApproveAgentFeishuPairing)
aiToolsRouter.POST("/agents/channel/telegram/get", baseApi.GetAgentTelegramConfig)
aiToolsRouter.POST("/agents/channel/telegram/update", baseApi.UpdateAgentTelegramConfig)
aiToolsRouter.POST("/agents/channel/discord/get", baseApi.GetAgentDiscordConfig)
aiToolsRouter.POST("/agents/channel/discord/update", baseApi.UpdateAgentDiscordConfig)
aiToolsRouter.POST("/agents/channel/wecom/get", baseApi.GetAgentWecomConfig)
aiToolsRouter.POST("/agents/channel/wecom/update", baseApi.UpdateAgentWecomConfig)
aiToolsRouter.POST("/agents/channel/qqbot/get", baseApi.GetAgentQQBotConfig)
aiToolsRouter.POST("/agents/channel/qqbot/update", baseApi.UpdateAgentQQBotConfig)
aiToolsRouter.POST("/agents/plugin/install", baseApi.InstallAgentPlugin)
aiToolsRouter.POST("/agents/plugin/check", baseApi.CheckAgentPlugin)
aiToolsRouter.POST("/agents/browser/get", baseApi.GetAgentBrowserConfig)
aiToolsRouter.POST("/agents/browser/update", baseApi.UpdateAgentBrowserConfig)
aiToolsRouter.POST("/agents/security/get", baseApi.GetAgentSecurityConfig)
aiToolsRouter.POST("/agents/security/update", baseApi.UpdateAgentSecurityConfig)
aiToolsRouter.POST("/agents/other/get", baseApi.GetAgentOtherConfig)
aiToolsRouter.POST("/agents/other/update", baseApi.UpdateAgentOtherConfig)
aiToolsRouter.POST("/agents/channel/pairing/approve", baseApi.ApproveAgentChannelPairing)
+6
View File
@@ -35,6 +35,12 @@ func (s *ContainerRouter) InitRouter(Router *gin.RouterGroup) {
baRouter.POST("/prune", baseApi.ContainerPrune)
baRouter.POST("/users", baseApi.LoadContainerUsers)
baRouter.POST("/files/search", baseApi.ListContainerFiles)
baRouter.POST("/files/upload", baseApi.UploadContainerFile)
baRouter.POST("/files/content", baseApi.GetContainerFileContent)
baRouter.POST("/files/size", baseApi.GetContainerFileSize)
baRouter.POST("/files/del", baseApi.DeleteContainerFile)
baRouter.POST("/files/download", baseApi.DownloadContainerFile)
baRouter.GET("/repo", baseApi.ListRepo)
baRouter.POST("/repo/status", baseApi.CheckRepoStatus)
@@ -34,10 +34,6 @@ type authorizer struct {
defAuth Authenticator
}
type preemptiveAuthorizer struct {
auth Authenticator
}
type authShim struct {
factory AuthFactory
body io.Reader
+1 -1
View File
@@ -150,7 +150,7 @@ func LoadServiceName(keyword string) (string, error) {
}
processedName := loadProcessedName(client.Name(), keyword)
exist, err := client.IsExist(processedName)
exist, _ := client.IsExist(processedName)
if exist {
return processedName, nil
}
+99 -2
View File
@@ -12,11 +12,13 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/docker/cli/cli/config"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/client"
)
@@ -301,7 +303,11 @@ func (c Client) BuildImageWithProcessAndOptions(task *task.Task, tar io.ReadClos
}
func (c Client) PullImageWithProcess(task *task.Task, imageName string) error {
return c.PullImageWithProcessAndOptions(task, imageName, image.PullOptions{})
options := image.PullOptions{}
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok {
options.RegistryAuth = authStr
}
return c.PullImageWithProcessAndOptions(task, imageName, options)
}
func logProcess(progress map[string]interface{}, task *task.Task) {
@@ -319,8 +325,99 @@ func PullImage(imageName string) error {
return err
}
defer cli.Close()
if _, err := cli.ImagePull(context.Background(), imageName, image.PullOptions{}); err != nil {
options := image.PullOptions{}
if authStr, ok := loadRegistryAuthFromDockerConfig(imageName); ok {
options.RegistryAuth = authStr
}
if _, err := cli.ImagePull(context.Background(), imageName, options); err != nil {
return err
}
return nil
}
func loadRegistryAuthFromDockerConfig(imageName string) (string, bool) {
registryHost, hasRegistry := extractRegistryHost(imageName)
cfg := config.LoadDefaultConfigFile(io.Discard)
if cfg == nil {
return "", false
}
candidates := make([]string, 0)
if hasRegistry {
candidates = append(candidates, registryHost, "https://"+registryHost, "http://"+registryHost)
}
if !hasRegistry || isDockerHubRegistry(registryHost) {
candidates = append(candidates,
"https://index.docker.io/v1/",
"index.docker.io",
"docker.io",
"registry-1.docker.io",
"https://registry-1.docker.io",
)
}
seen := make(map[string]struct{})
for _, key := range candidates {
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
auth, err := cfg.GetAuthConfig(key)
if err != nil {
continue
}
if auth.Username == "" && auth.Password == "" && auth.Auth == "" && auth.IdentityToken == "" && auth.RegistryToken == "" {
continue
}
authStr, err := registry.EncodeAuthConfig(registry.AuthConfig{
Username: auth.Username,
Password: auth.Password,
Auth: auth.Auth,
Email: auth.Email,
ServerAddress: auth.ServerAddress,
IdentityToken: auth.IdentityToken,
RegistryToken: auth.RegistryToken,
})
if err != nil {
return "", false
}
return authStr, true
}
return "", false
}
func isDockerHubRegistry(host string) bool {
switch normalizeRegistryHost(host) {
case "docker.io", "index.docker.io", "registry-1.docker.io":
return true
default:
return false
}
}
func extractRegistryHost(imageName string) (string, bool) {
parts := strings.Split(imageName, "/")
if len(parts) < 2 {
return "", false
}
first := parts[0]
if strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost" {
return normalizeRegistryHost(first), true
}
return "", false
}
func normalizeRegistryHost(registryKey string) string {
key := strings.TrimSpace(registryKey)
if key == "" {
return ""
}
key = strings.TrimPrefix(key, "http://")
key = strings.TrimPrefix(key, "https://")
key = strings.Trim(key, "/")
if strings.Contains(key, "/") {
key = strings.SplitN(key, "/", 2)[0]
}
return strings.ToLower(key)
}
+1 -1
View File
@@ -464,7 +464,7 @@ func (f FileOp) Cut(oldPaths []string, dst, name string, cover bool) error {
quotedPaths = append(quotedPaths, fmt.Sprintf("'%s'", p))
}
mvCommand := fmt.Sprintf("mv %s %s '%s'", coverFlag, strings.Join(quotedPaths, " "), dstPath)
if err := cmd.RunDefaultBashCf(mvCommand); err != nil {
if err := cmd.RunDefaultBashC(mvCommand); err != nil {
return err
}
return nil
-1
View File
@@ -127,7 +127,6 @@ func (c *CPUUsageState) GetCPUUsage() (float64, []float64, []float64) {
}
c.cachedTotalUsage = totalUsage
c.cachedPerCore = c.cachedPerCore
c.cachedDetailedPercent = detailedPercent
c.lastTotalStat = &curTotal
c.lastDetailStat = &curDetail
-5
View File
@@ -30,11 +30,6 @@ import (
var Orders = make(map[uint]*acme.Order)
type domainError struct {
Domain string
Error error
}
type zeroSSLRes struct {
Success bool `json:"success"`
EabKid string `json:"eab_kid"`
+4
View File
@@ -39,6 +39,10 @@ func IsUseCustomApp() bool {
return false
}
func IsXpack() bool {
return false
}
func CreateTaskScanSMSAlertLog(alert dto.AlertDTO, alertType string, create dto.AlertLogCreate, pushAlert dto.PushAlert, method string) error {
return nil
}
+1
View File
@@ -19,6 +19,7 @@ type SettingInfo struct {
Theme string `json:"theme"`
MenuTabs string `json:"menuTabs"`
Language string `json:"language"`
DocSource string `json:"docSource"`
ServerPort string `json:"serverPort"`
SSL string `json:"ssl"`
+12 -3
View File
@@ -286,10 +286,19 @@ type noteDetailHelper struct {
}
func (u *UpgradeService) LoadRelease() ([]dto.ReleasesNotes, error) {
docSource, _ := settingRepo.GetValueByKey("DocSource")
lang, _ := settingRepo.GetValueByKey("Language")
var notes []dto.ReleasesNotes
url := "https://docs.1panel.pro/v2/search/search_index.json"
if global.CONF.Base.Edition != "intl" {
url = "https://1panel.cn/docs/v2/search/search_index.json"
url := "https://1panel.cn/docs/v2/search/search_index.json"
useIntlDocs := false
lang = strings.ToLower(strings.TrimSpace(lang))
if docSource == "withByRegion" {
useIntlDocs = global.CONF.Base.Edition == "intl"
} else {
useIntlDocs = lang != "zh"
}
if useIntlDocs {
url = "https://docs.1panel.pro/v2/search/search_index.json"
}
resp, err := req_helper.HandleGet(url)
if err != nil {
+543
View File
@@ -523,6 +523,79 @@ const docTemplate = `{
]
}
},
"/ai/agents/channel/qqbot/get": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.AgentQQBotConfigReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.AgentQQBotConfig"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Get Agent QQ Bot channel config",
"tags": [
"AI"
]
}
},
"/ai/agents/channel/qqbot/update": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.AgentQQBotConfigUpdateReq"
}
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Update Agent QQ Bot channel config",
"tags": [
"AI"
]
}
},
"/ai/agents/channel/telegram/get": {
"post": {
"consumes": [
@@ -739,6 +812,79 @@ const docTemplate = `{
]
}
},
"/ai/agents/plugin/check": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.AgentPluginCheckReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.AgentPluginStatus"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Check Agent plugin installation status",
"tags": [
"AI"
]
}
},
"/ai/agents/plugin/install": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.AgentPluginInstallReq"
}
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Install Agent plugin",
"tags": [
"AI"
]
}
},
"/ai/agents/providers": {
"get": {
"responses": {
@@ -4095,6 +4241,225 @@ const docTemplate = `{
]
}
},
"/containers/files/content": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerFileReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.ContainerFileContent"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Get container file content",
"tags": [
"Container"
]
}
},
"/containers/files/del": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerFileBatchDeleteReq"
}
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Delete container file",
"tags": [
"Container"
]
}
},
"/containers/files/download": {
"post": {
"consumes": [
"application/json"
],
"responses": {},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Download container file",
"tags": [
"Container"
]
}
},
"/containers/files/search": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerFileReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"items": {
"$ref": "#/definitions/dto.ContainerFileInfo"
},
"type": "array"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "List container files",
"tags": [
"Container"
]
}
},
"/containers/files/size": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerFileReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "int"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Get container file size",
"tags": [
"Container"
]
}
},
"/containers/files/upload": {
"post": {
"consumes": [
"multipart/form-data"
],
"parameters": [
{
"description": "containerID",
"in": "formData",
"name": "containerID",
"required": true,
"type": "string"
},
{
"description": "path",
"in": "formData",
"name": "path",
"required": true,
"type": "string"
},
{
"description": "file",
"in": "formData",
"name": "file",
"required": true,
"type": "file"
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Upload container file",
"tags": [
"Container"
]
}
},
"/containers/image": {
"get": {
"produces": [
@@ -23746,6 +24111,104 @@ const docTemplate = `{
],
"type": "object"
},
"dto.AgentPluginCheckReq": {
"properties": {
"agentId": {
"type": "integer"
},
"type": {
"enum": [
"qqbot"
],
"type": "string"
}
},
"required": [
"agentId",
"type"
],
"type": "object"
},
"dto.AgentPluginInstallReq": {
"properties": {
"agentId": {
"type": "integer"
},
"taskID": {
"type": "string"
},
"type": {
"enum": [
"qqbot"
],
"type": "string"
}
},
"required": [
"agentId",
"taskID",
"type"
],
"type": "object"
},
"dto.AgentPluginStatus": {
"properties": {
"installed": {
"type": "boolean"
}
},
"type": "object"
},
"dto.AgentQQBotConfig": {
"properties": {
"appId": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"installed": {
"type": "boolean"
}
},
"type": "object"
},
"dto.AgentQQBotConfigReq": {
"properties": {
"agentId": {
"type": "integer"
}
},
"required": [
"agentId"
],
"type": "object"
},
"dto.AgentQQBotConfigUpdateReq": {
"properties": {
"agentId": {
"type": "integer"
},
"appId": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"enabled": {
"type": "boolean"
}
},
"required": [
"agentId",
"appId",
"clientSecret"
],
"type": "object"
},
"dto.AgentTelegramConfig": {
"properties": {
"botToken": {
@@ -25035,6 +25498,86 @@ const docTemplate = `{
],
"type": "object"
},
"dto.ContainerFileBatchDeleteReq": {
"properties": {
"containerID": {
"type": "string"
},
"paths": {
"items": {
"type": "string"
},
"minItems": 1,
"type": "array"
}
},
"required": [
"containerID",
"paths"
],
"type": "object"
},
"dto.ContainerFileContent": {
"properties": {
"content": {
"type": "string"
},
"isBinary": {
"type": "boolean"
},
"size": {
"type": "integer"
},
"truncated": {
"type": "boolean"
}
},
"type": "object"
},
"dto.ContainerFileInfo": {
"properties": {
"isDir": {
"type": "boolean"
},
"isLink": {
"type": "boolean"
},
"linkTo": {
"type": "string"
},
"modTime": {
"type": "string"
},
"mode": {
"type": "string"
},
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"size": {
"type": "integer"
}
},
"type": "object"
},
"dto.ContainerFileReq": {
"properties": {
"containerID": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"containerID",
"path"
],
"type": "object"
},
"dto.ContainerItemStats": {
"properties": {
"buildCacheReclaimable": {
+543
View File
@@ -519,6 +519,79 @@
]
}
},
"/ai/agents/channel/qqbot/get": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.AgentQQBotConfigReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.AgentQQBotConfig"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Get Agent QQ Bot channel config",
"tags": [
"AI"
]
}
},
"/ai/agents/channel/qqbot/update": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.AgentQQBotConfigUpdateReq"
}
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Update Agent QQ Bot channel config",
"tags": [
"AI"
]
}
},
"/ai/agents/channel/telegram/get": {
"post": {
"consumes": [
@@ -735,6 +808,79 @@
]
}
},
"/ai/agents/plugin/check": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.AgentPluginCheckReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.AgentPluginStatus"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Check Agent plugin installation status",
"tags": [
"AI"
]
}
},
"/ai/agents/plugin/install": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.AgentPluginInstallReq"
}
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Install Agent plugin",
"tags": [
"AI"
]
}
},
"/ai/agents/providers": {
"get": {
"responses": {
@@ -4091,6 +4237,225 @@
]
}
},
"/containers/files/content": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerFileReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.ContainerFileContent"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Get container file content",
"tags": [
"Container"
]
}
},
"/containers/files/del": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerFileBatchDeleteReq"
}
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Delete container file",
"tags": [
"Container"
]
}
},
"/containers/files/download": {
"post": {
"consumes": [
"application/json"
],
"responses": {},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Download container file",
"tags": [
"Container"
]
}
},
"/containers/files/search": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerFileReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"items": {
"$ref": "#/definitions/dto.ContainerFileInfo"
},
"type": "array"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "List container files",
"tags": [
"Container"
]
}
},
"/containers/files/size": {
"post": {
"consumes": [
"application/json"
],
"parameters": [
{
"description": "request",
"in": "body",
"name": "request",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ContainerFileReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "int"
}
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Get container file size",
"tags": [
"Container"
]
}
},
"/containers/files/upload": {
"post": {
"consumes": [
"multipart/form-data"
],
"parameters": [
{
"description": "containerID",
"in": "formData",
"name": "containerID",
"required": true,
"type": "string"
},
{
"description": "path",
"in": "formData",
"name": "path",
"required": true,
"type": "string"
},
{
"description": "file",
"in": "formData",
"name": "file",
"required": true,
"type": "file"
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"Timestamp": []
}
],
"summary": "Upload container file",
"tags": [
"Container"
]
}
},
"/containers/image": {
"get": {
"produces": [
@@ -23742,6 +24107,104 @@
],
"type": "object"
},
"dto.AgentPluginCheckReq": {
"properties": {
"agentId": {
"type": "integer"
},
"type": {
"enum": [
"qqbot"
],
"type": "string"
}
},
"required": [
"agentId",
"type"
],
"type": "object"
},
"dto.AgentPluginInstallReq": {
"properties": {
"agentId": {
"type": "integer"
},
"taskID": {
"type": "string"
},
"type": {
"enum": [
"qqbot"
],
"type": "string"
}
},
"required": [
"agentId",
"taskID",
"type"
],
"type": "object"
},
"dto.AgentPluginStatus": {
"properties": {
"installed": {
"type": "boolean"
}
},
"type": "object"
},
"dto.AgentQQBotConfig": {
"properties": {
"appId": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"installed": {
"type": "boolean"
}
},
"type": "object"
},
"dto.AgentQQBotConfigReq": {
"properties": {
"agentId": {
"type": "integer"
}
},
"required": [
"agentId"
],
"type": "object"
},
"dto.AgentQQBotConfigUpdateReq": {
"properties": {
"agentId": {
"type": "integer"
},
"appId": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"enabled": {
"type": "boolean"
}
},
"required": [
"agentId",
"appId",
"clientSecret"
],
"type": "object"
},
"dto.AgentTelegramConfig": {
"properties": {
"botToken": {
@@ -25031,6 +25494,86 @@
],
"type": "object"
},
"dto.ContainerFileBatchDeleteReq": {
"properties": {
"containerID": {
"type": "string"
},
"paths": {
"items": {
"type": "string"
},
"minItems": 1,
"type": "array"
}
},
"required": [
"containerID",
"paths"
],
"type": "object"
},
"dto.ContainerFileContent": {
"properties": {
"content": {
"type": "string"
},
"isBinary": {
"type": "boolean"
},
"size": {
"type": "integer"
},
"truncated": {
"type": "boolean"
}
},
"type": "object"
},
"dto.ContainerFileInfo": {
"properties": {
"isDir": {
"type": "boolean"
},
"isLink": {
"type": "boolean"
},
"linkTo": {
"type": "string"
},
"modTime": {
"type": "string"
},
"mode": {
"type": "string"
},
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"size": {
"type": "integer"
}
},
"type": "object"
},
"dto.ContainerFileReq": {
"properties": {
"containerID": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"containerID",
"path"
],
"type": "object"
},
"dto.ContainerItemStats": {
"properties": {
"buildCacheReclaimable": {
+30
View File
@@ -368,6 +368,36 @@
"formatZH": "docker 服务 [operation]",
"formatEN": "[operation] docker service"
},
"/containers/files/del": {
"bodyKeys": [
"containerID",
"paths"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "删除容器 [containerID] 文件 [paths]",
"formatEN": "Delete files [paths] in container [containerID]"
},
"/containers/files/download": {
"bodyKeys": [
"containerID",
"path"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "下载容器 [containerID] 文件 [path]",
"formatEN": "Download file [path] from container [containerID]"
},
"/containers/files/upload": {
"bodyKeys": [
"containerID",
"path"
],
"paramKeys": [],
"beforeFunctions": [],
"formatZH": "容器 [containerID] 上传文件到 [path]",
"formatEN": "Upload file to [path] in container [containerID]"
},
"/containers/image/build": {
"bodyKeys": [
"name"
+1
View File
@@ -34,6 +34,7 @@ func Init() {
migrations.AddDashboardCarouselSetting,
migrations.AddEditionSetting,
migrations.UpdateAiLocalModelMenuTitle,
migrations.AddDocSourceSetting,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)
+20
View File
@@ -70,6 +70,9 @@ var InitSetting = &gormigrate.Migration{
if err := tx.Create(&model.Setting{Key: "Language", Value: language}).Error; err != nil {
return err
}
if err := tx.Create(&model.Setting{Key: "DocSource", Value: "withByRegion"}).Error; err != nil {
return err
}
if err := tx.Create(&model.Setting{Key: "SessionTimeout", Value: "86400"}).Error; err != nil {
return err
}
@@ -835,6 +838,23 @@ var AddEditionSetting = &gormigrate.Migration{
},
}
var AddDocSourceSetting = &gormigrate.Migration{
ID: "20260316-add-doc-source-setting",
Migrate: func(tx *gorm.DB) error {
var setting model.Setting
if err := tx.Where("key = ?", "DocSource").First(&setting).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&model.Setting{Key: "DocSource", Value: "withByRegion"}).Error
}
return err
}
if setting.Value == "" {
return tx.Model(&model.Setting{}).Where("key = ?", "DocSource").Update("value", "withByRegion").Error
}
return nil
},
}
var UpdateAiLocalModelMenuTitle = &gormigrate.Migration{
ID: "20260307-update-ai-local-model-menu-title",
Migrate: func(tx *gorm.DB) error {
+33 -7
View File
@@ -78,7 +78,9 @@ func OperationLog() gin.HandlerFunc {
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
bodyMap, _ = parseMultipart(body, c.Request.Header.Get("Content-Type"))
} else {
_ = json.Unmarshal(body, &bodyMap)
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
_ = decoder.Decode(&bodyMap)
}
for _, key := range operationDic.BodyKeys {
if _, ok := bodyMap[key]; ok {
@@ -98,6 +100,7 @@ func OperationLog() gin.HandlerFunc {
writer := responseBodyWriter{
ResponseWriter: c.Writer,
body: &bytes.Buffer{},
captureBody: shouldCaptureResponseBody(c.Request.URL.Path),
}
c.Writer = &writer
now := time.Now()
@@ -141,12 +144,24 @@ func OperationLog() gin.HandlerFunc {
datas, _ = io.ReadAll(reader)
}
var res response
_ = json.Unmarshal(datas, &res)
if res.Code == 200 {
record.Status = constant.StatusSuccess
contentType := strings.ToLower(c.Writer.Header().Get("Content-Type"))
isJSONResponse := strings.Contains(contentType, "application/json")
if isJSONResponse {
_ = json.Unmarshal(datas, &res)
if res.Code == 200 {
record.Status = constant.StatusSuccess
} else {
record.Status = constant.StatusFailed
record.Message = res.Message
}
} else {
record.Status = constant.StatusFailed
record.Message = res.Message
statusCode := c.Writer.Status()
if statusCode >= 200 && statusCode < 400 {
record.Status = constant.StatusSuccess
} else {
record.Status = constant.StatusFailed
record.Message = http.StatusText(statusCode)
}
}
latency := time.Since(now)
@@ -209,6 +224,7 @@ type responseBodyWriter struct {
gin.ResponseWriter
body *bytes.Buffer
resolvedHeader string
captureBody bool
}
func (r *responseBodyWriter) sanitizeResolvedHeader() {
@@ -230,10 +246,20 @@ func (r *responseBodyWriter) WriteHeaderNow() {
func (r *responseBodyWriter) Write(b []byte) (int, error) {
r.sanitizeResolvedHeader()
r.body.Write(b)
if r.captureBody {
r.body.Write(b)
}
return r.ResponseWriter.Write(b)
}
func shouldCaptureResponseBody(reqPath string) bool {
reqPath = strings.ToLower(reqPath)
if strings.Contains(reqPath, "download") {
return false
}
return true
}
func loadLogInfo(path string) string {
path = replaceStr(path, "/api/v2", "/core", "/xpack")
if !strings.Contains(path, "/") {
+31 -15
View File
@@ -241,6 +241,7 @@ export namespace AI {
appVersion: string;
webUIPort: number;
bridgePort?: number;
allowedOrigins?: string[];
agentType: 'openclaw' | 'copaw';
provider?: string;
model?: string;
@@ -428,10 +429,30 @@ export namespace AI {
export interface AgentChannelPairingApproveReq {
agentId: number;
type: 'feishu' | 'telegram' | 'discord';
type: 'feishu' | 'telegram' | 'discord' | 'wecom';
pairingCode: string;
}
export interface AgentWecomConfigReq {
agentId: number;
}
export interface AgentWecomConfig {
enabled: boolean;
dmPolicy: 'pairing' | 'open';
botId: string;
secret: string;
installed: boolean;
}
export interface AgentWecomConfigUpdateReq {
agentId: number;
enabled: boolean;
dmPolicy: 'pairing' | 'open';
botId: string;
secret: string;
}
export interface AgentQQBotConfigReq {
agentId: number;
}
@@ -452,13 +473,13 @@ export namespace AI {
export interface AgentPluginInstallReq {
agentId: number;
type: 'qqbot';
type: 'qqbot' | 'wecom';
taskID: string;
}
export interface AgentPluginCheckReq {
agentId: number;
type: 'qqbot';
type: 'qqbot' | 'wecom';
}
export interface AgentPluginStatus {
@@ -486,24 +507,17 @@ export namespace AI {
proxy: string;
}
export interface AgentBrowserConfigReq {
export interface AgentSecurityConfigReq {
agentId: number;
}
export interface AgentBrowserConfig {
enabled: boolean;
executablePath: string;
headless: boolean;
noSandbox: boolean;
defaultProfile: string;
export interface AgentSecurityConfig {
allowedOrigins: string[];
}
export interface AgentBrowserConfigUpdateReq {
export interface AgentSecurityConfigUpdateReq {
agentId: number;
enabled: boolean;
headless: boolean;
noSandbox: boolean;
defaultProfile: string;
allowedOrigins: string[];
}
export interface AgentOtherConfigReq {
@@ -512,10 +526,12 @@ export namespace AI {
export interface AgentOtherConfig {
userTimezone: string;
browserEnabled: boolean;
}
export interface AgentOtherConfigUpdateReq {
agentId: number;
userTimezone: string;
browserEnabled: boolean;
}
}
+1
View File
@@ -93,6 +93,7 @@ export namespace Backup {
detailName: string;
secret: string;
taskID: string;
stopBefore?: boolean;
}
export interface Recover {
downloadAccountID: number;
+20
View File
@@ -56,6 +56,26 @@ export namespace Container {
name: string;
state: string;
}
export interface ContainerFileReq {
containerID: string;
path: string;
}
export interface ContainerFileInfo {
name: string;
path: string;
isDir: boolean;
isLink: boolean;
linkTo: string;
size: number;
mode: string;
modTime: string;
}
export interface ContainerFileContent {
content: string;
size: number;
truncated: boolean;
isBinary: boolean;
}
export interface ResourceLimit {
cpu: number;
memory: number;
+1
View File
@@ -21,6 +21,7 @@ export namespace Setting {
theme: string;
menuTabs: string;
language: string;
docSource: string;
defaultIO: string;
defaultNetwork: string;
lastCleanTime: string;
+12 -8
View File
@@ -145,10 +145,6 @@ export const updateAgentFeishuConfig = (req: AI.AgentFeishuConfigUpdateReq) => {
return http.post(`/ai/agents/channel/feishu/update`, req);
};
export const approveAgentFeishuPairing = (req: AI.AgentFeishuPairingApproveReq) => {
return http.post(`/ai/agents/channel/feishu/approve`, req);
};
export const getAgentTelegramConfig = (req: AI.AgentTelegramConfigReq) => {
return http.post<AI.AgentTelegramConfig>(`/ai/agents/channel/telegram/get`, req);
};
@@ -165,6 +161,14 @@ export const updateAgentDiscordConfig = (req: AI.AgentDiscordConfigUpdateReq) =>
return http.post(`/ai/agents/channel/discord/update`, req);
};
export const getAgentWecomConfig = (req: AI.AgentWecomConfigReq) => {
return http.post<AI.AgentWecomConfig>(`/ai/agents/channel/wecom/get`, req);
};
export const updateAgentWecomConfig = (req: AI.AgentWecomConfigUpdateReq) => {
return http.post(`/ai/agents/channel/wecom/update`, req);
};
export const getAgentQQBotConfig = (req: AI.AgentQQBotConfigReq) => {
return http.post<AI.AgentQQBotConfig>(`/ai/agents/channel/qqbot/get`, req);
};
@@ -181,12 +185,12 @@ export const checkAgentPlugin = (req: AI.AgentPluginCheckReq) => {
return http.post<AI.AgentPluginStatus>(`/ai/agents/plugin/check`, req);
};
export const getAgentBrowserConfig = (req: AI.AgentBrowserConfigReq) => {
return http.post<AI.AgentBrowserConfig>(`/ai/agents/browser/get`, req);
export const getAgentSecurityConfig = (req: AI.AgentSecurityConfigReq) => {
return http.post<AI.AgentSecurityConfig>(`/ai/agents/security/get`, req);
};
export const updateAgentBrowserConfig = (req: AI.AgentBrowserConfigUpdateReq) => {
return http.post(`/ai/agents/browser/update`, req);
export const updateAgentSecurityConfig = (req: AI.AgentSecurityConfigUpdateReq) => {
return http.post(`/ai/agents/security/update`, req);
};
export const getAgentOtherConfig = (req: AI.AgentOtherConfigReq) => {
+6 -4
View File
@@ -52,8 +52,9 @@ export const handleRecover = (params: Backup.Recover, node?: string) => {
const query = node ? `?operateNode=${node}` : '';
return http.post(`/backups/recover${query}`, params, TimeoutEnum.T_10M);
};
export const handleRecoverByUpload = (params: Backup.Recover) => {
return http.post(`/backups/recover/byupload`, params, TimeoutEnum.T_10M);
export const handleRecoverByUpload = (params: Backup.Recover, node?: string) => {
const query = node ? `?operateNode=${node}` : '';
return http.post(`/backups/recover/byupload${query}`, params, TimeoutEnum.T_10M);
};
export const downloadBackupRecord = (params: Backup.RecordDownload, node?: string) => {
const query = node ? `?operateNode=${node}` : '';
@@ -67,8 +68,9 @@ export const updateRecordDescription = (id: Number, description: String, node?:
const query = node ? `?operateNode=${node}` : '';
return http.post(`/backups/record/description/update${query}`, { id: id, description: description });
};
export const uploadByRecover = (filePath: string, targetDir: String) => {
return http.post(`/backups/upload`, { filePath: filePath, targetDir: targetDir });
export const uploadByRecover = (filePath: string, targetDir: String, node?: string) => {
const query = node ? `?operateNode=${node}` : '';
return http.post(`/backups/upload${query}`, { filePath: filePath, targetDir: targetDir });
};
export const searchBackupRecords = (params: Backup.SearchBackupRecord, node?: string) => {
const query = node ? `?operateNode=${node}` : '';
+24
View File
@@ -15,6 +15,30 @@ export const listContainerByImage = (image: string) => {
export const loadContainerUsers = (name: string) => {
return http.post<Array<string>>(`/containers/users`, { name: name });
};
export const listContainerFiles = (params: Container.ContainerFileReq) => {
return http.post<Array<Container.ContainerFileInfo>>(`/containers/files/search`, params, TimeoutEnum.T_40S);
};
export const uploadContainerFile = (params: FormData) => {
return http.upload(`/containers/files/upload`, params, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: TimeoutEnum.T_5M,
});
};
export const getContainerFileContent = (params: Container.ContainerFileReq) => {
return http.post<Container.ContainerFileContent>(`/containers/files/content`, params, TimeoutEnum.T_40S);
};
export const getContainerFileSize = (params: Container.ContainerFileReq) => {
return http.post<number>(`/containers/files/size`, params, TimeoutEnum.T_40S);
};
export const deleteContainerFile = (params: { containerID: string; paths: string[] }) => {
return http.post(`/containers/files/del`, params, TimeoutEnum.T_40S);
};
export const downloadContainerFile = (params: { containerID: string; path: string }) => {
return http.download<BlobPart>(`/containers/files/download`, params, {
responseType: 'blob',
timeout: TimeoutEnum.T_40S,
});
};
export const loadContainerStatus = () => {
return http.get<Container.ContainerStatus>(`/containers/status`);
};
+3 -2
View File
@@ -60,8 +60,9 @@ export const listAppNodes = () => {
};
// agent
export const loadBaseDir = () => {
return http.get<string>(`/settings/basedir`);
export const loadBaseDir = (node?: string) => {
const query = node ? `?operateNode=${node}` : '';
return http.get<string>(`/settings/basedir${query}`);
};
export const loadDaemonJsonPath = () => {
return http.get<string>(`/settings/daemonjson`, {});
@@ -0,0 +1,6 @@
<svg viewBox="0 0 175.35 235.42" xmlns="http://www.w3.org/2000/svg">
<path fill="#ff9d4d" d="M150.41,91.07c-7.76-7.58-16.99-13.51-27.67-17.84-10.69-4.33-22.28-6.5-34.76-6.5s-24.39,2.21-35.07,6.64c-10.69,4.43-19.95,10.48-27.81,18.13-7.86,7.68-14.01,16.58-18.44,26.77-4.44,10.16-6.66,21.11-6.66,32.8v.59c0,11.71,2.21,22.63,6.66,32.82,4.43,10.16,10.53,19.04,18.29,26.6,7.75,7.55,16.98,13.51,27.65,17.84,10.69,4.33,22.28,6.5,34.78,6.5s24.38-2.21,35.06-6.64c10.69-4.43,19.95-10.48,27.81-18.13,7.86-7.68,14.01-16.58,18.45-26.77,4.43-10.16,6.64-21.11,6.64-32.8v-.62c0-11.69-2.21-22.61-6.64-32.8-4.44-10.19-10.54-19.04-18.29-26.6ZM130,151.67c0,6.05-.96,11.76-2.88,17.1s-4.69,10.01-8.3,14.05c-3.63,4.03-8.02,7.21-13.15,9.52-5.14,2.31-11.05,3.47-17.69,3.47s-12.25-1.21-17.38-3.62c-5.14-2.41-9.63-5.68-13.46-9.82s-6.75-8.88-8.77-14.22c-2.02-5.34-3.01-11.02-3.01-17.07v-.62c0-6.03.95-11.74,2.87-17.07,1.92-5.34,4.69-10.01,8.32-14.05s8.01-7.21,13.15-9.52c5.14-2.34,11.03-3.49,17.69-3.49,6.24,0,11.98,1.21,17.22,3.64,5.24,2.41,9.78,5.68,13.61,9.82,3.83,4.13,6.75,8.88,8.77,14.22,2.02,5.34,3.03,11.02,3.03,17.07v.59Z" />
<path fill="#ff9d4d" d="M71.01,38.34c2.06,2.01,4.51,3.59,7.35,4.74,2.84,1.15,5.92,1.73,9.24,1.73s6.48-.59,9.31-1.76c2.84-1.18,5.3-2.78,7.39-4.82,2.09-2.04,3.72-4.41,4.9-7.11,1.18-2.7,1.76-5.61,1.76-8.71v-.16c0-3.1-.59-6.01-1.76-8.71-1.18-2.71-2.8-5.06-4.86-7.07-2.06-2.01-4.51-3.59-7.35-4.74-2.84-1.15-5.92-1.73-9.24-1.73s-6.48.59-9.32,1.76c-2.84,1.18-5.3,2.78-7.39,4.82-2.09,2.04-3.72,4.4-4.9,7.11-1.18,2.7-1.77,5.61-1.77,8.71v.16c0,3.11.59,6.01,1.77,8.72,1.18,2.7,2.8,5.06,4.86,7.06Z" />
<path fill="#ff9d4d" d="M14.35,54.93c2.06,2.01,4.51,3.59,7.35,4.74,2.84,1.15,5.92,1.73,9.24,1.73s6.48-.59,9.31-1.77c2.84-1.18,5.3-2.78,7.39-4.82,2.09-2.04,3.72-4.4,4.9-7.11,1.18-2.7,1.77-5.61,1.77-8.71v-.16c0-3.1-.59-6-1.77-8.71-1.18-2.71-2.8-5.06-4.86-7.06-2.06-2.01-4.51-3.59-7.35-4.74-2.84-1.15-5.92-1.73-9.24-1.73s-6.48.59-9.32,1.77c-2.84,1.18-5.3,2.78-7.39,4.82-2.09,2.04-3.72,4.41-4.9,7.11-1.18,2.7-1.77,5.61-1.77,8.71v.16c0,3.11.59,6.01,1.77,8.72,1.18,2.7,2.8,5.06,4.86,7.07Z" />
<path fill="#ff9d4d" d="M127.67,54.93c2.06,2.01,4.51,3.59,7.35,4.74,2.84,1.15,5.92,1.73,9.24,1.73s6.48-.59,9.31-1.77c2.84-1.18,5.3-2.78,7.39-4.82,2.09-2.04,3.72-4.4,4.9-7.11,1.18-2.7,1.76-5.61,1.76-8.71v-.16c0-3.1-.59-6-1.76-8.71-1.18-2.71-2.8-5.06-4.86-7.06-2.06-2.01-4.51-3.59-7.35-4.74-2.84-1.15-5.92-1.73-9.23-1.73s-6.48.59-9.32,1.77c-2.84,1.18-5.3,2.78-7.39,4.82-2.09,2.04-3.72,4.41-4.9,7.11-1.18,2.7-1.77,5.61-1.77,8.71v.16c0,3.11.59,6.01,1.77,8.72,1.18,2.7,2.8,5.06,4.86,7.07Z" />
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,17 @@
<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="openclaw-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff4d4d" />
<stop offset="100%" stop-color="#991b1b" />
</linearGradient>
</defs>
<path d="M60 10C30 10 15 35 15 55C15 75 30 95 45 100L45 110L55 110L55 100C55 100 60 102 65 100L65 110L75 110L75 100C90 95 105 75 105 55C105 35 90 10 60 10Z" fill="url(#openclaw-gradient)" />
<path d="M20 45C5 40 0 50 5 60C10 70 20 65 25 55C28 48 25 45 20 45Z" fill="url(#openclaw-gradient)" />
<path d="M100 45C115 40 120 50 115 60C110 70 100 65 95 55C92 48 95 45 100 45Z" fill="url(#openclaw-gradient)" />
<path d="M45 15Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
<path d="M75 15Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
<circle cx="45" cy="35" r="6" fill="#050810" />
<circle cx="75" cy="35" r="6" fill="#050810" />
<circle cx="46" cy="34" r="2.5" fill="#00e5cc" />
<circle cx="76" cy="34" r="2.5" fill="#00e5cc" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+21 -1
View File
@@ -27,7 +27,11 @@
style="width: 100%"
>
<template #toolbar>
<el-button type="primary" :disabled="status && status != 'Running'" @click="onBackup()">
<el-button
type="primary"
:disabled="status && status.toLowerCase() != 'running'"
@click="onBackup()"
>
{{ $t('commons.button.backup') }}
</el-button>
<el-button type="primary" plain :disabled="selects.length === 0" @click="onBatchDelete(null)">
@@ -106,6 +110,16 @@
{{ $t('commons.msg.' + (isBackup ? 'backupHelper' : 'recoverHelper'), [name + '( ' + detailName + ' )']) }}
</el-alert>
<el-form class="mt-5" ref="backupForm" @submit.prevent label-position="top" v-loading="loading">
<el-form-item v-if="isBackup && (type === 'container' || type === 'compose')">
<el-checkbox v-model="stopBefore">
{{
type === 'container'
? $t('container.stopContainerBeforeBackup')
: $t('container.stopComposeBeforeBackup')
}}
</el-checkbox>
<span class="input-help">{{ $t('container.stopBeforeBackupHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('setting.compressPassword')">
<el-input v-model="secret" :placeholder="$t('setting.backupRecoverMessage')" />
</el-form-item>
@@ -179,6 +193,8 @@ import { useGlobalStore } from '@/composables/useGlobalStore';
import { mysqlArgs } from '@/views/cronjob/cronjob/helper';
const { currentNode } = useGlobalStore();
const emit = defineEmits(['close']);
const PushApp = defineAsyncComponent(async () => {
const modules = import.meta.glob('@/xpack/views/appstore/push-app/index.vue');
const loader = modules['/src/xpack/views/appstore/push-app/index.vue'];
@@ -213,6 +229,7 @@ const description = ref();
const timeoutItem = ref(30);
const timeoutUnit = ref('m');
const node = ref();
const stopBefore = ref(false);
const open = ref();
const isBackup = ref();
@@ -243,6 +260,7 @@ const acceptParams = (params: DialogProps): void => {
};
const handleClose = () => {
backupVisible.value = false;
emit('close');
};
const handleBackupClose = () => {
open.value = false;
@@ -324,6 +342,7 @@ const backup = async () => {
taskID: taskID,
description: description.value,
args: args.value,
stopBefore: stopBefore.value,
};
loading.value = true;
await handleBackup(params, node.value)
@@ -366,6 +385,7 @@ const onBackup = async () => {
description.value = '';
secret.value = '';
args.value = [];
stopBefore.value = false;
isBackup.value = true;
open.value = true;
};
@@ -132,7 +132,6 @@ const handleBack = () => {
} else {
localOpenPage.value = false;
globalStore.isFullScreen = false;
emit('close');
}
};
emit('beforeClose', done);
@@ -142,7 +141,6 @@ const handleBack = () => {
} else {
localOpenPage.value = false;
globalStore.isFullScreen = false;
emit('close');
}
}
};
@@ -33,6 +33,7 @@ interface DialogProps {
protocol: string;
path?: string;
query?: string;
hash?: string;
}
const acceptParams = async (params: DialogProps): Promise<void> => {
@@ -58,6 +59,9 @@ const acceptParams = async (params: DialogProps): Promise<void> => {
if (params.query) {
url += params.query.startsWith('?') ? params.query : `?${params.query}`;
}
if (params.hash) {
url += params.hash.startsWith('#') ? params.hash : `#${params.hash}`;
}
return url;
};
if (res.data.systemIP.indexOf(':') === -1) {
+46 -8
View File
@@ -17,8 +17,12 @@
{{ $t('database.formatHelper', [remark]) }}
</li>
<li v-if="isDb()">{{ $t('database.supportUpType') }}</li>
<li v-if="!isDb()">{{ $t('website.websiteBackupWarn') }}</li>
<li v-if="!isDb()">{{ $t('website.supportUpType', [type]) }}</li>
<li v-if="type === 'website' || type === 'app'">
{{ $t('website.websiteBackupWarn') }}
</li>
<li v-if="type === 'container'">{{ $t('container.importContainerBackupTip') }}</li>
<li v-else-if="type === 'compose'">{{ $t('container.importComposeBackupTip') }}</li>
<li v-else-if="!isDb()">{{ $t('website.supportUpType', [type]) }}</li>
</ul>
</template>
</el-alert>
@@ -86,7 +90,7 @@
<DialogPro
v-model="recoverDialog"
:title="$t('commons.button.recover') + ' - ' + name"
:title="name ? $t('commons.button.recover') + ' - ' + name : $t('commons.button.recover')"
@close="handleRecoverClose"
size="small"
>
@@ -125,7 +129,7 @@
<OpDialog ref="opRef" @search="search" />
<FileList ref="fileRef" @choose="loadFile" />
<TaskLog ref="taskLogRef" @close="search" />
<TaskLog ref="taskLogRef" @close="handleTaskLogClose" />
</div>
</template>
@@ -141,11 +145,14 @@ import { MsgError, MsgSuccess } from '@/utils/message';
import { handleRecoverByUpload, uploadByRecover } from '@/api/modules/backup';
import TaskLog from '@/components/log/task/index.vue';
const emit = defineEmits(['close']);
interface DialogProps {
type: string;
name: string;
detailName: string;
remark: string;
node?: string;
}
const loading = ref();
const fileRef = ref();
@@ -171,6 +178,7 @@ const secret = ref();
const timeoutItem = ref(30);
const timeoutUnit = ref('m');
const taskLogRef = ref();
const node = ref();
const recoverDialog = ref();
@@ -179,8 +187,9 @@ const acceptParams = async (params: DialogProps): Promise<void> => {
name.value = params.name;
detailName.value = params.detailName;
remark.value = params.remark;
node.value = params.node;
const pathRes = await loadBaseDir();
const pathRes = await loadBaseDir(node.value);
switch (type.value) {
case 'mysql':
case 'mariadb':
@@ -201,6 +210,23 @@ const acceptParams = async (params: DialogProps): Promise<void> => {
case 'app':
title.value = name.value;
baseDir.value = `${pathRes.data}/uploads/app/${type.value}/${name.value}/`;
break;
case 'container':
title.value = name.value || i18n.global.t('menu.container');
if (name.value) {
baseDir.value = `${pathRes.data}/uploads/container/${name.value}/`;
} else {
baseDir.value = `${pathRes.data}/uploads/container/import/`;
}
break;
case 'compose':
title.value = name.value || i18n.global.t('container.compose');
if (name.value) {
baseDir.value = `${pathRes.data}/uploads/compose/${name.value}/`;
} else {
baseDir.value = `${pathRes.data}/uploads/compose/import/`;
}
break;
}
uploadOpen.value = true;
search();
@@ -236,6 +262,14 @@ const beforeUpload = (fileName: string) => {
const allowedExtensions = ['.tar.gz'];
const isValidFile = allowedExtensions.some((ext) => itemName.endsWith(ext));
if (!isValidFile) {
if (type.value === 'compose') {
MsgError(i18n.global.t('container.importComposeBackupTip'));
return false;
}
if (type.value === 'container') {
MsgError(i18n.global.t('container.importContainerBackupTip'));
return false;
}
MsgError(i18n.global.t('website.supportUpType'));
return false;
}
@@ -255,7 +289,7 @@ const loadFile = async (path: string) => {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
}).then(async () => {
uploadByRecover(path, baseDir.value)
uploadByRecover(path, baseDir.value, node.value)
.then(() => {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
search();
@@ -267,7 +301,7 @@ const loadFile = async (path: string) => {
};
const openTaskLog = (taskID: string) => {
taskLogRef.value.openWithTaskID(taskID);
taskLogRef.value.openWithTaskID(taskID, true, node.value);
};
const onHandleRecover = async () => {
@@ -282,7 +316,7 @@ const onHandleRecover = async () => {
timeout: timeoutItem.value === -1 ? -1 : transferTimeToSecond(timeoutItem.value + timeoutUnit.value),
};
loading.value = true;
await handleRecoverByUpload(params)
await handleRecoverByUpload(params, node.value)
.then(() => {
loading.value = false;
handleUploadClose();
@@ -343,6 +377,10 @@ const handleUploadClose = () => {
uploaderFiles.value = [];
uploadRef.value!.clearFiles();
uploadOpen.value = false;
emit('close');
};
const handleTaskLogClose = () => {
emit('close');
};
const handleRecoverClose = () => {
+33 -8
View File
@@ -679,26 +679,29 @@ const message = {
copawType: 'CoPaw',
appVersion: 'App Version',
webuiPort: 'WebUI Port',
bridgePort: 'Bridge Port',
allowedOrigins: 'Access Addresses',
allowedOriginsHelper:
'Enter one full HTTPS access address per line, for example https://192.168.1.2:18789. Fill it manually if the default access address is not configured.',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: 'Enter at least one access address',
allowedOriginsInvalid: 'Use the format http(s)://host-or-ip:port',
allowedOriginsHttpsOnly: 'Access addresses must start with https://',
provider: 'Provider',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
manualModel: 'Manual input',
verified: 'Verified',
verifySkipped: 'Skipped',
verifySkipped: 'No verification',
configTitle: 'Configuration',
settingsTab: 'Settings',
browserTab: 'Browser',
securityTab: 'Security',
otherTab: 'Other',
timeZone: 'Time Zone',
browserEnabled: 'Browser Enabled',
headless: 'Headless',
noSandbox: 'No Sandbox',
defaultProfile: 'Default Profile',
executablePath: 'Executable Path',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
wecom: 'WeCom',
feishu: 'Feishu',
pluginNotInstalled: 'Plugin is not installed. Please install it first.',
dmPolicy: 'DM Policy',
@@ -707,6 +710,7 @@ const message = {
policyOpen: 'Open',
policyDisabled: 'Disabled',
botName: 'Bot Name',
botId: 'Bot ID',
appId: 'App ID',
appSecret: 'App Secret',
saveAndRestartGateway: 'Save and restart gateway',
@@ -837,6 +841,12 @@ const message = {
},
container: {
createByCommand: 'Create by command',
importContainerBackupTip: 'Import container backup file, only .tar.gz is supported',
importComposeBackupTip: 'Import compose backup file, only .tar.gz is supported',
stopContainerBeforeBackup: 'Stop container before backup',
stopComposeBeforeBackup: 'Stop compose before backup',
stopBeforeBackupHelper:
'When enabled, the container or compose service is stopped before backup and automatically restored after completion to ensure data consistency.',
commandInput: 'Command input',
commandRule: 'Enter the correct docker run container creation command!',
commandHelper: 'This command will run on the server to create the container. Continue?',
@@ -943,11 +953,13 @@ const message = {
sameImageHelper: 'Containers using the same image can be batch upgraded after selection',
targetImage: 'Target image',
imageLoadErr: 'No image name detected for the container',
imageUpdateTagEmpty: 'No updatable image tags detected',
appHelper: 'The container comes from the app store, and upgrading may make the service unavailable.',
resource: 'Resource',
input: 'Manually input',
forcePull: 'Always pull image ',
forcePullHelper: 'This will ignore existing images on the server and pull the latest image from the registry.',
imageUpdateHelper: 'Check the same tag in registry, and pull to update local image only if changed.',
server: 'Host',
serverExample: '80, 80-88, ip:80 or ip:80-88',
containerExample: '80 or 80-88',
@@ -1799,6 +1811,10 @@ const message = {
light: 'Light',
auto: 'Follow System',
language: 'Language',
runtimeEnv: 'Runtime environment',
docSource: 'Document source',
withByRegion: 'Follow region (Default)',
withByLang: 'Follow system language',
region: 'Region',
cn: 'Mainland China',
intl: 'Global',
@@ -1807,6 +1823,8 @@ const message = {
regionHelper2: 'App Store and script library',
regionHelper3: 'User manual and related documents',
regionHelper4: 'This may affect future downloads and access. Proceed with caution.',
regionTip: 'Region affects the system update source and package download address.',
docSourceTip: 'Document source determines the language used for docs and release note links.',
languageHelper:
'By default, it follows the browser language. This parameter takes effect only on the current browser',
sessionTimeout: 'Session timeout',
@@ -1822,7 +1840,8 @@ const message = {
'Installation package download and synchronization from the app store (Professional edition only)',
proxyHelper2: 'System update and update information retrieval (Professional edition only)',
proxyHelper3: 'System license verification and synchronization',
proxyHelper4: 'Docker network will be accessed through a proxy server (Professional edition only)',
proxyHelper4:
'Network requests such as Docker image pulls will go through the proxy server (Professional edition only)',
proxyHelper5: 'Unified download and sync for system-type script libraries (Professional)',
proxyHelper6: 'Apply for certificate (Professional)',
proxyType: 'Proxy type',
@@ -2407,6 +2426,12 @@ const message = {
reload: 'Reload',
upgradeWarn:
'Upgrading the application will replace the docker-compose.yml file. If there are any changes, you can click to view the file comparison',
openclawHttpsUpgradeNoticeTitle:
'Note: The following instructions only apply to users upgrading OpenClaw from versions earlier than 2026.3.13:',
openclawHttpsUpgradeNoticeItem1:
'After the deployed agent is upgraded, go to Configuration -> Settings -> Security and manually add the access address.',
openclawHttpsUpgradeNoticeItem2:
'The new version now requires HTTPS to access the agent. If you previously used a reverse proxy website, change the proxy target to https://IP:Port.',
newVersion: 'New version',
oldVersion: 'Current version',
composeDiff: 'File comparison',
+36 -8
View File
@@ -687,26 +687,29 @@ const message = {
copawType: 'CoPaw',
appVersion: 'Versión de la app',
webuiPort: 'Puerto WebUI',
bridgePort: 'Puerto Bridge',
allowedOrigins: 'Direcciones de acceso',
allowedOriginsHelper:
'Introduce una dirección de acceso HTTPS completa por línea, por ejemplo https://192.168.1.2:18789. Si no hay una dirección predeterminada configurada, rellénala manualmente.',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: 'Introduce al menos una dirección de acceso',
allowedOriginsInvalid: 'Usa el formato http(s)://host-o-ip:puerto',
allowedOriginsHttpsOnly: 'Las direcciones de acceso deben comenzar con https://',
provider: 'Proveedor de modelos',
apiKey: 'Clave API',
baseUrl: 'URL base',
token: 'Token',
manualModel: 'Entrada manual de modelo',
verified: 'Verificado',
verifySkipped: 'Omitido',
verifySkipped: 'Sin verificacion',
configTitle: 'Configuration',
settingsTab: 'Settings',
browserTab: 'Browser',
securityTab: 'Security',
otherTab: 'Other',
timeZone: 'Zona horaria',
browserEnabled: 'Browser Enabled',
headless: 'Headless',
noSandbox: 'No Sandbox',
defaultProfile: 'Default Profile',
executablePath: 'Executable Path',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
wecom: 'WeCom',
feishu: 'Feishu',
pluginNotInstalled: 'El plugin no está instalado. Instálalo primero.',
dmPolicy: 'DM Policy',
@@ -715,6 +718,7 @@ const message = {
policyOpen: 'Open',
policyDisabled: 'Disabled',
botName: 'Bot Name',
botId: 'Bot ID',
appId: 'App ID',
appSecret: 'App Secret',
saveAndRestartGateway: 'Save and restart gateway',
@@ -849,6 +853,12 @@ const message = {
},
container: {
createByCommand: 'Crear por comando',
importContainerBackupTip: 'Importar archivo de copia de seguridad de contenedor, solo se admite .tar.gz',
importComposeBackupTip: 'Importar archivo de copia de seguridad de compose, solo se admite .tar.gz',
stopContainerBeforeBackup: 'Detener contenedor antes de la copia de seguridad',
stopComposeBeforeBackup: 'Detener compose antes de la copia de seguridad',
stopBeforeBackupHelper:
'Al habilitarlo, se detendrá el contenedor o servicio compose antes del respaldo y se restaurará automáticamente al finalizar para garantizar la consistencia de datos.',
commandInput: 'Introducir comando',
commandRule: 'Por favor introduzca el comando correcto para crear el contenedor con docker run.',
commandHelper: 'Este comando se ejecutará en el servidor para crear el contenedor. ¿Desea continuar?',
@@ -961,6 +971,7 @@ const message = {
'Los contenedores que usan la misma imagen pueden actualizarse en lote después de seleccionarlos',
targetImage: 'Imagen objetivo',
imageLoadErr: 'No se detectó un nombre de imagen para el contenedor',
imageUpdateTagEmpty: 'No se detectaron etiquetas de imagen actualizables',
appHelper:
'El contenedor proviene de la tienda de aplicaciones, y al actualizar podría hacer que el servicio no esté disponible.',
resource: 'Recurso',
@@ -968,6 +979,8 @@ const message = {
forcePull: 'Siempre descargar imagen',
forcePullHelper:
'Esto ignorará las imágenes existentes en el servidor y descargará la más reciente desde el repositorio.',
imageUpdateHelper:
'Comprobar la misma etiqueta en el registro y, si hay cambios, descargar y actualizar la imagen local.',
server: 'Servidor',
serverExample: '80, 80-88, ip:80 o ip:80-88',
containerExample: '80 o 80-88',
@@ -1829,6 +1842,10 @@ const message = {
light: 'Claro',
auto: 'Seguir sistema',
language: 'Idioma',
runtimeEnv: 'Entorno de ejecución',
docSource: 'Fuente de documentación',
withByRegion: 'Seguir región de operación (Predeterminado)',
withByLang: 'Seguir idioma del sistema',
region: 'Región de operación',
cn: 'China continental',
intl: 'Global',
@@ -1837,6 +1854,10 @@ const message = {
regionHelper2: 'Tienda de aplicaciones y biblioteca de scripts',
regionHelper3: 'Manual de usuario y documentación relacionada',
regionHelper4: 'Esto puede afectar futuras descargas y accesos. Proceda con precaución.',
regionTip:
'La región de operación afecta la fuente de actualización del sistema y la dirección de descarga de paquetes de instalación.',
docSourceTip:
'La fuente de documentación determina el idioma de los enlaces de documentación y registro de actualizaciones.',
languageHelper:
'Por defecto sigue el idioma del navegador. Este parámetro solo tiene efecto en el navegador actual',
sessionTimeout: 'Tiempo de espera de sesión',
@@ -1853,7 +1874,8 @@ const message = {
proxyHelper2:
'Actualización del sistema y recuperación de información de actualización (solo edición profesional)',
proxyHelper3: 'Verificación y sincronización de licencias del sistema',
proxyHelper4: 'La red de Docker se accederá a través de un servidor proxy (solo edición profesional)',
proxyHelper4:
'Las solicitudes de red, como la descarga de imágenes de Docker, se realizarán a través del servidor proxy (solo edición profesional)',
proxyHelper5: 'Descarga y sincronización unificada para bibliotecas de scripts del sistema (Profesional)',
proxyHelper6: 'Solicitud de certificado (Profesional)',
proxyType: 'Tipo de proxy',
@@ -2451,6 +2473,12 @@ const message = {
showLocal: 'Mostrar apps locales',
reload: 'Recargar',
upgradeWarn: 'Actualizar reemplazará docker-compose.yml. Si hay cambios, puede ver la comparación.',
openclawHttpsUpgradeNoticeTitle:
'Nota: Las siguientes instrucciones solo se aplican a los usuarios que actualizan OpenClaw desde versiones anteriores a 2026.3.13:',
openclawHttpsUpgradeNoticeItem1:
'Después de actualizar el agente desplegado, vaya a Configuración -> Ajustes -> Seguridad y agregue manualmente la dirección de acceso.',
openclawHttpsUpgradeNoticeItem2:
'La nueva versión exige HTTPS para acceder al agente. Si antes usaba un sitio con proxy inverso, cambie el destino del proxy a https://IP:Port.',
newVersion: 'Nueva versión',
oldVersion: 'Versión actual',
composeDiff: 'Comparación de archivos',
+32 -8
View File
@@ -680,26 +680,29 @@ const message = {
copawType: 'CoPaw',
appVersion: 'アプリバージョン',
webuiPort: 'WebUI ポート',
bridgePort: 'Bridge ポート',
allowedOrigins: 'アクセスアドレス',
allowedOriginsHelper:
'1 行に 1 つずつ完全な HTTPS アクセスアドレスを入力してください。例: https://192.168.1.2:18789。デフォルトのアクセスアドレスが未設定の場合は手動で入力してください。',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: '少なくとも 1 つのアクセスアドレスを入力してください',
allowedOriginsInvalid: 'http(s)://host-or-ip:port の形式で入力してください',
allowedOriginsHttpsOnly: 'アクセスアドレスは https:// で始めてください',
provider: 'モデルプロバイダー',
apiKey: 'API キー',
baseUrl: 'ベースURL',
token: 'トークン',
manualModel: '手動入力',
verified: '検証済み',
verifySkipped: 'スキップ済み',
verifySkipped: '検証なし',
configTitle: 'Configuration',
settingsTab: 'Settings',
browserTab: 'Browser',
securityTab: 'Security',
otherTab: 'Other',
timeZone: 'タイムゾーン',
browserEnabled: 'Browser Enabled',
headless: 'Headless',
noSandbox: 'No Sandbox',
defaultProfile: 'Default Profile',
executablePath: 'Executable Path',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
wecom: 'WeCom',
feishu: 'Feishu',
pluginNotInstalled: 'プラグインがインストールされていません。先にインストールしてください。',
dmPolicy: 'DM Policy',
@@ -708,6 +711,7 @@ const message = {
policyOpen: 'Open',
policyDisabled: 'Disabled',
botName: 'Bot Name',
botId: 'Bot ID',
appId: 'App ID',
appSecret: 'App Secret',
saveAndRestartGateway: 'Save and restart gateway',
@@ -838,6 +842,12 @@ const message = {
},
container: {
createByCommand: 'コマンドで作成',
importContainerBackupTip: 'コンテナバックアップファイルをインポートします。.tar.gz のみ対応です',
importComposeBackupTip: 'Compose バックアップファイルをインポートします。.tar.gz のみ対応です',
stopContainerBeforeBackup: 'バックアップ前にコンテナを停止',
stopComposeBeforeBackup: 'バックアップ前に Compose を停止',
stopBeforeBackupHelper:
'有効にすると、バックアップ前にコンテナまたは Compose サービスを停止し、完了後に自動で復元してデータ整合性を確保します。',
commandInput: 'コマンド入力',
commandRule: 'コンテナ作成用の正しい docker run コマンドを入力してください。',
commandHelper: 'このコマンドはサーバー上で実行され、コンテナを作成します。続行しますか?',
@@ -944,11 +954,13 @@ const message = {
sameImageContainer: '同一イメージコンテナ',
sameImageHelper: '同一イメージを使用するコンテナは選択後一括アップグレード可能',
imageLoadErr: 'コンテナの画像名は検出されません',
imageUpdateTagEmpty: '更新可能なイメージタグが検出されません',
appHelper:
'このコンテナはアプリストアから取得されたものであり、アップグレードによってサービスが利用不可になる可能性があります。',
input: '手動入力',
forcePull: '常に画像を引っ張ってください',
forcePullHelper: 'これにより、サーバー上の既存の画像が無視され、レジストリから最新の画像が引き出されます。',
imageUpdateHelper: 'レジストリの同名タグを確認し、更新があれば取得してローカルイメージを更新します。',
server: 'ホスト',
serverExample: '80、80-88、IP:80またはIP:80-88',
containerExample: '80または80-88',
@@ -1806,6 +1818,10 @@ const message = {
light: 'ライト',
auto: 'システムをフォローします',
language: '言語',
runtimeEnv: '実行環境',
docSource: 'ドキュメントソース',
withByRegion: '運用リージョンに従う(デフォルト)',
withByLang: 'システム言語に従う',
region: '運用リージョン',
cn: '中国本土',
intl: 'グローバル',
@@ -1814,6 +1830,8 @@ const message = {
regionHelper2: 'アプリストアおよびスクリプトライブラリ',
regionHelper3: 'ユーザーマニュアルおよび関連ドキュメント',
regionHelper4: '今後のダウンロードやアクセスに影響する可能性があります。ご注意ください。',
regionTip: '運用リージョンはシステム更新ソースとインストールパッケージのダウンロードアドレスに影響します。',
docSourceTip: 'ドキュメントソースはドキュメントと更新ログの遷移言語を決定します。',
languageHelper:
'デフォルトでは、ブラウザ言語に従います。このパラメーターは、現在のブラウザでのみ有効になります',
sessionTimeout: 'セッションタイムアウト',
@@ -1828,7 +1846,7 @@ const message = {
'アプリストアからのインストールパッケージのダウンロードと同期(プロフェッショナルエディションのみ)',
proxyHelper2: 'システムの更新と更新情報検索(プロフェッショナルエディションのみ)',
proxyHelper4:
'Dockerネットワークはプロキシサーバーを通じてアクセスされます(プロフェッショナルエディションのみ)',
'Docker イメージのプルなどのネットワークリクエストはプロキシサーバー経由でアクセスされます(プロフェッショナルエディションのみ)',
proxyHelper5: 'システムタイプスクリプトライブラリの統一下載と同期(プロフェッショナル版機能)',
proxyHelper6: '証明書を申請する(プロ版機能)',
proxyHelper3: 'システムライセンスの確認と同期',
@@ -2427,6 +2445,12 @@ const message = {
reload: 'リロード',
upgradeWarn:
'アプリケーションのアップグレードは、docker-compose.ymlファイルを置き換えます。変更がある場合は、クリックしてファイルの比較を表示できます',
openclawHttpsUpgradeNoticeTitle:
'注意: 以下の説明は、2026.3.13 より前のバージョンから OpenClaw をアップグレードするユーザーにのみ適用されます:',
openclawHttpsUpgradeNoticeItem1:
'デプロイ済みエージェントのアップグレード完了後、設定 -> 設定 -> セキュリティ に入り、アクセスアドレスを手動で追加してください。',
openclawHttpsUpgradeNoticeItem2:
'新バージョンではエージェントへのアクセスに HTTPS が必須です。以前リバースプロキシサイト経由でアクセスしていた場合は、プロキシ先を https://IP:Port に変更してください。',
newVersion: '新しいバージョン',
oldVersion: '現在のバージョン',
composeDiff: 'ファイルの比較',
+32 -8
View File
@@ -672,26 +672,29 @@ const message = {
copawType: 'CoPaw',
appVersion: '앱 버전',
webuiPort: 'WebUI 포트',
bridgePort: 'Bridge 포트',
allowedOrigins: '접속 주소',
allowedOriginsHelper:
'한 줄에 하나의 전체 HTTPS 접속 주소를 입력하세요. 예: https://192.168.1.2:18789. 기본 접속 주소가 설정되지 않은 경우 수동으로 입력하세요.',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: '접속 주소를 하나 이상 입력하세요',
allowedOriginsInvalid: 'http(s)://host-or-ip:port 형식으로 입력하세요',
allowedOriginsHttpsOnly: '접속 주소는 https:// 로 시작해야 합니다',
provider: '모델 제공자',
apiKey: 'API 키',
baseUrl: '기본 URL',
token: '토큰',
manualModel: '수동 입력',
verified: '검증됨',
verifySkipped: '건너뜀',
verifySkipped: '검증 안 함',
configTitle: 'Configuration',
settingsTab: 'Settings',
browserTab: 'Browser',
securityTab: 'Security',
otherTab: 'Other',
timeZone: '시간대',
browserEnabled: 'Browser Enabled',
headless: 'Headless',
noSandbox: 'No Sandbox',
defaultProfile: 'Default Profile',
executablePath: 'Executable Path',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
wecom: 'WeCom',
feishu: 'Feishu',
pluginNotInstalled: '플러그인이 설치되지 않았습니다. 먼저 설치해 주세요.',
dmPolicy: 'DM Policy',
@@ -700,6 +703,7 @@ const message = {
policyOpen: 'Open',
policyDisabled: 'Disabled',
botName: 'Bot Name',
botId: 'Bot ID',
appId: 'App ID',
appSecret: 'App Secret',
saveAndRestartGateway: 'Save and restart gateway',
@@ -827,6 +831,12 @@ const message = {
},
container: {
createByCommand: '명령으로 생성',
importContainerBackupTip: '컨테이너 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
importComposeBackupTip: 'Compose 백업 파일 가져오기, .tar.gz 형식만 지원합니다',
stopContainerBeforeBackup: '백업 전에 컨테이너 중지',
stopComposeBeforeBackup: '백업 전에 Compose 중지',
stopBeforeBackupHelper:
'활성화하면 백업 전에 컨테이너 또는 Compose 서비스를 중지하고, 완료 후 자동으로 복구하여 데이터 일관성을 보장합니다.',
commandInput: '명령 입력',
commandRule: '컨테이너 생성을 위한 올바른 docker run 명령을 입력하세요.',
commandHelper: '이 명령은 서버에서 실행되어 컨테이너를 생성합니다. 계속하시겠습니까?',
@@ -931,10 +941,12 @@ const message = {
sameImageHelper: '동일한 이미지를 사용하는 컨테이너는 선택 후 일괄 업그레이드 가능',
targetImage: '대상 이미지',
imageLoadErr: '컨테이너에 대한 이미지 이름이 감지되지 않았습니다.',
imageUpdateTagEmpty: '업데이트 가능한 이미지 태그를 찾지 못했습니다.',
appHelper: '이 컨테이너는 앱 스토어에서 왔으며 업그레이드 시 서비스가 중단될 수 있습니다.',
input: '수동 입력',
forcePull: '이미지 강제 풀',
forcePullHelper: '이 작업은 서버에 있는 기존 이미지를 무시하고 레지스트리에서 최신 이미지를 강제로 가져옵니다.',
imageUpdateHelper: '레지스트리의 동일 태그를 확인하고, 변경이 있으면 가져와 로컬 이미지를 업데이트합니다.',
server: '호스트',
serverExample: '80, 80-88, ip:80 또는 ip:80-88',
containerExample: '80 또는 80-88',
@@ -1774,6 +1786,10 @@ const message = {
light: '라이트',
auto: '시스템 따라가기',
language: '언어',
runtimeEnv: '실행 환경',
docSource: '문서 출처',
withByRegion: '운영 지역 따름(기본값)',
withByLang: '시스템 언어 따름',
region: '운영 지역',
cn: '중국 본토',
intl: '글로벌',
@@ -1782,6 +1798,8 @@ const message = {
regionHelper2: '앱 스토어 및 스크립트 라이브러리',
regionHelper3: '사용자 매뉴얼 및 관련 문서',
regionHelper4: '향후 다운로드 및 접근에 영향을 줄 수 있습니다. 주의하십시오.',
regionTip: '운영 지역은 시스템 업데이트 소스와 설치 패키지 다운로드 주소에 영향을 미칩니다.',
docSourceTip: '문서 출처는 문서와 업데이트 로그의 이동 언어를 결정합니다.',
languageHelper: '기본적으로 브라우저 언어를 따릅니다. 이 설정은 현재 브라우저에서만 적용됩니다.',
sessionTimeout: '세션 타임아웃',
sessionTimeoutError: '최소 세션 타임아웃은 300초입니다.',
@@ -1793,7 +1811,7 @@ const message = {
proxyHelper: '프록시 서버를 설정한 후 다음 시나리오에서 적용됩니다:',
proxyHelper1: '설치 패키지 다운로드 및 앱 스토어 동기화 (전문 버전에서만 제공)',
proxyHelper2: '시스템 업데이트 및 업데이트 정보 가져오기 (전문 버전에서만 제공)',
proxyHelper4: 'Docker 네트워크가 프록시 서버를 통해 액세스됩니다 (전문 버전에서만 제공)',
proxyHelper4: 'Docker 이미지 pull 등 네트워크 요청은 프록시 서버를 통해 이루어집니다 (전문 버전에서만 제공)',
proxyHelper3: '시스템 라이선스 인증 및 동기화',
proxyHelper5: '시스템 유형 스크립트 라이브러리의 통합 다운로드 및 동기화 (프로페셔널 에디션 기능)',
proxyHelper6: '인증서 신청 (프로 버전 기능)',
@@ -2370,6 +2388,12 @@ const message = {
reload: '새로고침',
upgradeWarn:
'애플리케이션 업그레이드는 docker-compose.yml 파일을 교체합니다. 변경 사항이 있으면 파일 비교를 클릭하여 확인할 수 있습니다.',
openclawHttpsUpgradeNoticeTitle:
'주의: 다음 안내는 2026.3.13 이전 버전에서 OpenClaw 를 업그레이드하는 사용자에게만 적용됩니다:',
openclawHttpsUpgradeNoticeItem1:
'배포된 에이전트 업그레이드가 완료되면 구성 -> 설정 -> 보안 페이지로 이동해 접근 주소를 수동으로 추가해야 합니다.',
openclawHttpsUpgradeNoticeItem2:
'새 버전은 에이전트 접근에 HTTPS 를 강제합니다. 이전에 리버스 프록시 사이트로 접근했다면 프록시 대상 주소를 https://IP:Port 로 변경하세요.',
newVersion: '새 버전',
oldVersion: '현재 버전',
composeDiff: '파일 비교',
+34 -8
View File
@@ -687,26 +687,29 @@ const message = {
copawType: 'CoPaw',
appVersion: 'Versi aplikasi',
webuiPort: 'Port WebUI',
bridgePort: 'Port Bridge',
allowedOrigins: 'Alamat akses',
allowedOriginsHelper:
'Masukkan satu alamat akses HTTPS penuh bagi setiap baris, contohnya https://192.168.1.2:18789. Isikan secara manual jika alamat akses lalai belum dikonfigurasi.',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: 'Masukkan sekurang-kurangnya satu alamat akses',
allowedOriginsInvalid: 'Gunakan format http(s)://hos-atau-ip:port',
allowedOriginsHttpsOnly: 'Alamat akses mesti bermula dengan https://',
provider: 'Penyedia model',
apiKey: 'Kunci API',
baseUrl: 'URL asas',
token: 'Token',
manualModel: 'Input manual',
verified: 'Disahkan',
verifySkipped: 'Dilangkau',
verifySkipped: 'Tanpa pengesahan',
configTitle: 'Configuration',
settingsTab: 'Settings',
browserTab: 'Browser',
securityTab: 'Security',
otherTab: 'Other',
timeZone: 'Zon Waktu',
browserEnabled: 'Browser Enabled',
headless: 'Headless',
noSandbox: 'No Sandbox',
defaultProfile: 'Default Profile',
executablePath: 'Executable Path',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
wecom: 'WeCom',
feishu: 'Feishu',
pluginNotInstalled: 'Plugin belum dipasang. Sila pasang dahulu.',
dmPolicy: 'DM Policy',
@@ -715,6 +718,7 @@ const message = {
policyOpen: 'Open',
policyDisabled: 'Disabled',
botName: 'Bot Name',
botId: 'Bot ID',
appId: 'App ID',
appSecret: 'App Secret',
saveAndRestartGateway: 'Save and restart gateway',
@@ -846,6 +850,12 @@ const message = {
},
container: {
createByCommand: 'Cipta melalui perintah',
importContainerBackupTip: 'Import fail sandaran kontena, hanya format .tar.gz disokong',
importComposeBackupTip: 'Import fail sandaran compose, hanya format .tar.gz disokong',
stopContainerBeforeBackup: 'Hentikan kontena sebelum sandaran',
stopComposeBeforeBackup: 'Hentikan compose sebelum sandaran',
stopBeforeBackupHelper:
'Apabila diaktifkan, kontena atau perkhidmatan compose akan dihentikan sebelum sandaran dan dipulihkan secara automatik selepas selesai untuk memastikan ketekalan data.',
commandInput: 'Input perintah',
commandRule: 'Sila masukkan arahan docker run yang sah untuk mencipta kontena.',
commandHelper: 'Arahan ini akan dijalankan pada pelayan untuk mencipta kontena. Teruskan?',
@@ -952,11 +962,14 @@ const message = {
sameImageHelper: 'Kontena yang menggunakan imej sama boleh dinaik taraf secara berkumpulan setelah dipilih',
targetImage: 'Imej sasaran',
imageLoadErr: 'Tiada nama imej dikesan untuk kontena',
imageUpdateTagEmpty: 'Tiada tag imej yang boleh dikemas kini dikesan',
appHelper:
'Kontena berasal dari gedung aplikasi, dan peningkatan boleh menyebabkan perkhidmatan tidak tersedia.',
input: 'Input manual',
forcePull: 'Tarik imej sentiasa ',
forcePullHelper: 'Ini akan mengabaikan imej sedia ada di pelayan dan menarik imej terkini dari pendaftaran.',
imageUpdateHelper:
'Semak tag yang sama di registry, jika ada kemas kini barulah tarik dan kemas kini imej tempatan.',
server: 'Hos',
serverExample: '80, 80-88, ip:80 atau ip:80-88',
containerExample: '80 atau 80-88',
@@ -1833,6 +1846,10 @@ const message = {
light: 'Terang',
auto: 'Ikut Sistem',
language: 'Bahasa',
runtimeEnv: 'Persekitaran operasi',
docSource: 'Sumber dokumentasi',
withByRegion: 'Ikut wilayah operasi (Lalai)',
withByLang: 'Ikut bahasa sistem',
region: 'Wilayah operasi',
cn: 'Tanah Besar China',
intl: 'Global',
@@ -1842,6 +1859,8 @@ const message = {
regionHelper3: 'Manual pengguna dan dokumen berkaitan',
regionHelper4:
'Tindakan ini mungkin menjejaskan muat turun dan akses seterusnya. Sila teruskan dengan berhati-hati.',
regionTip: 'Wilayah operasi mempengaruhi sumber kemas kini sistem serta alamat muat turun pakej pemasangan.',
docSourceTip: 'Sumber dokumentasi menentukan bahasa pautan untuk dokumentasi dan log kemas kini.',
languageHelper:
'Secara lalai, ia mengikuti bahasa penyemak imbas. Parameter ini hanya berkuat kuasa pada penyemak imbas semasa',
sessionTimeout: 'Tempoh tamat sesi',
@@ -1854,7 +1873,8 @@ const message = {
proxyHelper: 'Ia akan berkuat kuasa dalam senario berikut selepas anda menyediakan pelayan proksi:',
proxyHelper1: 'Muat turun pakej pemasangan dan penyelarasan dari kedai aplikasi (Edisi Profesional sahaja)',
proxyHelper2: 'Kemas kini sistem dan pengambilan maklumat kemas kini (Edisi Profesional sahaja)',
proxyHelper4: 'Rangkaian Docker akan diakses melalui pelayan proksi (Edisi Profesional sahaja)',
proxyHelper4:
'Permintaan rangkaian seperti tarikan imej Docker akan melalui pelayan proksi (Edisi Profesional sahaja)',
proxyHelper3: 'Pengesahan dan penyelarasan lesen sistem',
proxyHelper5: 'Muat turun dan penyegerakan bersepadu untuk pustaka skrip jenis sistem (Ciri Edisi Profesional)',
proxyHelper6: 'Mohon sijil (Fungsi versi Pro)',
@@ -2456,6 +2476,12 @@ const message = {
showLocal: 'Papar aplikasi tempatan',
reload: 'Muat Semula',
upgradeWarn: 'Meningkatkan aplikasi akan menggantikan fail docker-compose.yml.',
openclawHttpsUpgradeNoticeTitle:
'Nota: Arahan berikut hanya terpakai kepada pengguna yang menaik taraf OpenClaw daripada versi lebih awal daripada 2026.3.13:',
openclawHttpsUpgradeNoticeItem1:
'Selepas ejen yang telah dipasang selesai dinaik taraf, pergi ke Konfigurasi -> Tetapan -> Keselamatan dan tambah alamat akses secara manual.',
openclawHttpsUpgradeNoticeItem2:
'Versi baharu kini mewajibkan HTTPS untuk mengakses ejen. Jika anda sebelum ini menggunakan laman reverse proxy, tukar alamat sasaran proksi kepada https://IP:Port.',
newVersion: 'Versi baru',
oldVersion: 'Versi semasa',
composeDiff: 'Perbandingan fail',
+36 -8
View File
@@ -682,26 +682,29 @@ const message = {
copawType: 'CoPaw',
appVersion: 'Versão do app',
webuiPort: 'Porta WebUI',
bridgePort: 'Porta Bridge',
allowedOrigins: 'Endereços de acesso',
allowedOriginsHelper:
'Informe um endereço de acesso HTTPS completo por linha, por exemplo https://192.168.1.2:18789. Preencha manualmente se o endereço padrão não estiver configurado.',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: 'Informe pelo menos um endereço de acesso',
allowedOriginsInvalid: 'Use o formato http(s)://host-ou-ip:porta',
allowedOriginsHttpsOnly: 'Os endereços de acesso devem começar com https://',
provider: 'Provedor de modelos',
apiKey: 'Chave API',
baseUrl: 'URL base',
token: 'Token',
manualModel: 'Entrada manual',
verified: 'Verificado',
verifySkipped: 'Ignorado',
verifySkipped: 'Sem verificacao',
configTitle: 'Configuration',
settingsTab: 'Settings',
browserTab: 'Browser',
securityTab: 'Security',
otherTab: 'Other',
timeZone: 'Fuso horário',
browserEnabled: 'Browser Enabled',
headless: 'Headless',
noSandbox: 'No Sandbox',
defaultProfile: 'Default Profile',
executablePath: 'Executable Path',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
wecom: 'WeCom',
feishu: 'Feishu',
pluginNotInstalled: 'O plugin não está instalado. Instale-o primeiro.',
dmPolicy: 'DM Policy',
@@ -710,6 +713,7 @@ const message = {
policyOpen: 'Open',
policyDisabled: 'Disabled',
botName: 'Bot Name',
botId: 'Bot ID',
appId: 'App ID',
appSecret: 'App Secret',
saveAndRestartGateway: 'Save and restart gateway',
@@ -842,6 +846,12 @@ const message = {
},
container: {
createByCommand: 'Criar por comando',
importContainerBackupTip: 'Importar arquivo de backup de contêiner, apenas .tar.gz é suportado',
importComposeBackupTip: 'Importar arquivo de backup de compose, apenas .tar.gz é suportado',
stopContainerBeforeBackup: 'Parar contêiner antes do backup',
stopComposeBeforeBackup: 'Parar compose antes do backup',
stopBeforeBackupHelper:
'Quando habilitado, o contêiner ou serviço compose será interrompido antes do backup e restaurado automaticamente após a conclusão para garantir consistência dos dados.',
commandInput: 'Entrada de comando',
commandRule: 'Insira um comando docker run válido para criar o contêiner.',
commandHelper: 'Este comando será executado no servidor para criar o contêiner. Continuar?',
@@ -950,11 +960,14 @@ const message = {
sameImageHelper: 'Contêineres usando a mesma imagem podem ser atualizados em lote após seleção',
targetImage: 'Imagem alvo',
imageLoadErr: 'Nenhum nome de imagem detectado para o contêiner',
imageUpdateTagEmpty: 'Nenhuma tag de imagem atualizável foi detectada',
appHelper: 'O contêiner vem da loja de aplicativos, e o upgrade pode tornar o serviço indisponível.',
input: 'Entrada manual',
forcePull: 'Sempre puxar imagem',
forcePullHelper:
'Isso ignorará as imagens existentes no servidor e puxará a imagem mais recente do repositório.',
imageUpdateHelper:
'Verificar a mesma tag no registry e, se houver atualização, fazer pull e atualizar a imagem local.',
server: 'Host',
serverExample: '80, 80-88, ip:80 ou ip:80-88',
containerExample: '80 ou 80-88',
@@ -1948,6 +1961,10 @@ const message = {
light: 'Claro',
auto: 'Seguir o sistema',
language: 'Idioma',
runtimeEnv: 'Ambiente de execução',
docSource: 'Fonte da documentação',
withByRegion: 'Seguir região de operação (Padrão)',
withByLang: 'Seguir idioma do sistema',
region: 'Região de operação',
cn: 'China continental',
intl: 'Global',
@@ -1956,6 +1973,10 @@ const message = {
regionHelper2: 'Loja de aplicativos e biblioteca de scripts',
regionHelper3: 'Manual do usuário e documentação relacionada',
regionHelper4: 'Isso pode afetar downloads e acessos futuros. Prossiga com cautela.',
regionTip:
'A região de operação afeta a fonte de atualização do sistema e o endereço de download dos pacotes de instalação.',
docSourceTip:
'A fonte da documentação determina o idioma de redirecionamento da documentação e do registro de atualizações.',
languageHelper: 'Por padrão, segue o idioma do navegador. Este parâmetro tem efeito apenas no navegador atual',
sessionTimeout: 'Tempo limite de sessão',
sessionTimeoutError: 'O tempo mínimo de sessão é de 300 segundos',
@@ -1968,7 +1989,8 @@ const message = {
proxyHelper1:
'Download de pacotes de instalação e sincronização da loja de aplicativos (apenas edição profissional)',
proxyHelper2: 'Atualização do sistema e recuperação de informações de atualização (apenas edição profissional)',
proxyHelper4: 'A rede Docker será acessada por meio de um servidor proxy (apenas edição profissional)',
proxyHelper4:
'As solicitações de rede, como o pull de imagens do Docker, serão feitas por meio do servidor proxy (apenas edição profissional)',
proxyHelper3: 'Verificação e sincronização da licença do sistema',
proxyHelper5:
'Download e sincronização unificados para bibliotecas de scripts do tipo sistema (Recurso da Edição Profissional)',
@@ -2587,6 +2609,12 @@ const message = {
reload: 'Recarregar',
upgradeWarn:
'Atualizar o aplicativo substituirá o arquivo docker-compose.yml. Se houver alterações, você pode clicar para visualizar a comparação do arquivo',
openclawHttpsUpgradeNoticeTitle:
'Nota: As instruções a seguir se aplicam apenas a usuários que estão atualizando o OpenClaw de versões anteriores à 2026.3.13:',
openclawHttpsUpgradeNoticeItem1:
'Depois que o agente implantado for atualizado, vá para Configuração -> Ajustes -> Segurança e adicione manualmente o endereço de acesso.',
openclawHttpsUpgradeNoticeItem2:
'A nova versão agora exige HTTPS para acessar o agente. Se você usava um site com proxy reverso, altere o destino do proxy para https://IP:Port.',
newVersion: 'Nova versão',
oldVersion: 'Versão atual',
composeDiff: 'Comparação de arquivo',
+34 -8
View File
@@ -679,26 +679,29 @@ const message = {
copawType: 'CoPaw',
appVersion: 'Версия приложения',
webuiPort: 'Порт WebUI',
bridgePort: 'Порт Bridge',
allowedOrigins: 'Адреса доступа',
allowedOriginsHelper:
'Указывайте по одному полному HTTPS-адресу доступа в строке, например https://192.168.1.2:18789. Если адрес по умолчанию не настроен, введите его вручную.',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: 'Укажите хотя бы один адрес доступа',
allowedOriginsInvalid: 'Используйте формат http(s)://host-or-ip:port',
allowedOriginsHttpsOnly: 'Адреса доступа должны начинаться с https://',
provider: 'Поставщик моделей',
apiKey: 'API ключ',
baseUrl: 'Базовый URL',
token: 'Токен',
manualModel: 'Ручной ввод',
verified: 'Проверено',
verifySkipped: 'Пропущено',
verifySkipped: 'Без проверки',
configTitle: 'Configuration',
settingsTab: 'Settings',
browserTab: 'Browser',
securityTab: 'Security',
otherTab: 'Other',
timeZone: 'Часовой пояс',
browserEnabled: 'Browser Enabled',
headless: 'Headless',
noSandbox: 'No Sandbox',
defaultProfile: 'Default Profile',
executablePath: 'Executable Path',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
wecom: 'WeCom',
feishu: 'Feishu',
pluginNotInstalled: 'Плагин не установлен. Сначала установите его.',
dmPolicy: 'DM Policy',
@@ -707,6 +710,7 @@ const message = {
policyOpen: 'Open',
policyDisabled: 'Disabled',
botName: 'Bot Name',
botId: 'Bot ID',
appId: 'App ID',
appSecret: 'App Secret',
saveAndRestartGateway: 'Save and restart gateway',
@@ -838,6 +842,12 @@ const message = {
},
container: {
createByCommand: 'Создать с помощью команды',
importContainerBackupTip: 'Импорт файла резервной копии контейнера, поддерживается только .tar.gz',
importComposeBackupTip: 'Импорт файла резервной копии compose, поддерживается только .tar.gz',
stopContainerBeforeBackup: 'Остановить контейнер перед резервным копированием',
stopComposeBeforeBackup: 'Остановить compose перед резервным копированием',
stopBeforeBackupHelper:
'При включении контейнер или сервис compose будет остановлен перед резервным копированием и автоматически восстановлен после завершения для обеспечения целостности данных.',
commandInput: 'Ввод команды',
commandRule: 'Введите корректную команду docker run для создания контейнера.',
commandHelper: 'Эта команда будет выполнена на сервере для создания контейнера. Продолжить?',
@@ -945,11 +955,14 @@ const message = {
sameImageHelper: 'Контейнеры, использующие один образ, можно массово обновить после выбора',
targetImage: 'Целевой образ',
imageLoadErr: 'Не обнаружено имя образа для контейнера',
imageUpdateTagEmpty: 'Не обнаружены теги образов, доступные для обновления',
appHelper: 'Контейнер происходит из магазина приложений, и обновление может сделать сервис недоступным.',
input: 'Ручной ввод',
forcePull: 'Всегда загружать образ',
forcePullHelper:
'Это будет игнорировать существующие образы на сервере и загружать последний образ из реестра.',
imageUpdateHelper:
'Проверить одноимённый тег в реестре и, если есть обновление, загрузить и обновить локальный образ.',
server: 'Хост',
serverExample: '80, 80-88, ip:80 или ip:80-88',
containerExample: '80 или 80-88',
@@ -1819,6 +1832,10 @@ const message = {
light: 'Светлая',
auto: 'Как в системе',
language: 'Язык',
runtimeEnv: 'Среда выполнения',
docSource: 'Источник документации',
withByRegion: 'Следовать региону работы (по умолчанию)',
withByLang: 'Следовать языку системы',
region: 'Регион работы',
cn: 'Материковый Китай',
intl: 'Глобальный',
@@ -1827,6 +1844,8 @@ const message = {
regionHelper2: 'Магазин приложений и библиотека скриптов',
regionHelper3: 'Руководство пользователя и сопутствующая документация',
regionHelper4: 'Это может повлиять на последующие загрузки и доступ. Действуйте осторожно.',
regionTip: 'Регион работы влияет на источник обновления системы и адрес загрузки установочных пакетов.',
docSourceTip: 'Источник документации определяет язык перехода для документации и журнала обновлений.',
languageHelper: 'По умолчанию следует языку браузера. Этот параметр действует только в текущем браузере',
sessionTimeout: 'Время сессии',
sessionTimeoutError: 'Минимальное время сессии 300 секунд',
@@ -1839,7 +1858,8 @@ const message = {
proxyHelper1:
'Загрузка установочного пакета и синхронизация из магазина приложений (только профессиональная версия)',
proxyHelper2: 'Обновление системы и получение информации об обновлениях (только профессиональная версия)',
proxyHelper4: 'Сеть Docker будет доступна через прокси-сервер (только профессиональная версия)',
proxyHelper4:
'Сетевые запросы, такие как загрузка образов Docker, будут выполняться через прокси-сервер (только профессиональная версия)',
proxyHelper3: 'Проверка и синхронизация системной лицензии',
proxyHelper5: 'Единая загрузка и синхронизация системных скрипт-библиотек (функция Профессиональной версии)',
proxyHelper6: 'Подать заявку на сертификат (функция профессиональной версии)',
@@ -2451,6 +2471,12 @@ const message = {
reload: 'Перезагрузить',
upgradeWarn:
'Обновление приложения заменит файл docker-compose.yml. Если есть изменения, вы можете нажать для просмотра сравнения файлов',
openclawHttpsUpgradeNoticeTitle:
'Примечание: Следующие инструкции применяются только к пользователям, обновляющим OpenClaw с версий ниже 2026.3.13:',
openclawHttpsUpgradeNoticeItem1:
'После завершения обновления развернутого агента перейдите в Конфигурация -> Настройки -> Безопасность и вручную добавьте адрес доступа.',
openclawHttpsUpgradeNoticeItem2:
'Новая версия теперь требует HTTPS для доступа к агенту. Если раньше использовался сайт с обратным прокси, измените адрес назначения прокси на https://IP:Port.',
newVersion: 'Новая версия',
oldVersion: 'Текущая версия',
composeDiff: 'Сравнение файлов',
+34 -8
View File
@@ -683,26 +683,29 @@ const message = {
copawType: 'CoPaw',
appVersion: 'Uygulama sürümü',
webuiPort: 'WebUI portu',
bridgePort: 'Bridge portu',
allowedOrigins: 'Erişim adresleri',
allowedOriginsHelper:
'Her satıra bir tam HTTPS erişim adresi girin. Örnek: https://192.168.1.2:18789. Varsayılan erişim adresi yapılandırılmamışsa elle girin.',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: 'En az bir erişim adresi girin',
allowedOriginsInvalid: 'http(s)://host-veya-ip:port biçimini kullanın',
allowedOriginsHttpsOnly: 'Erişim adresleri https:// ile başlamalıdır',
provider: 'Model sağlayıcı',
apiKey: 'API anahtarı',
baseUrl: 'Temel URL',
token: 'Token',
manualModel: 'Manuel giriş',
verified: 'Doğrulandı',
verifySkipped: 'Atlandı',
verifySkipped: 'Doğrulama yok',
configTitle: 'Configuration',
settingsTab: 'Settings',
browserTab: 'Browser',
securityTab: 'Security',
otherTab: 'Other',
timeZone: 'Saat Dilimi',
browserEnabled: 'Browser Enabled',
headless: 'Headless',
noSandbox: 'No Sandbox',
defaultProfile: 'Default Profile',
executablePath: 'Executable Path',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
wecom: 'WeCom',
feishu: 'Feishu',
pluginNotInstalled: 'Eklenti yüklü değil. Lütfen önce yükleyin.',
dmPolicy: 'DM Policy',
@@ -711,6 +714,7 @@ const message = {
policyOpen: 'Open',
policyDisabled: 'Disabled',
botName: 'Bot Name',
botId: 'Bot ID',
appId: 'App ID',
appSecret: 'App Secret',
saveAndRestartGateway: 'Save and restart gateway',
@@ -843,6 +847,12 @@ const message = {
},
container: {
createByCommand: 'Komutla oluştur',
importContainerBackupTip: 'Konteyner yedek dosyasını içe aktar, yalnızca .tar.gz desteklenir',
importComposeBackupTip: 'Compose yedek dosyasını içe aktar, yalnızca .tar.gz desteklenir',
stopContainerBeforeBackup: 'Yedekleme öncesi konteyneri durdur',
stopComposeBeforeBackup: 'Yedekleme öncesi compose’u durdur',
stopBeforeBackupHelper:
'Etkinleştirildiğinde, yedekleme öncesinde konteyner veya compose servisi durdurulur ve veri tutarlılığını sağlamak için işlem tamamlandıktan sonra otomatik olarak geri açılır.',
commandInput: 'Komut girişi',
commandRule: 'Lütfen doğru docker run konteyner oluşturma komutunu girin!',
commandHelper: 'Bu komut konteyneri oluşturmak için sunucuda çalıştırılacak. Devam etmek istiyor musunuz?',
@@ -951,11 +961,13 @@ const message = {
sameImageHelper: 'Aynı imajı kullanan konteynerlar seçilerek toplu şekilde güncellenebilir',
targetImage: 'Hedef imaj',
imageLoadErr: 'Konteyner için imaj adı algılanmadı',
imageUpdateTagEmpty: 'Güncellenebilir imaj etiketi algılanmadı',
appHelper: 'Konteyner uygulama mağazasından geliyor ve yükseltme hizmeti kullanılamaz hale getirebilir.',
resource: 'Kaynak',
input: 'Manuel giriş',
forcePull: 'Her zaman imajı çek ',
forcePullHelper: 'Bu, sunucudaki mevcut imajları yok sayacak ve kayıt defterinden en son imajı çekecektir.',
imageUpdateHelper: 'Kayıt deposundaki aynı etiketi kontrol et, güncelleme varsa çekip yerel imajı güncelle.',
server: 'Ana bilgisayar',
serverExample: '80, 80-88, ip:80 veya ip:80-88',
containerExample: '80 veya 80-88',
@@ -1827,6 +1839,10 @@ const message = {
light: 'Açık',
auto: 'Sistemi takip et',
language: 'Dil',
runtimeEnv: 'Çalışma ortamı',
docSource: 'Doküman kaynağı',
withByRegion: 'Çalışma bölgesini takip et (Varsayılan)',
withByLang: 'Sistem dilini takip et',
region: 'Çalışma bölgesi',
cn: 'Çin anakarası',
intl: 'Küresel',
@@ -1835,6 +1851,9 @@ const message = {
regionHelper2: 'Uygulama mağazası ve betik kütüphanesi',
regionHelper3: 'Kullanım kılavuzu ve ilgili dokümanlar',
regionHelper4: 'Bu işlem sonraki indirme ve erişimleri etkileyebilir. Lütfen dikkatli olun.',
regionTip:
'Çalışma bölgesi, sistem güncelleme kaynağını ve kurulum paketi indirme adresini belirgin şekilde etkiler.',
docSourceTip: 'Doküman kaynağı, dokümantasyon ve güncelleme günlüğü bağlantılarının dilini belirler.',
languageHelper:
'Varsayılan olarak tarayıcı dilini takip eder. Bu parametre yalnızca geçerli tarayıcıda etkilidir',
sessionTimeout: 'Oturum zaman aşımı',
@@ -1848,7 +1867,8 @@ const message = {
proxyHelper1: 'Uygulama mağazasından kurulum paketi indirme ve senkronizasyon (Yalnızca Profesyonel sürüm)',
proxyHelper2: 'Sistem güncelleme ve güncelleme bilgisi alma (Yalnızca Profesyonel sürüm)',
proxyHelper3: 'Sistem lisansı doğrulama ve senkronizasyon',
proxyHelper4: 'Docker ağı vekil sunucu üzerinden erişilecek (Yalnızca Profesyonel sürüm)',
proxyHelper4:
'Docker imajı çekme gibi ağ istekleri vekil sunucu üzerinden yapılacaktır (Yalnızca Profesyonel sürüm)',
proxyHelper5: 'Sistem tipi komut kütüphaneleri için birleşik indirme ve senkronizasyon (Profesyonel)',
proxyHelper6: 'Sertifika başvurusu (Profesyonel)',
proxyType: 'Vekil türü',
@@ -2452,6 +2472,12 @@ const message = {
reload: 'Yeniden yükle',
upgradeWarn:
'Uygulamanın yükseltilmesi docker-compose.yml dosyasını değiştirecektir. Herhangi bir değişiklik varsa, dosya karşılaştırmasını görüntülemek için tıklayabilirsiniz',
openclawHttpsUpgradeNoticeTitle:
"Not: Aşağıdaki talimatlar yalnızca OpenClaw'ı 2026.3.13 öncesi sürümlerden yükselten kullanıcılar için geçerlidir:",
openclawHttpsUpgradeNoticeItem1:
'Dağıtılmış ajan yükseltmesi tamamlandıktan sonra Yapılandırma -> Ayarlar -> Güvenlik sayfasına gidip erişim adresini manuel olarak ekleyin.',
openclawHttpsUpgradeNoticeItem2:
'Yeni sürüm artık ajana erişim için HTTPS zorunlu kılıyor. Daha önce ters proxy sitesi kullanıyorsanız proxy hedef adresini https://IP:Port olarak değiştirin.',
newVersion: 'Yeni sürüm',
oldVersion: 'Mevcut sürüm',
composeDiff: 'Dosya karşılaştırması',
+29 -8
View File
@@ -647,26 +647,29 @@ const message = {
copawType: 'CoPaw',
appVersion: '應用版本',
webuiPort: 'WebUI 埠',
bridgePort: 'Bridge 埠',
allowedOrigins: '訪問地址',
allowedOriginsHelper:
'一行一個完整 HTTPS 訪問地址,例如 https://192.168.1.2:18789;未設定預設訪問地址時請手動填寫',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: '請至少填寫一個訪問地址',
allowedOriginsInvalid: '訪問地址格式錯誤,請輸入 http(s)://網域或IP:埠',
allowedOriginsHttpsOnly: '訪問地址必須以 https:// 開頭',
provider: '模型供應商',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
manualModel: '手動輸入模型',
verified: '驗證狀態',
verifySkipped: '已跳過',
verifySkipped: '不驗證',
configTitle: '設定',
settingsTab: '設定',
browserTab: '瀏覽器',
securityTab: '安全',
otherTab: '其他',
timeZone: '時區',
browserEnabled: '瀏覽器開關',
headless: '無頭模式',
noSandbox: '禁用沙箱',
defaultProfile: '預設設定檔',
executablePath: '瀏覽器可執行路徑',
switchModelSuccess: '模型切換成功',
channelsTab: '頻道',
wecom: '企業微信',
feishu: '飛書',
pluginNotInstalled: '插件未安裝,請先安裝插件',
dmPolicy: '私聊策略',
@@ -675,6 +678,7 @@ const message = {
policyOpen: '開放',
policyDisabled: '禁用',
botName: '機器人名稱',
botId: 'Bot ID',
appId: '應用 App ID',
appSecret: '應用 App Secret',
saveAndRestartGateway: '保存並重新啟動網關',
@@ -799,6 +803,11 @@ const message = {
},
container: {
createByCommand: '指令建立',
importContainerBackupTip: '匯入容器備份檔案,僅支援 .tar.gz 格式',
importComposeBackupTip: '匯入編排備份檔案,僅支援 .tar.gz 格式',
stopContainerBeforeBackup: '備份前停止容器',
stopComposeBeforeBackup: '備份前停止編排',
stopBeforeBackupHelper: '啟用後備份前將停止容器或編排服務,完成後自動恢復,以確保資料一致性',
commandInput: '指令輸入',
commandRule: '請輸入正確的 docker run 容器建立指令',
commandHelper: '將在伺服器上執行該條指令以建立容器,是否繼續?',
@@ -896,11 +905,13 @@ const message = {
sameImageHelper: '同映像容器可勾選後批次升級',
targetImage: '目標映像',
imageLoadErr: '未偵測到容器的映像名稱',
imageUpdateTagEmpty: '未偵測到可更新的映像標籤',
appHelper: '該容器來源於應用商店,升級可能導致該服務不可用',
resource: '資源',
input: '手動輸入',
forcePull: '強制拉取映像',
forcePullHelper: '忽略伺服器已存在的映像,重新拉取一次',
imageUpdateHelper: '將檢查映像倉庫中的同名標籤,若有更新則拉取並更新本地映像。',
server: '伺服器',
serverExample: '80, 80-88, ip:80 或 ip:80-88',
containerExample: '80 或 80-88',
@@ -1699,6 +1710,10 @@ const message = {
light: '亮色',
auto: '跟隨系統',
language: '系統語言',
runtimeEnv: '運行環境',
docSource: '文件來源',
withByRegion: '跟隨運行區域(預設)',
withByLang: '跟隨系統語言',
region: '運行區域',
cn: '中國大陸',
intl: '全球',
@@ -1707,6 +1722,8 @@ const message = {
regionHelper2: '應用商店與腳本庫',
regionHelper3: '使用手冊及相關文件',
regionHelper4: '此操作可能影響後續資源下載與存取,請謹慎操作。',
regionTip: '運行區域影響系統更新源以及安裝包下載地址。',
docSourceTip: '文件來源決定文件與更新日誌的跳轉語言。',
languageHelper: '預設跟隨瀏覽器語言,設定後只對目前瀏覽器生效,更換瀏覽器後需要重新設定',
sessionTimeout: '逾時時間',
sessionTimeoutError: '最小逾時時間為 300 秒',
@@ -1718,7 +1735,7 @@ const message = {
proxyHelper1: '應用商店的安裝包下載和同步(專業版功能)',
proxyHelper2: '系統版本升級及取得更新說明(專業版功能)',
proxyHelper3: '系統許可證的驗證和同步',
proxyHelper4: 'Docker 的網路存取將透過代理伺服器進行(專業版功能)',
proxyHelper4: 'Docker 拉取映像等網路請求將透過代理伺服器存取(專業版功能)',
proxyHelper5: '系統類型腳本庫的統一下載與同步(專業版功能)',
proxyHelper6: '申請憑證(專業版功能)',
proxyType: '代理類型',
@@ -2235,6 +2252,10 @@ const message = {
showLocal: '顯示本機應用程式',
reload: '重載',
upgradeWarn: '升級應用程式會取代 docker-compose.yml 檔案,如有更改,可以點選檢視檔案對比',
openclawHttpsUpgradeNoticeTitle: '注意:以下說明僅適用於 OpenClaw 從 2026.3.13 之前版本升級的使用者:',
openclawHttpsUpgradeNoticeItem1: '已部署的智慧體升級完成後,需要進入 配置 → 設定 → 安全 頁面手動新增存取地址。',
openclawHttpsUpgradeNoticeItem2:
'新版本已強制使用 HTTPS 存取智慧體。如果之前透過反向代理網站存取,請將代理目標地址修改為 https://IP:Port。',
newVersion: '新版本',
oldVersion: '當前版本',
composeDiff: '檔案對比',
+30 -13
View File
@@ -646,26 +646,29 @@ const message = {
copawType: 'CoPaw',
appVersion: '应用版本',
webuiPort: 'WebUI 端口',
bridgePort: 'Bridge 端口',
allowedOrigins: '访问地址',
allowedOriginsHelper:
'一行一个完整 HTTPS 访问地址,例如 https://192.168.1.2:18789;未配置默认访问地址时请手动填写',
allowedOriginsPlaceholder: 'https://192.168.1.2:18789',
allowedOriginsRequired: '请至少填写一个访问地址',
allowedOriginsInvalid: '访问地址格式错误,请输入 http(s)://域名或IP:端口',
allowedOriginsHttpsOnly: '访问地址必须以 https:// 开头',
provider: '模型供应商',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
manualModel: '手动输入模型',
verified: '验证状态',
verifySkipped: '已跳过',
verifySkipped: '不验证',
configTitle: '配置',
settingsTab: '设置',
browserTab: '浏览器',
securityTab: '安全',
otherTab: '其他',
timeZone: '时区',
browserEnabled: '浏览器开关',
headless: '无头模式',
noSandbox: '禁用沙箱',
defaultProfile: '默认配置文件',
executablePath: '浏览器可执行路径',
switchModelSuccess: '模型切换成功',
channelsTab: '频道',
wecom: '企业微信',
feishu: '飞书',
pluginNotInstalled: '插件未安装,请先安装插件',
dmPolicy: '私聊策略',
@@ -674,6 +677,7 @@ const message = {
policyOpen: '开放',
policyDisabled: '禁用',
botName: '机器人名称',
botId: 'Bot ID',
appId: '应用 App ID',
appSecret: '应用 App Secret',
pairingCode: '配对码',
@@ -798,6 +802,11 @@ const message = {
},
container: {
createByCommand: '命令创建',
importContainerBackupTip: '导入容器备份文件,仅支持 .tar.gz 格式',
importComposeBackupTip: '导入编排备份文件,仅支持 .tar.gz 格式',
stopContainerBeforeBackup: '备份前停止容器',
stopComposeBeforeBackup: '备份前停止编排',
stopBeforeBackupHelper: '启用后备份前将停止容器或编排服务,完成后自动恢复,以确保数据一致性',
commandInput: '命令输入',
commandRule: '请输入正确的 docker run 容器创建命令!',
commandHelper: '将在服务器上执行该条命令以创建容器,是否继续?',
@@ -898,12 +907,14 @@ const message = {
sameImageHelper: '同镜像容器可勾选后批量升级',
targetImage: '目标镜像',
imageLoadErr: '未检测到容器的镜像名称',
imageUpdateTagEmpty: '未检测到可更新的镜像标签',
appHelper: '该容器来源于应用商店,升级可能导致该服务不可用',
resource: '资源',
input: '手动输入',
forcePull: '强制拉取镜像',
forcePullHelper: '忽略服务器已存在的镜像,重新拉取一次',
imageUpdateHelper: '将检查镜像仓库中的同名标签,若有更新则拉取并更新本地镜像。',
server: '服务器',
serverExample: '80, 80-88, ip:80 或 ip:80-88',
containerExample: '80 或 80-88',
@@ -1700,14 +1711,16 @@ const message = {
light: '亮色',
auto: '跟随系统',
language: '系统语言',
docSource: '文档来源',
withByRegion: '跟随运行区域(默认)',
withByLang: '跟随系统语言',
runtimeEnv: '运行环境',
region: '运行区域',
cn: '中国大陆',
intl: '全球',
regionHelper: '更改运行区域后,将调整以下资源来源:',
regionHelper1: '系统安装包',
regionHelper2: '应用商店与脚本库',
regionHelper3: '使用手册及相关文档',
regionHelper4: '该操作可能影响后续资源下载与访问,请谨慎操作。',
regionHelper: '该操作可能影响后续资源下载与访问,是否继续?',
regionTip: '运行区域影响系统更新源以及安装包下载地址。',
docSourceTip: '文档来源决定文档与更新日志的跳转语言。',
languageHelper: '默认跟随浏览器语言,设置后只对当前浏览器生效,更换浏览器后需要重新设置',
sessionTimeout: '超时时间',
sessionTimeoutError: '最小超时时间为 300 秒',
@@ -1719,7 +1732,7 @@ const message = {
proxyHelper1: '应用商店的安装包下载和同步(专业版功能)',
proxyHelper2: '系统版本升级及获取更新说明(专业版功能)',
proxyHelper3: '系统许可证的验证和同步',
proxyHelper4: 'Docker 的网络访问将通过代理服务器进行(专业版功能)',
proxyHelper4: 'Docker 拉取镜像等网络请求将通过代理服务器访问(专业版功能)',
proxyHelper5: '系统类型脚本库的统一下载与同步(专业版功能)',
proxyHelper6: '申请证书(专业版功能)',
proxyType: '代理类型',
@@ -2239,6 +2252,10 @@ const message = {
showLocal: '本地应用',
reload: '重载',
upgradeWarn: '升级应用会替换 docker-compose.yml 文件,如有更改,可以点击查看文件对比',
openclawHttpsUpgradeNoticeTitle: '注意:以下说明仅适用于 OpenClaw 从 2026.3.13 之前版本升级的用户:',
openclawHttpsUpgradeNoticeItem1: '已部署的智能体升级完成后,需要进入 配置 → 设置 → 安全 页面手动添加访问地址。',
openclawHttpsUpgradeNoticeItem2:
'新版本已强制使用 HTTPS 访问智能体。如果之前通过反向代理网站访问,请将代理目标地址修改为 https://IP:Port。',
newVersion: '新版本',
oldVersion: '当前版本',
composeDiff: '文件对比',
+11 -1
View File
@@ -1,5 +1,14 @@
import { Layout } from '@/routers/constant';
const vllmModules = import.meta.glob('@/xpack/views/vllm/index.vue');
const loadVllmView = () => {
const loader = vllmModules['/src/xpack/views/vllm/index.vue'];
if (loader) {
return loader();
}
return import('@/views/ai/model/vllm/index.vue');
};
const databaseRouter = {
sort: 4,
path: '/ai',
@@ -38,6 +47,7 @@ const databaseRouter = {
meta: {
icon: 'p-moxing-menu',
title: 'aiTools.model.localModel',
activeMenu: '/ai/model/ollama',
requiresAuth: true,
},
},
@@ -66,7 +76,7 @@ const databaseRouter = {
path: '/ai/model/vllm',
hidden: true,
name: 'Vllm',
component: () => import('@/xpack/views/vllm/index.vue'),
component: loadVllmView,
meta: {
title: 'vLLM',
activeMenu: '/ai/model/ollama',
+1
View File
@@ -53,6 +53,7 @@ export interface GlobalState {
isProductPro: boolean;
isIntl: boolean;
docWithRegion: boolean;
productProExpires: number;
isMasterProductPro: boolean;
isOffLine: boolean;
+12 -1
View File
@@ -4,6 +4,9 @@ import { GlobalState, ThemeConfigProp } from '../interface';
import { DeviceType } from '@/enums/app';
import i18n, { setActiveLocale } from '@/lang';
const CN_DOCS_URL = 'https://1panel.cn/docs/v2';
const INTL_DOCS_URL = 'https://docs.1panel.pro/v2';
const GlobalStore = defineStore({
id: 'GlobalState',
state: (): GlobalState => ({
@@ -47,6 +50,7 @@ const GlobalStore = defineStore({
isProductPro: false,
isIntl: false,
docWithRegion: true,
productProExpires: 0,
isMasterProductPro: false,
isOffLine: false,
@@ -60,7 +64,14 @@ const GlobalStore = defineStore({
state.themeConfig.theme === 'dark' ||
(state.themeConfig.theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches),
isDarkGoldTheme: (state) => state.themeConfig.primary === '#F0BE96' && state.isProductPro,
docsUrl: (state) => (state.isIntl ? 'https://docs.1panel.pro/v2' : 'https://1panel.cn/docs/v2'),
docsUrl: (state) => {
if (state.docWithRegion) {
return state.isIntl ? INTL_DOCS_URL : CN_DOCS_URL;
}
const lang = state.language.toLowerCase();
const isChinese = lang === 'zh';
return isChinese ? CN_DOCS_URL : INTL_DOCS_URL;
},
isMaster: (state) => state.currentNode === 'local',
},
actions: {
+72
View File
@@ -1,5 +1,9 @@
import i18n from '@/lang';
interface AllowedOriginOptions {
httpsOnly?: boolean;
}
export const getAgentProviderDisplayName = (provider: string, displayName?: string): string => {
if (provider === 'custom' || displayName === 'Custom') {
return i18n.global.t('container.custom');
@@ -9,3 +13,71 @@ export const getAgentProviderDisplayName = (provider: string, displayName?: stri
}
return displayName || provider;
};
export const buildDefaultAllowedOrigin = (systemIP: string, port?: number | string): string => {
const target = String(systemIP || '').trim();
if (!target || !port) {
return '';
}
const host = target.includes(':') && !target.startsWith('[') && !target.endsWith(']') ? `[${target}]` : target;
return `https://${host}:${port}`;
};
export const normalizeAllowedOrigin = (value: string, options: AllowedOriginOptions = {}): string => {
const target = String(value || '').trim();
if (!target) {
throw new Error(i18n.global.t('aiTools.agents.allowedOriginsInvalid'));
}
let parsed: URL;
try {
parsed = new URL(target);
} catch (error) {
throw new Error(i18n.global.t('aiTools.agents.allowedOriginsInvalid'));
}
if (options.httpsOnly && parsed.protocol !== 'https:') {
throw new Error(i18n.global.t('aiTools.agents.allowedOriginsHttpsOnly'));
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(i18n.global.t('aiTools.agents.allowedOriginsInvalid'));
}
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
throw new Error(i18n.global.t('aiTools.agents.allowedOriginsInvalid'));
}
if (parsed.pathname && parsed.pathname !== '/') {
throw new Error(i18n.global.t('aiTools.agents.allowedOriginsInvalid'));
}
if (!parsed.host || !parsed.port) {
throw new Error(i18n.global.t('aiTools.agents.allowedOriginsInvalid'));
}
return `https://${parsed.host}`;
};
export const parseAllowedOriginsInput = (value: string, options: AllowedOriginOptions = {}): string[] => {
const result: string[] = [];
const seen = new Set<string>();
for (const line of String(value || '').split(/\r?\n/)) {
const target = line.trim();
if (!target) {
continue;
}
const normalized = normalizeAllowedOrigin(target, options);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
};
export const validateAllowedOriginsInput = (value: string, options: AllowedOriginOptions = {}): string => {
try {
const parsed = parseAllowedOriginsInput(value, options);
if (parsed.length === 0) {
return i18n.global.t('aiTools.agents.allowedOriginsRequired');
}
return '';
} catch (error: any) {
return error?.message || i18n.global.t('aiTools.agents.allowedOriginsInvalid');
}
};
@@ -21,10 +21,19 @@
</el-form-item>
<el-form-item
v-if="form.agentType === 'openclaw'"
:label="$t('aiTools.agents.bridgePort')"
prop="bridgePort"
:label="$t('aiTools.agents.allowedOrigins')"
prop="allowedOrigins"
>
<el-input-number v-model="form.bridgePort" :min="1" :max="65535" />
<el-input
v-model="form.allowedOrigins"
type="textarea"
:rows="3"
:placeholder="$t('aiTools.agents.allowedOriginsPlaceholder')"
@input="handleAllowedOriginsInput"
/>
<span class="input-help">
{{ $t('aiTools.agents.allowedOriginsHelper') }}
</span>
</el-form-item>
</el-card>
<el-card class="form-card" v-if="form.agentType === 'openclaw'">
@@ -103,8 +112,14 @@ import { checkNumberRange, Rules } from '@/global/form-rules';
import { createAgent, getAgentProviders, pageAgentAccounts } from '@/api/modules/ai';
import { AI } from '@/api/interface/ai';
import { getAppByKey, getAppDetail } from '@/api/modules/app';
import { getAgentSettingByKey } from '@/api/modules/setting';
import { getRandomStr, newUUID } from '@/utils/util';
import { getAgentProviderDisplayName } from '@/utils/agent';
import {
buildDefaultAllowedOrigin,
getAgentProviderDisplayName,
parseAllowedOriginsInput,
validateAllowedOriginsInput,
} from '@/utils/agent';
import { App } from '@/api/interface/app';
import AdvancedSetting from '@/components/advanced-setting/index.vue';
import AccountAddDialog from '@/views/ai/agents/model/add/index.vue';
@@ -122,6 +137,9 @@ const providerAccountCount = ref<Record<string, number>>({});
const manualModel = ref(false);
const appInfo = ref<App.AppDTO>();
const accountAddRef = ref();
const systemIP = ref('');
const lastAutoAllowedOrigins = ref('');
const allowedOriginsAutoFilled = ref(true);
const { isIntl } = useGlobalStore();
const form = reactive({
@@ -129,7 +147,7 @@ const form = reactive({
agentType: 'openclaw' as 'openclaw' | 'copaw',
appVersion: '',
webUIPort: 18789,
bridgePort: 18790,
allowedOrigins: '',
provider: 'deepseek',
accountId: undefined as unknown as number,
model: '',
@@ -152,37 +170,28 @@ const form = reactive({
dockerCompose: '',
});
const validateOpenclawOnly = (field: 'provider' | 'accountId' | 'model' | 'bridgePort') => {
return (_rule: any, value: any, callback: (error?: Error) => void) => {
if (form.agentType !== 'openclaw') {
callback();
return;
}
if (field === 'bridgePort') {
if (!value || Number(value) <= 0) {
callback(new Error('bridge port is required'));
return;
}
callback();
return;
}
if (value === undefined || value === null || value === '') {
callback(new Error(`${field} is required`));
return;
}
callback();
};
};
const rules = reactive({
name: [Rules.requiredInput],
agentType: [Rules.requiredSelect],
appVersion: [Rules.requiredSelect],
webUIPort: [Rules.requiredInput],
bridgePort: [{ validator: validateOpenclawOnly('bridgePort'), trigger: 'blur' }],
provider: [{ validator: validateOpenclawOnly('provider'), trigger: 'change' }],
accountId: [{ validator: validateOpenclawOnly('accountId'), trigger: 'change' }],
model: [{ validator: validateOpenclawOnly('model'), trigger: 'change' }],
allowedOrigins: [
Rules.requiredInput,
{
validator: (_rule: any, value: any, callback: (error?: Error) => void) => {
const message = validateAllowedOriginsInput(String(value || ''));
if (message) {
callback(new Error(message));
return;
}
callback();
},
trigger: 'blur',
},
],
provider: [Rules.requiredSelect],
accountId: [Rules.requiredSelect],
model: [Rules.requiredInput],
containerName: [Rules.containerName],
restartPolicy: [Rules.requiredSelect],
cpuQuota: [checkNumberRange(0, 99999)],
@@ -192,6 +201,28 @@ const rules = reactive({
const filteredModels = computed(() => providerModels.value[form.provider] || []);
const syncAllowedOriginsWithDefault = (force = false) => {
if (form.agentType !== 'openclaw') {
return;
}
const defaultOrigin = buildDefaultAllowedOrigin(systemIP.value, form.webUIPort);
if (!force && !allowedOriginsAutoFilled.value && form.allowedOrigins !== lastAutoAllowedOrigins.value) {
return;
}
form.allowedOrigins = defaultOrigin;
lastAutoAllowedOrigins.value = defaultOrigin;
allowedOriginsAutoFilled.value = true;
};
const loadSystemIP = async () => {
try {
const res = await getAgentSettingByKey('SystemIP');
systemIP.value = String(res.data || '').trim();
} catch (error) {
systemIP.value = '';
}
};
const loadVersions = async (appKey: 'openclaw' | 'copaw') => {
const res = await getAppByKey(appKey);
appInfo.value = res.data;
@@ -300,12 +331,17 @@ const handleAgentTypeChange = async () => {
form.baseURL = '';
form.apiType = 'openai-completions';
if (form.agentType === 'openclaw') {
form.bridgePort = form.bridgePort || 18790;
await loadSystemIP();
allowedOriginsAutoFilled.value = true;
syncAllowedOriginsWithDefault(true);
await loadVersions('openclaw');
await loadProviders();
await loadAccounts();
return;
}
form.allowedOrigins = '';
lastAutoAllowedOrigins.value = '';
allowedOriginsAutoFilled.value = true;
await loadVersions('copaw');
};
@@ -353,6 +389,10 @@ const setDefaultModel = () => {
}
};
const handleAllowedOriginsInput = () => {
allowedOriginsAutoFilled.value = form.allowedOrigins === lastAutoAllowedOrigins.value;
};
const submit = async () => {
if (!formRef.value) {
return;
@@ -367,7 +407,7 @@ const submit = async () => {
name: form.name,
appVersion: form.appVersion,
webUIPort: form.webUIPort,
bridgePort: form.agentType === 'openclaw' ? form.bridgePort : undefined,
allowedOrigins: form.agentType === 'openclaw' ? parseAllowedOriginsInput(form.allowedOrigins) : undefined,
agentType: form.agentType,
provider: form.agentType === 'openclaw' ? form.provider : undefined,
model: form.agentType === 'openclaw' ? form.model : undefined,
@@ -408,7 +448,10 @@ const submit = async () => {
const handleClose = () => {
formRef.value?.resetFields();
form.token = '';
form.allowedOrigins = '';
form.dockerCompose = '';
lastAutoAllowedOrigins.value = '';
allowedOriginsAutoFilled.value = true;
};
const openDrawer = async (agentType?: 'openclaw' | 'copaw') => {
@@ -419,12 +462,18 @@ const openDrawer = async (agentType?: 'openclaw' | 'copaw') => {
form.agentType = targetType;
form.token = getRandomStr(32).toLowerCase();
if (form.agentType === 'copaw') {
form.allowedOrigins = '';
lastAutoAllowedOrigins.value = '';
allowedOriginsAutoFilled.value = true;
await loadVersions('copaw');
providerOptions.value = [];
providerModels.value = {};
accountOptions.value = [];
return;
}
await loadSystemIP();
allowedOriginsAutoFilled.value = true;
syncAllowedOriginsWithDefault(true);
await loadVersions('openclaw');
await loadProviders();
await loadAccounts();
@@ -464,6 +513,13 @@ watch(
},
);
watch(
() => form.webUIPort,
() => {
syncAllowedOriginsWithDefault();
},
);
defineExpose({
open: openDrawer,
});
@@ -37,11 +37,11 @@ const modelRef = ref();
const settingsRef = ref();
const loadSettings = async () => {
if (agentId.value <= 0) {
if (!currentAgent.value) {
return;
}
await nextTick();
await settingsRef.value?.load(agentId.value);
await settingsRef.value?.load(currentAgent.value);
};
const loadModel = async () => {
@@ -3,6 +3,9 @@
<el-tab-pane label="QQ" name="qqbot">
<QQBotTab ref="qqbotRef" />
</el-tab-pane>
<el-tab-pane :label="t('aiTools.agents.wecom')" name="wecom">
<WecomTab ref="wecomRef" />
</el-tab-pane>
<el-tab-pane :label="t('aiTools.agents.feishu')" name="feishu">
<FeishuTab ref="feishuRef" />
</el-tab-pane>
@@ -22,6 +25,7 @@ import FeishuTab from './channels/feishu.vue';
import TelegramTab from './channels/telegram.vue';
import DiscordTab from './channels/discord.vue';
import QQBotTab from './channels/qq.vue';
import WecomTab from './channels/wecom.vue';
const { t } = useI18n();
const activeTab = ref('qqbot');
@@ -30,6 +34,7 @@ const feishuRef = ref();
const telegramRef = ref();
const discordRef = ref();
const qqbotRef = ref();
const wecomRef = ref();
const loadCurrentTab = async () => {
if (agentId.value <= 0) {
@@ -44,6 +49,10 @@ const loadCurrentTab = async () => {
await telegramRef.value?.load(agentId.value);
return;
}
if (activeTab.value === 'wecom') {
await wecomRef.value?.load(agentId.value);
return;
}
if (activeTab.value === 'qqbot') {
await qqbotRef.value?.load(agentId.value);
return;
@@ -0,0 +1,175 @@
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-alert
v-if="!form.installed"
type="warning"
:closable="false"
:title="t('aiTools.agents.pluginNotInstalled')"
class="mb-4"
/>
<el-form-item>
<el-button v-if="!form.installed" type="primary" :loading="installing" @click="installPlugin">
{{ t('commons.button.install') }}
</el-button>
</el-form-item>
<el-form-item :label="t('commons.table.status')">
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item :label="t('aiTools.agents.dmPolicy')" prop="dmPolicy">
<el-select v-model="form.dmPolicy">
<el-option :label="t('aiTools.agents.policyPairing')" value="pairing" />
<el-option :label="t('aiTools.agents.policyOpen')" value="open" />
</el-select>
</el-form-item>
<el-form-item :label="t('aiTools.agents.botId')" prop="botId">
<el-input v-model="form.botId" />
</el-form-item>
<el-form-item :label="t('setting.secret')" prop="secret">
<el-input v-model="form.secret" type="password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveChannel">
{{ t('commons.button.save') }}
</el-button>
</el-form-item>
<el-divider />
<el-form-item :label="t('aiTools.agents.pairingCode')">
<el-input v-model="pairingCode" :placeholder="t('aiTools.agents.pairingCodePlaceholder')" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="approving" @click="approvePairing">
{{ t('aiTools.agents.approvePairing') }}
</el-button>
</el-form-item>
</el-form>
<TaskLog ref="taskLogRef" @close="checkPluginStatus" />
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import type { FormInstance } from 'element-plus';
import { useI18n } from 'vue-i18n';
import { AI } from '@/api/interface/ai';
import {
approveAgentChannelPairing,
checkAgentPlugin,
getAgentWecomConfig,
installAgentPlugin,
updateAgentWecomConfig,
} from '@/api/modules/ai';
import { MsgSuccess, MsgWarning } from '@/utils/message';
import { Rules } from '@/global/form-rules';
import { newUUID } from '@/utils/util';
import TaskLog from '@/components/log/task/index.vue';
const { t } = useI18n();
const saving = ref(false);
const approving = ref(false);
const installing = ref(false);
const agentId = ref(0);
const pairingCode = ref('');
const formRef = ref<FormInstance>();
const taskLogRef = ref();
const form = reactive<AI.AgentWecomConfig>({
enabled: true,
dmPolicy: 'pairing',
botId: '',
secret: '',
installed: false,
});
const rules = reactive({
dmPolicy: [Rules.requiredSelect],
botId: [Rules.requiredInput],
secret: [Rules.requiredInput],
});
const checkPluginStatus = async () => {
if (!agentId.value) {
return;
}
const res = await checkAgentPlugin({
agentId: agentId.value,
type: 'wecom',
});
form.installed = Boolean(res.data?.installed);
};
const load = async (id: number) => {
agentId.value = id;
pairingCode.value = '';
const res = await getAgentWecomConfig({ agentId: id });
Object.assign(form, res.data || {});
if (!form.dmPolicy) {
form.dmPolicy = 'pairing';
}
await checkPluginStatus();
};
const saveChannel = async () => {
if (!agentId.value || !formRef.value) {
return;
}
await formRef.value.validate();
saving.value = true;
try {
await updateAgentWecomConfig({
agentId: agentId.value,
enabled: form.enabled,
dmPolicy: form.dmPolicy,
botId: form.botId,
secret: form.secret,
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
} finally {
saving.value = false;
}
};
const approvePairing = async () => {
if (!agentId.value) {
return;
}
if (!pairingCode.value) {
MsgWarning(t('aiTools.agents.pairingCodeRequired'));
return;
}
approving.value = true;
try {
await approveAgentChannelPairing({
agentId: agentId.value,
type: 'wecom',
pairingCode: pairingCode.value,
});
MsgSuccess(t('aiTools.agents.pairingApproveSuccess'));
pairingCode.value = '';
} finally {
approving.value = false;
}
};
const installPlugin = async () => {
if (!agentId.value) {
return;
}
const taskID = newUUID();
installing.value = true;
try {
await installAgentPlugin({
agentId: agentId.value,
type: 'wecom',
taskID,
});
taskLogRef.value?.openWithTaskID(taskID);
} finally {
installing.value = false;
}
};
defineExpose({
load,
});
</script>
@@ -52,6 +52,15 @@ const accountOptions = ref<AI.AgentAccountItem[]>([]);
const modelOptions = ref<AI.ProviderModelInfo[]>([]);
const provdier = ref('');
const getModelPrefix = (provider: string) => {
switch (provider) {
case 'gemini':
return 'google';
default:
return provider;
}
};
const form = reactive({
accountId: undefined as unknown as number,
manualModel: false,
@@ -128,8 +137,9 @@ const handleAccountChange = () => {
setModelsByProvider(selected.provider);
return;
}
const modelPrefix = getModelPrefix(selected.provider);
setModelsByProvider(selected.provider);
if (!form.manualModel && (!form.model || !form.model.startsWith(`${selected.provider}/`))) {
if (!form.manualModel && (!form.model || !form.model.startsWith(`${modelPrefix}/`))) {
form.model = modelOptions.value.length > 0 ? modelOptions.value[0].id : '';
}
};
@@ -150,7 +160,8 @@ const handleManualModelChange = (val: unknown) => {
form.model = '';
return;
}
if (!form.model || !form.model.startsWith(`${selected.provider}/`)) {
const modelPrefix = getModelPrefix(selected.provider);
if (!form.model || !form.model.startsWith(`${modelPrefix}/`)) {
form.model = modelOptions.value.length > 0 ? modelOptions.value[0].id : '';
}
};
@@ -172,13 +183,14 @@ const load = async (agent: AI.AgentItem) => {
form.accountId = currentAccount.id;
provdier.value = currentAccount.provider;
setModelsByProvider(currentAccount.provider);
const modelPrefix = getModelPrefix(currentAccount.provider);
const inProviderModels = modelOptions.value.some((item) => item.id === agent.model);
form.manualModel =
currentAccount.provider === 'custom' ||
currentAccount.provider === 'vllm' ||
currentAccount.provider === 'ollama' ||
!inProviderModels;
if (agent.model && (form.manualModel || agent.model.startsWith(`${currentAccount.provider}/`))) {
if (agent.model && (form.manualModel || agent.model.startsWith(`${modelPrefix}/`))) {
form.model = agent.model;
} else {
form.model = modelOptions.value.length > 0 ? modelOptions.value[0].id : '';
@@ -1,7 +1,7 @@
<template>
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane :label="t('aiTools.agents.browserTab')" name="browser">
<BrowserTab ref="browserRef" />
<el-tab-pane v-if="agentType === 'openclaw'" :label="t('aiTools.agents.securityTab')" name="security">
<SecurityTab ref="securityRef" />
</el-tab-pane>
<el-tab-pane :label="t('aiTools.agents.otherTab')" name="other">
<OtherTab ref="otherRef" />
@@ -12,13 +12,15 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import BrowserTab from './settings/browser.vue';
import { AI } from '@/api/interface/ai';
import SecurityTab from './settings/security.vue';
import OtherTab from './settings/other.vue';
const { t } = useI18n();
const activeTab = ref('browser');
const activeTab = ref('security');
const agentId = ref(0);
const browserRef = ref();
const agentType = ref<AI.AgentItem['agentType']>('openclaw');
const securityRef = ref();
const otherRef = ref();
const loadCurrentTab = async () => {
@@ -26,8 +28,8 @@ const loadCurrentTab = async () => {
return;
}
await nextTick();
if (activeTab.value === 'browser') {
await browserRef.value?.load(agentId.value);
if (activeTab.value === 'security' && agentType.value === 'openclaw') {
await securityRef.value?.load(agentId.value);
return;
}
if (activeTab.value === 'other') {
@@ -39,9 +41,10 @@ const handleTabClick = async () => {
await loadCurrentTab();
};
const load = async (id: number) => {
agentId.value = id;
activeTab.value = 'browser';
const load = async (agent: AI.AgentItem) => {
agentId.value = agent.id;
agentType.value = agent.agentType;
activeTab.value = agent.agentType === 'openclaw' ? 'security' : 'other';
await loadCurrentTab();
};
@@ -1,87 +0,0 @@
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" v-loading="loading">
<el-form-item :label="t('aiTools.agents.browserEnabled')">
<el-switch v-model="form.enabled" />
</el-form-item>
<el-form-item :label="t('aiTools.agents.headless')">
<el-switch v-model="form.headless" />
</el-form-item>
<el-form-item :label="t('aiTools.agents.noSandbox')">
<el-switch v-model="form.noSandbox" />
</el-form-item>
<el-form-item :label="t('aiTools.agents.defaultProfile')" prop="defaultProfile">
<el-input v-model="form.defaultProfile" />
</el-form-item>
<el-form-item :label="t('aiTools.agents.executablePath')">
<el-input v-model="form.executablePath" disabled />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveConfig">
{{ t('commons.button.save') }}
</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import type { FormInstance } from 'element-plus';
import { useI18n } from 'vue-i18n';
import { Rules } from '@/global/form-rules';
import { AI } from '@/api/interface/ai';
import { getAgentBrowserConfig, updateAgentBrowserConfig } from '@/api/modules/ai';
import { MsgSuccess } from '@/utils/message';
const { t } = useI18n();
const loading = ref(false);
const saving = ref(false);
const agentId = ref(0);
const formRef = ref<FormInstance>();
const form = reactive<AI.AgentBrowserConfig>({
enabled: true,
executablePath: '',
headless: true,
noSandbox: true,
defaultProfile: 'openclaw',
});
const rules = reactive({
defaultProfile: [Rules.requiredInput],
});
const load = async (id: number) => {
agentId.value = id;
loading.value = true;
try {
const res = await getAgentBrowserConfig({ agentId: id });
Object.assign(form, res.data || {});
} finally {
loading.value = false;
}
};
const saveConfig = async () => {
if (!agentId.value || !formRef.value) {
return;
}
await formRef.value.validate();
saving.value = true;
try {
await updateAgentBrowserConfig({
agentId: agentId.value,
enabled: form.enabled,
headless: form.headless,
noSandbox: form.noSandbox,
defaultProfile: form.defaultProfile,
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
} finally {
saving.value = false;
}
};
defineExpose({
load,
});
</script>
@@ -1,5 +1,8 @@
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" v-loading="loading">
<el-form-item :label="t('aiTools.agents.browserEnabled')">
<el-switch v-model="form.browserEnabled" />
</el-form-item>
<el-form-item :label="t('aiTools.agents.timeZone')" prop="userTimezone">
<el-input v-model="form.userTimezone" />
</el-form-item>
@@ -28,6 +31,7 @@ const formRef = ref<FormInstance>();
const form = reactive<AI.AgentOtherConfig>({
userTimezone: '',
browserEnabled: true,
});
const rules = reactive({
@@ -55,6 +59,7 @@ const saveConfig = async () => {
await updateAgentOtherConfig({
agentId: agentId.value,
userTimezone: form.userTimezone,
browserEnabled: form.browserEnabled,
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
} finally {
@@ -0,0 +1,87 @@
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" v-loading="loading">
<el-form-item :label="t('aiTools.agents.allowedOrigins')" prop="allowedOrigins">
<el-input
v-model="form.allowedOrigins"
type="textarea"
:rows="4"
:placeholder="t('aiTools.agents.allowedOriginsPlaceholder')"
/>
<span class="input-help">
{{ t('aiTools.agents.allowedOriginsHelper') }}
</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveConfig">
{{ t('commons.button.save') }}
</el-button>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import type { FormInstance } from 'element-plus';
import { useI18n } from 'vue-i18n';
import { getAgentSecurityConfig, updateAgentSecurityConfig } from '@/api/modules/ai';
import { MsgSuccess } from '@/utils/message';
import { parseAllowedOriginsInput, validateAllowedOriginsInput } from '@/utils/agent';
const { t } = useI18n();
const loading = ref(false);
const saving = ref(false);
const agentId = ref(0);
const formRef = ref<FormInstance>();
const form = reactive({
allowedOrigins: '',
});
const rules = reactive({
allowedOrigins: [
{
validator: (_rule: any, value: any, callback: (error?: Error) => void) => {
const message = validateAllowedOriginsInput(String(value || ''), { httpsOnly: true });
if (message) {
callback(new Error(message));
return;
}
callback();
},
trigger: 'blur',
},
],
});
const load = async (id: number) => {
agentId.value = id;
loading.value = true;
try {
const res = await getAgentSecurityConfig({ agentId: id });
form.allowedOrigins = (res.data?.allowedOrigins || []).join('\n');
} finally {
loading.value = false;
}
};
const saveConfig = async () => {
if (!agentId.value || !formRef.value) {
return;
}
await formRef.value.validate();
saving.value = true;
try {
await updateAgentSecurityConfig({
agentId: agentId.value,
allowedOrigins: parseAllowedOriginsInput(form.allowedOrigins, { httpsOnly: true }),
});
MsgSuccess(t('aiTools.agents.saveSuccess'));
} finally {
saving.value = false;
}
};
defineExpose({
load,
});
</script>
+47 -18
View File
@@ -21,17 +21,22 @@
prop="name"
min-width="120"
/>
<el-table-column
:label="`${$t('aiTools.agents.agents')}${$t('commons.table.type')}`"
prop="agentType"
min-width="120"
>
<el-table-column :label="$t('commons.table.type')" prop="agentType" min-width="120">
<template #default="{ row }">
{{
row.agentType === 'copaw'
? $t('aiTools.agents.copawType')
: $t('aiTools.agents.openclawType')
}}
<div class="agent-type-cell">
<img
class="agent-type-icon"
:src="row.agentType === 'copaw' ? copawIcon : openclawIcon"
:alt="row.agentType === 'copaw' ? 'CoPaw' : 'OpenClaw'"
/>
<span>
{{
row.agentType === 'copaw'
? $t('aiTools.agents.copawType')
: $t('aiTools.agents.openclawType')
}}
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" prop="status" width="120">
@@ -70,11 +75,6 @@
<el-button icon="Position" plain size="small" @click="jumpWebUI(row)">
{{ $t('aiTools.agents.webuiPort') }}: {{ row.webUIPort }}
</el-button>
<div v-if="row.agentType !== 'copaw'" class="mt-0.5">
<el-button plain size="small">
{{ $t('aiTools.agents.bridgePort') }} {{ row.bridgePort }}
</el-button>
</div>
</template>
</el-table-column>
<el-table-column :label="$t('runtime.workDir')" min-width="90">
@@ -149,7 +149,10 @@ import PortJumpDialog from '@/components/port-jump/index.vue';
import DockerStatus from '@/views/container/docker-status/index.vue';
import { getAgentProviderDisplayName } from '@/utils/agent';
import { routerToFileWithPath } from '@/utils/router';
import { compareVersion } from '@/utils/version';
import NoApp from '@/views/app-store/apps/no-app/index.vue';
import openclawIcon from '@/assets/images/ai-agent-openclaw.svg';
import copawIcon from '@/assets/images/ai-agent-copaw.svg';
const items = ref<AI.AgentItem[]>([]);
const loading = ref(false);
@@ -314,13 +317,26 @@ const openWorkDir = (row: AI.AgentItem) => {
routerToFileWithPath(`${row.path}/data`);
};
const isOpenClawHttpsVersion = (version: string) => {
const target = String(version || '')
.trim()
.toLowerCase();
if (!target || target === 'latest') {
return true;
}
if (!/\d/.test(target)) {
return true;
}
return compareVersion(target, '2026.3.13');
};
const jumpWebUI = (row: AI.AgentItem) => {
const query = row.agentType === 'copaw' ? '' : `token=${row.token}`;
if (dialogPortJumpRef.value?.acceptParams) {
dialogPortJumpRef.value.acceptParams({
port: row.webUIPort,
protocol: 'http',
query,
protocol: row.agentType === 'openclaw' && isOpenClawHttpsVersion(row.appVersion) ? 'https' : 'http',
path: row.agentType === 'copaw' ? undefined : '/',
hash: row.agentType === 'copaw' ? undefined : `token=${row.token}`,
});
}
};
@@ -372,4 +388,17 @@ onMounted(async () => {
align-items: center;
gap: 4px;
}
.agent-type-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.agent-type-icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
object-fit: contain;
}
</style>
+1 -1
View File
@@ -128,7 +128,7 @@ const verificationLabel = (row: AI.AgentAccountItem) => {
const verificationTagType = (row: AI.AgentAccountItem) => {
if (isVerificationSkipped(row.provider)) {
return 'warning';
return 'info';
}
return row.verified ? 'success' : 'info';
};
+15 -2
View File
@@ -1,6 +1,6 @@
<template>
<div>
<RouterMenu />
<RouterButton :buttons="buttons" />
<LayoutContent>
<router-view></router-view>
</LayoutContent>
@@ -8,5 +8,18 @@
</template>
<script lang="ts" setup>
import RouterMenu from '@/views/ai/model/router-menu.vue';
const buttons = [
{
label: 'Ollama',
path: '/ai/model/ollama',
},
{
label: 'vLLM',
path: '/ai/model/vllm',
},
{
label: 'TensorRT LLM',
path: '/ai/model/tensorrt',
},
];
</script>
+1 -1
View File
@@ -179,9 +179,9 @@ import Conn from '@/views/ai/model/ollama/conn/index.vue';
import TaskLog from '@/components/log/task/index.vue';
import Terminal from '@/views/ai/model/ollama/terminal/index.vue';
import Del from '@/views/ai/model/ollama/del/index.vue';
import RouterMenu from '@/views/ai/model/router-menu.vue';
import PortJumpDialog from '@/components/port-jump/index.vue';
import CodemirrorDrawer from '@/components/codemirror-pro/drawer.vue';
import RouterMenu from '@/views/ai/model/router-menu.vue';
import { computed, onMounted, reactive, ref } from 'vue';
import i18n from '@/lang';
import { App } from '@/api/interface/app';
+1 -67
View File
@@ -1,25 +1,8 @@
<template>
<el-card class="router_card p-1 sm:p-0">
<div class="flex w-full flex-col justify-start items-start sm:flex-row sm:items-center sm:justify-between">
<el-radio-group v-model="activePath" @change="handleChange">
<el-radio-button
v-for="item in buttons"
:key="item.path"
class="router_card_button"
:value="item.path"
size="large"
>
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
</el-card>
<RouterButton :buttons="buttons" />
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const buttons = [
{
label: 'Ollama',
@@ -34,53 +17,4 @@ const buttons = [
path: '/ai/model/tensorrt',
},
];
const route = useRoute();
const router = useRouter();
const activePath = ref('');
watch(
() => route.path,
(path) => {
activePath.value = path;
},
{ immediate: true },
);
const handleChange = async (path: string) => {
if (!path || path === route.path) {
return;
}
await router.push({ path });
};
</script>
<style lang="scss">
.router_card {
--el-card-padding: 0;
.el-card__body {
display: flex;
justify-content: space-between;
align-items: center;
}
}
.router_card_button {
.el-radio-button__inner {
min-width: 100px;
height: 100%;
background-color: var(--panel-button-active) !important;
box-shadow: none !important;
border: 2px solid transparent !important;
color: var(--el-text-color-regular) !important;
}
.el-radio-button__original-radio:checked + .el-radio-button__inner {
color: var(--panel-button-text-color) !important;
background-color: var(--panel-button-bg-color) !important;
border-color: var(--panel-color-primary) !important;
border-radius: 4px;
}
}
</style>

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