Compare commits

..
Author SHA1 Message Date
CityFun 79703e3c71 fix: resolve missing arrow in compose file comparison during app upgrade (#12663) 2026-05-06 07:38:15 +00:00
ssongliu aee4e4e282 fix: adjust sidebar collapse popover (#12662) 2026-05-06 07:36:15 +00:00
CityFun 744c7559f3 feat: add package-lock.json (#12661) 2026-05-06 14:47:56 +08:00
ssongliu d94c3ec9cc fix: adjust frontend panel sizing (#12660) 2026-05-06 06:22:15 +00:00
ssongliu f2625eab16 fix: refactor local agent proxy handling (#12659) 2026-05-06 06:20:14 +00:00
CityFun c6d498a9be style: remove table column sorting (#12658) 2026-05-06 03:26:14 +00:00
Sanjay Santhanam b06bc0a455 fix(cmd): use exec.LookPath in Which() so detection works without external 'which' (#12651)
cmd.Which() previously shelled out to `which <name>` to determine whether
a binary was on PATH. On distributions that do not ship a `which` package
by default — Arch Linux is the canonical example, but the same applies to
several minimal container images — `which` itself is missing, so every
call to Which() returned false and 1Panel reported core dependencies
(notably Docker) as 'not installed' even when they were running normally.

Switch the primary path to Go's exec.LookPath, which uses the process
PATH directly and has no external dependency. The original shell-out is
preserved as a fallback so any environment where the agent's PATH
differs from the user's interactive shell PATH (the original reason the
shell-out existed) keeps working unchanged.

Both copies are updated (agent/utils/cmd/cmd.go and core/utils/cmd/cmd.go)
and a small unit test is added to each package to guard the regression.

Fixes #12605

Signed-off-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
2026-05-06 10:47:02 +08:00
Sanjay Santhanam 68411bddd9 fix(ssl): support IPv6 hosts in panel SSL self-signed certificate flow (#12652)
Enabling Panel SSL with the self-sign provider rejected IPv6 hosts with
"domain format invalid". Two coupled bugs caused this:

1. The frontend extracted the host from window.location.href with
   href.split('//')[1].split(':')[0]. For an IPv6 URL like
   https://[::1]:1234 that yields '[' \u2014 not a valid host \u2014 because
   the second split splits on the first colon inside the bracketed
   address. Use window.location.hostname, which natively returns the
   bracket-stripped IPv6 host.

2. The backend ObtainSSL flow used net.ParseIP(domain) directly. Even if
   the frontend sent the bracketed form ('[::1]'), net.ParseIP rejects
   brackets, so the value flowed into IsValidDomain() and failed the
   regex.

Add common.ParseIPLoose() that accepts both bare and bracketed IPv6 in
addition to bare IPv4. Use it at both call sites in ObtainSSL (renew
path and create path). A unit test guards the regression.

Files:
  - agent/utils/common/common.go               (new ParseIPLoose helper)
  - agent/utils/common/parse_ip_test.go        (12 cases, all green)
  - agent/app/service/website_ca.go            (call sites switched)
  - frontend/src/views/setting/safe/ssl/index.vue
                                                (host extraction fix)

Fixes #12646

Signed-off-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
2026-05-06 10:36:23 +08:00
Ran a2ac2e0b22 fix(agent/file): correct return type of NewIFileService (#12648)
`NewIFileService` returns the bare struct type `FileService`, but its
body returns a pointer (`&FileService{}`), and the function name suggests
it should return the interface (`IFileService`). The current signature
breaks `go build ./...` on the `agent` module:

    app/service/file.go:95:9: cannot use &FileService{} (value of type
        *FileService) as FileService value in return statement
    app/service/website_proxy.go:276:36: cannot call pointer method
        GetFileList on FileService

Both errors resolve when the constructor returns the interface, since
`*FileService` implements every method on `IFileService` (verified with
`go vet ./...`).

After this change, `go build ./...` and `go vet ./...` are clean on the
`agent` module.
2026-05-06 10:33:04 +08:00
ssongliu a5a5d9f1ac fix: adjust container log download timeout (#12632) 2026-04-29 09:41:53 +00:00
ssongliu 82876eac8d fix: close task log dialog state (#12631)
Refs #12595
2026-04-29 02:43:42 +00:00
ssongliu 19c9c0f4e5 fix: adjust agent terminal height (#12630)
Refs #12611
2026-04-29 02:41:42 +00:00
ssongliu 70f044dc50 fix: adjust home memo empty state (#12629)
Refs #12609
2026-04-29 02:39:41 +00:00
Waynexxxx 1c8418026c fix: clean up docker process in DownloadContainerLogs on timeout (#12607) 2026-04-28 15:47:03 +08:00
CityFun 17f8471c14 fix: Fix the issue where *.domain.com fails to redirect. (#12625) 2026-04-28 07:41:40 +00:00
CityFun 4deb2ea484 feat: add deepseek-v4-pro and deepseek-v4-flash model (#12622)
https://github.com/1Panel-dev/1Panel/issues/12614
2026-04-28 07:39:40 +00:00
蘭 d05317dbf5 feat: enhance VS Code connection instructions and add SSH setup script details (#12613)
#9695
2026-04-28 07:37:42 +00:00
蘭 1db6ccd72b fix: Fix the right-click and filtering function of Monaco editor (#12612)
#12593
2026-04-28 07:35:41 +00:00
ssongliu bc00760404 fix: fix home monitor chart loading (#12591) 2026-04-28 07:33:40 +00:00
蘭 e57dc1240b feat: add stop functionality for decompress tasks and improve UI for task management (#12582) 2026-04-28 07:31:40 +00:00
56 changed files with 13329 additions and 263 deletions
+20
View File
@@ -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
+5
View File
@@ -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 {
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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
}
+9 -5
View File
@@ -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()
+63 -1
View File
@@ -40,6 +40,7 @@ import (
"golang.org/x/text/transform"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/files"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
@@ -59,6 +60,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 +474,16 @@ func preflightCompressTool(compressType files.CompressType) error {
}
}
func preflightDecompressTool(decompressType files.CompressType) error {
switch decompressType {
case files.Tar, files.Zip, files.TarGz, 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 +492,62 @@ 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 := cmd.NewCommandMgr(cmd.WithContext(t.TaskCtx)).RunBashCf("cp -rfp '%s'/. '%s'", tempDst, c.Dst); err != nil {
return err
}
success = true
return nil
}, nil)
_ = taskItem.Execute()
}()
return nil
}
func (f *FileService) GetContent(op request.FileContentReq) (response.FileInfo, error) {
+25 -2
View File
@@ -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
+2 -2
View File
@@ -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
+1
View File
@@ -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)
+10
View File
@@ -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
+23
View File
@@ -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")
}
}
+20
View File
@@ -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
+36
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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
}
+12 -12
View File
@@ -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 {
@@ -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
@@ -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)
}
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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 {
+16 -7
View File
@@ -50,14 +50,13 @@ func Proxy() gin.HandlerFunc {
return
}
if c.Request.URL.Path == "/api/v2/hosts/terminal" {
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 {
+10
View File
@@ -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
+23
View File
@@ -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")
}
}
-1
View File
@@ -7,7 +7,6 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
pnpm-lock.yaml
package-lock.json
node_modules
dist
+11864
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -149,6 +149,11 @@ export namespace File {
dst: string;
type: string;
secret: string;
taskID?: string;
}
export interface FileDeCompressStopReq {
taskID: string;
}
export interface FileEdit {
+1 -1
View File
@@ -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,
});
};
+9 -2
View File
@@ -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) => {
+10 -2
View File
@@ -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);
};
+549 -66
View File
@@ -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>
+21 -1
View File
@@ -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!',
+21 -1
View File
@@ -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!',
+20 -1
View File
@@ -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: 'ファイルは変更されておらず、これを行う必要はありません!',
+21 -1
View File
@@ -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: '파일이 수정되지 않았습니다. 이 작업은 필요하지 않습니다!',
+21 -1
View File
@@ -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!',
+20 -1
View File
@@ -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!',
+22 -3
View File
@@ -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: 'Файл не был изменен, не нужно этого делать!',
+20 -1
View File
@@ -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!',
+16 -1
View File
@@ -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: '檔案未修改,無需此操作',
+16 -1
View File
@@ -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: '文件未修改,无需此操作!',
@@ -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') {
+1 -1
View File
@@ -53,7 +53,7 @@
}
.fu-table-column-select__panel {
min-width: 220px;
width: 100%;
}
.fu-table-column-select__title {
+6
View File
@@ -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({
+22 -7
View File
@@ -76,7 +76,6 @@
<CardWithHeader
:header="$t('menu.monitor')"
class="card-interval chart-card"
v-loading="!chartsOption['networkChart']"
@mouseenter="refreshOptionsOnHover"
>
<template #header-r>
@@ -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>
@@ -353,9 +350,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>
@@ -604,7 +603,18 @@ const currentChartInfo = reactive({
});
const skipNextCurrentInfoDelta = ref(false);
const chartsOption = ref({ ioChart1: null, networkChart: null });
const chartsOption = ref({
ioChart: {
xData: [],
yData: [],
formatStr: 'MB',
},
networkChart: {
xData: [],
yData: [],
formatStr: 'KB/s',
},
});
const updateCurrentInfo = (data: Dashboard.CurrentInfo) => {
currentInfo.value = {
@@ -1255,6 +1265,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,
@@ -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,166 @@ 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();
currentTaskID.value = '';
taskInfo.value = null;
emitTaskChange();
}
} catch {
stopTaskPolling();
currentTaskID.value = '';
taskInfo.value = null;
emitTaskChange();
}
};
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 +287,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;