mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-10-09 08:00:27 +00:00
Compare commits
29
Commits
v2.1.9
...
release-2.1.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c392b72470 | ||
|
|
50a70ef1e5 | ||
|
|
75266ef659 | ||
|
|
b3c593b852 | ||
|
|
73530bb8b9 | ||
|
|
1b6e70964b | ||
|
|
61d42b05e9 | ||
|
|
79703e3c71 | ||
|
|
aee4e4e282 | ||
|
|
744c7559f3 | ||
|
|
d94c3ec9cc | ||
|
|
f2625eab16 | ||
|
|
c6d498a9be | ||
|
|
b06bc0a455 | ||
|
|
68411bddd9 | ||
|
|
a2ac2e0b22 | ||
|
|
a5a5d9f1ac | ||
|
|
82876eac8d | ||
|
|
19c9c0f4e5 | ||
|
|
70f044dc50 | ||
|
|
1c8418026c | ||
|
|
17f8471c14 | ||
|
|
4deb2ea484 | ||
|
|
d05317dbf5 | ||
|
|
1db6ccd72b | ||
|
|
bc00760404 | ||
|
|
e57dc1240b | ||
|
|
4c396d68ca | ||
|
|
ab0fe4286a |
@@ -42,6 +42,7 @@ frontend/components.d.ts
|
||||
frontend/src/xpack
|
||||
frontend/src/xpack-ee
|
||||
agent/xpack
|
||||
agent/xpack-ee
|
||||
core/xpack
|
||||
core/xpack-ee
|
||||
|
||||
|
||||
@@ -292,6 +292,26 @@ func (b *BaseApi) DeCompressFile(c *gin.Context) {
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags File
|
||||
// @Summary Stop decompress task
|
||||
// @Accept json
|
||||
// @Param request body request.FileDeCompressStopReq true "request"
|
||||
// @Success 200
|
||||
// @Security ApiKeyAuth
|
||||
// @Security Timestamp
|
||||
// @Router /files/decompress/stop [post]
|
||||
func (b *BaseApi) StopDeCompressFile(c *gin.Context) {
|
||||
var req request.FileDeCompressStopReq
|
||||
if err := helper.CheckBindAndValidate(&req, c); err != nil {
|
||||
return
|
||||
}
|
||||
if err := fileService.StopDeCompress(req.TaskID); err != nil {
|
||||
helper.InternalServer(c, err)
|
||||
return
|
||||
}
|
||||
helper.Success(c)
|
||||
}
|
||||
|
||||
// @Tags File
|
||||
// @Summary Load file content
|
||||
// @Accept json
|
||||
@@ -391,7 +411,7 @@ func (b *BaseApi) UploadFiles(c *gin.Context) {
|
||||
helper.BadRequest(c, errors.New("error paths in request"))
|
||||
return
|
||||
}
|
||||
dir := path.Dir(paths[0])
|
||||
dir := path.Clean(paths[0])
|
||||
|
||||
_, err = os.Stat(dir)
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
@@ -432,8 +452,14 @@ func (b *BaseApi) UploadFiles(c *gin.Context) {
|
||||
}
|
||||
_ = os.Chown(dstDir, uid, gid)
|
||||
}
|
||||
dstDirMode := mode
|
||||
dstFileMode := mode.Perm()
|
||||
if dstDirInfo, err := os.Stat(dstDir); err == nil {
|
||||
dstDirMode = dstDirInfo.Mode()
|
||||
dstFileMode = dstDirInfo.Mode().Perm()
|
||||
}
|
||||
tmpFilename := dstFilename + ".tmp"
|
||||
if err := c.SaveUploadedFile(file, tmpFilename); err != nil {
|
||||
if err := c.SaveUploadedFile(file, tmpFilename, dstDirMode); err != nil {
|
||||
_ = os.Remove(tmpFilename)
|
||||
e := fmt.Errorf("upload [%s] file failed, err: %v", file.Filename, err)
|
||||
failures[file.Filename] = e
|
||||
@@ -456,7 +482,7 @@ func (b *BaseApi) UploadFiles(c *gin.Context) {
|
||||
if statErr == nil {
|
||||
_ = os.Chmod(dstFilename, dstInfo.Mode())
|
||||
} else {
|
||||
_ = os.Chmod(dstFilename, mode)
|
||||
_ = os.Chmod(dstFilename, dstFileMode)
|
||||
}
|
||||
if uid != -1 && gid != -1 {
|
||||
_ = os.Chown(dstFilename, uid, gid)
|
||||
@@ -767,12 +793,13 @@ func mergeChunks(fileName string, fileDir string, dstDir string, chunkCount int,
|
||||
return err
|
||||
}
|
||||
}
|
||||
if dstDirInfo, err := os.Stat(dstDir); err == nil {
|
||||
mode = dstDirInfo.Mode().Perm()
|
||||
}
|
||||
dstFileName := filepath.Join(dstDir, fileName)
|
||||
dstInfo, statErr := os.Stat(dstFileName)
|
||||
if statErr == nil {
|
||||
mode = dstInfo.Mode()
|
||||
} else {
|
||||
mode = 0644
|
||||
}
|
||||
if overwrite {
|
||||
_ = os.Remove(dstFileName)
|
||||
@@ -794,6 +821,7 @@ func mergeChunks(fileName string, fileDir string, dstDir string, chunkCount int,
|
||||
}
|
||||
_ = os.Remove(chunkPath)
|
||||
}
|
||||
_ = os.Chmod(dstFileName, mode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,6 +90,11 @@ type FileDeCompress struct {
|
||||
Type string `json:"type" validate:"required"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
Secret string `json:"secret"`
|
||||
TaskID string `json:"taskID"`
|
||||
}
|
||||
|
||||
type FileDeCompressStopReq struct {
|
||||
TaskID string `json:"taskID" validate:"required"`
|
||||
}
|
||||
|
||||
type FileEdit struct {
|
||||
|
||||
@@ -83,8 +83,8 @@ var catalog = map[string]Meta{
|
||||
Input: []string{"text"},
|
||||
},
|
||||
Models: []Model{
|
||||
{ID: "deepseek/deepseek-chat", Name: "DeepSeek Chat"},
|
||||
{ID: "deepseek/deepseek-reasoner", Name: "DeepSeek Reasoner", MaxTokens: 65536, ContextWindow: 131072, Reasoning: true},
|
||||
{ID: "deepseek/deepseek-v4-flash", Name: "deepseek-v4-flash"},
|
||||
{ID: "deepseek/deepseek-v4-pro", Name: "deepseek-v4-pro"},
|
||||
},
|
||||
},
|
||||
"bailian-coding-plan": {
|
||||
|
||||
@@ -77,11 +77,15 @@ func writeHermesConfig(confDir string, account *model.AgentAccount, modelName st
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["model"] = map[string]interface{}{
|
||||
model := map[string]interface{}{
|
||||
"default": resolveHermesModel(account.Provider, provider, modelName),
|
||||
"provider": provider,
|
||||
"base_url": account.BaseURL,
|
||||
}
|
||||
if provider == "custom" && account.APIKey != "" {
|
||||
model["api_key"] = "${CUSTOM_API_KEY}"
|
||||
}
|
||||
cfg["model"] = model
|
||||
cfg["terminal"] = map[string]interface{}{
|
||||
"backend": "local",
|
||||
"cwd": hermesWorkspaceDir,
|
||||
@@ -94,7 +98,7 @@ func writeHermesConfig(confDir string, account *model.AgentAccount, modelName st
|
||||
if err := writeHermesConfigMap(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeHermesEnv(path.Join(confDir, ".env"), resolveHermesEnvEntries(account))
|
||||
return writeHermesModelEnv(path.Join(confDir, ".env"), account)
|
||||
}
|
||||
|
||||
func prepareHermesInstallFiles(appInstall *model.AppInstall, account *model.AgentAccount, modelName string) error {
|
||||
@@ -458,6 +462,14 @@ func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
|
||||
if account == nil {
|
||||
return nil
|
||||
}
|
||||
if resolveHermesProvider(account.Provider) == "custom" {
|
||||
if account.APIKey == "" {
|
||||
return nil
|
||||
}
|
||||
return []hermesEnvEntry{
|
||||
{Key: "CUSTOM_API_KEY", Value: account.APIKey},
|
||||
}
|
||||
}
|
||||
apiKey := account.APIKey
|
||||
baseURL := account.BaseURL
|
||||
entries := make([]hermesEnvEntry, 0, 4)
|
||||
@@ -519,6 +531,73 @@ func resolveHermesEnvEntries(account *model.AgentAccount) []hermesEnvEntry {
|
||||
return entries
|
||||
}
|
||||
|
||||
func writeHermesModelEnv(envPath string, account *model.AgentAccount) error {
|
||||
envMap, err := readHermesEnvMap(envPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range hermesManagedModelEnvKeys() {
|
||||
delete(envMap, key)
|
||||
}
|
||||
entries := resolveHermesEnvEntries(account)
|
||||
order := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Key == "" || entry.Value == "" {
|
||||
continue
|
||||
}
|
||||
envMap[entry.Key] = entry.Value
|
||||
order = append(order, entry.Key)
|
||||
}
|
||||
return writeHermesEnvMap(envPath, envMap, order)
|
||||
}
|
||||
|
||||
func hermesManagedModelEnvKeys() []string {
|
||||
keys := []string{
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENROUTER_BASE_URL",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GEMINI_BASE_URL",
|
||||
"GLM_API_KEY",
|
||||
"ZAI_API_KEY",
|
||||
"Z_AI_API_KEY",
|
||||
"GLM_BASE_URL",
|
||||
"KIMI_API_KEY",
|
||||
"KIMI_CN_API_KEY",
|
||||
"KIMI_BASE_URL",
|
||||
"MINIMAX_CN_API_KEY",
|
||||
"MINIMAX_CN_BASE_URL",
|
||||
"XIAOMI_API_KEY",
|
||||
"XIAOMI_BASE_URL",
|
||||
"DEEPSEEK_API_KEY",
|
||||
"DEEPSEEK_BASE_URL",
|
||||
"DASHSCOPE_API_KEY",
|
||||
"DASHSCOPE_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
}
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
result := make([]string, 0, len(keys))
|
||||
appendKey := func(key string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, key)
|
||||
}
|
||||
for _, key := range keys {
|
||||
appendKey(key)
|
||||
}
|
||||
for _, meta := range providercatalog.All() {
|
||||
appendKey(meta.EnvKey)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func writeHermesEnv(envPath string, entries []hermesEnvEntry) error {
|
||||
envMap, err := readHermesEnvMap(envPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -896,7 +896,7 @@ func getAppFromRepo(downloadPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := fileOp.Decompress(packagePath, global.Dir.ResourceDir, files.SdkZip, ""); err != nil {
|
||||
if err := fileOp.Decompress(context.Background(), packagePath, global.Dir.ResourceDir, files.SdkZip, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
|
||||
@@ -1034,7 +1034,7 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
|
||||
}
|
||||
return
|
||||
}
|
||||
if err = fileOp.Decompress(filePath, appResourceDir, files.SdkTarGz, ""); err != nil {
|
||||
if err = fileOp.Decompress(context.Background(), filePath, appResourceDir, files.SdkTarGz, ""); err != nil {
|
||||
if logger == nil {
|
||||
global.LOG.Errorf("decompress app[%s] error %v", app.Name, err)
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
@@ -263,7 +264,7 @@ func loadSqlFile(file string) (string, error) {
|
||||
_ = os.RemoveAll(dstDir)
|
||||
return "", err
|
||||
}
|
||||
if err := archiver.Extract(file, dstDir, ""); err != nil {
|
||||
if err := archiver.Extract(context.Background(), file, dstDir, ""); err != nil {
|
||||
_ = os.RemoveAll(dstDir)
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -1076,14 +1076,18 @@ func (u *ContainerService) DownloadContainerLogs(containerType, container, since
|
||||
}
|
||||
stdout, err := dockerCmd.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = dockerCmd.Process.Signal(syscall.SIGTERM)
|
||||
return err
|
||||
}
|
||||
dockerCmd.Stderr = dockerCmd.Stdout
|
||||
if err := dockerCmd.Start(); err != nil {
|
||||
_ = dockerCmd.Process.Signal(syscall.SIGTERM)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if dockerCmd.Process != nil {
|
||||
_ = dockerCmd.Process.Kill()
|
||||
_ = dockerCmd.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
tempFile, err := os.CreateTemp("", "cmd_output_*.txt")
|
||||
if err != nil {
|
||||
@@ -1095,7 +1099,7 @@ func (u *ContainerService) DownloadContainerLogs(containerType, container, since
|
||||
global.LOG.Errorf("os.Remove() failed: %v", err)
|
||||
}
|
||||
}()
|
||||
errCh := make(chan error)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
var ansiRegex = re.GetRegex(re.AnsiEscapePattern)
|
||||
@@ -1118,8 +1122,8 @@ func (u *ContainerService) DownloadContainerLogs(containerType, container, since
|
||||
if err != nil {
|
||||
global.LOG.Errorf("Error: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
global.LOG.Errorf("Timeout reached")
|
||||
case <-time.After(40 * time.Second):
|
||||
global.LOG.Errorf("Download container logs timeout reached")
|
||||
}
|
||||
info, _ := tempFile.Stat()
|
||||
|
||||
|
||||
+218
-9
@@ -49,6 +49,8 @@ import (
|
||||
type FileService struct {
|
||||
}
|
||||
|
||||
const fileHistorySnapshotMaxSize = 10 * 1024 * 1024
|
||||
|
||||
type IFileService interface {
|
||||
GetFileList(op request.FileOption) (response.FileInfo, error)
|
||||
SearchUploadWithPage(req request.SearchUploadWithPage) (int64, interface{}, error)
|
||||
@@ -59,6 +61,7 @@ type IFileService interface {
|
||||
Compress(c request.FileCompress) error
|
||||
StopCompress(taskID string) error
|
||||
DeCompress(c request.FileDeCompress) error
|
||||
StopDeCompress(taskID string) error
|
||||
GetContent(op request.FileContentReq) (response.FileInfo, error)
|
||||
GetPreviewContent(op request.FileContentReq) (response.FileInfo, error)
|
||||
SaveContent(edit request.FileEdit) error
|
||||
@@ -472,6 +475,16 @@ func preflightCompressTool(compressType files.CompressType) error {
|
||||
}
|
||||
}
|
||||
|
||||
func preflightDecompressTool(decompressType files.CompressType) error {
|
||||
switch decompressType {
|
||||
case files.Rar, files.X7z:
|
||||
_, err := files.NewExtractShellArchiver(decompressType)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FileService) StopCompress(taskID string) error {
|
||||
if cancel, ok := global.TaskCtxMap[taskID]; ok {
|
||||
cancel()
|
||||
@@ -480,12 +493,180 @@ func (f *FileService) StopCompress(taskID string) error {
|
||||
return buserr.New("TaskNotFound")
|
||||
}
|
||||
|
||||
func (f *FileService) StopDeCompress(taskID string) error {
|
||||
if cancel, ok := global.TaskCtxMap[taskID]; ok {
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
return buserr.New("TaskNotFound")
|
||||
}
|
||||
|
||||
func (f *FileService) DeCompress(c request.FileDeCompress) error {
|
||||
fo := files.NewFileOp()
|
||||
if c.Type == "tar" && len(c.Secret) != 0 {
|
||||
c.Type = "tar.gz"
|
||||
}
|
||||
return fo.Decompress(c.Path, c.Dst, files.CompressType(c.Type), c.Secret)
|
||||
if err := preflightDecompressTool(files.CompressType(c.Type)); err != nil {
|
||||
return err
|
||||
}
|
||||
taskItem, err := task.NewTask(c.Path, task.TaskExec, task.TaskScopeTask, c.TaskID, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
taskItem.AddSubTask(c.Path, func(t *task.Task) error {
|
||||
t.LogStart(c.Path)
|
||||
dstExisted := fo.Stat(c.Dst)
|
||||
parentDir := filepath.Dir(c.Dst)
|
||||
if !fo.Stat(parentDir) {
|
||||
if err := fo.CreateDir(parentDir, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tempDst, err := os.MkdirTemp(parentDir, ".decompress-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
_ = os.RemoveAll(tempDst)
|
||||
if !success && !dstExisted {
|
||||
_ = os.RemoveAll(c.Dst)
|
||||
}
|
||||
}()
|
||||
if err := fo.Decompress(t.TaskCtx, c.Path, tempDst, files.CompressType(c.Type), c.Secret); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fo.CreateDir(c.Dst, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyDecompressTree(t.TaskCtx, tempDst, c.Dst); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}, nil)
|
||||
_ = taskItem.Execute()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyDecompressTree(ctx context.Context, srcDir, dstDir string) error {
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyDecompressEntry(ctx, filepath.Join(srcDir, entry.Name()), filepath.Join(dstDir, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyDecompressEntry(ctx context.Context, srcPath, dstPath string) (retErr error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Lstat(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
if err := os.RemoveAll(dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := os.Readlink(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(target, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return applyDecompressOwnership(srcPath, dstPath)
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
dstInfo, err := os.Lstat(dstPath)
|
||||
keepExistingDir := err == nil && dstInfo.IsDir() && dstInfo.Mode()&os.ModeSymlink == 0
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if !keepExistingDir {
|
||||
if err := os.RemoveAll(dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dstPath, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyDecompressOwnership(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyDecompressEntry(ctx, filepath.Join(srcPath, entry.Name()), filepath.Join(dstPath, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if keepExistingDir {
|
||||
return nil
|
||||
}
|
||||
return os.Chtimes(dstPath, info.ModTime(), info.ModTime())
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := dstFile.Close(); cerr != nil && retErr == nil {
|
||||
retErr = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyDecompressOwnership(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chtimes(dstPath, info.ModTime(), info.ModTime())
|
||||
}
|
||||
|
||||
func applyDecompressOwnership(srcPath, dstPath string) error {
|
||||
info, err := os.Lstat(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stat, ok := info.Sys().(*unix.Stat_t)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return os.Lchown(dstPath, int(stat.Uid), int(stat.Gid))
|
||||
}
|
||||
return os.Chown(dstPath, int(stat.Uid), int(stat.Gid))
|
||||
}
|
||||
|
||||
func (f *FileService) GetContent(op request.FileContentReq) (response.FileInfo, error) {
|
||||
@@ -630,11 +811,11 @@ func (f *FileService) ChangeName(req request.FileRename) error {
|
||||
}
|
||||
fo := files.NewFileOp()
|
||||
info, _ := files.NewFileInfo(files.FileOption{Path: req.OldName, Expand: false})
|
||||
content, _ := os.ReadFile(req.OldName)
|
||||
content, shouldRecordHistory := readEditableFileHistoryContent(req.OldName, info)
|
||||
if err := fo.Rename(req.OldName, req.NewName); err != nil {
|
||||
return err
|
||||
}
|
||||
if info != nil && !info.IsDir {
|
||||
if shouldRecordHistory {
|
||||
if histErr := historyService.RecordOperation(fileHistoryOpRename, req.OldName, content, info.FileMode, req.OldName, req.NewName); histErr != nil {
|
||||
global.LOG.Warnf("record file rename history failed for %s: %v", req.OldName, histErr)
|
||||
}
|
||||
@@ -665,18 +846,18 @@ func (f *FileService) MvFile(m request.FileMove) error {
|
||||
path string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
isDir bool
|
||||
record bool
|
||||
}
|
||||
snapshots := make([]moveSnapshot, 0, len(m.OldPaths))
|
||||
for _, oldPath := range m.OldPaths {
|
||||
content, _ := os.ReadFile(oldPath)
|
||||
mode := os.FileMode(0640)
|
||||
isDir := false
|
||||
record := false
|
||||
var content []byte
|
||||
if info, err := files.NewFileInfo(files.FileOption{Path: oldPath, Expand: false}); err == nil {
|
||||
mode = info.FileMode
|
||||
isDir = info.IsDir
|
||||
content, record = readEditableFileHistoryContent(oldPath, info)
|
||||
}
|
||||
snapshots = append(snapshots, moveSnapshot{path: oldPath, content: content, mode: mode, isDir: isDir})
|
||||
snapshots = append(snapshots, moveSnapshot{path: oldPath, content: content, mode: mode, record: record})
|
||||
}
|
||||
var errs []error
|
||||
if m.Type == "cut" {
|
||||
@@ -692,7 +873,7 @@ func (f *FileService) MvFile(m request.FileMove) error {
|
||||
return err
|
||||
}
|
||||
for _, snapshot := range snapshots {
|
||||
if !snapshot.isDir {
|
||||
if snapshot.record {
|
||||
targetPath := buildHistoryMoveTargetPath(m.NewPath, m.Name, snapshot.path, len(m.OldPaths))
|
||||
if histErr := historyService.RecordOperation(fileHistoryOpMove, snapshot.path, snapshot.content, snapshot.mode, snapshot.path, targetPath); histErr != nil {
|
||||
global.LOG.Warnf("record file move history failed for %s: %v", snapshot.path, histErr)
|
||||
@@ -728,6 +909,34 @@ func (f *FileService) MvFile(m request.FileMove) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func readEditableFileHistoryContent(filePath string, info *files.FileInfo) ([]byte, bool) {
|
||||
if info == nil || info.IsDir || files.IsBlockDevice(info.FileMode) || info.Size > fileHistorySnapshotMaxSize {
|
||||
return nil, false
|
||||
}
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
headBuf := make([]byte, 1024)
|
||||
n, err := file.Read(headBuf)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, false
|
||||
}
|
||||
if n > 0 && files.DetectBinary(headBuf[:n]) {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(file, fileHistorySnapshotMaxSize+1))
|
||||
if err != nil || int64(len(content)) > fileHistorySnapshotMaxSize {
|
||||
return nil, false
|
||||
}
|
||||
return content, true
|
||||
}
|
||||
|
||||
func buildHistoryMoveTargetPath(dst, name, sourcePath string, sourceCount int) string {
|
||||
if strings.TrimSpace(dst) == "" {
|
||||
return sourcePath
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -46,6 +47,28 @@ import (
|
||||
type WebsiteService struct {
|
||||
}
|
||||
|
||||
func buildRedirectHostPattern(domain string) string {
|
||||
labels := strings.Split(domain, ".")
|
||||
patternLabels := make([]string, 0, len(labels))
|
||||
for _, label := range labels {
|
||||
if label == "*" {
|
||||
patternLabels = append(patternLabels, `[^.]+`)
|
||||
continue
|
||||
}
|
||||
patternLabels = append(patternLabels, regexp.QuoteMeta(label))
|
||||
}
|
||||
return fmt.Sprintf("^%s$", strings.Join(patternLabels, `\.`))
|
||||
}
|
||||
|
||||
func parseRedirectHostPattern(pattern string) string {
|
||||
pattern = strings.Trim(pattern, "'")
|
||||
pattern = strings.TrimPrefix(pattern, "^")
|
||||
pattern = strings.TrimSuffix(pattern, "$")
|
||||
pattern = strings.ReplaceAll(pattern, `[^.]+`, "*")
|
||||
pattern = strings.ReplaceAll(pattern, `\.`, ".")
|
||||
return pattern
|
||||
}
|
||||
|
||||
type IWebsiteService interface {
|
||||
PageWebsite(req request.WebsiteSearch) (int64, []response.WebsiteRes, error)
|
||||
GetWebsites() ([]response.WebsiteDTO, error)
|
||||
@@ -1728,7 +1751,7 @@ func (w WebsiteService) OperateRedirect(req request.NginxRedirectReq) (err error
|
||||
for _, domain := range req.Domains {
|
||||
block.Directives = append(block.Directives, &components.Directive{
|
||||
Name: "if",
|
||||
Parameters: []string{"($host", "~", fmt.Sprintf("'^%s')", domain)},
|
||||
Parameters: []string{"($host", "~", fmt.Sprintf("'%s')", buildRedirectHostPattern(domain))},
|
||||
Block: returnBlock,
|
||||
})
|
||||
}
|
||||
@@ -1834,7 +1857,7 @@ func (w WebsiteService) GetRedirect(id uint) (res []response.NginxRedirectConfig
|
||||
for _, ifDir := range dirs {
|
||||
params := ifDir.GetParameters()
|
||||
if len(params) > 2 && params[0] == "($host" {
|
||||
domain := strings.Trim(strings.Trim(params[2], "'"), "^")
|
||||
domain := parseRedirectHostPattern(params[2])
|
||||
redirectConfig.Domains = append(redirectConfig.Domains, domain)
|
||||
if len(redirectConfig.Domains) > 1 {
|
||||
continue
|
||||
|
||||
@@ -188,7 +188,7 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
|
||||
existDomains = append(existDomains, strings.Split(websiteSSL.Domains, ",")...)
|
||||
}
|
||||
for _, domain := range existDomains {
|
||||
if ipAddress := net.ParseIP(domain); ipAddress == nil {
|
||||
if ipAddress := common.ParseIPLoose(domain); ipAddress == nil {
|
||||
domains = append(domains, domain)
|
||||
} else {
|
||||
ips = append(ips, ipAddress)
|
||||
@@ -220,7 +220,7 @@ func (w WebsiteCAService) ObtainSSL(req request.WebsiteCAObtain) (*model.Website
|
||||
if req.Domains != "" {
|
||||
domainArray := strings.Split(req.Domains, "\n")
|
||||
for _, domain := range domainArray {
|
||||
if ipAddress := net.ParseIP(domain); ipAddress == nil {
|
||||
if ipAddress := common.ParseIPLoose(domain); ipAddress == nil {
|
||||
if domain != "localhost" && !common.IsValidDomain(domain) {
|
||||
err = buserr.WithName("ErrDomainFormat", domain)
|
||||
return nil, err
|
||||
|
||||
@@ -25,6 +25,7 @@ func (f *FileRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
fileRouter.POST("/compress", baseApi.CompressFile)
|
||||
fileRouter.POST("/compress/stop", baseApi.StopCompressFile)
|
||||
fileRouter.POST("/decompress", baseApi.DeCompressFile)
|
||||
fileRouter.POST("/decompress/stop", baseApi.StopDeCompressFile)
|
||||
fileRouter.POST("/content", baseApi.GetContent)
|
||||
fileRouter.POST("/preview", baseApi.PreviewContent)
|
||||
fileRouter.POST("/save", baseApi.SaveContent)
|
||||
|
||||
@@ -29,6 +29,16 @@ func SudoHandleCmd() string {
|
||||
}
|
||||
|
||||
func Which(name string) bool {
|
||||
// Prefer Go's built-in PATH lookup so we don't depend on an external
|
||||
// `which` binary, which is not installed by default on minimal
|
||||
// distributions (e.g. Arch Linux, some Alpine images, slim containers).
|
||||
// See 1Panel-dev/1Panel#12605.
|
||||
if _, err := exec.LookPath(name); err == nil {
|
||||
return true
|
||||
}
|
||||
// Fall back to shelling out for environments where PATH inside the
|
||||
// agent process differs from the user's interactive shell PATH (the
|
||||
// previous behaviour, preserved for compatibility).
|
||||
stdout, err := RunDefaultWithStdoutBashCf("which %s", name)
|
||||
if err != nil || (len(strings.ReplaceAll(stdout, "\n", "")) == 0) {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestWhich_ExistingBinary verifies that Which() returns true for a
|
||||
// binary that is guaranteed to exist on every Unix-like build host
|
||||
// (`sh`). Regression test for #12605: the previous implementation
|
||||
// shelled out to `which`, which is not always available on minimal
|
||||
// distributions like Arch Linux. The new implementation tries
|
||||
// exec.LookPath first, so this assertion holds regardless of whether
|
||||
// `which` itself is on PATH.
|
||||
func TestWhich_ExistingBinary(t *testing.T) {
|
||||
if !Which("sh") {
|
||||
t.Errorf("Which(\"sh\") = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhich_MissingBinary(t *testing.T) {
|
||||
// A binary name that is extremely unlikely to exist on any host.
|
||||
if Which("definitely-not-a-real-binary-xyzzy-1panel") {
|
||||
t.Errorf("Which(\"definitely-not-a-real-binary-xyzzy-1panel\") = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -346,6 +346,26 @@ func IsValidIP(ip string) bool {
|
||||
return net.ParseIP(ip) != nil
|
||||
}
|
||||
|
||||
// ParseIPLoose parses an IP address, accepting bracketed IPv6 forms
|
||||
// such as "[::1]" or "[fe80::1%eth0]" in addition to the bare forms
|
||||
// that net.ParseIP supports natively. It also trims surrounding
|
||||
// whitespace. Returns nil for non-IP input, matching net.ParseIP.
|
||||
//
|
||||
// This is required because some flows pass the host portion of a URL
|
||||
// (e.g. "[::1]") rather than a bare IP address. Rejecting that form
|
||||
// caused 1Panel-dev/1Panel#12646 — the panel SSL self-sign workflow
|
||||
// reported “domain format invalid” for IPv6 hosts.
|
||||
func ParseIPLoose(s string) net.IP {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
if len(trimmed) >= 2 && trimmed[0] == '[' && trimmed[len(trimmed)-1] == ']' {
|
||||
trimmed = trimmed[1 : len(trimmed)-1]
|
||||
}
|
||||
return net.ParseIP(trimmed)
|
||||
}
|
||||
|
||||
const (
|
||||
b = uint64(1)
|
||||
kb = 1024 * b
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
// Regression test for 1Panel-dev/1Panel#12646: panel SSL self-signed flow
|
||||
// rejected IPv6 hosts because net.ParseIP does not accept the bracketed
|
||||
// form (e.g. "[::1]") and the upstream caller passes the host portion of
|
||||
// a URL rather than a bare IP.
|
||||
func TestParseIPLoose(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"bare ipv4", "127.0.0.1", true},
|
||||
{"bare ipv6", "::1", true},
|
||||
{"bracketed ipv6", "[::1]", true},
|
||||
{"bracketed full ipv6", "[2001:db8::1]", true},
|
||||
{"trimmed bare ipv6", " ::1 ", true},
|
||||
{"trimmed bracketed ipv6", " [::1] ", true},
|
||||
{"empty", "", false},
|
||||
{"only brackets", "[]", false},
|
||||
{"hostname", "example.com", false},
|
||||
{"bracketed garbage", "[notanip]", false},
|
||||
{"unbalanced bracket left", "[::1", false},
|
||||
{"unbalanced bracket right", "::1]", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ParseIPLoose(tc.in) != nil
|
||||
if got != tc.want {
|
||||
t.Errorf("ParseIPLoose(%q) ok = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func SelectInitScript(keyword string) (string, error) {
|
||||
keyword = strings.TrimSuffix(keyword, ".service") + ".openrc"
|
||||
case "sysvinit":
|
||||
if _, err := os.Stat("/etc/rc.common"); err == nil {
|
||||
keyword = strings.TrimSuffix(keyword, ".service") + ".prod"
|
||||
keyword = strings.TrimSuffix(keyword, ".service") + ".procd"
|
||||
} else {
|
||||
keyword = strings.TrimSuffix(keyword, ".service") + ".init"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
type ShellArchiver interface {
|
||||
Extract(filePath, dstDir string, secret string) error
|
||||
Extract(ctx context.Context, filePath, dstDir string, secret string) error
|
||||
Compress(ctx context.Context, sourcePaths []string, dstFile string, secret string) error
|
||||
}
|
||||
|
||||
|
||||
@@ -910,13 +910,13 @@ func decodeGBK(input string) (string, error) {
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func (f FileOp) decompressWithSDK(srcFile string, dst string, cType CompressType) error {
|
||||
func (f FileOp) decompressWithSDK(ctx context.Context, srcFile string, dst string, cType CompressType) error {
|
||||
format := getFormat(cType)
|
||||
if cType == Gz {
|
||||
if err := f.tryDecompressTarGz(srcFile, dst, format); err == nil {
|
||||
if err := f.tryDecompressTarGz(ctx, srcFile, dst, format); err == nil {
|
||||
return nil
|
||||
}
|
||||
return f.DecompressGzFile(srcFile, dst)
|
||||
return f.DecompressGzFile(ctx, srcFile, dst)
|
||||
}
|
||||
|
||||
type dirEntry struct {
|
||||
@@ -950,7 +950,7 @@ func (f FileOp) decompressWithSDK(srcFile string, dst string, cType CompressType
|
||||
} else {
|
||||
parentDir := path.Dir(filePath)
|
||||
if !f.Stat(parentDir) {
|
||||
if err := f.Fs.MkdirAll(parentDir, info.Mode()); err != nil {
|
||||
if err := f.Fs.MkdirAll(parentDir, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -976,7 +976,7 @@ func (f FileOp) decompressWithSDK(srcFile string, dst string, cType CompressType
|
||||
return err
|
||||
}
|
||||
defer input.Close()
|
||||
if err := format.Extract(context.Background(), input, nil, handler); err != nil {
|
||||
if err := format.Extract(ctx, input, nil, handler); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
@@ -985,21 +985,21 @@ func (f FileOp) decompressWithSDK(srcFile string, dst string, cType CompressType
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FileOp) Decompress(srcFile string, dst string, cType CompressType, secret string) error {
|
||||
func (f FileOp) Decompress(ctx context.Context, srcFile string, dst string, cType CompressType, secret string) error {
|
||||
if cType == Tar || cType == Zip || cType == TarGz || cType == Rar || cType == X7z {
|
||||
shellArchiver, err := NewExtractShellArchiver(cType)
|
||||
if !f.Stat(dst) {
|
||||
_ = f.CreateDir(dst, 0755)
|
||||
}
|
||||
if err == nil {
|
||||
if err = shellArchiver.Extract(srcFile, dst, secret); err == nil {
|
||||
if err = shellArchiver.Extract(ctx, srcFile, dst, secret); err == nil {
|
||||
return nil
|
||||
}
|
||||
if cType == TarGz {
|
||||
if strings.Contains(err.Error(), "bad decrypt") {
|
||||
return buserr.New("ErrBadDecrypt")
|
||||
}
|
||||
if err := shellArchiver.Extract(srcFile, dst, "-"); strings.Contains(err.Error(), "bad decrypt") {
|
||||
if err := shellArchiver.Extract(ctx, srcFile, dst, "-"); strings.Contains(err.Error(), "bad decrypt") {
|
||||
return buserr.New("ErrBadDecrypt")
|
||||
}
|
||||
}
|
||||
@@ -1009,7 +1009,7 @@ func (f FileOp) Decompress(srcFile string, dst string, cType CompressType, secre
|
||||
}
|
||||
}
|
||||
}
|
||||
return f.decompressWithSDK(srcFile, dst, cType)
|
||||
return f.decompressWithSDK(ctx, srcFile, dst, cType)
|
||||
}
|
||||
|
||||
func ZipFile(ctx context.Context, files []archiver.File, dst afero.File, progress func(current, total int, message string)) error {
|
||||
@@ -1088,7 +1088,7 @@ func (r *contextReader) Read(p []byte) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (f FileOp) tryDecompressTarGz(srcFile string, dst string, format archiver.CompressedArchive) error {
|
||||
func (f FileOp) tryDecompressTarGz(ctx context.Context, srcFile string, dst string, format archiver.CompressedArchive) error {
|
||||
input, err := f.Fs.Open(srcFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1116,7 +1116,7 @@ func (f FileOp) tryDecompressTarGz(srcFile string, dst string, format archiver.C
|
||||
} else {
|
||||
parentDir := filepath.Dir(filePath)
|
||||
if !f.Stat(parentDir) {
|
||||
if err := f.Fs.MkdirAll(parentDir, info.Mode()); err != nil {
|
||||
if err := f.Fs.MkdirAll(parentDir, constant.DirPerm); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1139,7 +1139,7 @@ func (f FileOp) tryDecompressTarGz(srcFile string, dst string, format archiver.C
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := format.Extract(context.Background(), input, nil, handler); err != nil {
|
||||
if err := format.Extract(ctx, input, nil, handler); err != nil {
|
||||
return err
|
||||
}
|
||||
if !extracted {
|
||||
@@ -1151,7 +1151,7 @@ func (f FileOp) tryDecompressTarGz(srcFile string, dst string, format archiver.C
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FileOp) DecompressGzFile(srcFile, dst string) error {
|
||||
func (f FileOp) DecompressGzFile(ctx context.Context, srcFile, dst string) error {
|
||||
var archiveModTime time.Time
|
||||
if st, err := f.Fs.Stat(srcFile); err == nil {
|
||||
archiveModTime = st.ModTime()
|
||||
@@ -1163,7 +1163,7 @@ func (f FileOp) DecompressGzFile(srcFile, dst string) error {
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
gr, err := gzip.NewReader(in)
|
||||
gr, err := gzip.NewReader(&contextReader{ctx: ctx, r: in})
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip reader creation failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ func NewRarArchiver() ShellArchiver {
|
||||
return &RarArchiver{}
|
||||
}
|
||||
|
||||
func (z RarArchiver) Extract(filePath, dstDir string, _ string) error {
|
||||
func (z RarArchiver) Extract(ctx context.Context, filePath, dstDir string, _ string) error {
|
||||
if err := checkCmdAvailability("unrar"); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.RunDefaultBashCf("unrar x -y -o+ %q %q", filePath, dstDir)
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).RunBashCf("unrar x -y -o+ %q %q", filePath, dstDir)
|
||||
}
|
||||
|
||||
func (z RarArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile string, _ string) (err error) {
|
||||
|
||||
@@ -18,8 +18,8 @@ func NewTarArchiver(compressType CompressType) ShellArchiver {
|
||||
}
|
||||
}
|
||||
|
||||
func (t TarArchiver) Extract(FilePath string, dstDir string, secret string) error {
|
||||
return cmd.RunDefaultBashCf("%s %s \"%s\" -C \"%s\"", t.Cmd, t.getOptionStr("extract"), FilePath, dstDir)
|
||||
func (t TarArchiver) Extract(ctx context.Context, FilePath string, dstDir string, secret string) error {
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).RunBashCf("%s %s \"%s\" -C \"%s\"", t.Cmd, t.getOptionStr("extract"), FilePath, dstDir)
|
||||
}
|
||||
|
||||
func (t TarArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile string, secret string) error {
|
||||
|
||||
@@ -21,7 +21,7 @@ func NewTarGzArchiver() ShellArchiver {
|
||||
return &TarGzArchiver{}
|
||||
}
|
||||
|
||||
func (t TarGzArchiver) Extract(filePath, dstDir string, secret string) error {
|
||||
func (t TarGzArchiver) Extract(ctx context.Context, filePath, dstDir string, secret string) error {
|
||||
if err := os.MkdirAll(dstDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination dir: %w", err)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func (t TarGzArchiver) Extract(filePath, dstDir string, secret string) error {
|
||||
commands = fmt.Sprintf("tar -zxvf '%s' -C '%s' > /dev/null 2>&1", filePath, dstDir)
|
||||
global.LOG.Debug(commands)
|
||||
}
|
||||
if err = cmd.RunDefaultBashC(commands); err != nil {
|
||||
if err = cmd.NewCommandMgr(cmd.WithContext(ctx)).RunBashC(commands); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -19,11 +19,11 @@ func NewX7zArchiver() ShellArchiver {
|
||||
return &X7zArchiver{}
|
||||
}
|
||||
|
||||
func (z X7zArchiver) Extract(filePath, dstDir string, _ string) error {
|
||||
func (z X7zArchiver) Extract(ctx context.Context, filePath, dstDir string, _ string) error {
|
||||
if err := checkCmdAvailability("7z"); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.RunDefaultBashCf("7z x -y -o%q %q", dstDir, filePath)
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).RunBashCf("7z x -y -o%q %q", dstDir, filePath)
|
||||
}
|
||||
|
||||
func (z X7zArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile string, _ string) (err error) {
|
||||
|
||||
@@ -20,11 +20,11 @@ func NewZipArchiver() ShellArchiver {
|
||||
return &ZipArchiver{}
|
||||
}
|
||||
|
||||
func (z ZipArchiver) Extract(filePath, dstDir string, secret string) error {
|
||||
func (z ZipArchiver) Extract(ctx context.Context, filePath, dstDir string, secret string) error {
|
||||
if err := checkCmdAvailability("unzip"); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.RunDefaultBashCf("unzip -qo %s -d %s", filePath, dstDir)
|
||||
return cmd.NewCommandMgr(cmd.WithContext(ctx)).RunBashCf("unzip -qo %s -d %s", filePath, dstDir)
|
||||
}
|
||||
|
||||
func (z ZipArchiver) Compress(ctx context.Context, sourcePaths []string, dstFile string, _ string) error {
|
||||
|
||||
@@ -50,14 +50,13 @@ func Proxy() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if c.Request.URL.Path == "/api/v2/hosts/terminal" && (currentNode == "local" || len(currentNode) == 0) {
|
||||
proxyLocalAgent(c)
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(c.Request.URL.Path, "/api/v2/core") && (currentNode == "local" || len(currentNode) == 0) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil && err != http.ErrAbortHandler {
|
||||
global.LOG.Debug(err)
|
||||
}
|
||||
}()
|
||||
proxy.LocalAgentProxy.ServeHTTP(c.Writer, c.Request)
|
||||
c.Abort()
|
||||
proxyLocalAgent(c)
|
||||
return
|
||||
}
|
||||
xpack.Proxy(c, currentNode)
|
||||
@@ -65,6 +64,16 @@ func Proxy() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func proxyLocalAgent(c *gin.Context) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil && err != http.ErrAbortHandler {
|
||||
global.LOG.Debug(err)
|
||||
}
|
||||
}()
|
||||
proxy.LocalAgentProxy.ServeHTTP(c.Writer, c.Request)
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func checkSession(c *gin.Context) bool {
|
||||
psession, err := global.SESSION.Get(c)
|
||||
if err != nil {
|
||||
|
||||
@@ -17,6 +17,16 @@ func SudoHandleCmd() string {
|
||||
}
|
||||
|
||||
func Which(name string) bool {
|
||||
// Prefer Go's built-in PATH lookup so we don't depend on an external
|
||||
// `which` binary, which is not installed by default on minimal
|
||||
// distributions (e.g. Arch Linux, some Alpine images, slim containers).
|
||||
// See 1Panel-dev/1Panel#12605.
|
||||
if _, err := exec.LookPath(name); err == nil {
|
||||
return true
|
||||
}
|
||||
// Fall back to shelling out for environments where PATH inside the
|
||||
// agent process differs from the user's interactive shell PATH (the
|
||||
// previous behaviour, preserved for compatibility).
|
||||
stdout, err := RunDefaultWithStdoutBashCf("which %s", name)
|
||||
if err != nil || (len(strings.ReplaceAll(stdout, "\n", "")) == 0) {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestWhich_ExistingBinary verifies that Which() returns true for a
|
||||
// binary that is guaranteed to exist on every Unix-like build host
|
||||
// (`sh`). Regression test for #12605: the previous implementation
|
||||
// shelled out to `which`, which is not always available on minimal
|
||||
// distributions like Arch Linux. The new implementation tries
|
||||
// exec.LookPath first, so this assertion holds regardless of whether
|
||||
// `which` itself is on PATH.
|
||||
func TestWhich_ExistingBinary(t *testing.T) {
|
||||
if !Which("sh") {
|
||||
t.Errorf("Which(\"sh\") = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhich_MissingBinary(t *testing.T) {
|
||||
// A binary name that is extremely unlikely to exist on any host.
|
||||
if Which("definitely-not-a-real-binary-xyzzy-1panel") {
|
||||
t.Errorf("Which(\"definitely-not-a-real-binary-xyzzy-1panel\") = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -285,7 +285,7 @@ func SelectInitScript(keyword string) (string, error) {
|
||||
keyword = strings.TrimSuffix(keyword, ".service") + ".openrc"
|
||||
case "sysvinit":
|
||||
if _, err := os.Stat("/etc/rc.common"); err == nil {
|
||||
keyword = strings.TrimSuffix(keyword, ".service") + ".prod"
|
||||
keyword = strings.TrimSuffix(keyword, ".service") + ".procd"
|
||||
} else {
|
||||
keyword = strings.TrimSuffix(keyword, ".service") + ".init"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
|
||||
node_modules
|
||||
dist
|
||||
|
||||
Generated
+11864
File diff suppressed because it is too large
Load Diff
@@ -149,6 +149,11 @@ export namespace File {
|
||||
dst: string;
|
||||
type: string;
|
||||
secret: string;
|
||||
taskID?: string;
|
||||
}
|
||||
|
||||
export interface FileDeCompressStopReq {
|
||||
taskID: string;
|
||||
}
|
||||
|
||||
export interface FileEdit {
|
||||
|
||||
@@ -97,7 +97,7 @@ export const inspect = (params: Container.ContainerInspect) => {
|
||||
export const DownloadFile = (params: Container.ContainerLogInfo) => {
|
||||
return http.download<BlobPart>('/containers/download/log', params, {
|
||||
responseType: 'blob',
|
||||
timeout: TimeoutEnum.T_40S,
|
||||
timeout: TimeoutEnum.T_60S,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -57,8 +57,15 @@ export const stopCompressFile = (taskID: string) => {
|
||||
return http.post('files/compress/stop', { taskID } as File.FileCompressStopReq);
|
||||
};
|
||||
|
||||
export const deCompressFile = (form: File.FileDeCompress) => {
|
||||
return http.post<File.File>('files/decompress', form, TimeoutEnum.T_10M);
|
||||
export const deCompressFile = (form: File.FileDeCompress, config?: AxiosRequestConfig) => {
|
||||
return http.service.post<File.File>('files/decompress', form, {
|
||||
timeout: TimeoutEnum.T_10M,
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
export const stopDeCompressFile = (taskID: string) => {
|
||||
return http.post('files/decompress/stop', { taskID } as File.FileDeCompressStopReq);
|
||||
};
|
||||
|
||||
export const getFileContent = (params: File.ReqFile) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ElTable } from 'element-plus';
|
||||
import { computed, defineComponent, h, onMounted, ref, watch, type PropType, type VNode } from 'vue';
|
||||
import { cloneVNode, computed, defineComponent, h, onMounted, ref, watch, type PropType, type VNode } from 'vue';
|
||||
|
||||
import {
|
||||
FU_TABLE_STORAGE_PREFIX,
|
||||
@@ -145,7 +145,15 @@ export default defineComponent({
|
||||
);
|
||||
return resolvedColumns.value
|
||||
.filter((column) => column.show !== false)
|
||||
.map((column) => columnNodeMap.get(column.key))
|
||||
.map((column) => {
|
||||
const vnode = columnNodeMap.get(column.key);
|
||||
if (!vnode) {
|
||||
return null;
|
||||
}
|
||||
return cloneVNode(vnode, {
|
||||
key: column.key,
|
||||
});
|
||||
})
|
||||
.filter((column): column is VNode => Boolean(column));
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:trigger="trigger"
|
||||
:popper-class="resolvedPopperClass"
|
||||
placement="bottom-end"
|
||||
:width="260"
|
||||
:width="popoverWidth"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button class="fu-search-bar-button" :icon="iconComponent" @click.stop>
|
||||
@@ -14,14 +14,8 @@
|
||||
</template>
|
||||
<div class="fu-table-column-select__panel" @click.stop>
|
||||
<div class="fu-table-column-select__title">{{ resolvedTitle }}</div>
|
||||
<div ref="listRef" class="fu-table-column-select__list">
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="fu-table-column-select__item"
|
||||
:data-key="column.key"
|
||||
>
|
||||
<el-icon class="fu-table-column-select__drag"><Rank /></el-icon>
|
||||
<div class="fu-table-column-select__list">
|
||||
<div v-for="column in columns" :key="column.key" class="fu-table-column-select__item">
|
||||
<el-checkbox v-model="column.show" :disabled="isFixedColumn(column)">
|
||||
{{ column.label }}
|
||||
</el-checkbox>
|
||||
@@ -33,9 +27,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Sortable from 'sortablejs';
|
||||
import { Rank, Setting } from '@element-plus/icons-vue';
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch, type PropType } from 'vue';
|
||||
import { Setting } from '@element-plus/icons-vue';
|
||||
import { computed, ref, type PropType } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import type { FuTableColumnConfig } from './shared';
|
||||
@@ -72,54 +65,22 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const visible = ref(false);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
let sortable: Sortable | undefined;
|
||||
|
||||
const resolvedTitle = computed(() => props.title || t('fu.table.custom_table_rows'));
|
||||
const resolvedPopperClass = computed(() =>
|
||||
['fu-table-column-select__popover', props.popperClass].filter(Boolean).join(' '),
|
||||
);
|
||||
const iconComponent = computed(() => props.icon || Setting);
|
||||
|
||||
const initSortable = async () => {
|
||||
await nextTick();
|
||||
sortable?.destroy();
|
||||
if (!listRef.value) {
|
||||
return;
|
||||
}
|
||||
sortable = Sortable.create(listRef.value, {
|
||||
animation: 150,
|
||||
handle: '.fu-table-column-select__drag',
|
||||
onEnd: ({ oldIndex, newIndex }) => {
|
||||
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
const nextColumns = [...props.columns];
|
||||
const [movedColumn] = nextColumns.splice(oldIndex, 1);
|
||||
if (!movedColumn) {
|
||||
return;
|
||||
}
|
||||
nextColumns.splice(newIndex, 0, movedColumn);
|
||||
props.columns.splice(0, props.columns.length, ...nextColumns);
|
||||
},
|
||||
});
|
||||
const estimateTextWidth = (text: string) => {
|
||||
return Array.from(text).reduce((total, char) => total + (char.charCodeAt(0) > 255 ? 14 : 8), 0);
|
||||
};
|
||||
|
||||
const popoverWidth = computed(() => {
|
||||
const texts = [resolvedTitle.value, ...props.columns.map((column) => String(column.label || ''))];
|
||||
const contentWidth = texts.reduce((maxWidth, text) => Math.max(maxWidth, estimateTextWidth(text)), 0);
|
||||
return Math.min(Math.max(contentWidth + 72, 180), 320);
|
||||
});
|
||||
|
||||
const isFixedColumn = (column: FuTableColumnConfig) => {
|
||||
return Boolean(column.fixed);
|
||||
};
|
||||
|
||||
watch(visible, (value) => {
|
||||
if (value) {
|
||||
initSortable();
|
||||
return;
|
||||
}
|
||||
sortable?.destroy();
|
||||
sortable = undefined;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sortable?.destroy();
|
||||
sortable = undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="open"
|
||||
:header="resource"
|
||||
@close="handleClose"
|
||||
:size="globalStore.isFullScreen ? 'full' : 'large'"
|
||||
:size="globalStore.isFullScreen ? 'full' : '60%'"
|
||||
:resource="container"
|
||||
>
|
||||
<template #extra v-if="!mobile">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:header="$t('commons.button.log')"
|
||||
@close="handleClose"
|
||||
:resource="logSearch.container"
|
||||
:size="globalStore.isFullScreen ? 'full' : 'large'"
|
||||
:size="globalStore.isFullScreen ? 'full' : '60%'"
|
||||
>
|
||||
<template #extra v-if="!mobile">
|
||||
<el-tooltip :content="loadTooltip()" placement="top">
|
||||
|
||||
@@ -67,6 +67,7 @@ const openWithResourceID = (taskType: string, taskOperate: string, resourceID: n
|
||||
const em = defineEmits(['close']);
|
||||
const handleClose = () => {
|
||||
em('close', true);
|
||||
open.value = false;
|
||||
bus.emit('refreshTask', true);
|
||||
bus.emit('refreshApp', true);
|
||||
};
|
||||
|
||||
@@ -241,8 +241,9 @@ const initWebSocket = (endpoint_: string, args: string = '') => {
|
||||
const protocol = href.split('//')[0] === 'http:' ? 'ws' : 'wss';
|
||||
const host = href.split('//')[1].split('/')[0];
|
||||
const endpoint = endpoint_.replace(/^\/+/, '');
|
||||
let conn = `${protocol}://${host}/${endpoint}?cols=${term.value.cols}&rows=${term.value.rows}&${args}&operateNode=${globalStore.currentNode}`;
|
||||
if (args.indexOf('&operateNode=') !== -1) {
|
||||
let node = args.indexOf('id=') !== -1 ? 'local' : globalStore.currentNode;
|
||||
let conn = `${protocol}://${host}/${endpoint}?cols=${term.value.cols}&rows=${term.value.rows}&${args}&operateNode=${node}`;
|
||||
if (args.indexOf('operateNode=') !== -1) {
|
||||
conn = `${protocol}://${host}/${endpoint}?cols=${term.value.cols}&rows=${term.value.rows}&${args}`;
|
||||
}
|
||||
terminalSocket.value = new WebSocket(conn);
|
||||
|
||||
@@ -141,6 +141,7 @@ function initChart() {
|
||||
});
|
||||
}
|
||||
|
||||
const grid = props.option.grid || { left: '7%', right: '7%', bottom: '20%' };
|
||||
const option = {
|
||||
title: [
|
||||
{
|
||||
@@ -205,9 +206,9 @@ function initChart() {
|
||||
return res;
|
||||
},
|
||||
},
|
||||
grid: props.option.grid || { left: '7%', right: '7%', bottom: '20%' },
|
||||
grid,
|
||||
legend: itemSelect || {
|
||||
right: 10,
|
||||
right: grid.right || 10,
|
||||
itemWidth: 8,
|
||||
textStyle: {
|
||||
color: '#646A73',
|
||||
@@ -228,7 +229,14 @@ function initChart() {
|
||||
},
|
||||
},
|
||||
series: series,
|
||||
dataZoom: [{ startValue: props?.option.xData[0], show: props.dataZoom }],
|
||||
dataZoom: [
|
||||
{
|
||||
...(props.option.xData?.[0] ? { startValue: props.option.xData[0] } : {}),
|
||||
left: grid.left,
|
||||
right: grid.right,
|
||||
show: props.dataZoom,
|
||||
},
|
||||
],
|
||||
};
|
||||
// 渲染数据
|
||||
itemChart.setOption(option, true);
|
||||
|
||||
@@ -1,92 +1,575 @@
|
||||
<template>
|
||||
<div>
|
||||
<DialogPro v-model="open" :title="$t('app.checkTitle')" size="small">
|
||||
<el-row>
|
||||
<el-col :span="22" :offset="1">
|
||||
<el-alert :closable="false" :title="$t('file.vscodeHelper')" type="info"></el-alert>
|
||||
<el-form
|
||||
ref="vscodeConnectInfoForm"
|
||||
label-position="top"
|
||||
:model="addForm"
|
||||
label-width="100px"
|
||||
class="mt-4"
|
||||
>
|
||||
<el-form-item :label="$t('setting.systemIP')" prop="host">
|
||||
<el-input v-model="addForm.host" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.port')" prop="port">
|
||||
<el-input v-model="addForm.port" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.login.username')" prop="username">
|
||||
<el-input v-model="addForm.username" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="submit(vscodeConnectInfoForm)">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</DialogPro>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { FormInstance } from 'element-plus';
|
||||
<DialogPro v-model="open" :title="$t('file.openWithVscode')" size="large" @close="handleClose">
|
||||
<div v-loading="loading" class="vscode-open space-y-4">
|
||||
<el-alert
|
||||
v-if="addForm.authMode === 'key'"
|
||||
:closable="false"
|
||||
:title="$t('file.vscodeHelper')"
|
||||
:description="$t('file.vscodeKeyHelper')"
|
||||
show-icon
|
||||
type="info"
|
||||
/>
|
||||
|
||||
const open = ref();
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
|
||||
<section class="vscode-open__panel">
|
||||
<el-form ref="vscodeConnectInfoForm" :model="addForm" :rules="rules" label-position="top">
|
||||
<el-form-item :label="$t('terminal.authMode')" prop="authMode" class="mb-5">
|
||||
<el-radio-group v-model="addForm.authMode" @change="handleAuthModeChange">
|
||||
<el-radio-button value="password">{{ $t('terminal.passwordMode') }}</el-radio-button>
|
||||
<el-radio-button value="key">{{ $t('terminal.keyMode') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<el-form-item :label="$t('terminal.ip')" prop="host">
|
||||
<el-input v-model.trim="addForm.host" autocomplete="off" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('commons.table.port')" prop="port">
|
||||
<el-input v-model.number="addForm.port" autocomplete="off" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item :label="$t('commons.login.username')" prop="username">
|
||||
<el-input v-model.trim="addForm.username" autocomplete="off" />
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="addForm.authMode === 'key'">
|
||||
<el-form-item :label="$t('file.vscodeSelectKey')" prop="certID">
|
||||
<el-select
|
||||
v-model="addForm.certID"
|
||||
class="w-full"
|
||||
clearable
|
||||
filterable
|
||||
:loading="loading"
|
||||
:placeholder="$t('commons.msg.inputOrSelect')"
|
||||
@change="handleCertChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in certOptions"
|
||||
:key="item.id"
|
||||
:label="formatCertLabel(item)"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('file.vscodeKeyPath')" prop="sshKeyPath">
|
||||
<el-input
|
||||
v-model.trim="addForm.sshKeyPath"
|
||||
:placeholder="$t('file.vscodeKeyPathPlaceholder')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</section>
|
||||
|
||||
<section class="vscode-open__panel">
|
||||
<div v-if="addForm.authMode === 'key'" class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="vscode-open__title">
|
||||
{{ $t('file.vscodeScriptPreview') }}
|
||||
</div>
|
||||
<p class="vscode-open__tip">
|
||||
{{ $t('file.vscodeScriptPreviewHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<el-button
|
||||
:disabled="!canGenerate"
|
||||
plain
|
||||
@click="showScriptPreview = !showScriptPreview"
|
||||
>
|
||||
{{
|
||||
showScriptPreview ? $t('commons.button.collapse') : $t('commons.button.preview')
|
||||
}}
|
||||
</el-button>
|
||||
<el-button :disabled="!canGenerate" plain @click="copySetupScript">
|
||||
{{ $t('file.vscodeCopyConfig') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
v-if="showScriptPreview"
|
||||
class="vscode-open__script max-h-[430px] overflow-auto p-3 text-xs leading-6"
|
||||
>{{ scriptText }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex h-full min-h-[320px] flex-col justify-between gap-4">
|
||||
<div class="space-y-3">
|
||||
<div class="vscode-open__title">
|
||||
{{ $t('file.vscodePasswordModeTitle') }}
|
||||
</div>
|
||||
<p class="vscode-open__tip">
|
||||
{{ $t('file.vscodePasswordModeHint') }}
|
||||
</p>
|
||||
<div class="vscode-open__steps space-y-2 p-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="vscode-open__step-index">1</span>
|
||||
{{ $t('file.vscodePasswordStep1') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="vscode-open__step-index">2</span>
|
||||
{{ $t('file.vscodePasswordStep2') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="vscode-open__step-index">3</span>
|
||||
{{ $t('file.vscodePasswordStep3') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer flex flex-wrap items-center justify-end gap-2">
|
||||
<el-button @click="handleClose">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button
|
||||
v-if="addForm.authMode === 'key'"
|
||||
:disabled="!selectedCert"
|
||||
plain
|
||||
@click="downloadPrivateKey"
|
||||
>
|
||||
{{ $t('commons.button.download') }}
|
||||
</el-button>
|
||||
<el-button v-if="addForm.authMode === 'key'" :disabled="!canGenerate" @click="copySetupScript">
|
||||
{{ $t('file.vscodeCopyConfig') }}
|
||||
</el-button>
|
||||
<el-button :disabled="!canGenerate" type="primary" @click="submit">
|
||||
{{ $t('file.openWithVscode') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</DialogPro>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import type { FormInstance, FormItemRule, FormRules } from 'element-plus';
|
||||
import { Base64 } from 'js-base64';
|
||||
import i18n from '@/lang';
|
||||
import { GlobalStore } from '@/store';
|
||||
import { getSSHInfo, searchCert } from '@/api/modules/host';
|
||||
import { loadLocalConn } from '@/api/modules/terminal';
|
||||
import { Host } from '@/api/interface/host';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { copyText } from '@/utils/clipboard';
|
||||
import { MsgError } from '@/utils/message';
|
||||
|
||||
const globalStore = GlobalStore();
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const showScriptPreview = ref(false);
|
||||
const vscodeConnectInfoForm = ref<FormInstance>();
|
||||
const certOptions = ref<Host.RootCertInfo[]>([]);
|
||||
|
||||
interface DialogProps {
|
||||
path: string;
|
||||
}
|
||||
const vscodeConnectInfoForm = ref<FormInstance>();
|
||||
|
||||
const addForm = reactive({
|
||||
const STORAGE_KEY = 'VscodeConnectInfo';
|
||||
type AuthMode = 'key' | 'password';
|
||||
|
||||
const normalizeAuthMode = (value: unknown): AuthMode => (value === 'key' ? 'key' : 'password');
|
||||
|
||||
const defaultForm = () => ({
|
||||
authMode: 'password' as AuthMode,
|
||||
host: '',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
certID: '' as number | '',
|
||||
sshKeyPath: '',
|
||||
path: '',
|
||||
});
|
||||
|
||||
const em = defineEmits(['close']);
|
||||
const handleClose = () => {
|
||||
open.value = false;
|
||||
if (vscodeConnectInfoForm.value) {
|
||||
vscodeConnectInfoForm.value.resetFields();
|
||||
const addForm = reactive(defaultForm());
|
||||
|
||||
const getStorageKey = () => `${STORAGE_KEY}:${globalStore.currentNode}:${globalStore.currentNodeAddr || 'local'}`;
|
||||
|
||||
const isKeyMode = computed(() => addForm.authMode === 'key');
|
||||
const selectedCert = computed(() => certOptions.value.find((item) => String(item.id) === String(addForm.certID)));
|
||||
|
||||
const sshConfigValue = (value: string) =>
|
||||
`"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
|
||||
|
||||
const normalizeHost = (value: string) => value.trim().replace(/^\[(.*)\]$/, '$1');
|
||||
|
||||
const formatHostForRemote = (value: string) => {
|
||||
const trimmed = normalizeHost(value);
|
||||
if (trimmed.includes(':') && !trimmed.startsWith('[')) {
|
||||
return `[${trimmed}]`;
|
||||
}
|
||||
em('close', false);
|
||||
return trimmed;
|
||||
};
|
||||
const submit = async (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return;
|
||||
await formEl.validate((valid) => {
|
||||
if (!valid) {
|
||||
return;
|
||||
|
||||
const getKeyPathFileName = () => {
|
||||
const keyPath = addForm.sshKeyPath.trim();
|
||||
const normalizedPath = keyPath.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
return normalizedPath.split('/').pop() || selectedCert.value?.name || 'ssh-private-key';
|
||||
};
|
||||
|
||||
const getSafeKeyFileName = (name = 'ssh-private-key') =>
|
||||
name
|
||||
.trim()
|
||||
.replace(/[/\\]/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[.-]+|[.-]+$/g, '') || 'ssh-private-key';
|
||||
|
||||
const buildSshAlias = () => {
|
||||
const host = normalizeHost(addForm.host);
|
||||
const port = Number(addForm.port) || 22;
|
||||
const username = addForm.username.trim() || 'root';
|
||||
const certName = selectedCert.value?.name?.trim() || 'key';
|
||||
const certID = selectedCert.value?.id ?? 0;
|
||||
return `1panel-${host}-${port}-${username}-${certName}-${certID}`
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
};
|
||||
|
||||
const buildVscodeUrl = () => {
|
||||
const remoteTarget = isKeyMode.value
|
||||
? buildSshAlias()
|
||||
: `${addForm.username.trim() || 'root'}@${formatHostForRemote(addForm.host)}:${Number(addForm.port) || 22}`;
|
||||
const remotePath = encodeURI(addForm.path);
|
||||
return `vscode://vscode-remote/ssh-remote+${encodeURIComponent(remoteTarget)}${remotePath}?windowId=_blank`;
|
||||
};
|
||||
|
||||
const buildSetupScript = () => {
|
||||
const host = normalizeHost(addForm.host);
|
||||
const port = Number(addForm.port) || 22;
|
||||
const username = addForm.username.trim() || 'root';
|
||||
const keyPath = addForm.sshKeyPath.trim();
|
||||
const alias = buildSshAlias();
|
||||
const managedBegin = `# 1Panel VS Code managed block: ${alias}`;
|
||||
const managedEnd = `# 1Panel VS Code managed block end: ${alias}`;
|
||||
|
||||
return [
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'',
|
||||
'# Run this script on the computer running VS Code.',
|
||||
`SSH_CONFIG="$HOME/.ssh/config"`,
|
||||
`KEY_PATH=${sshConfigValue(keyPath)}`,
|
||||
`MANAGED_BEGIN=${sshConfigValue(managedBegin)}`,
|
||||
`MANAGED_END=${sshConfigValue(managedEnd)}`,
|
||||
'',
|
||||
'mkdir -p "$(dirname "$SSH_CONFIG")"',
|
||||
'chmod 700 "$(dirname "$SSH_CONFIG")" || true',
|
||||
'touch "$SSH_CONFIG"',
|
||||
'',
|
||||
'tmp_file="$(mktemp)"',
|
||||
'awk -v begin="$MANAGED_BEGIN" -v end="$MANAGED_END" \'',
|
||||
' $0 == begin { skip = 1; next }',
|
||||
' $0 == end { skip = 0; next }',
|
||||
' !skip { print }',
|
||||
'\' "$SSH_CONFIG" > "$tmp_file"',
|
||||
'',
|
||||
'cat <<EOF >> "$tmp_file"',
|
||||
managedBegin,
|
||||
`Host ${alias}`,
|
||||
` HostName ${sshConfigValue(host)}`,
|
||||
` User ${sshConfigValue(username)}`,
|
||||
` Port ${port}`,
|
||||
` IdentityFile ${sshConfigValue(keyPath)}`,
|
||||
' IdentitiesOnly yes',
|
||||
managedEnd,
|
||||
'EOF',
|
||||
'',
|
||||
'mv "$tmp_file" "$SSH_CONFIG"',
|
||||
'chmod 600 "$SSH_CONFIG"',
|
||||
'if [ ! -f "$KEY_PATH" ]; then',
|
||||
' echo "1Panel: private key not found: $KEY_PATH" >&2',
|
||||
' echo "1Panel: download the private key and save it to this exact path, then rerun this script." >&2',
|
||||
' exit 1',
|
||||
'fi',
|
||||
'chmod 600 "$KEY_PATH"',
|
||||
`echo "1Panel: SSH config ready for ${alias}"`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const scriptText = computed(() =>
|
||||
addForm.authMode === 'key' && addForm.path && canGenerate.value ? buildSetupScript() : '',
|
||||
);
|
||||
|
||||
const canGenerate = computed(() => {
|
||||
if (!addForm.host.trim() || !addForm.username.trim() || !addForm.port) {
|
||||
return false;
|
||||
}
|
||||
if (!isKeyMode.value) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(selectedCert.value && addForm.sshKeyPath.trim());
|
||||
});
|
||||
|
||||
const requiredKeyValidator = (_rule: FormItemRule, value: unknown, callback: (error?: Error) => void) => {
|
||||
if (!isKeyMode.value) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
if (value === '' || typeof value === 'undefined' || value === null) {
|
||||
callback(new Error(String(i18n.global.t('commons.rule.requiredSelect'))));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
const requiredKeyPathValidator = (_rule: FormItemRule, value: unknown, callback: (error?: Error) => void) => {
|
||||
if (!isKeyMode.value) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
if (value === '' || typeof value === 'undefined' || value === null) {
|
||||
callback(new Error(String(i18n.global.t('commons.rule.requiredInput'))));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
authMode: [Rules.requiredSelect],
|
||||
host: [Rules.requiredInput, Rules.ipV4V6OrDomain],
|
||||
port: [Rules.requiredInput, Rules.port],
|
||||
username: [Rules.requiredInput],
|
||||
certID: [{ validator: requiredKeyValidator, trigger: 'change' }],
|
||||
sshKeyPath: [{ validator: requiredKeyPathValidator, trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const saveDraft = () => {
|
||||
localStorage.setItem(
|
||||
getStorageKey(),
|
||||
JSON.stringify({
|
||||
authMode: addForm.authMode,
|
||||
host: addForm.host,
|
||||
port: addForm.port,
|
||||
username: addForm.username,
|
||||
certID: addForm.certID,
|
||||
sshKeyPath: addForm.sshKeyPath,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const restoreDraft = () => {
|
||||
const storedInfo = localStorage.getItem(getStorageKey());
|
||||
if (!storedInfo) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(storedInfo);
|
||||
addForm.authMode = normalizeAuthMode(parsed.authMode);
|
||||
addForm.host = parsed.host?.trim() || addForm.host;
|
||||
addForm.port = parsed.port || addForm.port;
|
||||
addForm.username = parsed.username?.trim() || addForm.username;
|
||||
addForm.certID = parsed.certID ?? '';
|
||||
addForm.sshKeyPath = parsed.sshKeyPath?.trim() || '';
|
||||
} catch {
|
||||
localStorage.removeItem(getStorageKey());
|
||||
}
|
||||
};
|
||||
|
||||
const loadConnectionInfo = async () => {
|
||||
if (globalStore.currentNode === 'local') {
|
||||
try {
|
||||
const res = await loadLocalConn();
|
||||
if (res.data) {
|
||||
addForm.host = res.data.addr || globalStore.currentNodeAddr || '127.0.0.1';
|
||||
addForm.port = Number(res.data.port) || 22;
|
||||
addForm.username = res.data.user || 'root';
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getSSHInfo();
|
||||
if (res.data) {
|
||||
addForm.port = Number(res.data.port) || 22;
|
||||
addForm.username = res.data.currentUser || 'root';
|
||||
}
|
||||
localStorage.setItem('VscodeConnectInfo', JSON.stringify(addForm));
|
||||
} catch {}
|
||||
|
||||
addForm.host = globalStore.currentNodeAddr || addForm.host || '127.0.0.1';
|
||||
};
|
||||
|
||||
const loadCertOptions = async () => {
|
||||
const res = await searchCert({ page: 1, pageSize: 500 });
|
||||
certOptions.value = res.data?.items || [];
|
||||
};
|
||||
|
||||
const loadDialogData = async () => {
|
||||
loading.value = true;
|
||||
await Promise.allSettled([loadConnectionInfo(), loadCertOptions()]);
|
||||
restoreDraft();
|
||||
if (addForm.authMode === 'key' && addForm.certID && !selectedCert.value) {
|
||||
addForm.certID = '';
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const formatCertLabel = (item: Host.RootCertInfo) => {
|
||||
const desc = item.description?.trim();
|
||||
return desc ? `${item.name} (${item.encryptionMode}) - ${desc}` : `${item.name} (${item.encryptionMode})`;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
saveDraft();
|
||||
open.value = false;
|
||||
showScriptPreview.value = false;
|
||||
vscodeConnectInfoForm.value?.resetFields();
|
||||
Object.assign(addForm, defaultForm());
|
||||
certOptions.value = [];
|
||||
emit('close', false);
|
||||
};
|
||||
|
||||
const handleAuthModeChange = () => {
|
||||
showScriptPreview.value = false;
|
||||
};
|
||||
|
||||
const handleCertChange = () => {
|
||||
if (addForm.sshKeyPath.trim() || !selectedCert.value?.name) {
|
||||
return;
|
||||
}
|
||||
addForm.sshKeyPath = `~/.ssh/${getSafeKeyFileName(selectedCert.value.name)}`;
|
||||
};
|
||||
|
||||
const copySetupScript = async () => {
|
||||
if (addForm.authMode !== 'key') return;
|
||||
if (!canGenerate.value) {
|
||||
MsgError(String(i18n.global.t('commons.msg.inputOrSelect')));
|
||||
return;
|
||||
}
|
||||
await copyText(buildSetupScript());
|
||||
};
|
||||
|
||||
const downloadPrivateKey = () => {
|
||||
if (!selectedCert.value?.privateKey) {
|
||||
MsgError(String(i18n.global.t('commons.msg.noneData')));
|
||||
return;
|
||||
}
|
||||
const content = Base64.decode(selectedCert.value.privateKey);
|
||||
const fileName = getKeyPathFileName();
|
||||
const downloadUrl = window.URL.createObjectURL(new Blob([content], { type: 'application/octet-stream' }));
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = downloadUrl;
|
||||
a.download = fileName;
|
||||
a.dispatchEvent(new MouseEvent('click'));
|
||||
|
||||
setTimeout(() => {
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!vscodeConnectInfoForm.value) return;
|
||||
|
||||
await vscodeConnectInfoForm.value.validate((valid) => {
|
||||
if (!valid || !canGenerate.value) return;
|
||||
|
||||
saveDraft();
|
||||
open.value = false;
|
||||
const vscodeUrl = `vscode://vscode-remote/ssh-remote+${addForm.username}@${addForm.host}:${addForm.port}${addForm.path}?windowId=_blank`;
|
||||
window.open(vscodeUrl);
|
||||
window.open(buildVscodeUrl(), '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
};
|
||||
|
||||
const acceptParams = async (params: DialogProps): Promise<void> => {
|
||||
const vscodeConnectInfo = localStorage.getItem('VscodeConnectInfo');
|
||||
|
||||
if (vscodeConnectInfo) {
|
||||
try {
|
||||
const storedInfo = JSON.parse(vscodeConnectInfo);
|
||||
addForm.host = storedInfo.host;
|
||||
addForm.port = storedInfo.port;
|
||||
addForm.username = storedInfo.username;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
Object.assign(addForm, defaultForm());
|
||||
certOptions.value = [];
|
||||
showScriptPreview.value = false;
|
||||
addForm.path = params.path;
|
||||
open.value = true;
|
||||
await loadDialogData();
|
||||
};
|
||||
|
||||
defineExpose({ acceptParams });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vscode-open {
|
||||
:deep(.el-descriptions__label) {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.vscode-open__panel {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.vscode-open__title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.vscode-open__tip {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.vscode-open__script {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.vscode-open__steps {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
color: var(--el-text-color-regular);
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.vscode-open__step-index {
|
||||
display: inline-flex;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vscode-open {
|
||||
:deep(.el-descriptions__table) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__body),
|
||||
:deep(.el-descriptions__table tbody),
|
||||
:deep(.el-descriptions__table tr),
|
||||
:deep(.el-descriptions__table th),
|
||||
:deep(.el-descriptions__table td) {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__label) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.vscode-open__panel {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1951,7 +1951,27 @@ const message = {
|
||||
top: 'Go Back',
|
||||
up: 'Go back',
|
||||
openWithVscode: 'Open with VS Code',
|
||||
vscodeHelper: 'Please make sure that VS Code is installed locally and the SSH Remote plugin is configured',
|
||||
vscodeHelper:
|
||||
'Confirm the authentication mode, host, port, and username. Key authentication requires SSH configuration on the computer running VS Code before use.',
|
||||
vscodeCopyConfig: 'Copy setup script',
|
||||
vscodeKeyHelper:
|
||||
'The private key download name is derived from the configured path. Save the file to that path; the setup script will verify it and apply permissions.',
|
||||
vscodeKeyPath: 'SSH private key path',
|
||||
vscodeKeyPathPlaceholder: 'For example: ~/.ssh/id_ed25519',
|
||||
vscodeSelectKey: 'SSH private key',
|
||||
vscodeScriptPreview: 'SSH setup script',
|
||||
vscodePasswordHelper:
|
||||
'Password authentication does not write to the local SSH config. Enter the password in VS Code when connecting to the remote host.',
|
||||
vscodePasswordHint:
|
||||
'Password authentication connects with the configured host, port, and username. No setup script or private key file is required.',
|
||||
vscodeScriptPreviewHint:
|
||||
'Run the setup script on the computer running VS Code to register the SSH alias for remote connections. Rerun it after changing the host, port, username, private key, or key path.',
|
||||
vscodePasswordModeTitle: 'Password authentication',
|
||||
vscodePasswordModeHint:
|
||||
'Password authentication connects with the configured host, port, and username. No setup script or private key file is required.',
|
||||
vscodePasswordStep1: 'Confirm the host, port, and username.',
|
||||
vscodePasswordStep2: 'Open VS Code and enter the password when prompted.',
|
||||
vscodePasswordStep3: 'No private key download is required for this mode.',
|
||||
saveContentAndClose: 'The file has been modified, do you want to save and close it?',
|
||||
saveAndOpenNewFile: 'The file has been modified, do you want to save and open the new file?',
|
||||
noEdit: 'The file has not been modified, no need to do this!',
|
||||
@@ -4025,12 +4045,12 @@ const message = {
|
||||
setDefaultHelper: 'All panel settings will be restored to default. Continue?',
|
||||
logoGroup: 'Logo',
|
||||
imageGroup: 'Image',
|
||||
loginImage: 'Login Page Image',
|
||||
loginImage: 'Image',
|
||||
loginImageHelper: 'Displayed on the login page (recommended image size: 500*416px)',
|
||||
loginBgType: 'Login Page Background Type',
|
||||
loginBgImage: 'Login Page Background Image',
|
||||
loginBgType: 'Background Type',
|
||||
loginBgImage: 'Background Image',
|
||||
loginBgImageHelper: 'Displayed as background image on the login page (recommended image size: 1920*1080px)',
|
||||
loginBgColor: 'Login Page Background Color',
|
||||
loginBgColor: 'Background Color',
|
||||
loginBgColorHelper: 'Displayed as background color on the login page',
|
||||
image: 'Image',
|
||||
bgColor: 'Background Color',
|
||||
|
||||
@@ -1990,7 +1990,27 @@ const message = {
|
||||
top: 'Volver arriba',
|
||||
up: 'Subir',
|
||||
openWithVscode: 'Abrir con VS Code',
|
||||
vscodeHelper: 'Asegúrese de tener instalado VS Code y configurado el plugin SSH Remote',
|
||||
vscodeHelper:
|
||||
'Confirme el modo de autenticación, el host, el puerto y el usuario. La autenticación con clave requiere configurar SSH en el equipo donde se ejecuta VS Code antes de usarla.',
|
||||
vscodeCopyConfig: 'Copiar script de configuración',
|
||||
vscodeKeyHelper:
|
||||
'El nombre de descarga de la clave privada se deriva de la ruta configurada. Guarde el archivo en esa ruta; el script de configuración lo verificará y aplicará permisos.',
|
||||
vscodeKeyPath: 'Ruta de la clave privada SSH',
|
||||
vscodeKeyPathPlaceholder: 'Por ejemplo: ~/.ssh/id_ed25519',
|
||||
vscodeSelectKey: 'Clave privada SSH',
|
||||
vscodeScriptPreview: 'Script de configuración SSH',
|
||||
vscodePasswordHelper:
|
||||
'La autenticación por contraseña no escribe en la configuración SSH local. Introduzca la contraseña en VS Code al conectarse al host remoto.',
|
||||
vscodePasswordHint:
|
||||
'La autenticación por contraseña conecta con el host, puerto y usuario configurados. No requiere script de configuración ni archivo de clave privada.',
|
||||
vscodeScriptPreviewHint:
|
||||
'Ejecute el script de configuración en el equipo donde se ejecuta VS Code para registrar el alias SSH de conexión remota. Vuelva a ejecutarlo si cambia el host, puerto, usuario, clave privada o ruta de la clave.',
|
||||
vscodePasswordModeTitle: 'Autenticación por contraseña',
|
||||
vscodePasswordModeHint:
|
||||
'La autenticación por contraseña conecta con el host, puerto y usuario configurados. No requiere script de configuración ni archivo de clave privada.',
|
||||
vscodePasswordStep1: 'Confirme el host, el puerto y el usuario.',
|
||||
vscodePasswordStep2: 'Abra VS Code e introduzca la contraseña cuando se le solicite.',
|
||||
vscodePasswordStep3: 'No es necesario descargar la clave privada.',
|
||||
saveContentAndClose: 'El archivo se ha modificado, ¿desea guardar y cerrar?',
|
||||
saveAndOpenNewFile: 'El archivo se ha modificado, ¿desea guardar y abrir el nuevo archivo?',
|
||||
noEdit: 'El archivo no ha sido modificado, ¡no es necesario hacer esto!',
|
||||
@@ -4056,12 +4076,12 @@ const message = {
|
||||
setDefaultHelper: 'Todas las configuraciones se restaurarán a valores por defecto. ¿Continuar?',
|
||||
logoGroup: 'Logo',
|
||||
imageGroup: 'Imagen',
|
||||
loginImage: 'Imagen de login',
|
||||
loginImage: 'Imagen',
|
||||
loginImageHelper: 'Se muestra en la página de login (tamaño recomendado: 500*416px)',
|
||||
loginBgType: 'Tipo de fondo de login',
|
||||
loginBgImage: 'Imagen de fondo de login',
|
||||
loginBgType: 'Tipo de fondo',
|
||||
loginBgImage: 'Imagen de fondo',
|
||||
loginBgImageHelper: 'Se muestra como fondo de la página de login (tamaño recomendado: 1920*1080px)',
|
||||
loginBgColor: 'Color de fondo de login',
|
||||
loginBgColor: 'Color de fondo',
|
||||
loginBgColorHelper: 'Se muestra como color de fondo en la página de login',
|
||||
image: 'Imagen',
|
||||
bgColor: 'Color de fondo',
|
||||
|
||||
@@ -1960,7 +1960,26 @@ const message = {
|
||||
up: '戻って行きます',
|
||||
openWithVscode: 'VSコードで開く',
|
||||
vscodeHelper:
|
||||
'VSコードがローカルにインストールされ、SSHリモートプラグインが構成されていることを確認してください',
|
||||
'認証方式、ホスト、ポート、ユーザー名を確認してください。鍵認証を使用する場合は、事前に VS Code を実行するローカルコンピューターで SSH 設定を完了してください。',
|
||||
vscodeCopyConfig: 'セットアップスクリプトをコピー',
|
||||
vscodeKeyHelper:
|
||||
'秘密鍵のダウンロード名は設定したパスから生成されます。そのパスに保存してください。設定スクリプトがファイルを確認し、権限を設定します。',
|
||||
vscodeKeyPath: 'SSH 秘密鍵のパス',
|
||||
vscodeKeyPathPlaceholder: '例: ~/.ssh/id_ed25519',
|
||||
vscodeSelectKey: 'SSH 秘密鍵',
|
||||
vscodeScriptPreview: 'SSH 設定スクリプト',
|
||||
vscodePasswordHelper:
|
||||
'パスワード認証はローカル SSH 設定に書き込まれません。リモートホストへ接続する際は、VS Code でパスワードを入力してください。',
|
||||
vscodePasswordHint:
|
||||
'パスワード認証は、設定したホスト、ポート、ユーザー名で接続します。設定スクリプトや秘密鍵ファイルは不要です。',
|
||||
vscodeScriptPreviewHint:
|
||||
'VS Code を実行するローカルコンピューターで設定スクリプトを実行すると、リモート接続用の SSH エイリアスが登録されます。ホスト、ポート、ユーザー名、秘密鍵、または鍵パスを変更した場合は再実行してください。',
|
||||
vscodePasswordModeTitle: 'パスワード認証',
|
||||
vscodePasswordModeHint:
|
||||
'パスワード認証は、設定したホスト、ポート、ユーザー名で接続します。設定スクリプトや秘密鍵ファイルは不要です。',
|
||||
vscodePasswordStep1: 'ホスト、ポート、ユーザー名を確認します。',
|
||||
vscodePasswordStep2: 'VS Code を開き、接続時にパスワードを入力します。',
|
||||
vscodePasswordStep3: '秘密鍵のダウンロードは不要です。',
|
||||
saveContentAndClose: 'ファイルが変更されましたが、保存して閉じたいですか?',
|
||||
saveAndOpenNewFile: 'ファイルが変更されましたが、新しいファイルを保存して開きますか?',
|
||||
noEdit: 'ファイルは変更されておらず、これを行う必要はありません!',
|
||||
@@ -4046,12 +4065,12 @@ const message = {
|
||||
setDefaultHelper: 'すべてのパネル設定がデフォルトに戻されます。続けますか?',
|
||||
logoGroup: 'ロゴ',
|
||||
imageGroup: '画像',
|
||||
loginImage: 'ログインページの画像',
|
||||
loginImage: '画像',
|
||||
loginImageHelper: 'ログインページに表示されます(推奨画像サイズ:500×416px)',
|
||||
loginBgType: 'ログインページ背景タイプ',
|
||||
loginBgImage: 'ログインページ背景画像',
|
||||
loginBgType: '背景タイプ',
|
||||
loginBgImage: '背景画像',
|
||||
loginBgImageHelper: 'ログインページの背景画像として表示されます(推奨画像サイズ:1920×1080px)',
|
||||
loginBgColor: 'ログインページ背景色',
|
||||
loginBgColor: '背景色',
|
||||
loginBgColorHelper: 'ログインページの背景色として表示されます',
|
||||
image: '画像',
|
||||
bgColor: '背景色',
|
||||
|
||||
@@ -1919,7 +1919,27 @@ const message = {
|
||||
top: '처음으로 돌아가기',
|
||||
up: '뒤로가기',
|
||||
openWithVscode: 'VS Code 로 열기',
|
||||
vscodeHelper: '로컬에 VS Code 가 설치되어 있고 SSH Remote 플러그인이 구성되어 있는지 확인하세요.',
|
||||
vscodeHelper:
|
||||
'인증 방식, 호스트, 포트, 사용자명을 확인하세요. 키 인증을 사용하려면 먼저 VS Code를 실행하는 로컬 컴퓨터에서 SSH 설정을 완료해야 합니다.',
|
||||
vscodeCopyConfig: '설정 스크립트 복사',
|
||||
vscodeKeyHelper:
|
||||
'개인 키 다운로드 파일명은 설정된 경로를 기준으로 생성됩니다. 파일을 해당 경로에 저장하면 설정 스크립트가 파일을 확인하고 권한을 적용합니다.',
|
||||
vscodeKeyPath: 'SSH 개인 키 경로',
|
||||
vscodeKeyPathPlaceholder: '예: ~/.ssh/id_ed25519',
|
||||
vscodeSelectKey: 'SSH 개인 키',
|
||||
vscodeScriptPreview: 'SSH 설정 스크립트',
|
||||
vscodePasswordHelper:
|
||||
'비밀번호 인증은 로컬 SSH 설정에 기록되지 않습니다. 원격 호스트에 연결할 때 VS Code에서 비밀번호를 입력해야 합니다.',
|
||||
vscodePasswordHint:
|
||||
'비밀번호 인증은 설정된 호스트, 포트, 사용자명으로 연결합니다. 설정 스크립트나 개인 키 파일은 필요하지 않습니다.',
|
||||
vscodeScriptPreviewHint:
|
||||
'VS Code를 실행하는 로컬 컴퓨터에서 설정 스크립트를 실행하면 원격 연결용 SSH 별칭이 등록됩니다. 호스트, 포트, 사용자명, 개인 키 또는 키 경로가 변경되면 다시 실행하세요.',
|
||||
vscodePasswordModeTitle: '비밀번호 인증',
|
||||
vscodePasswordModeHint:
|
||||
'비밀번호 인증은 설정된 호스트, 포트, 사용자명으로 연결합니다. 설정 스크립트나 개인 키 파일은 필요하지 않습니다.',
|
||||
vscodePasswordStep1: '호스트, 포트, 사용자명을 확인하세요.',
|
||||
vscodePasswordStep2: 'VS Code를 열고 연결 시 비밀번호를 입력하세요.',
|
||||
vscodePasswordStep3: '개인 키 다운로드는 필요하지 않습니다.',
|
||||
saveContentAndClose: '파일이 수정되었습니다. 저장 후 닫으시겠습니까?',
|
||||
saveAndOpenNewFile: '파일이 수정되었습니다. 저장 후 새 파일을 열겠습니까?',
|
||||
noEdit: '파일이 수정되지 않았습니다. 이 작업은 필요하지 않습니다!',
|
||||
@@ -3957,12 +3977,12 @@ const message = {
|
||||
setDefaultHelper: '모든 패널 설정이 기본값으로 복원됩니다. 계속하시겠습니까?',
|
||||
logoGroup: '로고',
|
||||
imageGroup: '이미지',
|
||||
loginImage: '로그인 페이지 이미지',
|
||||
loginImage: '이미지',
|
||||
loginImageHelper: '로그인 페이지에 표시됩니다 (권장 크기: 500*416px)',
|
||||
loginBgType: '로그인 배경 유형',
|
||||
loginBgImage: '로그인 배경 이미지',
|
||||
loginBgType: '배경 유형',
|
||||
loginBgImage: '배경 이미지',
|
||||
loginBgImageHelper: '로그인 페이지의 배경 이미지로 표시됩니다 (권장 크기: 1920*1080px)',
|
||||
loginBgColor: '로그인 배경 색상',
|
||||
loginBgColor: '배경 색상',
|
||||
loginBgColorHelper: '로그인 페이지의 배경 색상으로 표시됩니다',
|
||||
image: '이미지',
|
||||
bgColor: '배경 색상',
|
||||
|
||||
@@ -1994,7 +1994,27 @@ const message = {
|
||||
top: 'Pergi ke atas',
|
||||
up: 'Kembali',
|
||||
openWithVscode: 'Buka dengan VS Code',
|
||||
vscodeHelper: 'Pastikan VS Code dipasang secara tempatan dan pemalam SSH Remote dikonfigurasikan',
|
||||
vscodeHelper:
|
||||
'Sahkan mod pengesahan, hos, port dan nama pengguna. Pengesahan kunci memerlukan konfigurasi SSH pada komputer yang menjalankan VS Code sebelum digunakan.',
|
||||
vscodeCopyConfig: 'Salin skrip persediaan',
|
||||
vscodeKeyHelper:
|
||||
'Nama muat turun kunci peribadi dijana daripada laluan yang dikonfigurasi. Simpan fail ke laluan tersebut; skrip persediaan akan menyemak fail dan menetapkan kebenaran.',
|
||||
vscodeKeyPath: 'Laluan kunci peribadi SSH',
|
||||
vscodeKeyPathPlaceholder: 'Contohnya: ~/.ssh/id_ed25519',
|
||||
vscodeSelectKey: 'Kunci peribadi SSH',
|
||||
vscodeScriptPreview: 'Skrip persediaan SSH',
|
||||
vscodePasswordHelper:
|
||||
'Pengesahan kata laluan tidak ditulis ke konfigurasi SSH tempatan. Masukkan kata laluan dalam VS Code semasa menyambung ke hos jauh.',
|
||||
vscodePasswordHint:
|
||||
'Pengesahan kata laluan menyambung menggunakan hos, port dan nama pengguna yang dikonfigurasi. Tiada skrip persediaan atau fail kunci peribadi diperlukan.',
|
||||
vscodeScriptPreviewHint:
|
||||
'Jalankan skrip persediaan pada komputer yang menjalankan VS Code untuk mendaftar alias SSH sambungan jauh. Jalankan semula selepas hos, port, nama pengguna, kunci peribadi atau laluan kunci berubah.',
|
||||
vscodePasswordModeTitle: 'Pengesahan kata laluan',
|
||||
vscodePasswordModeHint:
|
||||
'Pengesahan kata laluan menyambung menggunakan hos, port dan nama pengguna yang dikonfigurasi. Tiada skrip persediaan atau fail kunci peribadi diperlukan.',
|
||||
vscodePasswordStep1: 'Sahkan hos, port, dan nama pengguna.',
|
||||
vscodePasswordStep2: 'Buka VS Code dan masukkan kata laluan apabila diminta.',
|
||||
vscodePasswordStep3: 'Tiada muat turun kunci peribadi diperlukan.',
|
||||
saveContentAndClose: 'Fail telah diubah suai, adakah anda mahu menyimpan dan menutupnya?',
|
||||
saveAndOpenNewFile: 'Fail telah diubah suai, adakah anda mahu menyimpan dan membuka fail baru?',
|
||||
noEdit: 'Fail tidak diubah suai, tiada keperluan untuk tindakan ini!',
|
||||
@@ -4106,13 +4126,13 @@ const message = {
|
||||
setDefaultHelper: 'Semua tetapan panel akan dikembalikan ke asal. Adakah anda ingin meneruskan?',
|
||||
logoGroup: 'Logo',
|
||||
imageGroup: 'Imej',
|
||||
loginImage: 'Imej Halaman Log Masuk',
|
||||
loginImage: 'Imej',
|
||||
loginImageHelper: 'Akan dipaparkan di halaman log masuk (Saiz disyorkan: 500*416px)',
|
||||
loginBgType: 'Jenis Latar Halaman Log Masuk',
|
||||
loginBgImage: 'Imej Latar Halaman Log Masuk',
|
||||
loginBgType: 'Jenis Latar',
|
||||
loginBgImage: 'Imej Latar',
|
||||
loginBgImageHelper:
|
||||
'Akan dipaparkan sebagai latar belakang halaman log masuk (Saiz disyorkan: 1920*1080px)',
|
||||
loginBgColor: 'Warna Latar Halaman Log Masuk',
|
||||
loginBgColor: 'Warna Latar',
|
||||
loginBgColorHelper: 'Akan dipaparkan sebagai warna latar belakang halaman log masuk',
|
||||
image: 'Imej',
|
||||
bgColor: 'Warna Latar',
|
||||
|
||||
@@ -2108,7 +2108,26 @@ const message = {
|
||||
up: 'Voltar',
|
||||
openWithVscode: 'Abrir com VS Code',
|
||||
vscodeHelper:
|
||||
'Por favor, certifique-se de que o VS Code está instalado localmente e o plugin SSH Remote está configurado',
|
||||
'Confirme o modo de autenticação, host, porta e usuário. A autenticação por chave exige configurar o SSH no computador que executa o VS Code antes do uso.',
|
||||
vscodeCopyConfig: 'Copiar script de configuração',
|
||||
vscodeKeyHelper:
|
||||
'O nome do download da chave privada é derivado do caminho configurado. Salve o arquivo nesse caminho; o script de configuração verificará o arquivo e aplicará permissões.',
|
||||
vscodeKeyPath: 'Caminho da chave privada SSH',
|
||||
vscodeKeyPathPlaceholder: 'Por exemplo: ~/.ssh/id_ed25519',
|
||||
vscodeSelectKey: 'Chave privada SSH',
|
||||
vscodeScriptPreview: 'Script de configuração SSH',
|
||||
vscodePasswordHelper:
|
||||
'A autenticação por senha não grava na configuração SSH local. Informe a senha no VS Code ao conectar ao host remoto.',
|
||||
vscodePasswordHint:
|
||||
'A autenticação por senha conecta usando host, porta e usuário configurados. Não exige script de configuração nem arquivo de chave privada.',
|
||||
vscodeScriptPreviewHint:
|
||||
'Execute o script de configuração no computador que executa o VS Code para registrar o alias SSH de conexão remota. Execute-o novamente se alterar host, porta, usuário, chave privada ou caminho da chave.',
|
||||
vscodePasswordModeTitle: 'Autenticação por senha',
|
||||
vscodePasswordModeHint:
|
||||
'A autenticação por senha conecta usando host, porta e usuário configurados. Não exige script de configuração nem arquivo de chave privada.',
|
||||
vscodePasswordStep1: 'Confirme host, porta e usuário.',
|
||||
vscodePasswordStep2: 'Abra o VS Code e informe a senha quando solicitado.',
|
||||
vscodePasswordStep3: 'Não é necessário baixar a chave privada.',
|
||||
saveContentAndClose: 'O arquivo foi modificado, deseja salvar e fechar?',
|
||||
saveAndOpenNewFile: 'O arquivo foi modificado, deseja salvar e abrir o novo arquivo?',
|
||||
noEdit: 'O arquivo não foi modificado, não é necessário fazer isso!',
|
||||
@@ -4250,13 +4269,13 @@ const message = {
|
||||
setDefaultHelper: 'Todas as configurações do painel serão restauradas para o padrão. Deseja continuar?',
|
||||
logoGroup: 'Logo',
|
||||
imageGroup: 'Imagem',
|
||||
loginImage: 'Imagem da Página de Login',
|
||||
loginImage: 'Imagem',
|
||||
loginImageHelper: 'Será exibida na página de login (Tamanho recomendado: 500x416px)',
|
||||
loginBgType: 'Tipo de Fundo da Página de Login',
|
||||
loginBgImage: 'Imagem de Fundo da Página de Login',
|
||||
loginBgType: 'Tipo de Fundo',
|
||||
loginBgImage: 'Imagem de Fundo',
|
||||
loginBgImageHelper:
|
||||
'Será exibida como imagem de fundo na página de login (Tamanho recomendado: 1920x1080px)',
|
||||
loginBgColor: 'Cor de Fundo da Página de Login',
|
||||
loginBgColor: 'Cor de Fundo',
|
||||
loginBgColorHelper: 'Será exibida como cor de fundo na página de login',
|
||||
image: 'Imagem',
|
||||
bgColor: 'Cor de Fundo',
|
||||
|
||||
@@ -687,8 +687,7 @@ const message = {
|
||||
allowedOriginsRequired: 'Укажите хотя бы один адрес доступа',
|
||||
allowedOriginsInvalid: 'Используйте формат http(s)://host-or-ip[:port]',
|
||||
provider: 'Поставщик моделей',
|
||||
accountModelsHelper:
|
||||
'Настройте модели, которые этот аккаунт предоставляет OpenClaw для выбора и настройки',
|
||||
accountModelsHelper: 'Настройте модели, которые этот аккаунт предоставляет OpenClaw для выбора и настройки',
|
||||
accountModelsRequired: 'Выберите хотя бы одну модель',
|
||||
accountModelsDuplicate: 'В каталоге обнаружены повторяющиеся модели',
|
||||
modelPool: 'Каталог моделей',
|
||||
@@ -1974,7 +1973,27 @@ const message = {
|
||||
top: 'Вернуться',
|
||||
up: 'Назад',
|
||||
openWithVscode: 'Открыть в VS Code',
|
||||
vscodeHelper: 'Пожалуйста, убедитесь что VS Code установлен локально и настроен плагин SSH Remote',
|
||||
vscodeHelper:
|
||||
'Проверьте способ аутентификации, хост, порт и имя пользователя. Для аутентификации по ключу сначала настройте SSH на компьютере, где запущен VS Code.',
|
||||
vscodeCopyConfig: 'Скопировать скрипт настройки',
|
||||
vscodeKeyHelper:
|
||||
'Имя файла приватного ключа формируется из указанного пути. Сохраните файл по этому пути; скрипт настройки проверит его и применит права доступа.',
|
||||
vscodeKeyPath: 'Путь к SSH приватному ключу',
|
||||
vscodeKeyPathPlaceholder: 'Например: ~/.ssh/id_ed25519',
|
||||
vscodeSelectKey: 'SSH приватный ключ',
|
||||
vscodeScriptPreview: 'Скрипт настройки SSH',
|
||||
vscodePasswordHelper:
|
||||
'Парольная аутентификация не записывается в локальную SSH config. Введите пароль в VS Code при подключении к удаленному хосту.',
|
||||
vscodePasswordHint:
|
||||
'Парольная аутентификация подключается с указанными хостом, портом и именем пользователя. Скрипт настройки и файл приватного ключа не требуются.',
|
||||
vscodeScriptPreviewHint:
|
||||
'Выполните скрипт настройки на компьютере, где запущен VS Code, чтобы зарегистрировать SSH alias для удаленного подключения. Повторите выполнение после изменения хоста, порта, имени пользователя, приватного ключа или пути к ключу.',
|
||||
vscodePasswordModeTitle: 'Парольная аутентификация',
|
||||
vscodePasswordModeHint:
|
||||
'Парольная аутентификация подключается с указанными хостом, портом и именем пользователя. Скрипт настройки и файл приватного ключа не требуются.',
|
||||
vscodePasswordStep1: 'Проверьте хост, порт и имя пользователя.',
|
||||
vscodePasswordStep2: 'Откройте VS Code и введите пароль при подключении.',
|
||||
vscodePasswordStep3: 'Загрузка приватного ключа не требуется.',
|
||||
saveContentAndClose: 'Файл был изменен, хотите сохранить и закрыть его?',
|
||||
saveAndOpenNewFile: 'Файл был изменен, хотите сохранить и открыть новый файл?',
|
||||
noEdit: 'Файл не был изменен, не нужно этого делать!',
|
||||
@@ -4100,12 +4119,12 @@ const message = {
|
||||
setDefaultHelper: 'Все настройки панели будут восстановлены по умолчанию. Вы хотите продолжить?',
|
||||
logoGroup: 'Логотип',
|
||||
imageGroup: 'Изображение',
|
||||
loginImage: 'Изображение страницы входа',
|
||||
loginImage: 'Изображение',
|
||||
loginImageHelper: 'Будет отображаться на странице входа (Рекомендуемый размер: 500x416px)',
|
||||
loginBgType: 'Тип фона страницы входа',
|
||||
loginBgImage: 'Фоновое изображение страницы входа',
|
||||
loginBgType: 'Тип фона',
|
||||
loginBgImage: 'Фоновое изображение',
|
||||
loginBgImageHelper: 'Будет отображаться как фон страницы входа (Рекомендуемый размер: 1920x1080px)',
|
||||
loginBgColor: 'Цвет фона страницы входа',
|
||||
loginBgColor: 'Цвет фона',
|
||||
loginBgColorHelper: 'Будет отображаться как цвет фона страницы входа',
|
||||
image: 'Изображение',
|
||||
bgColor: 'Цвет фона',
|
||||
|
||||
@@ -1986,7 +1986,26 @@ const message = {
|
||||
up: 'Geri dön',
|
||||
openWithVscode: 'VS Code ile aç',
|
||||
vscodeHelper:
|
||||
'Lütfen VS Code’un yerel olarak yüklü olduğundan ve SSH Remote eklentisinin yapılandırıldığından emin olun',
|
||||
'Kimlik doğrulama modu, host, port ve kullanıcı adını doğrulayın. Anahtar kimlik doğrulaması için kullanmadan önce VS Code’un çalıştığı yerel bilgisayarda SSH yapılandırması tamamlanmalıdır.',
|
||||
vscodeCopyConfig: 'Kurulum betiğini kopyala',
|
||||
vscodeKeyHelper:
|
||||
'Özel anahtar indirme adı yapılandırılan yoldan türetilir. Dosyayı bu yola kaydedin; kurulum betiği dosyayı doğrular ve izinleri uygular.',
|
||||
vscodeKeyPath: 'SSH özel anahtar yolu',
|
||||
vscodeKeyPathPlaceholder: 'Örneğin: ~/.ssh/id_ed25519',
|
||||
vscodeSelectKey: 'SSH özel anahtarı',
|
||||
vscodeScriptPreview: 'SSH kurulum betiği',
|
||||
vscodePasswordHelper:
|
||||
'Parola kimlik doğrulaması yerel SSH yapılandırmasına yazılmaz. Uzak host’a bağlanırken parolayı VS Code içinde girin.',
|
||||
vscodePasswordHint:
|
||||
'Parola kimlik doğrulaması yapılandırılan host, port ve kullanıcı adıyla bağlanır. Kurulum betiği veya özel anahtar dosyası gerekmez.',
|
||||
vscodeScriptPreviewHint:
|
||||
'Uzak bağlantı SSH alias’ını kaydetmek için kurulum betiğini VS Code’un çalıştığı yerel bilgisayarda çalıştırın. Host, port, kullanıcı adı, özel anahtar veya anahtar yolu değişirse yeniden çalıştırın.',
|
||||
vscodePasswordModeTitle: 'Parola kimlik doğrulaması',
|
||||
vscodePasswordModeHint:
|
||||
'Parola kimlik doğrulaması yapılandırılan host, port ve kullanıcı adıyla bağlanır. Kurulum betiği veya özel anahtar dosyası gerekmez.',
|
||||
vscodePasswordStep1: 'Host, port ve kullanıcı adını doğrulayın.',
|
||||
vscodePasswordStep2: 'VS Code’u açın ve istendiğinde parolayı girin.',
|
||||
vscodePasswordStep3: 'Özel anahtar indirme gerekmez.',
|
||||
saveContentAndClose: 'Dosya değiştirildi, kaydedip kapatmak istiyor musunuz?',
|
||||
saveAndOpenNewFile: 'Dosya değiştirildi, kaydedip yeni dosyayı açmak istiyor musunuz?',
|
||||
noEdit: 'Dosya değiştirilmedi, buna gerek yok!',
|
||||
@@ -4098,13 +4117,13 @@ const message = {
|
||||
setDefaultHelper: 'Tüm panel ayarları varsayılana geri yüklenecek. Devam etmek istiyor musunuz?',
|
||||
logoGroup: 'Logo',
|
||||
imageGroup: 'Görüntü',
|
||||
loginImage: 'Giriş Sayfası Görüntüsü',
|
||||
loginImage: 'Görüntü',
|
||||
loginImageHelper: 'Giriş sayfasında gösterilir (önerilen görüntü boyutu: 500*416px)',
|
||||
loginBgType: 'Giriş Sayfası Arka Plan Türü',
|
||||
loginBgImage: 'Giriş Sayfası Arka Plan Görüntüsü',
|
||||
loginBgType: 'Arka Plan Türü',
|
||||
loginBgImage: 'Arka Plan Görüntüsü',
|
||||
loginBgImageHelper:
|
||||
'Giriş sayfasında arka plan görüntüsü olarak gösterilir (önerilen görüntü boyutu: 1920*1080px)',
|
||||
loginBgColor: 'Giriş Sayfası Arka Plan Rengi',
|
||||
loginBgColor: 'Arka Plan Rengi',
|
||||
loginBgColorHelper: 'Giriş sayfasında arka plan rengi olarak gösterilir',
|
||||
image: 'Görüntü',
|
||||
bgColor: 'Arka Plan Rengi',
|
||||
|
||||
@@ -1833,7 +1833,22 @@ const message = {
|
||||
top: '返回上一層',
|
||||
up: '上一層',
|
||||
openWithVscode: 'VS Code 開啟',
|
||||
vscodeHelper: '請確保本機已安裝 VS Code 並設定了 SSH Remote 外掛程式',
|
||||
vscodeHelper: '請確認認證方式、主機、埠和使用者名稱。私鑰認證需先在執行 VS Code 的本地電腦完成 SSH 設定。',
|
||||
vscodeCopyConfig: '複製腳本',
|
||||
vscodeKeyHelper: '私鑰下載檔名將根據填寫的路徑產生。請將檔案儲存至該路徑,設定腳本會校驗檔案並設定存取權限。',
|
||||
vscodeKeyPath: 'SSH 私鑰路徑',
|
||||
vscodeKeyPathPlaceholder: '例如:~/.ssh/id_ed25519',
|
||||
vscodeSelectKey: 'SSH 私鑰',
|
||||
vscodeScriptPreview: 'SSH 設定腳本',
|
||||
vscodePasswordHelper: '密碼認證不會寫入本地 SSH 設定,連線遠端主機時需在 VS Code 中輸入密碼。',
|
||||
vscodePasswordHint: '密碼認證將使用填寫的主機、埠和使用者名稱發起連線,無需設定腳本或私鑰檔案。',
|
||||
vscodeScriptPreviewHint:
|
||||
'在執行 VS Code 的本地電腦執行設定腳本後,將寫入用於遠端連線的 SSH 別名。主機、埠、使用者名稱、私鑰或私鑰路徑變更後,請重新執行腳本。',
|
||||
vscodePasswordModeTitle: '密碼認證',
|
||||
vscodePasswordModeHint: '密碼認證將使用填寫的主機、埠和使用者名稱發起連線,無需設定腳本或私鑰檔案。',
|
||||
vscodePasswordStep1: '確認主機、埠和使用者名稱。',
|
||||
vscodePasswordStep2: '開啟 VS Code,連線時輸入密碼。',
|
||||
vscodePasswordStep3: '不需要下載私鑰。',
|
||||
saveContentAndClose: '檔案已被修改,是否儲存並關閉?',
|
||||
saveAndOpenNewFile: '檔案已被修改,是否儲存並開啟新檔案?',
|
||||
noEdit: '檔案未修改,無需此操作',
|
||||
@@ -3723,12 +3738,12 @@ const message = {
|
||||
setDefaultHelper: '即將復原所有介面設定到初始狀態,是否繼續?',
|
||||
logoGroup: 'Logo',
|
||||
imageGroup: '圖片',
|
||||
loginImage: '登入頁圖片',
|
||||
loginImage: '圖片',
|
||||
loginImageHelper: '將會顯示在登入頁面(建議圖片大小為:500*416px)',
|
||||
loginBgType: '登入頁背景類型',
|
||||
loginBgImage: '登入頁背景圖片',
|
||||
loginBgType: '背景類型',
|
||||
loginBgImage: '背景圖片',
|
||||
loginBgImageHelper: '將會顯示在登入頁面背景圖片(建議圖片大小為:1920*1080px)',
|
||||
loginBgColor: '登入頁背景顏色',
|
||||
loginBgColor: '背景顏色',
|
||||
loginBgColorHelper: '將會顯示在登入頁面背景顏色',
|
||||
image: '圖片',
|
||||
bgColor: '背景色',
|
||||
|
||||
@@ -1830,7 +1830,22 @@ const message = {
|
||||
top: '返回上一级',
|
||||
up: '上一级',
|
||||
openWithVscode: 'VS Code 打开',
|
||||
vscodeHelper: '请确保本地已安装 VS Code 并配置了 SSH Remote 插件',
|
||||
vscodeHelper: '请确认认证方式、主机、端口和用户名。私钥认证需先在运行 VS Code 的本地电脑完成 SSH 配置。',
|
||||
vscodeCopyConfig: '复制脚本',
|
||||
vscodeKeyHelper: '私钥下载文件名将根据填写的路径生成。请将文件保存至该路径,配置脚本会校验文件并设置访问权限。',
|
||||
vscodeKeyPath: 'SSH 私钥路径',
|
||||
vscodeKeyPathPlaceholder: '例如:~/.ssh/id_ed25519',
|
||||
vscodeSelectKey: 'SSH 私钥',
|
||||
vscodeScriptPreview: 'SSH 配置脚本',
|
||||
vscodePasswordHelper: '密码认证不会写入本地 SSH 配置,连接远程主机时需在 VS Code 中输入密码。',
|
||||
vscodePasswordHint: '密码认证将使用填写的主机、端口和用户名发起连接,无需配置脚本或私钥文件。',
|
||||
vscodeScriptPreviewHint:
|
||||
'在运行 VS Code 的本地电脑执行配置脚本后,将写入用于远程连接的 SSH 别名。主机、端口、用户名、私钥或私钥路径变更后,请重新执行脚本。',
|
||||
vscodePasswordModeTitle: '密码认证',
|
||||
vscodePasswordModeHint: '密码认证将使用填写的主机、端口和用户名发起连接,无需配置脚本或私钥文件。',
|
||||
vscodePasswordStep1: '确认主机、端口和用户名。',
|
||||
vscodePasswordStep2: '打开 VS Code,连接时输入密码。',
|
||||
vscodePasswordStep3: '无需下载私钥。',
|
||||
saveContentAndClose: '文件已被修改,是否保存并关闭?',
|
||||
saveAndOpenNewFile: '文件已被修改,是否保存并打开新文件?',
|
||||
noEdit: '文件未修改,无需此操作!',
|
||||
@@ -3710,12 +3725,12 @@ const message = {
|
||||
setHelper: '即将保存当前界面设置内容,是否继续?',
|
||||
setDefaultHelper: '即将恢复所有界面设置到初始状态,是否继续?',
|
||||
logoGroup: 'Logo',
|
||||
loginImage: '登录页图片',
|
||||
loginImage: '图片',
|
||||
loginImageHelper: '将会显示在登录页面(建议图片大小为: 500*416px)',
|
||||
loginBgType: '登录页背景类型',
|
||||
loginBgImage: '登录页背景图片',
|
||||
loginBgType: '背景类型',
|
||||
loginBgImage: '背景图片',
|
||||
loginBgImageHelper: '将会显示在登录页面背景图片(建议图片大小为: 1920*1080px)',
|
||||
loginBgColor: '登录页背景颜色',
|
||||
loginBgColor: '背景颜色',
|
||||
image: '图片',
|
||||
bgColor: '背景色',
|
||||
loginGroup: '登录页',
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-popover
|
||||
ref="nodeChangeRef"
|
||||
v-model:visible="popoverVisible"
|
||||
placement="right-end"
|
||||
:show-arrow="false"
|
||||
:offset="0"
|
||||
@@ -14,7 +12,7 @@
|
||||
<template #reference>
|
||||
<div class="el-dropdown-link" v-if="!menuStore.isCollapse">
|
||||
<el-badge is-dot :value="taskCount" :show-zero="false" :offset="[5, 5]">
|
||||
<el-button link @click="openChangeNode" @mouseenter="openChangeNode">
|
||||
<el-button link>
|
||||
<SvgIcon class="icon" iconName="p-pcm" />
|
||||
<span class="ellipsis-text">{{ loadCurrentName() }}</span>
|
||||
</el-button>
|
||||
@@ -28,14 +26,18 @@
|
||||
</template>
|
||||
<div class="dropdown-menu" v-loading="loading">
|
||||
<div class="dropdown-item" @click="openTask">
|
||||
<SvgIcon class="icon" iconName="p-renwuzhongxin1" />
|
||||
{{ $t('menu.msgCenter') }}
|
||||
<div class="node">
|
||||
<SvgIcon class="icon" iconName="p-renwuzhongxin1" />
|
||||
{{ $t('menu.msgCenter') }}
|
||||
</div>
|
||||
<el-tag class="msg-tag" v-if="taskCount !== 0" size="small" round>{{ taskCount }}</el-tag>
|
||||
</div>
|
||||
<el-divider v-if="showNodes()" class="divider" />
|
||||
<div class="dropdown-item" @click="openNodeDashboard" v-if="isMasterPro">
|
||||
<SvgIcon class="icon" iconName="p-gailan1" />
|
||||
{{ $t('xpack.node.multiOverview') }}
|
||||
<div class="node">
|
||||
<SvgIcon class="icon" iconName="p-gailan1" />
|
||||
{{ $t('xpack.node.multiOverview') }}
|
||||
</div>
|
||||
</div>
|
||||
<el-divider v-if="isMasterPro" class="divider" />
|
||||
|
||||
@@ -89,7 +91,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GlobalStore, MenuStore } from '@/store';
|
||||
import type { PopoverInstance } from 'element-plus';
|
||||
import { countExecutingTask } from '@/api/modules/log';
|
||||
import { MsgError, MsgSuccess } from '@/utils/message';
|
||||
import i18n from '@/lang';
|
||||
@@ -108,8 +109,6 @@ const menuStore = MenuStore();
|
||||
const nodes = ref([]);
|
||||
const nodeOptions = ref([]);
|
||||
const loading = ref();
|
||||
const popoverVisible = ref(false);
|
||||
const nodeChangeRef = ref<PopoverInstance>();
|
||||
const props = defineProps({
|
||||
version: String,
|
||||
});
|
||||
@@ -128,10 +127,6 @@ bus.on('refreshTask', () => {
|
||||
checkTask();
|
||||
});
|
||||
|
||||
const openChangeNode = () => {
|
||||
popoverVisible.value = true;
|
||||
};
|
||||
|
||||
const loadCurrentName = () => {
|
||||
if (globalStore.currentNode) {
|
||||
if (globalStore.currentNode === 'local') {
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
|
||||
.fu-table-column-select__panel {
|
||||
min-width: 220px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fu-table-column-select__title {
|
||||
|
||||
@@ -39,6 +39,12 @@ export async function loadMonacoLanguageSupport() {
|
||||
languageSupportPromise = Promise.all([
|
||||
import('monaco-editor/esm/vs/base/browser/ui/codicons/codiconStyles.js'),
|
||||
import('monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js'),
|
||||
import('monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu.js'),
|
||||
import('monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js'),
|
||||
import('monaco-editor/esm/vs/editor/contrib/dropOrPasteInto/browser/copyPasteContribution.js'),
|
||||
import('monaco-editor/esm/vs/editor/contrib/find/browser/findController.js'),
|
||||
import('monaco-editor/esm/vs/editor/contrib/multicursor/browser/multicursor.js'),
|
||||
import('monaco-editor/esm/vs/editor/standalone/browser/quickAccess/standaloneCommandsQuickAccess.js'),
|
||||
import('monaco-editor/esm/vs/basic-languages/monaco.contribution'),
|
||||
import('monaco-editor/esm/vs/language/json/monaco.contribution'),
|
||||
import('monaco-editor/esm/vs/language/css/monaco.contribution'),
|
||||
|
||||
@@ -108,6 +108,6 @@ defineExpose({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.terminal {
|
||||
height: calc(100vh - 180px);
|
||||
height: calc(100vh - 240px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,8 +105,11 @@ const initEditor = async () => {
|
||||
readOnly: false,
|
||||
automaticLayout: true,
|
||||
folding: true,
|
||||
glyphMargin: true,
|
||||
roundedSelection: false,
|
||||
overviewRulerBorder: false,
|
||||
renderMarginRevertIcon: true,
|
||||
renderGutterMenu: false,
|
||||
});
|
||||
|
||||
editor.setModel({
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="1" class="h-app-side-col">
|
||||
<el-col :span="3" class="h-app-side-col">
|
||||
<el-button
|
||||
class="h-app-button"
|
||||
type="primary"
|
||||
@@ -293,8 +293,7 @@ defineExpose({
|
||||
.h-app-side-col {
|
||||
align-items: center;
|
||||
min-height: 58px;
|
||||
justify-content: flex-start;
|
||||
margin-left: -2px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.h-app-main-col {
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
<CardWithHeader
|
||||
:header="$t('menu.monitor')"
|
||||
class="card-interval chart-card"
|
||||
v-loading="!chartsOption['networkChart']"
|
||||
@mouseenter="refreshOptionsOnHover"
|
||||
>
|
||||
<template #header-r>
|
||||
@@ -119,7 +118,7 @@
|
||||
</template>
|
||||
<template #body>
|
||||
<div style="position: relative; margin-top: 60px">
|
||||
<div class="monitor-tags" v-if="chartOption === 'network'">
|
||||
<div class="monitor-tags" :style="monitorTagsStyle" v-if="chartOption === 'network'">
|
||||
<el-tag>
|
||||
{{ $t('monitor.up') }}: {{ computeSizeFromKBs(currentChartInfo.netBytesSent) }}
|
||||
</el-tag>
|
||||
@@ -129,7 +128,7 @@
|
||||
<el-tag>{{ $t('home.totalSend') }}: {{ computeSize(currentInfo.netBytesSent) }}</el-tag>
|
||||
<el-tag>{{ $t('home.totalRecv') }}: {{ computeSize(currentInfo.netBytesRecv) }}</el-tag>
|
||||
</div>
|
||||
<div class="monitor-tags" v-if="chartOption === 'io'">
|
||||
<div class="monitor-tags" :style="monitorTagsStyle" v-if="chartOption === 'io'">
|
||||
<el-tag>{{ $t('monitor.read') }}: {{ currentChartInfo.ioReadBytes }} MB</el-tag>
|
||||
<el-tag>{{ $t('monitor.write') }}: {{ currentChartInfo.ioWriteBytes }} MB</el-tag>
|
||||
<el-tag>
|
||||
@@ -145,7 +144,6 @@
|
||||
id="ioChart"
|
||||
type="line"
|
||||
:option="chartsOption['ioChart']"
|
||||
v-if="chartsOption['ioChart']"
|
||||
:dataZoom="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -155,7 +153,6 @@
|
||||
id="networkChart"
|
||||
type="line"
|
||||
:option="chartsOption['networkChart']"
|
||||
v-if="chartsOption['networkChart']"
|
||||
:dataZoom="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -171,6 +168,7 @@
|
||||
height="368px"
|
||||
indicator-position=""
|
||||
arrow="never"
|
||||
:autoplay="!showMemoCarousel || !memoEditing"
|
||||
>
|
||||
<el-carousel-item key="systemInfo">
|
||||
<CardWithHeader :header="$t('home.systemInfo')">
|
||||
@@ -353,9 +351,11 @@
|
||||
/>
|
||||
<div v-else class="memo-content">
|
||||
<MarkDownEditor v-if="memoContent" :content="memoContent" />
|
||||
<span v-else class="memo-placeholder">
|
||||
{{ $t('home.memoPlaceholder') }}
|
||||
</span>
|
||||
<div v-else class="memo-empty">
|
||||
<span class="memo-placeholder">
|
||||
{{ $t('home.memoPlaceholder') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
@@ -469,6 +469,46 @@ const DASHBOARD_CACHE_TTL = {
|
||||
netOptions: 60 * 60 * 1000,
|
||||
ioOptions: 60 * 60 * 1000,
|
||||
};
|
||||
const monitorChartGrid = { left: 65, right: 65, bottom: '20%' };
|
||||
const monitorTagsStyle = {
|
||||
left: `${monitorChartGrid.left}px`,
|
||||
right: `${monitorChartGrid.right}px`,
|
||||
};
|
||||
const monitorChartEmptyLength = 20;
|
||||
const loadMonitorEmptyData = () => Array.from({ length: monitorChartEmptyLength }, () => null);
|
||||
const loadMonitorEmptyTime = () => Array.from({ length: monitorChartEmptyLength }, () => '');
|
||||
const loadMonitorChartData = (data: Array<number>) => (data.length === 0 ? loadMonitorEmptyData() : data);
|
||||
const loadMonitorChartTime = (data: Array<string>) => (data.length === 0 ? loadMonitorEmptyTime() : data);
|
||||
const loadIOChartOption = () => ({
|
||||
xData: loadMonitorChartTime(timeIODatas.value),
|
||||
yData: [
|
||||
{
|
||||
name: i18n.global.t('monitor.read'),
|
||||
data: loadMonitorChartData(ioReadBytes.value),
|
||||
},
|
||||
{
|
||||
name: i18n.global.t('monitor.write'),
|
||||
data: loadMonitorChartData(ioWriteBytes.value),
|
||||
},
|
||||
],
|
||||
grid: monitorChartGrid,
|
||||
formatStr: 'MB',
|
||||
});
|
||||
const loadNetworkChartOption = () => ({
|
||||
xData: loadMonitorChartTime(timeNetDatas.value),
|
||||
yData: [
|
||||
{
|
||||
name: i18n.global.t('monitor.up'),
|
||||
data: loadMonitorChartData(netBytesSents.value),
|
||||
},
|
||||
{
|
||||
name: i18n.global.t('monitor.down'),
|
||||
data: loadMonitorChartData(netBytesRecvs.value),
|
||||
},
|
||||
],
|
||||
grid: monitorChartGrid,
|
||||
formatStr: 'KB/s',
|
||||
});
|
||||
|
||||
const statusRef = ref();
|
||||
const appRef = ref();
|
||||
@@ -604,7 +644,10 @@ const currentChartInfo = reactive({
|
||||
});
|
||||
const skipNextCurrentInfoDelta = ref(false);
|
||||
|
||||
const chartsOption = ref({ ioChart1: null, networkChart: null });
|
||||
const chartsOption = ref({
|
||||
ioChart: loadIOChartOption(),
|
||||
networkChart: loadNetworkChartOption(),
|
||||
});
|
||||
|
||||
const updateCurrentInfo = (data: Dashboard.CurrentInfo) => {
|
||||
currentInfo.value = {
|
||||
@@ -678,14 +721,21 @@ const onLoadIOOptions = async (force?: boolean) => {
|
||||
};
|
||||
|
||||
const onLoadBaseInfo = async (isInit: boolean, range: string) => {
|
||||
let resetChartData = false;
|
||||
if (range === 'all' || range === 'io') {
|
||||
ioReadBytes.value = [];
|
||||
ioWriteBytes.value = [];
|
||||
timeIODatas.value = [];
|
||||
} else if (range === 'all' || range === 'network') {
|
||||
resetChartData = true;
|
||||
}
|
||||
if (range === 'all' || range === 'network') {
|
||||
netBytesSents.value = [];
|
||||
netBytesRecvs.value = [];
|
||||
timeNetDatas.value = [];
|
||||
resetChartData = true;
|
||||
}
|
||||
if (resetChartData) {
|
||||
loadData();
|
||||
}
|
||||
const res = await loadBaseInfo(searchInfo.ioOption, searchInfo.netOption);
|
||||
baseInfo.value = res.data;
|
||||
@@ -910,35 +960,9 @@ const saveMemo = async () => {
|
||||
|
||||
const loadData = async () => {
|
||||
if (chartOption.value === 'io') {
|
||||
chartsOption.value['ioChart'] = {
|
||||
xData: timeIODatas.value,
|
||||
yData: [
|
||||
{
|
||||
name: i18n.global.t('monitor.read'),
|
||||
data: ioReadBytes.value,
|
||||
},
|
||||
{
|
||||
name: i18n.global.t('monitor.write'),
|
||||
data: ioWriteBytes.value,
|
||||
},
|
||||
],
|
||||
formatStr: 'MB',
|
||||
};
|
||||
chartsOption.value['ioChart'] = loadIOChartOption();
|
||||
} else {
|
||||
chartsOption.value['networkChart'] = {
|
||||
xData: timeNetDatas.value,
|
||||
yData: [
|
||||
{
|
||||
name: i18n.global.t('monitor.up'),
|
||||
data: netBytesSents.value,
|
||||
},
|
||||
{
|
||||
name: i18n.global.t('monitor.down'),
|
||||
data: netBytesRecvs.value,
|
||||
},
|
||||
],
|
||||
formatStr: 'KB/s',
|
||||
};
|
||||
chartsOption.value['networkChart'] = loadNetworkChartOption();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1198,12 +1222,9 @@ onBeforeUnmount(() => {
|
||||
.monitor-tags {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 20px;
|
||||
|
||||
:deep(.el-tag) {
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.version {
|
||||
@@ -1255,6 +1276,11 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.memo-empty {
|
||||
min-height: 275px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
.memo-placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
display: inline-block;
|
||||
|
||||
@@ -439,8 +439,11 @@ const renderDiff = async () => {
|
||||
readOnly: true,
|
||||
automaticLayout: true,
|
||||
folding: true,
|
||||
glyphMargin: true,
|
||||
roundedSelection: false,
|
||||
overviewRulerBorder: false,
|
||||
renderMarginRevertIcon: true,
|
||||
renderGutterMenu: false,
|
||||
});
|
||||
diffEditor.setModel({
|
||||
original: originalModel,
|
||||
|
||||
@@ -205,15 +205,13 @@ const loadTaskInfo = async () => {
|
||||
emitTaskChange();
|
||||
if (!taskInfo.value || taskInfo.value.status !== 'Executing') {
|
||||
stopTaskPolling();
|
||||
currentTaskID.value = '';
|
||||
taskInfo.value = null;
|
||||
emitTaskChange();
|
||||
resetDrawerState();
|
||||
em('close', false);
|
||||
}
|
||||
} catch (error) {
|
||||
stopTaskPolling();
|
||||
currentTaskID.value = '';
|
||||
taskInfo.value = null;
|
||||
emitTaskChange();
|
||||
resetDrawerState();
|
||||
em('close', false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,49 +1,109 @@
|
||||
<template>
|
||||
<DrawerPro v-model="open" :header="$t('file.deCompress')" :resource="name" @close="handleClose" size="normal">
|
||||
<el-form
|
||||
ref="fileForm"
|
||||
label-position="top"
|
||||
:model="form"
|
||||
label-width="100px"
|
||||
:rules="rules"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-form-item :label="$t('commons.table.name')">
|
||||
<el-input v-model="name" disabled></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('file.deCompressDst')" prop="dst">
|
||||
<el-input v-model="form.dst">
|
||||
<template #prepend>
|
||||
<el-button icon="Folder" @click="fileRef.acceptParams({ path: form.dst, dir: true })" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('setting.compressPassword')" prop="secret" v-if="name.includes('tar.gz')">
|
||||
<el-input v-model="form.secret"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<DrawerPro v-model="open" :header="title" @close="handleClose" size="large">
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="showTaskStatus"
|
||||
class="rounded-lg border border-[var(--el-border-color-light)] bg-[var(--el-fill-color-light)] px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium">
|
||||
{{ $t('commons.status.executing') }}
|
||||
</div>
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ taskInfo?.name || $t('file.deCompress') }}
|
||||
</div>
|
||||
</div>
|
||||
<el-button link type="primary" @click="openTaskLog" :disabled="!currentTaskID">
|
||||
{{ $t('commons.button.log') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-progress
|
||||
class="mt-3"
|
||||
:percentage="100"
|
||||
:indeterminate="true"
|
||||
:duration="1"
|
||||
:stroke-width="8"
|
||||
:show-text="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
v-if="!currentTaskID"
|
||||
ref="fileForm"
|
||||
label-position="top"
|
||||
:model="form"
|
||||
label-width="100px"
|
||||
:rules="rules"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-form-item :label="$t('commons.table.name')">
|
||||
<el-input v-model="name" disabled></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('file.deCompressDst')" prop="dst">
|
||||
<el-input v-model="form.dst">
|
||||
<template #prepend>
|
||||
<el-button icon="Folder" @click="fileRef.acceptParams({ path: form.dst, dir: true })" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('setting.compressPassword')" prop="secret" v-if="name.includes('tar.gz')">
|
||||
<el-input v-model="form.secret"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="handleClose">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="submit(fileForm)">{{ $t('commons.button.confirm') }}</el-button>
|
||||
<template v-if="!currentTaskID">
|
||||
<el-button :type="loading ? 'danger' : 'default'" :loading="canceling" @click="handleClose">
|
||||
{{ $t('commons.button.cancel') }}
|
||||
</el-button>
|
||||
<el-button v-if="!loading" type="primary" @click="submit(fileForm)">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button
|
||||
v-if="isTaskExecuting || loading"
|
||||
type="danger"
|
||||
:loading="stopping"
|
||||
@click="stopCurrentTask"
|
||||
>
|
||||
{{ $t('commons.button.cancel') }}
|
||||
</el-button>
|
||||
<el-button v-if="currentTaskID" @click="openTaskLog">
|
||||
{{ $t('commons.button.log') }}
|
||||
</el-button>
|
||||
<el-button type="default" @click="closeDrawer">
|
||||
{{ $t('commons.button.close') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
<FileList ref="fileRef" @choose="getLinkPath" />
|
||||
<TaskLog ref="taskLogRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import i18n from '@/lang';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import { File } from '@/api/interface/file';
|
||||
import { Log } from '@/api/interface/log';
|
||||
import { FormInstance, FormRules } from 'element-plus';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { deCompressFile } from '@/api/modules/files';
|
||||
import { deCompressFile, stopDeCompressFile } from '@/api/modules/files';
|
||||
import { searchTasks } from '@/api/modules/log';
|
||||
import FileList from '@/components/file-list/index.vue';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import TaskLog from '@/components/log/task/index.vue';
|
||||
import { MsgError } from '@/utils/message';
|
||||
import { getErrorMessage } from '@/utils/misc';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import { GlobalStore } from '@/store';
|
||||
|
||||
interface CompressProps {
|
||||
files: Array<any>;
|
||||
const globalStore = GlobalStore();
|
||||
|
||||
interface DecompressProps {
|
||||
dst: string;
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -55,20 +115,164 @@ const rules = reactive<FormRules>({
|
||||
});
|
||||
|
||||
const fileForm = ref<FormInstance>();
|
||||
let loading = ref(false);
|
||||
let form = ref<File.FileDeCompress>({ type: 'zip', dst: '', path: '', secret: '' });
|
||||
let open = ref(false);
|
||||
let name = ref('');
|
||||
const loading = ref(false);
|
||||
const canceling = ref(false);
|
||||
const stopping = ref(false);
|
||||
const abortController = ref<AbortController | null>(null);
|
||||
const form = ref<File.FileDeCompress>({ type: 'zip', dst: '', path: '', secret: '' });
|
||||
const open = ref(false);
|
||||
const title = ref('');
|
||||
const fileRef = ref();
|
||||
const taskLogRef = ref<InstanceType<typeof TaskLog> | null>(null);
|
||||
const currentTaskID = ref('');
|
||||
const taskInfo = ref<Log.Task | null>(null);
|
||||
let taskTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const decompressTaskKey = 'file-management-decompress-task';
|
||||
const name = ref('');
|
||||
|
||||
const em = defineEmits(['close']);
|
||||
const em = defineEmits<{
|
||||
(e: 'close', value: boolean): void;
|
||||
(
|
||||
e: 'task-change',
|
||||
value: {
|
||||
taskID: string;
|
||||
status: string;
|
||||
},
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const handleClose = () => {
|
||||
const isTaskExecuting = computed(() => taskInfo.value?.status === 'Executing');
|
||||
const showTaskStatus = computed(() =>
|
||||
Boolean(currentTaskID.value || loading.value || canceling.value || stopping.value),
|
||||
);
|
||||
|
||||
const emitTaskChange = () => {
|
||||
const payload = {
|
||||
taskID: currentTaskID.value,
|
||||
status: taskInfo.value?.status || (currentTaskID.value || loading.value ? 'Executing' : ''),
|
||||
};
|
||||
if (payload.taskID && payload.status) {
|
||||
localStorage.setItem(decompressTaskKey, JSON.stringify(payload));
|
||||
} else {
|
||||
localStorage.removeItem(decompressTaskKey);
|
||||
}
|
||||
em('task-change', payload);
|
||||
};
|
||||
|
||||
const stopTaskPolling = () => {
|
||||
if (taskTimer) {
|
||||
clearInterval(taskTimer);
|
||||
taskTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadTaskInfo = async () => {
|
||||
if (!currentTaskID.value) {
|
||||
taskInfo.value = null;
|
||||
emitTaskChange();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await searchTasks(
|
||||
{
|
||||
taskID: currentTaskID.value,
|
||||
type: '',
|
||||
status: '',
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
},
|
||||
globalStore.currentNode,
|
||||
);
|
||||
taskInfo.value = res.data.items?.[0] || null;
|
||||
emitTaskChange();
|
||||
if (!taskInfo.value || taskInfo.value.status !== 'Executing') {
|
||||
stopTaskPolling();
|
||||
resetDrawerState();
|
||||
em('close', false);
|
||||
}
|
||||
} catch {
|
||||
stopTaskPolling();
|
||||
resetDrawerState();
|
||||
em('close', false);
|
||||
}
|
||||
};
|
||||
|
||||
const startTaskPolling = () => {
|
||||
stopTaskPolling();
|
||||
void loadTaskInfo();
|
||||
taskTimer = setInterval(() => {
|
||||
void loadTaskInfo();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const resetDrawerState = () => {
|
||||
if (fileForm.value) {
|
||||
fileForm.value.resetFields();
|
||||
}
|
||||
abortController.value = null;
|
||||
canceling.value = false;
|
||||
loading.value = false;
|
||||
taskInfo.value = null;
|
||||
currentTaskID.value = '';
|
||||
stopTaskPolling();
|
||||
emitTaskChange();
|
||||
open.value = false;
|
||||
em('close', open.value);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
if (currentTaskID.value && isTaskExecuting.value) {
|
||||
stopTaskPolling();
|
||||
open.value = false;
|
||||
em('close', false);
|
||||
return;
|
||||
}
|
||||
resetDrawerState();
|
||||
em('close', false);
|
||||
};
|
||||
|
||||
const resumeTask = () => {
|
||||
if (!currentTaskID.value) {
|
||||
return;
|
||||
}
|
||||
open.value = true;
|
||||
};
|
||||
|
||||
const stopCurrentTask = async () => {
|
||||
if (stopping.value || (!loading.value && !currentTaskID.value)) {
|
||||
return;
|
||||
}
|
||||
stopping.value = true;
|
||||
try {
|
||||
if (loading.value) {
|
||||
if (abortController.value) {
|
||||
canceling.value = true;
|
||||
abortController.value.abort();
|
||||
}
|
||||
} else if (currentTaskID.value) {
|
||||
await stopDeCompressFile(currentTaskID.value);
|
||||
}
|
||||
open.value = false;
|
||||
em('close', false);
|
||||
} catch (err) {
|
||||
MsgError(getErrorMessage(err));
|
||||
} finally {
|
||||
stopping.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (loading.value) {
|
||||
stopCurrentTask();
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
};
|
||||
|
||||
const openTaskLog = () => {
|
||||
if (!currentTaskID.value) {
|
||||
return;
|
||||
}
|
||||
taskLogRef.value?.openWithTaskID(currentTaskID.value, true, globalStore.currentNode);
|
||||
};
|
||||
|
||||
const getLinkPath = (path: string) => {
|
||||
@@ -81,25 +285,111 @@ const submit = async (formEl: FormInstance | undefined) => {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
const taskID = newUUID();
|
||||
form.value.taskID = taskID;
|
||||
canceling.value = false;
|
||||
abortController.value = new AbortController();
|
||||
loading.value = true;
|
||||
deCompressFile(form.value)
|
||||
deCompressFile(form.value, {
|
||||
signal: abortController.value?.signal,
|
||||
})
|
||||
.then(() => {
|
||||
MsgSuccess(i18n.global.t('file.deCompressSuccess'));
|
||||
handleClose();
|
||||
currentTaskID.value = taskID;
|
||||
loading.value = false;
|
||||
taskInfo.value = null;
|
||||
emitTaskChange();
|
||||
startTaskPolling();
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = getErrorMessage(error).toLowerCase();
|
||||
if (canceling.value || message.includes('canceled') || message.includes('errshutdown')) {
|
||||
currentTaskID.value = '';
|
||||
emitTaskChange();
|
||||
return;
|
||||
}
|
||||
currentTaskID.value = '';
|
||||
emitTaskChange();
|
||||
MsgError(getErrorMessage(error));
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
const wasCanceling = canceling.value;
|
||||
abortController.value = null;
|
||||
canceling.value = false;
|
||||
if (wasCanceling) {
|
||||
closeDrawer();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const acceptParams = (props: CompressProps) => {
|
||||
const acceptParams = (props: DecompressProps) => {
|
||||
if (currentTaskID.value) {
|
||||
open.value = true;
|
||||
if (!taskTimer) {
|
||||
startTaskPolling();
|
||||
}
|
||||
emitTaskChange();
|
||||
return;
|
||||
}
|
||||
|
||||
form.value.type = props.type;
|
||||
form.value.dst = props.dst;
|
||||
form.value.path = props.path;
|
||||
form.value.secret = '';
|
||||
name.value = props.name;
|
||||
abortController.value = null;
|
||||
canceling.value = false;
|
||||
stopping.value = false;
|
||||
taskInfo.value = null;
|
||||
currentTaskID.value = '';
|
||||
emitTaskChange();
|
||||
stopTaskPolling();
|
||||
open.value = true;
|
||||
|
||||
title.value = i18n.global.t('file.deCompress');
|
||||
};
|
||||
|
||||
defineExpose({ acceptParams });
|
||||
const restoreRunningTask = () => {
|
||||
const taskText = localStorage.getItem(decompressTaskKey);
|
||||
if (!taskText) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const task = JSON.parse(taskText) as {
|
||||
taskID?: string;
|
||||
status?: string;
|
||||
};
|
||||
if (!task.taskID) {
|
||||
localStorage.removeItem(decompressTaskKey);
|
||||
return;
|
||||
}
|
||||
currentTaskID.value = task.taskID;
|
||||
if (task.status === 'Executing') {
|
||||
emitTaskChange();
|
||||
} else {
|
||||
localStorage.removeItem(decompressTaskKey);
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem(decompressTaskKey);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
restoreRunningTask();
|
||||
});
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val && currentTaskID.value) {
|
||||
startTaskPolling();
|
||||
} else {
|
||||
stopTaskPolling();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTaskPolling();
|
||||
});
|
||||
|
||||
defineExpose({ acceptParams, resumeTask });
|
||||
</script>
|
||||
|
||||
@@ -660,7 +660,7 @@
|
||||
|
||||
<CreateFile ref="createRef" @close="search" />
|
||||
<ChangeRole ref="roleRef" @close="search" />
|
||||
<Compress ref="compressRef" @close="search" @task-change="onCompressTaskChange" />
|
||||
<Compress ref="compressRef" @close="search" />
|
||||
<Decompress ref="deCompressRef" @close="search" />
|
||||
<CodeEditor ref="codeEditorRef" @close="search" />
|
||||
<FileRename ref="renameRef" @close="search" />
|
||||
@@ -883,10 +883,6 @@ const recycleBinRef = ref();
|
||||
const favoriteRef = ref();
|
||||
const shareListRef = ref();
|
||||
const historyDrawerRef = ref<InstanceType<typeof FileHistoryDrawer> | null>(null);
|
||||
const compressTaskState = ref<{
|
||||
taskID: string;
|
||||
status: string;
|
||||
} | null>(null);
|
||||
const hoveredRowPath = ref(null);
|
||||
const favorites = ref([]);
|
||||
const batchRoleRef = ref();
|
||||
@@ -926,14 +922,6 @@ const setPathRef = (key: string, el: any) => {
|
||||
};
|
||||
const getCurrentPath = () => pathRefs.value[editableTabsKey.value];
|
||||
|
||||
const onCompressTaskChange = (task: { taskID: string; status: string }) => {
|
||||
if (!task.taskID) {
|
||||
compressTaskState.value = null;
|
||||
return;
|
||||
}
|
||||
compressTaskState.value = task;
|
||||
};
|
||||
|
||||
const { searchableStatus, searchablePath, setSearchableInputRef, searchableInputBlur } = useMultipleSearchable(paths);
|
||||
|
||||
const paginationConfig = reactive({
|
||||
|
||||
@@ -243,8 +243,11 @@ const onSaveSSL = async (formEl: FormInstance | undefined) => {
|
||||
cert: form.cert,
|
||||
key: form.key,
|
||||
};
|
||||
let href = window.location.href;
|
||||
param.domain = href.split('//')[1].split(':')[0];
|
||||
// window.location.hostname yields the bare host name and strips
|
||||
// surrounding brackets from IPv6 addresses (e.g. '[::1]' -> '::1'),
|
||||
// unlike `href.split('//')[1].split(':')[0]` which incorrectly
|
||||
// returns '[' for IPv6 URLs. See 1Panel-dev/1Panel#12646.
|
||||
param.domain = window.location.hostname;
|
||||
await updateSSL(param).then(() => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
let href = window.location.href;
|
||||
|
||||
Reference in New Issue
Block a user