mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-05-06 04:50:35 +00:00
Merge remote-tracking branch 'origin/main' into garethgeorge/tmp
# Conflicts: # .github/workflows/reusable-release.yml
This commit is contained in:
@@ -79,7 +79,7 @@ jobs:
|
||||
installer:
|
||||
name: Windows installers
|
||||
needs: [goreleaser]
|
||||
runs-on: windows-2022
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
- "386"
|
||||
goarm:
|
||||
- 6
|
||||
- 7
|
||||
@@ -53,6 +54,7 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
- "386"
|
||||
goarm:
|
||||
- 6
|
||||
- 7
|
||||
@@ -124,6 +126,51 @@ dockers:
|
||||
- linux
|
||||
- docker-entrypoint
|
||||
|
||||
- image_templates:
|
||||
- garethgeorge/backrest:{{ .Tag }}-alpine-armv6
|
||||
- ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6
|
||||
dockerfile: Dockerfile.alpine
|
||||
use: buildx
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--provenance=false"
|
||||
- "--platform=linux/arm/v6"
|
||||
ids:
|
||||
- linux
|
||||
- docker-entrypoint
|
||||
|
||||
- image_templates:
|
||||
- garethgeorge/backrest:{{ .Tag }}-alpine-armv7
|
||||
- ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7
|
||||
dockerfile: Dockerfile.alpine
|
||||
use: buildx
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--provenance=false"
|
||||
- "--platform=linux/arm/v7"
|
||||
ids:
|
||||
- linux
|
||||
- docker-entrypoint
|
||||
|
||||
- image_templates:
|
||||
- garethgeorge/backrest:{{ .Tag }}-alpine-386
|
||||
- ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386
|
||||
dockerfile: Dockerfile.alpine
|
||||
use: buildx
|
||||
goarch: "386"
|
||||
goarm: 7
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--provenance=false"
|
||||
- "--platform=linux/386"
|
||||
ids:
|
||||
- linux
|
||||
- docker-entrypoint
|
||||
|
||||
- image_templates:
|
||||
- garethgeorge/backrest:{{ .Tag }}-scratch-arm64
|
||||
- ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-arm64
|
||||
@@ -179,95 +226,148 @@ dockers:
|
||||
- linux
|
||||
- docker-entrypoint
|
||||
|
||||
- image_templates:
|
||||
- garethgeorge/backrest:{{ .Tag }}-scratch-386
|
||||
- ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-386
|
||||
dockerfile: Dockerfile.scratch
|
||||
use: buildx
|
||||
goarch: "386"
|
||||
goarm: 7
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--provenance=false"
|
||||
- "--platform=linux/386"
|
||||
ids:
|
||||
- linux
|
||||
- docker-entrypoint
|
||||
|
||||
docker_manifests:
|
||||
- name_template: "garethgeorge/backrest:latest"
|
||||
image_templates:
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-386"
|
||||
- name_template: "ghcr.io/garethgeorge/backrest:latest"
|
||||
image_templates:
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386"
|
||||
- name_template: "garethgeorge/backrest:v{{ .Major }}"
|
||||
image_templates:
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-386"
|
||||
- name_template: "ghcr.io/garethgeorge/backrest:v{{ .Major }}"
|
||||
image_templates:
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386"
|
||||
- name_template: "garethgeorge/backrest:v{{ .Major }}.{{ .Minor }}"
|
||||
image_templates:
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-386"
|
||||
- name_template: "ghcr.io/garethgeorge/backrest:v{{ .Major }}.{{ .Minor }}"
|
||||
image_templates:
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386"
|
||||
- name_template: "garethgeorge/backrest:{{ .Tag }}"
|
||||
image_templates:
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-386"
|
||||
- name_template: "ghcr.io/garethgeorge/backrest:{{ .Tag }}"
|
||||
image_templates:
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386"
|
||||
- name_template: "garethgeorge/backrest:latest-alpine"
|
||||
image_templates:
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-alpine-386"
|
||||
- name_template: "ghcr.io/garethgeorge/backrest:latest-alpine"
|
||||
image_templates:
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-amd64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-arm64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv6"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-armv7"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-alpine-386"
|
||||
- name_template: "garethgeorge/backrest:scratch"
|
||||
image_templates:
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-386"
|
||||
- name_template: "ghcr.io/garethgeorge/backrest:scratch"
|
||||
image_templates:
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-386"
|
||||
- name_template: "garethgeorge/backrest:v{{ .Major }}-scratch"
|
||||
image_templates:
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-386"
|
||||
- name_template: "ghcr.io/garethgeorge/backrest:v{{ .Major }}-scratch"
|
||||
image_templates:
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-386"
|
||||
- name_template: "garethgeorge/backrest:v{{ .Major }}.{{ .Minor }}-scratch"
|
||||
image_templates:
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-386"
|
||||
- name_template: "ghcr.io/garethgeorge/backrest:v{{ .Major }}.{{ .Minor }}-scratch"
|
||||
image_templates:
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-386"
|
||||
- name_template: "garethgeorge/backrest:{{ .Tag }}-scratch"
|
||||
image_templates:
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
|
||||
- "garethgeorge/backrest:{{ .Tag }}-scratch-386"
|
||||
- name_template: "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch"
|
||||
image_templates:
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-amd64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-arm64"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv6"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-armv7"
|
||||
- "ghcr.io/garethgeorge/backrest:{{ .Tag }}-scratch-386"
|
||||
|
||||
brews:
|
||||
- name: backrest
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [1.12.1](https://github.com/garethgeorge/backrest/compare/v1.12.0...v1.12.1) (2026-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add new translation strings and expand Japanese language translations ([#1120](https://github.com/garethgeorge/backrest/issues/1120)) ([7f050d2](https://github.com/garethgeorge/backrest/commit/7f050d2fae6ab6a2a26b53de0dbac2043ee1cddb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow --stdin-from-command flag to be used ([c603f00](https://github.com/garethgeorge/backrest/commit/c603f0010e995f9680befdd37244700ec2f43336))
|
||||
* condition snapshot start hook may not run if repo is unreachable and autounlock is enabled ([cd72b7f](https://github.com/garethgeorge/backrest/commit/cd72b7f8a94f830cf0a1365547d1ac47bc37a738))
|
||||
* disable strict sftp trust / known host checking ([1bad3d0](https://github.com/garethgeorge/backrest/commit/1bad3d088792e80fc4c0c29d58b3f503a224c7e3))
|
||||
* ensure that docker images package the latest rclone ([#1128](https://github.com/garethgeorge/backrest/issues/1128)) ([4734bc2](https://github.com/garethgeorge/backrest/commit/4734bc21b7940775d69920b1ca9a48e031763d71))
|
||||
* increase bufio's max buffer size to handle large json lines for big repos ([bebe608](https://github.com/garethgeorge/backrest/commit/bebe60850566c603eb37659daf89b853ffdc9f63))
|
||||
* reduce occurrence of benign 'API error reconnecting' warnings ([d4594cc](https://github.com/garethgeorge/backrest/commit/d4594cc6954820c9df2f1f87408853562aefbe84))
|
||||
* snap operation details to view port when scrolling through a large operation tree ([7344541](https://github.com/garethgeorge/backrest/commit/7344541588ea4cc9fe1fbcd404430d09558d4321))
|
||||
* support for linux/arm/v7 ([#1151](https://github.com/garethgeorge/backrest/issues/1151)) ([96d6dff](https://github.com/garethgeorge/backrest/commit/96d6dffabc29cfdaf4c1c3c90c448e2ce8408608))
|
||||
* use LLM to generate more consistent translations ([#1103](https://github.com/garethgeorge/backrest/issues/1103)) ([0199440](https://github.com/garethgeorge/backrest/commit/0199440780418177452f0c9fcad945a549f8f95f))
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* set next release version to 1.12.1 ([9e5d795](https://github.com/garethgeorge/backrest/commit/9e5d795efb5d91276729c9e54f4f5cceee27e148))
|
||||
|
||||
## [1.12.0](https://github.com/garethgeorge/backrest/compare/v1.11.2...v1.12.0) (2026-02-22)
|
||||
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Source: "restic.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
; For user install mode only.
|
||||
Name: "{autostartup}\{#B} systray"; Filename: "{app}\backrest.exe"; Parameters: "--windows-tray {code:GetPortParam}"; IconFilename: "{app}\icon.ico"; Check: IsUserInstallMode
|
||||
Name: "{autostartup}\{#B} systray"; Filename: "{app}\backrest.exe"; Parameters: "--windows-tray {code:GetPortParam}"; IconFilename: "{app}\icon.ico"; Check: IsUserInstallMode; Tasks: autostart
|
||||
Name: "{group}\{#B} systray"; Filename: "{app}\backrest.exe"; Parameters: "--windows-tray {code:GetPortParam}"; IconFilename: "{app}\icon.ico"; Check: IsUserInstallMode
|
||||
; For both modes.
|
||||
Name: "{group}\{#B}{code:GetIconSuffix}"; Filename: "http://localhost:{code:GetPort}/"; IconFilename: "{app}\icon.ico"
|
||||
@@ -208,14 +208,14 @@ begin
|
||||
if StrToVersion(InstalledVersion, InstalledVersionFull) and StrToVersion(AppVersion, NewVersionFull) then
|
||||
begin
|
||||
CompResult := ComparePackedVersion(InstalledVersionFull, NewVersionFull);
|
||||
if CompResult < 0 then Msg := 'upgrade'
|
||||
if CompResult < 0 then Msg := 'upgrade to'
|
||||
else if CompResult = 0 then Msg := 'reinstall'
|
||||
else if CompResult > 0 then Msg := 'downgrade'
|
||||
else if CompResult > 0 then Msg := 'downgrade to'
|
||||
else Msg := 'upgrade/reinstall/downgrade';
|
||||
end;
|
||||
MsgBox('Detected existing installation of Backrest ' + InstalledVersion +
|
||||
' in ' + Chr(13) + Chr(10) + AppDir + Chr(13) + Chr(10) + Chr(13) + Chr(10) +
|
||||
'Setup will ' + Msg + ' to version ' + AppVersion + '.', mbInformation, MB_OK);
|
||||
'Setup will ' + Msg + ' version ' + AppVersion + '.', mbInformation, MB_OK);
|
||||
end;
|
||||
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
|
||||
@@ -280,53 +280,29 @@ func (s *BackrestHandler) SetupSftp(ctx context.Context, req *connect.Request[v1
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
user := req.Msg.Username
|
||||
password := req.Msg.Password // Optional
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("automated SFTP setup is not supported on Windows"))
|
||||
}
|
||||
|
||||
// 1. Host Key Verification/Addition
|
||||
if err := sftputil.AddHostKey(host, port, env.SSHDir()); err != nil {
|
||||
return connect.NewResponse(&v1.SetupSftpResponse{
|
||||
Error: fmt.Sprintf("Failed to add host key: %v", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// 2. Generate Key
|
||||
// 1. Generate key pair (local, always succeeds)
|
||||
_, pubBytes, keyPath, err := sftputil.GenerateKey(host, env.SSHDir())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key: %w", err)
|
||||
}
|
||||
|
||||
pubKeyStr := string(pubBytes)
|
||||
|
||||
// 3. Install if password provided
|
||||
if password != nil {
|
||||
if err := sftputil.InstallKey(host, port, user, *password, pubBytes); err != nil {
|
||||
return connect.NewResponse(&v1.SetupSftpResponse{
|
||||
Error: fmt.Sprintf("Failed to install key: %v", err),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// Verify
|
||||
privPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read generated private key for verification: %w", err)
|
||||
}
|
||||
|
||||
if err := sftputil.VerifyConnection(host, port, user, privPEM); err != nil {
|
||||
return connect.NewResponse(&v1.SetupSftpResponse{
|
||||
Error: fmt.Sprintf("Key installed but verification failed: %v", err),
|
||||
}), nil
|
||||
}
|
||||
// 2. Scan remote host key into known_hosts (network, non-fatal)
|
||||
var hostKeyWarning string
|
||||
if err := sftputil.AddHostKey(host, port, env.SSHDir()); err != nil {
|
||||
zap.S().Warnf("SFTP host key scan failed for %s: %v", host, err)
|
||||
hostKeyWarning = fmt.Sprintf("Could not scan host key (%v). Add the host key to known_hosts manually or ensure the host is reachable.", err)
|
||||
}
|
||||
|
||||
return connect.NewResponse(&v1.SetupSftpResponse{
|
||||
PublicKey: pubKeyStr,
|
||||
PublicKey: string(pubBytes),
|
||||
KeyPath: keyPath,
|
||||
KnownHostsPath: filepath.Join(env.SSHDir(), "known_hosts"),
|
||||
Error: hostKeyWarning,
|
||||
}), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ func RequireAuthentication(h http.Handler, auth *Authenticator) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Pass OPTIONS through unauthenticated so CORS preflight succeeds.
|
||||
if r.Method == http.MethodOptions {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username, password, usesBasicAuth := r.BasicAuth()
|
||||
if usesBasicAuth {
|
||||
user, err := auth.Login(username, password)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var migration003RelativeScheduling = func(config *v1.Config) {
|
||||
var migration003RelativeScheduling = func(config *v1.Config) error {
|
||||
zap.L().Info("applying config migration 003: relative scheduling")
|
||||
// loop over plans and examine prune policy's
|
||||
for _, repo := range config.Repos {
|
||||
@@ -22,4 +22,5 @@ var migration003RelativeScheduling = func(config *v1.Config) {
|
||||
schedule.Clock = v1.Schedule_CLOCK_LAST_RUN_TIME
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
)
|
||||
|
||||
var migration004RepoGuid = func(config *v1.Config) {
|
||||
var migration004RepoGuid = func(config *v1.Config) error {
|
||||
for _, repo := range config.Repos {
|
||||
if repo.Guid != "" {
|
||||
continue
|
||||
@@ -16,4 +16,5 @@ var migration004RepoGuid = func(config *v1.Config) {
|
||||
h.Write([]byte(repo.Id))
|
||||
repo.Guid = hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var conflictingEnvVars = []string{
|
||||
"RESTIC_PASSWORD",
|
||||
"RESTIC_PASSWORD_FILE",
|
||||
"RESTIC_PASSWORD_COMMAND",
|
||||
}
|
||||
|
||||
// migration005CheckRepoPasswords detects repos whose restic password may have
|
||||
// been set incorrectly due to a bug fixed in this release
|
||||
// (https://github.com/garethgeorge/backrest/issues/1139).
|
||||
//
|
||||
// The bug: RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, and RESTIC_PASSWORD_COMMAND
|
||||
// inherited from the process environment previously took precedence over the
|
||||
// password configured in Backrest's UI, because WithEnviron() was appended
|
||||
// after the config password. Repos initialized or used under those conditions
|
||||
// were encrypted with the env var's password, not the one the user entered.
|
||||
//
|
||||
// On first run after upgrading, if any repo has a config password AND one of
|
||||
// the above env vars is set, this migration returns an error and refuses to
|
||||
// start, printing instructions for the user.
|
||||
//
|
||||
// If ISSUE_1139_FIX_PASSWORDS=1 is set in the environment, the migration
|
||||
// instead applies the following automatic fixes to each affected repo and
|
||||
// writes the corrected config to disk before startup continues:
|
||||
//
|
||||
// - RESTIC_PASSWORD: the env var's value is written into repo.Password,
|
||||
// replacing the (likely wrong) value the user had entered in the UI.
|
||||
//
|
||||
// - RESTIC_PASSWORD_FILE or RESTIC_PASSWORD_COMMAND: repo.Password is
|
||||
// cleared and the env var (name=value) is appended to repo.Env, so
|
||||
// restic continues to resolve the password via the same mechanism it
|
||||
// was using before. The env var can then be removed from the Backrest
|
||||
// process environment, as the password source is now explicit in the
|
||||
// repo config.
|
||||
//
|
||||
// After this migration runs successfully (whether or not a fix was needed)
|
||||
// the config version is bumped and it will not run again on future starts.
|
||||
var migration005CheckRepoPasswords = func(config *v1.Config) error {
|
||||
// Find repos that have a password configured in the UI.
|
||||
var affectedRepos []*v1.Repo
|
||||
for _, repo := range config.Repos {
|
||||
if repo.GetPassword() != "" {
|
||||
affectedRepos = append(affectedRepos, repo)
|
||||
}
|
||||
}
|
||||
if len(affectedRepos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check which conflicting env vars are set.
|
||||
var setEnvVars []string
|
||||
for _, envVar := range conflictingEnvVars {
|
||||
if os.Getenv(envVar) != "" {
|
||||
setEnvVars = append(setEnvVars, envVar)
|
||||
}
|
||||
}
|
||||
if len(setEnvVars) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// There is a potential conflict. Check if the user has acknowledged it.
|
||||
if os.Getenv("ISSUE_1139_FIX_PASSWORDS") != "" {
|
||||
for _, repo := range affectedRepos {
|
||||
for _, envVar := range setEnvVars {
|
||||
val := os.Getenv(envVar)
|
||||
switch envVar {
|
||||
case "RESTIC_PASSWORD":
|
||||
zap.S().Warnf("repo %q: overwriting config password with value of RESTIC_PASSWORD env var (issue 1139 fix)", repo.Id)
|
||||
repo.Password = val
|
||||
case "RESTIC_PASSWORD_FILE", "RESTIC_PASSWORD_COMMAND":
|
||||
zap.S().Warnf("repo %q: clearing config password and adding %s=%s to repo env (issue 1139 fix)", repo.Id, envVar, val)
|
||||
repo.Password = ""
|
||||
repo.Env = append(repo.Env, envVar+"="+val)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Block startup with a detailed error message.
|
||||
var b strings.Builder
|
||||
b.WriteString("IMPORTANT: Backrest detected a potential password conflict affecting your restic repos (see https://github.com/garethgeorge/backrest/issues/1139).\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString("What happened:\n")
|
||||
b.WriteString(" Backrest was using the wrong password for restic repos. The environment variables listed\n")
|
||||
b.WriteString(" below were inherited by Backrest's process and previously took precedence over the password\n")
|
||||
b.WriteString(" you configured in the UI. Your repos may have been encrypted with a different password than\n")
|
||||
b.WriteString(" what your Backrest config says.\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString("Conflicting environment variables found:\n")
|
||||
for _, envVar := range setEnvVars {
|
||||
fmt.Fprintf(&b, " - %s\n", envVar)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString("Potentially affected repos:\n")
|
||||
for _, repo := range affectedRepos {
|
||||
fmt.Fprintf(&b, " - %s\n", repo.Id)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString("How to fix:\n")
|
||||
b.WriteString(" Rerun Backrest with ISSUE_1139_FIX_PASSWORDS=1 set in the environment.\n")
|
||||
b.WriteString(" Backrest will automatically update each affected repo's config as follows,\n")
|
||||
b.WriteString(" then write the corrected config to disk and start normally:\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" RESTIC_PASSWORD: the env var's value is written into the repo's\n")
|
||||
b.WriteString(" password field, replacing the value entered in the UI.\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" RESTIC_PASSWORD_FILE / the repo's password field is cleared and the env var\n")
|
||||
b.WriteString(" RESTIC_PASSWORD_COMMAND: (name=value) is appended to the repo's env list, so\n")
|
||||
b.WriteString(" restic continues to resolve the password the same way.\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString(" Once the fix has been applied you can remove the conflicting environment variable\n")
|
||||
b.WriteString(" from your Backrest process environment — the password source will be stored\n")
|
||||
b.WriteString(" explicitly in the repo config from that point on.\n")
|
||||
|
||||
return fmt.Errorf("%s", b.String())
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
)
|
||||
|
||||
func TestMigration005CheckRepoPasswords(t *testing.T) {
|
||||
// Cannot use t.Parallel() because subtests use t.Setenv.
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config func() *v1.Config // factory so each run gets a fresh config
|
||||
envVars map[string]string // env vars to set for the test
|
||||
ackEnvVar bool // whether to set ISSUE_1139_FIX_PASSWORDS=1
|
||||
wantErr bool
|
||||
errContains string // substring that must appear in the error
|
||||
checkConfig func(*testing.T, *v1.Config) // optional post-migration assertions
|
||||
}{
|
||||
{
|
||||
name: "no repos with passwords - no conflict",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r"}}}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD": "somevalue"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "repos with passwords but no conflicting env vars - no conflict",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "secret"}}}
|
||||
},
|
||||
envVars: map[string]string{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "RESTIC_PASSWORD conflict blocks startup without ack",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{{Id: "my-repo", Uri: "/tmp/r", Password: "wrong"}}}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD": "envpassword"},
|
||||
wantErr: true,
|
||||
errContains: "RESTIC_PASSWORD",
|
||||
},
|
||||
{
|
||||
name: "RESTIC_PASSWORD_FILE conflict blocks startup without ack",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD_FILE": "/run/secrets/restic"},
|
||||
wantErr: true,
|
||||
errContains: "RESTIC_PASSWORD_FILE",
|
||||
},
|
||||
{
|
||||
name: "RESTIC_PASSWORD_COMMAND conflict blocks startup without ack",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD_COMMAND": "cat /secrets/pw"},
|
||||
wantErr: true,
|
||||
errContains: "RESTIC_PASSWORD_COMMAND",
|
||||
},
|
||||
{
|
||||
name: "error message names the affected repo",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{{Id: "my-repo", Uri: "/tmp/r", Password: "wrong"}}}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD": "envpassword"},
|
||||
wantErr: true,
|
||||
errContains: "my-repo",
|
||||
},
|
||||
{
|
||||
name: "error message references ISSUE_1139_FIX_PASSWORDS",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD": "envpassword"},
|
||||
wantErr: true,
|
||||
errContains: "ISSUE_1139_FIX_PASSWORDS",
|
||||
},
|
||||
{
|
||||
name: "RESTIC_PASSWORD: ack inlines value into repo.Password",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD": "real-password"},
|
||||
ackEnvVar: true,
|
||||
wantErr: false,
|
||||
checkConfig: func(t *testing.T, cfg *v1.Config) {
|
||||
t.Helper()
|
||||
if got := cfg.Repos[0].Password; got != "real-password" {
|
||||
t.Errorf("expected repo.Password = %q, got %q", "real-password", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RESTIC_PASSWORD_FILE: ack moves to repo.Env and clears Password",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD_FILE": "/run/secrets/pw"},
|
||||
ackEnvVar: true,
|
||||
wantErr: false,
|
||||
checkConfig: func(t *testing.T, cfg *v1.Config) {
|
||||
t.Helper()
|
||||
repo := cfg.Repos[0]
|
||||
if repo.Password != "" {
|
||||
t.Errorf("expected repo.Password to be cleared, got %q", repo.Password)
|
||||
}
|
||||
wantEnv := "RESTIC_PASSWORD_FILE=/run/secrets/pw"
|
||||
for _, e := range repo.Env {
|
||||
if e == wantEnv {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("expected repo.Env to contain %q, got %v", wantEnv, repo.Env)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RESTIC_PASSWORD_COMMAND: ack moves to repo.Env and clears Password",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{{Id: "r", Uri: "/tmp/r", Password: "wrong"}}}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD_COMMAND": "cat /secrets/pw"},
|
||||
ackEnvVar: true,
|
||||
wantErr: false,
|
||||
checkConfig: func(t *testing.T, cfg *v1.Config) {
|
||||
t.Helper()
|
||||
repo := cfg.Repos[0]
|
||||
if repo.Password != "" {
|
||||
t.Errorf("expected repo.Password to be cleared, got %q", repo.Password)
|
||||
}
|
||||
wantEnv := "RESTIC_PASSWORD_COMMAND=cat /secrets/pw"
|
||||
for _, e := range repo.Env {
|
||||
if e == wantEnv {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("expected repo.Env to contain %q, got %v", wantEnv, repo.Env)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "only repos with passwords are considered affected",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{Repos: []*v1.Repo{
|
||||
{Id: "no-pass", Uri: "/tmp/r1"},
|
||||
{Id: "with-pass", Uri: "/tmp/r2", Password: "wrong"},
|
||||
}}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD": "envpassword"},
|
||||
wantErr: true,
|
||||
errContains: "with-pass",
|
||||
},
|
||||
{
|
||||
name: "no repos at all - no conflict",
|
||||
config: func() *v1.Config {
|
||||
return &v1.Config{}
|
||||
},
|
||||
envVars: map[string]string{"RESTIC_PASSWORD": "envpassword"},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for k, v := range tc.envVars {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
// Ensure conflicting env vars not in tc.envVars are unset.
|
||||
for _, k := range conflictingEnvVars {
|
||||
if _, ok := tc.envVars[k]; !ok {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
if tc.ackEnvVar {
|
||||
t.Setenv("ISSUE_1139_FIX_PASSWORDS", "1")
|
||||
} else {
|
||||
t.Setenv("ISSUE_1139_FIX_PASSWORDS", "")
|
||||
}
|
||||
|
||||
cfg := tc.config()
|
||||
err := migration005CheckRepoPasswords(cfg)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("migration005CheckRepoPasswords() error = %v, wantErr %v", err, tc.wantErr)
|
||||
}
|
||||
if tc.errContains != "" && err != nil {
|
||||
if !strings.Contains(err.Error(), tc.errContains) {
|
||||
t.Errorf("expected error to contain %q, got: %s", tc.errContains, err.Error())
|
||||
}
|
||||
}
|
||||
if tc.checkConfig != nil {
|
||||
tc.checkConfig(t, cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,12 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var migrations = []*func(*v1.Config){
|
||||
var migrations = []*func(*v1.Config) error{
|
||||
&noop, // migration001PrunePolicy is deprecated
|
||||
&noop, // migration002Schedules is deprecated
|
||||
&migration003RelativeScheduling,
|
||||
&migration004RepoGuid,
|
||||
&migration005CheckRepoPasswords,
|
||||
}
|
||||
|
||||
var CurrentVersion = int32(len(migrations))
|
||||
@@ -39,13 +40,16 @@ func ApplyMigrations(config *v1.Config) error {
|
||||
if m == &noop {
|
||||
return fmt.Errorf("config version %d is too old to migrate, please try first upgrading to backrest 1.4.0 which is the last version that may be compatible with your config", config.Version)
|
||||
}
|
||||
(*m)(config)
|
||||
if err := (*m)(config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
config.Version = CurrentVersion
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var noop = func(config *v1.Config) {
|
||||
var noop = func(config *v1.Config) error {
|
||||
// do nothing
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,12 +39,20 @@ func NewRepoOrchestrator(config *v1.Config, repoConfig *v1.Repo, resticPath stri
|
||||
}
|
||||
|
||||
var opts []restic.GenericOption
|
||||
if p := repoConfig.GetPassword(); p != "" {
|
||||
opts = append(opts, restic.WithEnv("RESTIC_PASSWORD="+p))
|
||||
}
|
||||
|
||||
opts = append(opts, restic.WithEnviron())
|
||||
|
||||
if p := repoConfig.GetPassword(); p != "" {
|
||||
// Set config password last so it takes precedence over any RESTIC_PASSWORD
|
||||
// in the system environment (e.g. set via Docker Compose). Also clear
|
||||
// RESTIC_PASSWORD_FILE and RESTIC_PASSWORD_COMMAND so they cannot
|
||||
// override the password that the user configured in Backrest.
|
||||
opts = append(opts, restic.WithEnv(
|
||||
"RESTIC_PASSWORD="+p,
|
||||
"RESTIC_PASSWORD_FILE=",
|
||||
"RESTIC_PASSWORD_COMMAND=",
|
||||
))
|
||||
}
|
||||
|
||||
if env := repoConfig.GetEnv(); len(env) != 0 {
|
||||
for _, e := range env {
|
||||
opts = append(opts, restic.WithEnv(ExpandEnv(e)))
|
||||
|
||||
@@ -371,6 +371,48 @@ func initRepoHelper(t *testing.T, config *v1.Config, repo *v1.Repo) *RepoOrchest
|
||||
return orchestrator
|
||||
}
|
||||
|
||||
// TestConfigPasswordPrecedence verifies that a password set in the Backrest
|
||||
// repo config takes precedence over RESTIC_PASSWORD (and related env vars) set
|
||||
// in the process environment. This is a regression test for
|
||||
// https://github.com/garethgeorge/backrest/issues/1139.
|
||||
func TestConfigPasswordPrecedence(t *testing.T) {
|
||||
// Cannot use t.Parallel() because t.Setenv is used.
|
||||
|
||||
repoDir := t.TempDir()
|
||||
configPassword := "config-password"
|
||||
wrongEnvPassword := "env-password-should-be-ignored"
|
||||
|
||||
repo := &v1.Repo{
|
||||
Id: "test",
|
||||
Uri: repoDir,
|
||||
Password: configPassword,
|
||||
Flags: []string{"--no-cache"},
|
||||
}
|
||||
|
||||
// Initialize the repo using the config password.
|
||||
orchestrator := initRepoHelper(t, configForTest, repo)
|
||||
|
||||
// Now set RESTIC_PASSWORD in the environment to a *different* value.
|
||||
// NewRepoOrchestrator should still use the config password, not this one.
|
||||
t.Setenv("RESTIC_PASSWORD", wrongEnvPassword)
|
||||
t.Setenv("RESTIC_PASSWORD_FILE", "")
|
||||
t.Setenv("RESTIC_PASSWORD_COMMAND", "")
|
||||
|
||||
// Re-create the orchestrator so it picks up the current environment.
|
||||
orchestrator2, err := NewRepoOrchestrator(configForTest, repo, helpers.ResticBinary(t))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create repo orchestrator: %v", err)
|
||||
}
|
||||
|
||||
// Listing snapshots exercises the repo unlock path and will fail with
|
||||
// "wrong password" if the env var is incorrectly taking precedence.
|
||||
if _, err := orchestrator2.Snapshots(context.Background()); err != nil {
|
||||
t.Fatalf("Snapshots() failed — config password did not take precedence over RESTIC_PASSWORD env var: %v", err)
|
||||
}
|
||||
|
||||
_ = orchestrator // suppress unused warning
|
||||
}
|
||||
|
||||
func TestRestoreAmbiguity(t *testing.T) {
|
||||
t.Parallel()
|
||||
repoDir := t.TempDir()
|
||||
|
||||
@@ -17,7 +17,7 @@ func NewOneoffForgetTask(repo *v1.Repo, planID string, flowID int64, at time.Tim
|
||||
OneoffTask: OneoffTask{
|
||||
BaseTask: BaseTask{
|
||||
TaskType: "forget",
|
||||
TaskName: fmt.Sprintf("forget for plan %q in repo %q", repo.Id, planID),
|
||||
TaskName: fmt.Sprintf("forget for plan %q in repo %q", planID, repo.Id),
|
||||
TaskRepo: repo,
|
||||
TaskPlanID: planID,
|
||||
},
|
||||
|
||||
Generated
+67
-26
@@ -63,6 +63,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.0.tgz",
|
||||
"integrity": "sha512-6YzkKCw30EI/E9kHOIXsQDHmMvTllT8STzjMb4K2qzit33RW2pqCJP0sk+hidBntXxE+Vz4n1+RvCTfBw6OErw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ant-design/fast-color": "^3.0.0"
|
||||
}
|
||||
@@ -72,6 +73,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.0.1.tgz",
|
||||
"integrity": "sha512-Lw1Z4cUQxdMmTNir67gU0HCpTl5TtkKCJPZ6UBvCqzcOTl/QmMFB6qAEoj8qFl0CuZDX9qQYa3m9+rEKfaBSbA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.11.1",
|
||||
"@emotion/hash": "^0.8.0",
|
||||
@@ -91,6 +93,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.0.2.tgz",
|
||||
"integrity": "sha512-Mq3Hm6fJuQeFNKSp3+yT4bjuhVbdrsyXE2RyfpJFL0xiYNZdaJ6oFaE3zFrzmHbmvTd2Wp3HCbRtkD4fU+v2ZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ant-design/cssinjs": "^2.0.1",
|
||||
"@babel/runtime": "^7.23.2",
|
||||
@@ -105,25 +108,29 @@
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
|
||||
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@ant-design/cssinjs/node_modules/@emotion/unitless": {
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
|
||||
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@ant-design/cssinjs/node_modules/stylis": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
||||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@ant-design/fast-color": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.0.tgz",
|
||||
"integrity": "sha512-eqvpP7xEDm2S7dUzl5srEQCBTXZMmY3ekf97zI+M2DHOYyKdJGH0qua0JACHTqbkRnD/KHFQP9J1uMJ/XWVzzA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.x"
|
||||
}
|
||||
@@ -133,6 +140,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.1.0.tgz",
|
||||
"integrity": "sha512-KrWMu1fIg3w/1F2zfn+JlfNDU8dDqILfA5Tg85iqs1lf8ooyGlbkA+TkwfOKKgqpUmAiRY1PTFpuOU2DAIgSUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^8.0.0",
|
||||
"@ant-design/icons-svg": "^4.4.0",
|
||||
@@ -151,13 +159,15 @@
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
|
||||
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@ant-design/react-slick": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",
|
||||
"integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -275,7 +285,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -538,8 +547,7 @@
|
||||
"version": "2.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz",
|
||||
"integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
||||
"peer": true
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@chakra-ui/react": {
|
||||
"version": "3.30.0",
|
||||
@@ -566,7 +574,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz",
|
||||
"integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "^2.7.0"
|
||||
}
|
||||
@@ -598,7 +605,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -693,7 +699,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -1352,7 +1357,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
||||
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
@@ -1702,7 +1706,6 @@
|
||||
"integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^4.0.0",
|
||||
"@octokit/graphql": "^7.1.0",
|
||||
@@ -2404,6 +2407,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz",
|
||||
"integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.4"
|
||||
},
|
||||
@@ -2416,6 +2420,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.9.0.tgz",
|
||||
"integrity": "sha512-2jbthe1QZrMBgtCvNKkJFjZYC3uKl4N/aYm5SsMvO3T+F+qRT1CGsSM9bXnh1rLj7jDk/GK0natShWF/jinhWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/select": "~1.3.0",
|
||||
"@rc-component/tree": "~1.1.0",
|
||||
@@ -2432,6 +2437,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-1.0.1.tgz",
|
||||
"integrity": "sha512-08yTH8m+bSm8TOqbybbJ9KiAuIATti6bDs2mVeSfu4QfEnyeF6X0enHVvD1NEAyuBWEAo56QtLe++MYs2D9XiQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.3.0",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -2446,6 +2452,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.1.2.tgz",
|
||||
"integrity": "sha512-ilBYk1dLLJHu5Q74dF28vwtKUYQ42ZXIIDmqTuVy4rD8JQVvkXOs+KixVNbweyuIEtJYJ7+t+9GVD9dPc6N02w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.10.1",
|
||||
"@rc-component/motion": "^1.1.4",
|
||||
@@ -2462,6 +2469,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.0.3.tgz",
|
||||
"integrity": "sha512-V7gFF9O7o5XwIWafdbOtqI4BUUkEUkgdBwp6favy3xajMX/2dDqytFaiXlcwrpq6aRyPLp5dKLAG5RFKLXMeGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ant-design/fast-color": "^3.0.0",
|
||||
"@rc-component/util": "^1.3.0",
|
||||
@@ -2477,6 +2485,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.1.tgz",
|
||||
"integrity": "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.3.0"
|
||||
},
|
||||
@@ -2490,6 +2499,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.5.1.tgz",
|
||||
"integrity": "sha512-by4Sf/a3azcb89WayWuwG19/Y312xtu8N81HoVQQtnsBDylfs+dog98fTAvLinnpeoWG52m/M7QLRW6fXR3l1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/motion": "^1.1.3",
|
||||
"@rc-component/portal": "^2.0.0",
|
||||
@@ -2506,6 +2516,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.3.0.tgz",
|
||||
"integrity": "sha512-rE+sdXEmv2W25VBQ9daGbnb4J4hBIEKmdbj0b3xpY+K7TUmLXDIlSnoXraIbFZdGyek9WxxGKK887uRnFgI+pQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/motion": "^1.1.4",
|
||||
"@rc-component/portal": "^2.0.0",
|
||||
@@ -2522,6 +2533,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz",
|
||||
"integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/trigger": "^3.0.0",
|
||||
"@rc-component/util": "^1.2.1",
|
||||
@@ -2537,6 +2549,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.6.0.tgz",
|
||||
"integrity": "sha512-A7vrN8kExtw4sW06mrsgCb1rowhvBFFvQU6Bk/NL0Fj6Wet/5GF0QnGCxBu/sG3JI9FEhsJWES0D44BW2d0hzg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/async-validator": "^5.0.3",
|
||||
"@rc-component/util": "^1.5.0",
|
||||
@@ -2555,6 +2568,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.5.3.tgz",
|
||||
"integrity": "sha512-/NR7QW9uCN8Ugar+xsHZOPvzPySfEhcW2/vLcr7VPRM+THZMrllMRv7LAUgW7ikR+Z67Ab67cgPp5K5YftpJsQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/motion": "^1.0.0",
|
||||
"@rc-component/portal": "^2.0.0",
|
||||
@@ -2571,6 +2585,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.1.2.tgz",
|
||||
"integrity": "sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.4.0",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -2585,6 +2600,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz",
|
||||
"integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/mini-decimal": "^1.0.1",
|
||||
"@rc-component/util": "^1.4.0",
|
||||
@@ -2600,6 +2616,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.6.0.tgz",
|
||||
"integrity": "sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/input": "~1.1.0",
|
||||
"@rc-component/menu": "~1.2.0",
|
||||
@@ -2618,6 +2635,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.2.0.tgz",
|
||||
"integrity": "sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/motion": "^1.1.4",
|
||||
"@rc-component/overflow": "^1.0.0",
|
||||
@@ -2635,6 +2653,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz",
|
||||
"integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.0"
|
||||
},
|
||||
@@ -2647,6 +2666,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.1.6.tgz",
|
||||
"integrity": "sha512-aEQobs/YA0kqRvHIPjQvOytdtdRVyhf/uXAal4chBjxDu6odHckExJzjn2D+Ju1aKK6hx3pAs6BXdV9+86xkgQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.2.0",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -2661,6 +2681,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz",
|
||||
"integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.2.0"
|
||||
},
|
||||
@@ -2677,6 +2698,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-1.2.0.tgz",
|
||||
"integrity": "sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/motion": "^1.1.4",
|
||||
"@rc-component/util": "^1.2.1",
|
||||
@@ -2695,6 +2717,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.0.tgz",
|
||||
"integrity": "sha512-GSlBeoE0XTBi5cf3zl8Qh7Uqhn7v8RrlJ8ajeVpEkNe94HWy5l5BQ0Mwn2TVUq9gdgbfEMUmTX7tJFAg7mz0Rw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.11.1",
|
||||
"@rc-component/resize-observer": "^1.0.1",
|
||||
@@ -2711,6 +2734,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.2.0.tgz",
|
||||
"integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.3.0",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -2725,6 +2749,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.9.0.tgz",
|
||||
"integrity": "sha512-OLisdk8AWVCG9goBU1dWzuH5QlBQk8jktmQ6p0/IyBFwdKGwyIZOSjnBYo8hooHiTdl0lU+wGf/OfMtVBw02KQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/overflow": "^1.0.0",
|
||||
"@rc-component/resize-observer": "^1.0.0",
|
||||
@@ -2763,6 +2788,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.1.0.tgz",
|
||||
"integrity": "sha512-P25IXWkzvBbyEtrAHRfqSNRkILXgAjDfuk0s4daPfHHO0XzVk3D3KJY3Lh069xwuBGtsTZpg+mP4WBLYl9GNaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.2.1",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -2780,6 +2806,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.2.tgz",
|
||||
"integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.2.1",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -2794,6 +2821,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz",
|
||||
"integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.7"
|
||||
},
|
||||
@@ -2810,6 +2838,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz",
|
||||
"integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.3.0",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -2827,6 +2856,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.0.1.tgz",
|
||||
"integrity": "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.2.0"
|
||||
},
|
||||
@@ -2840,6 +2870,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz",
|
||||
"integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.11.1",
|
||||
"@rc-component/motion": "^1.1.4",
|
||||
@@ -2856,6 +2887,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.3.6.tgz",
|
||||
"integrity": "sha512-CzbJ9TwmWcF5asvTMZ9BMiTE9CkkrigeOGRPpzCNmeZP7KBwwmYrmOIiKh9tMG7d6DyGAEAQ75LBxzPx+pGTHA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/overflow": "^1.0.0",
|
||||
"@rc-component/trigger": "^3.0.0",
|
||||
@@ -2876,6 +2908,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.0.1.tgz",
|
||||
"integrity": "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.3.0",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -2893,6 +2926,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz",
|
||||
"integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.2.1",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -2910,6 +2944,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz",
|
||||
"integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.3.0",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -2924,6 +2959,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.9.0.tgz",
|
||||
"integrity": "sha512-cq3P9FkD+F3eglkFYhBuNlHclg+r4jY8+ZIgK7zbEFo6IwpnA77YL/Gq4ensLw9oua3zFCTA6JDu6YgBei0TxA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/context": "^2.0.1",
|
||||
"@rc-component/resize-observer": "^1.0.0",
|
||||
@@ -2944,6 +2980,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.7.0.tgz",
|
||||
"integrity": "sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/dropdown": "~1.0.0",
|
||||
"@rc-component/menu": "~1.2.0",
|
||||
@@ -2965,6 +3002,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/textarea/-/textarea-1.1.2.tgz",
|
||||
"integrity": "sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/input": "~1.1.0",
|
||||
"@rc-component/resize-observer": "^1.0.0",
|
||||
@@ -2981,6 +3019,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz",
|
||||
"integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/trigger": "^3.7.1",
|
||||
"@rc-component/util": "^1.3.0",
|
||||
@@ -2996,6 +3035,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.2.1.tgz",
|
||||
"integrity": "sha512-BUCrVikGJsXli38qlJ+h2WyDD6dYxzDA9dV3o0ij6gYhAq6ooT08SUMWOikva9v4KZ2BEuluGl5bPcsjrSoBgQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/portal": "^2.0.0",
|
||||
"@rc-component/trigger": "^3.0.0",
|
||||
@@ -3015,6 +3055,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.1.0.tgz",
|
||||
"integrity": "sha512-HZs3aOlvFgQdgrmURRc/f4IujiNBf4DdEeXUlkS0lPoLlx9RoqsZcF0caXIAMVb+NaWqKtGQDnrH8hqLCN5zlA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/motion": "^1.0.0",
|
||||
"@rc-component/util": "^1.2.1",
|
||||
@@ -3034,6 +3075,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.4.0.tgz",
|
||||
"integrity": "sha512-I3UAlO2hNqy9CSKc8EBaESgnmKk2QaRzuZ2XHZGFCgsSMkGl06mdF97sVfROM02YIb64ocgLKefsjE0Ch4ocwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/select": "~1.3.0",
|
||||
"@rc-component/tree": "~1.1.0",
|
||||
@@ -3050,6 +3092,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.7.2.tgz",
|
||||
"integrity": "sha512-25x+D2k9SAkaK/MNMNmv2Nlv8FH1D9RtmjoMoLEw1Cid+sMV4pAAT5k49ku59UeXaOA1qwLUVrBUMq4A6gUSsQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/motion": "^1.1.4",
|
||||
"@rc-component/portal": "^2.0.0",
|
||||
@@ -3070,6 +3113,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.0.tgz",
|
||||
"integrity": "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rc-component/util": "^1.3.0",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -3084,6 +3128,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.6.0.tgz",
|
||||
"integrity": "sha512-YbjuIVAm8InCnXVoA4n6G+uh31yESTxQ6fSY2frZ2/oMSvktoB+bumFUfNN7RKh7YeOkZgOvN2suGtEDhJSX0A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-mobile": "^5.0.0",
|
||||
"react-is": "^18.2.0"
|
||||
@@ -3098,6 +3143,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.0.2.tgz",
|
||||
"integrity": "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@rc-component/resize-observer": "^1.0.1",
|
||||
@@ -3622,7 +3668,6 @@
|
||||
"integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3646,7 +3691,6 @@
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -4842,7 +4886,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5118,7 +5161,8 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
||||
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.0",
|
||||
@@ -5994,7 +6038,8 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz",
|
||||
"integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
@@ -6078,6 +6123,7 @@
|
||||
"resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
|
||||
"integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"string-convert": "^0.2.0"
|
||||
}
|
||||
@@ -6173,7 +6219,6 @@
|
||||
"integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
@@ -6709,7 +6754,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -6722,7 +6766,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -6980,7 +7023,6 @@
|
||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -7085,7 +7127,6 @@
|
||||
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.0.2",
|
||||
@@ -7115,6 +7156,7 @@
|
||||
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
|
||||
"integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"compute-scroll-into-view": "^3.0.2"
|
||||
}
|
||||
@@ -7212,7 +7254,8 @@
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
|
||||
"integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
@@ -7354,6 +7397,7 @@
|
||||
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
||||
"integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.22"
|
||||
}
|
||||
@@ -7428,7 +7472,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -7449,7 +7492,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -7595,7 +7637,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Operation, OperationEvent, OperationStatus } from "../../gen/ts/v1/operations_pb";
|
||||
import { OpSelector } from "../../gen/ts/v1/service_pb";
|
||||
import { subscribeToOperations, unsubscribeFromOperations } from "./oplog";
|
||||
import { getStatusForSelector, matchSelector } from "./logState";
|
||||
import { debounce } from "../lib/util";
|
||||
|
||||
// Module-level shared state: all registered selectors and their cached statuses.
|
||||
const selectors = new Map<string, { selector: OpSelector; refCount: number }>();
|
||||
const statuses = new Map<string, OperationStatus>();
|
||||
const listeners = new Set<() => void>();
|
||||
let subscribed = false;
|
||||
|
||||
const notify = () => {
|
||||
for (const l of listeners) l();
|
||||
};
|
||||
|
||||
const fetchStatus = async (key: string, selector: OpSelector) => {
|
||||
try {
|
||||
const status = await getStatusForSelector(selector);
|
||||
statuses.set(key, status);
|
||||
notify();
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// Track pending keys to refresh; the debounced function drains this set.
|
||||
const pendingKeys = new Set<string>();
|
||||
|
||||
const flushPending = debounce(
|
||||
() => {
|
||||
for (const key of pendingKeys) {
|
||||
const entry = selectors.get(key);
|
||||
if (entry) {
|
||||
fetchStatus(key, entry.selector);
|
||||
}
|
||||
}
|
||||
pendingKeys.clear();
|
||||
},
|
||||
1000,
|
||||
{ maxWait: 10000, trailing: true },
|
||||
);
|
||||
|
||||
const refreshAll = () => {
|
||||
for (const key of selectors.keys()) {
|
||||
pendingKeys.add(key);
|
||||
}
|
||||
flushPending();
|
||||
};
|
||||
|
||||
const refreshMatching = (ops: Operation[]) => {
|
||||
for (const [key, { selector }] of selectors) {
|
||||
if (ops.some((op) => matchSelector(selector, op))) {
|
||||
pendingKeys.add(key);
|
||||
}
|
||||
}
|
||||
if (pendingKeys.size > 0) {
|
||||
flushPending();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEvent = (event?: OperationEvent, _err?: Error) => {
|
||||
if (!event || !event.event) return;
|
||||
switch (event.event.case) {
|
||||
case "createdOperations":
|
||||
case "updatedOperations":
|
||||
refreshMatching(event.event.value.operations);
|
||||
break;
|
||||
case "deletedOperations":
|
||||
refreshAll();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const register = (selector: OpSelector): string => {
|
||||
const key = JSON.stringify(selector);
|
||||
const existing = selectors.get(key);
|
||||
if (existing) {
|
||||
existing.refCount++;
|
||||
return key;
|
||||
}
|
||||
selectors.set(key, { selector, refCount: 1 });
|
||||
fetchStatus(key, selector);
|
||||
if (!subscribed) {
|
||||
subscribed = true;
|
||||
subscribeToOperations(handleEvent);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
const unregister = (key: string) => {
|
||||
const entry = selectors.get(key);
|
||||
if (!entry) return;
|
||||
entry.refCount--;
|
||||
if (entry.refCount <= 0) {
|
||||
selectors.delete(key);
|
||||
statuses.delete(key);
|
||||
notify();
|
||||
if (selectors.size === 0 && subscribed) {
|
||||
subscribed = false;
|
||||
unsubscribeFromOperations(handleEvent);
|
||||
flushPending.cancel();
|
||||
pendingKeys.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that returns the OperationStatus for a given selector.
|
||||
* Shares a single global operation event subscription across all consumers.
|
||||
*/
|
||||
export const useResourceStatus = (selector: OpSelector): OperationStatus => {
|
||||
const key = JSON.stringify(selector);
|
||||
const [status, setStatus] = useState<OperationStatus>(
|
||||
() => statuses.get(key) ?? OperationStatus.STATUS_UNKNOWN,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const k = register(selector);
|
||||
const listener = () => {
|
||||
setStatus(statuses.get(k) ?? OperationStatus.STATUS_UNKNOWN);
|
||||
};
|
||||
listeners.add(listener);
|
||||
// Sync in case status was fetched between render and effect
|
||||
listener();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
unregister(k);
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
return status;
|
||||
};
|
||||
+193
-162
@@ -1,4 +1,4 @@
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
FiCalendar,
|
||||
FiDatabase,
|
||||
@@ -44,15 +44,15 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "../components/ui/drawer";
|
||||
import { Config, Multihost_Peer } from "../../gen/ts/v1/config_pb";
|
||||
import { Config, Multihost_Peer, Plan, Repo } from "../../gen/ts/v1/config_pb";
|
||||
import { alerts } from "../components/common/Alerts";
|
||||
import { useShowModal } from "../components/common/ModalManager";
|
||||
import { uiBuildVersion } from "../state/buildcfg";
|
||||
import { ActivityBar } from "../components/layout/ActivityBar";
|
||||
import { OperationEvent, OperationStatus } from "../../gen/ts/v1/operations_pb";
|
||||
import { subscribeToOperations, unsubscribeFromOperations } from "../api/oplog";
|
||||
import { OperationStatus } from "../../gen/ts/v1/operations_pb";
|
||||
import { useResourceStatus } from "../api/resourceStatus";
|
||||
import LogoSvg from "../../assets/logo.svg";
|
||||
import { debounce, keyBy } from "../lib/util";
|
||||
import { keyBy } from "../lib/util";
|
||||
import { Code } from "@connectrpc/connect";
|
||||
import { LoginModal } from "../features/auth/LoginModal";
|
||||
import { backrestService, setAuthToken } from "../api/client";
|
||||
@@ -60,7 +60,6 @@ import { useConfig } from "./provider";
|
||||
import { shouldShowSettings } from "../state/configutil";
|
||||
import { OpSelector, OpSelectorSchema } from "../../gen/ts/v1/service_pb";
|
||||
import { colorForStatus } from "../api/flowDisplayAggregator";
|
||||
import { getStatusForSelector, matchSelector } from "../api/logState";
|
||||
import {
|
||||
Route,
|
||||
Routes,
|
||||
@@ -204,6 +203,155 @@ const PlanViewContainer = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarPlanItem = React.memo(
|
||||
({
|
||||
plan,
|
||||
repoGuid,
|
||||
active,
|
||||
onNav,
|
||||
onEdit,
|
||||
}: {
|
||||
plan: Plan;
|
||||
repoGuid: string | undefined;
|
||||
active: boolean;
|
||||
onNav: (path: string) => void;
|
||||
onEdit: (plan: Plan) => void;
|
||||
}) => {
|
||||
const sel = useMemo(
|
||||
() =>
|
||||
create(OpSelectorSchema, {
|
||||
originalInstanceKeyid: "",
|
||||
planId: plan.id,
|
||||
repoGuid: repoGuid,
|
||||
}),
|
||||
[plan.id, repoGuid],
|
||||
);
|
||||
const planPath = `/plan/${plan.id}`;
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
pl={9}
|
||||
pr={2}
|
||||
py={1}
|
||||
bg={active ? "bg.emphasized" : undefined}
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
className="group"
|
||||
>
|
||||
<Box flexShrink={0} mr={2}>
|
||||
<IconForResource selector={sel} />
|
||||
</Box>
|
||||
<Tooltip content={plan.id}>
|
||||
<Box
|
||||
flex="1"
|
||||
minW="0"
|
||||
cursor="pointer"
|
||||
onClick={() => onNav(planPath)}
|
||||
userSelect="none"
|
||||
>
|
||||
<Text
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{plan.id}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Box
|
||||
opacity={0}
|
||||
_groupHover={{ opacity: 1 }}
|
||||
transition="opacity 0.2s"
|
||||
>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onEdit(plan);
|
||||
}}
|
||||
>
|
||||
<FiEdit2 />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const SidebarRepoItem = React.memo(
|
||||
({
|
||||
repo,
|
||||
instanceId,
|
||||
active,
|
||||
onNav,
|
||||
onEdit,
|
||||
}: {
|
||||
repo: Repo;
|
||||
instanceId: string;
|
||||
active: boolean;
|
||||
onNav: (path: string) => void;
|
||||
onEdit: (repo: Repo) => void;
|
||||
}) => {
|
||||
const sel = useMemo(
|
||||
() =>
|
||||
create(OpSelectorSchema, {
|
||||
instanceId: instanceId,
|
||||
repoGuid: repo.guid,
|
||||
}),
|
||||
[instanceId, repo.guid],
|
||||
);
|
||||
const repoPath = `/repo/${repo.id}`;
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
pl={9}
|
||||
pr={2}
|
||||
py={1}
|
||||
bg={active ? "bg.emphasized" : undefined}
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
className="group"
|
||||
>
|
||||
<Box flexShrink={0} mr={2}>
|
||||
<IconForResource selector={sel} />
|
||||
</Box>
|
||||
<Tooltip content={repo.uri}>
|
||||
<Box
|
||||
flex="1"
|
||||
minW="0"
|
||||
cursor="pointer"
|
||||
onClick={() => onNav(repoPath)}
|
||||
userSelect="none"
|
||||
>
|
||||
<Text
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{repo.id}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Box
|
||||
opacity={0}
|
||||
_groupHover={{ opacity: 1 }}
|
||||
transition="opacity 0.2s"
|
||||
>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onEdit(repo);
|
||||
}}
|
||||
>
|
||||
<FiEdit2 />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
|
||||
const [config] = useConfig();
|
||||
const peerStates = useSyncStates();
|
||||
@@ -218,12 +366,11 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
const reposById = useMemo(() => config ? keyBy(config.repos, (r) => r.id) : {}, [config?.repos]);
|
||||
|
||||
// Replicate getSidenavItems functionality with Chakra components
|
||||
if (!config) return null;
|
||||
|
||||
const reposById = keyBy(config.repos, (r) => r.id);
|
||||
|
||||
// Sort logic can be added here if needed, currently adhering to original order
|
||||
const configPlans = config.plans || [];
|
||||
const configRepos = config.repos || [];
|
||||
|
||||
@@ -242,6 +389,7 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
|
||||
multiple
|
||||
defaultValue={["plans", "repos", "authorized-clients"]}
|
||||
variant="plain"
|
||||
lazyMount
|
||||
>
|
||||
{/* DASHBOARD */}
|
||||
<Box
|
||||
@@ -285,67 +433,21 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
|
||||
>
|
||||
<FiPlus /> {m.app_menu_add_plan()}
|
||||
</Button>
|
||||
{configPlans.map((plan) => {
|
||||
const sel = create(OpSelectorSchema, {
|
||||
originalInstanceKeyid: "",
|
||||
planId: plan.id,
|
||||
repoGuid: reposById[plan.repo]?.guid,
|
||||
});
|
||||
const planPath = `/plan/${plan.id}`;
|
||||
const active = isActive(planPath);
|
||||
return (
|
||||
<Flex
|
||||
key={plan.id}
|
||||
align="center"
|
||||
pl={9}
|
||||
pr={2}
|
||||
py={1}
|
||||
bg={active ? "bg.emphasized" : undefined}
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
className="group"
|
||||
>
|
||||
<Box flexShrink={0} mr={2}>
|
||||
<IconForResource selector={sel} />
|
||||
</Box>
|
||||
<Tooltip content={plan.id}>
|
||||
<Box
|
||||
flex="1"
|
||||
minW="0"
|
||||
cursor="pointer"
|
||||
onClick={() => handleNav(planPath)}
|
||||
userSelect="none"
|
||||
>
|
||||
<Text
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{plan.id}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Box
|
||||
opacity={0}
|
||||
_groupHover={{ opacity: 1 }}
|
||||
transition="opacity 0.2s"
|
||||
>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const { AddPlanModal } =
|
||||
await import("../features/plans/AddPlanModal");
|
||||
showModal(<AddPlanModal template={plan} />);
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<FiEdit2 />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
{configPlans.map((plan) => (
|
||||
<SidebarPlanItem
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
repoGuid={reposById[plan.repo]?.guid}
|
||||
active={isActive(`/plan/${plan.id}`)}
|
||||
onNav={handleNav}
|
||||
onEdit={async (plan) => {
|
||||
const { AddPlanModal } =
|
||||
await import("../features/plans/AddPlanModal");
|
||||
showModal(<AddPlanModal template={plan} />);
|
||||
onClose?.();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AccordionItemContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -375,67 +477,21 @@ const SidebarContent = ({ onClose }: { onClose?: () => void }) => {
|
||||
>
|
||||
<FiPlus /> {m.app_menu_add_repo()}
|
||||
</Button>
|
||||
{configRepos.map((repo) => {
|
||||
const repoPath = `/repo/${repo.id}`;
|
||||
const active = isActive(repoPath);
|
||||
return (
|
||||
<Flex
|
||||
key={repo.id}
|
||||
align="center"
|
||||
pl={9}
|
||||
pr={2}
|
||||
py={1}
|
||||
bg={active ? "bg.emphasized" : undefined}
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
className="group"
|
||||
>
|
||||
<Box flexShrink={0} mr={2}>
|
||||
<IconForResource
|
||||
selector={create(OpSelectorSchema, {
|
||||
instanceId: config.instance,
|
||||
repoGuid: repo.guid,
|
||||
})}
|
||||
/>
|
||||
</Box>
|
||||
<Tooltip content={repo.id}>
|
||||
<Box
|
||||
flex="1"
|
||||
minW="0"
|
||||
cursor="pointer"
|
||||
onClick={() => handleNav(repoPath)}
|
||||
userSelect="none"
|
||||
>
|
||||
<Text
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{repo.id}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Box
|
||||
opacity={0}
|
||||
_groupHover={{ opacity: 1 }}
|
||||
transition="opacity 0.2s"
|
||||
>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const { AddRepoModal } =
|
||||
await import("../features/repositories/AddRepoModal");
|
||||
showModal(<AddRepoModal template={repo} />);
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<FiEdit2 />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
{configRepos.map((repo) => (
|
||||
<SidebarRepoItem
|
||||
key={repo.id}
|
||||
repo={repo}
|
||||
instanceId={config.instance}
|
||||
active={isActive(`/repo/${repo.id}`)}
|
||||
onNav={handleNav}
|
||||
onEdit={async (repo) => {
|
||||
const { AddRepoModal } =
|
||||
await import("../features/repositories/AddRepoModal");
|
||||
showModal(<AddRepoModal template={repo} />);
|
||||
onClose?.();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AccordionItemContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -779,37 +835,12 @@ const AuthenticationBoundary = ({
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const IconForResource = ({ selector }: { selector: OpSelector }) => {
|
||||
const [status, setStatus] = useState(OperationStatus.STATUS_UNKNOWN);
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setStatus(await getStatusForSelector(selector));
|
||||
};
|
||||
load();
|
||||
const refresh = debounce(load, 1000, { maxWait: 10000, trailing: true });
|
||||
const callback = (event?: OperationEvent, err?: Error) => {
|
||||
if (!event || !event.event) return;
|
||||
switch (event.event.case) {
|
||||
case "createdOperations":
|
||||
case "updatedOperations":
|
||||
const ops = event.event.value.operations;
|
||||
if (ops.find((op) => matchSelector(selector, op))) {
|
||||
refresh();
|
||||
}
|
||||
break;
|
||||
case "deletedOperations":
|
||||
refresh();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
subscribeToOperations(callback);
|
||||
return () => {
|
||||
unsubscribeFromOperations(callback);
|
||||
};
|
||||
}, [JSON.stringify(selector)]);
|
||||
return iconForStatus(status);
|
||||
};
|
||||
const IconForResource = React.memo(
|
||||
({ selector }: { selector: OpSelector }) => {
|
||||
const status = useResourceStatus(selector);
|
||||
return iconForStatus(status);
|
||||
},
|
||||
);
|
||||
|
||||
const iconForStatus = (status: OperationStatus) => {
|
||||
const color = colorForStatus(status);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
@@ -277,7 +277,7 @@ export const OperationTreeView = ({
|
||||
);
|
||||
};
|
||||
|
||||
const DisplayOperationTree = ({
|
||||
const DisplayOperationTree = React.memo(({
|
||||
operations,
|
||||
isPlanView,
|
||||
onSelect,
|
||||
@@ -442,6 +442,84 @@ const DisplayOperationTree = ({
|
||||
setExpandedValue(Array.from([...expandedValue, ...toExpand]));
|
||||
}, [operations, isPlanView]);
|
||||
|
||||
const renderNode = useCallback(
|
||||
({ node, nodeState }: { node: OpTreeNode; nodeState: any }) =>
|
||||
nodeState.isBranch ? (
|
||||
<TreeViewBranchControl
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
setExpandedValue((prev) =>
|
||||
prev.includes(node.id)
|
||||
? prev.filter((id) => id !== node.id)
|
||||
: [...prev, node.id],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
transform={nodeState.expanded ? "rotate(90deg)" : undefined}
|
||||
transition="transform 0.2s"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="20px"
|
||||
flexShrink={0}
|
||||
>
|
||||
<LuChevronRight size="14px" />
|
||||
</Box>
|
||||
<TreeViewBranchTrigger>
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="24px"
|
||||
flexShrink={0}
|
||||
>
|
||||
<LuFolder />
|
||||
</Box>
|
||||
</TreeViewBranchTrigger>
|
||||
<TreeViewBranchText>{node.label}</TreeViewBranchText>
|
||||
<TreeViewBranchContent />
|
||||
</TreeViewBranchControl>
|
||||
) : (
|
||||
<TreeViewItem>
|
||||
{/* Spacer to match chevron width */}
|
||||
<Box w="20px" flexShrink={0} />
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="24px"
|
||||
flexShrink={0}
|
||||
>
|
||||
{node.icon ? node.icon : <LuFile />}
|
||||
</Box>
|
||||
<TreeViewItemText>
|
||||
{node.backup ? (
|
||||
<VStack align="start" gap="0">
|
||||
<Text>
|
||||
{displayTypeToString(node.backup.type)}{" "}
|
||||
{formatTime(node.backup.displayTime)}
|
||||
</Text>
|
||||
{node.backup.subtitleComponents &&
|
||||
node.backup.subtitleComponents.length > 0 && (
|
||||
<Text
|
||||
color="fg.muted"
|
||||
fontSize="xs"
|
||||
fontFamily="mono"
|
||||
>
|
||||
{node.backup.subtitleComponents.join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
node.label
|
||||
)}
|
||||
</TreeViewItemText>
|
||||
</TreeViewItem>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!treeCollection) return <></>;
|
||||
|
||||
return (
|
||||
@@ -460,87 +538,11 @@ const DisplayOperationTree = ({
|
||||
}}
|
||||
>
|
||||
<TreeViewTree>
|
||||
<TreeViewNode<OpTreeNode>
|
||||
render={({ node, nodeState }) =>
|
||||
nodeState.isBranch ? (
|
||||
<TreeViewBranchControl
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
setExpandedValue((prev) =>
|
||||
prev.includes(node.id)
|
||||
? prev.filter((id) => id !== node.id)
|
||||
: [...prev, node.id],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
transform={nodeState.expanded ? "rotate(90deg)" : undefined}
|
||||
transition="transform 0.2s"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="20px"
|
||||
flexShrink={0}
|
||||
>
|
||||
<LuChevronRight size="14px" />
|
||||
</Box>
|
||||
<TreeViewBranchTrigger>
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="24px"
|
||||
flexShrink={0}
|
||||
>
|
||||
<LuFolder />
|
||||
</Box>
|
||||
</TreeViewBranchTrigger>
|
||||
<TreeViewBranchText>{node.label}</TreeViewBranchText>
|
||||
<TreeViewBranchContent />
|
||||
</TreeViewBranchControl>
|
||||
) : (
|
||||
<TreeViewItem>
|
||||
{/* Spacer to match chevron width */}
|
||||
<Box w="20px" flexShrink={0} />
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w="24px"
|
||||
flexShrink={0}
|
||||
>
|
||||
{node.icon ? node.icon : <LuFile />}
|
||||
</Box>
|
||||
<TreeViewItemText>
|
||||
{node.backup ? (
|
||||
<VStack align="start" gap="0">
|
||||
<Text>
|
||||
{displayTypeToString(node.backup.type)}{" "}
|
||||
{formatTime(node.backup.displayTime)}
|
||||
</Text>
|
||||
{node.backup.subtitleComponents &&
|
||||
node.backup.subtitleComponents.length > 0 && (
|
||||
<Text
|
||||
color="fg.muted"
|
||||
fontSize="xs"
|
||||
fontFamily="mono"
|
||||
>
|
||||
{node.backup.subtitleComponents.join(", ")}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
node.label
|
||||
)}
|
||||
</TreeViewItemText>
|
||||
</TreeViewItem>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TreeViewNode<OpTreeNode> render={renderNode} />
|
||||
</TreeViewTree>
|
||||
</TreeViewRoot>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const BackupView = ({ backup }: { backup?: FlowDisplayInfo }) => {
|
||||
if (!backup) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
AccordionItemTrigger,
|
||||
AccordionRoot,
|
||||
} from "../../components/ui/accordion";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useShowModal } from "../../components/common/ModalManager";
|
||||
import {
|
||||
CommandPrefix_CPUNiceLevel,
|
||||
@@ -108,6 +108,7 @@ interface SftpConfigSectionProps {
|
||||
onChangeIdentityFile: (path: string) => void;
|
||||
port: number | null;
|
||||
onChangePort: (port: number | null) => void;
|
||||
knownHostsPath: string;
|
||||
onChangeKnownHostsPath: (path: string) => void;
|
||||
isWindows: boolean;
|
||||
}
|
||||
@@ -118,70 +119,48 @@ const SftpConfigSection = ({
|
||||
onChangeIdentityFile,
|
||||
port,
|
||||
onChangePort,
|
||||
knownHostsPath,
|
||||
onChangeKnownHostsPath,
|
||||
isWindows,
|
||||
}: SftpConfigSectionProps) => {
|
||||
// Setup Keys state
|
||||
const [sftpUsername, setSftpUsername] = useState("");
|
||||
const [sftpPassword, setSftpPassword] = useState("");
|
||||
const [setupLoading, setSetupLoading] = useState(false);
|
||||
const [generatedPublicKey, setGeneratedPublicKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [generatedPublicKey, setGeneratedPublicKey] = useState<string | null>(null);
|
||||
const [hostKeyWarning, setHostKeyWarning] = useState<string | null>(null);
|
||||
const [keyCopied, setKeyCopied] = useState(false);
|
||||
|
||||
if (isWindows) return null;
|
||||
|
||||
const handleSetupKeys = async () => {
|
||||
const handleGenerateKey = async () => {
|
||||
setSetupLoading(true);
|
||||
setGeneratedPublicKey(null);
|
||||
setHostKeyWarning(null);
|
||||
try {
|
||||
if (!uri) return;
|
||||
// Simple parse of URI for host/port if not fully robust
|
||||
let host = "";
|
||||
|
||||
// Parse host and port from the SFTP URI
|
||||
const authority = uri.replace("sftp:", "").split("/")[0];
|
||||
const hostPart = authority.includes("@") ? authority.split("@")[1] : authority;
|
||||
let host = hostPart;
|
||||
let defaultPort = "22";
|
||||
const uriParts = uri.replace("sftp:", "").split("/");
|
||||
const authority = uriParts[0];
|
||||
let hostPart = authority;
|
||||
if (authority.includes("@")) {
|
||||
setSftpUsername(authority.split("@")[0]);
|
||||
hostPart = authority.split("@")[1];
|
||||
}
|
||||
|
||||
if (hostPart.includes(":")) {
|
||||
host = hostPart.split(":")[0];
|
||||
defaultPort = hostPart.split(":")[1];
|
||||
} else {
|
||||
host = hostPart;
|
||||
[host, defaultPort] = hostPart.split(":");
|
||||
}
|
||||
|
||||
// Override from manual input if username is set there
|
||||
const username = sftpUsername || uri.match(/([^@]+)@/)?.[1] || "";
|
||||
|
||||
const res = await backrestService.setupSftp({
|
||||
host: host,
|
||||
host,
|
||||
port: port ? port.toString() : defaultPort,
|
||||
username: username,
|
||||
password: sftpPassword || undefined,
|
||||
username: "",
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
onChangeIdentityFile(res.keyPath);
|
||||
onChangeKnownHostsPath(res.knownHostsPath);
|
||||
if (res.publicKey) {
|
||||
setGeneratedPublicKey(res.publicKey);
|
||||
}
|
||||
alerts.success(
|
||||
"Created SSH keypair at " +
|
||||
res.keyPath +
|
||||
" and updated known hosts file at " +
|
||||
res.knownHostsPath,
|
||||
);
|
||||
alerts.success(
|
||||
"Updated restic flags to use the SSH keypair and known hosts file.",
|
||||
);
|
||||
if (res.error) {
|
||||
setHostKeyWarning(res.error);
|
||||
}
|
||||
alerts.success("Generated SSH keypair at " + res.keyPath);
|
||||
} catch (e: any) {
|
||||
alerts.error(formatErrorAlert(e, "SFTP Setup Failed"));
|
||||
} finally {
|
||||
@@ -195,34 +174,22 @@ const SftpConfigSection = ({
|
||||
<AccordionRoot collapsible variant="enclosed">
|
||||
<AccordionItem value="bootstrap">
|
||||
<AccordionItemTrigger>
|
||||
Bootstrap SSH Key (Optional)
|
||||
Setup SSH Key (Optional)
|
||||
</AccordionItemTrigger>
|
||||
<AccordionItemContent>
|
||||
<Stack gap={3} p={2}>
|
||||
<CText fontSize="sm">
|
||||
Enter your SSH credentials here. When you click "Setup Keys",
|
||||
backrest will generate an SSH key pair.
|
||||
Click "Generate Key" to create an SSH key pair for this host.
|
||||
Backrest will attempt to scan the host key into known_hosts automatically.
|
||||
You will then need to add the generated public key to{" "}
|
||||
<Code>~/.ssh/authorized_keys</Code> on the remote server.
|
||||
</CText>
|
||||
<Field label="SSH Username">
|
||||
<Input
|
||||
placeholder="user"
|
||||
value={sftpUsername}
|
||||
onChange={(e) => setSftpUsername(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="SSH Password">
|
||||
<PasswordInput
|
||||
placeholder="password (optional)"
|
||||
value={sftpPassword}
|
||||
onChange={(e) => setSftpPassword(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSetupKeys}
|
||||
onClick={handleGenerateKey}
|
||||
loading={setupLoading}
|
||||
>
|
||||
Setup Keys
|
||||
Generate Key
|
||||
</Button>
|
||||
</Stack>
|
||||
</AccordionItemContent>
|
||||
@@ -237,8 +204,7 @@ const SftpConfigSection = ({
|
||||
Key Generated Successfully!
|
||||
</CText>
|
||||
<CText fontSize="sm">
|
||||
Please add the following public key to your server's{" "}
|
||||
<Code>~/.ssh/authorized_keys</Code> file:
|
||||
Add the following public key to <Code>~/.ssh/authorized_keys</Code> on the remote server:
|
||||
</CText>
|
||||
<Box position="relative">
|
||||
<Code
|
||||
@@ -254,13 +220,22 @@ const SftpConfigSection = ({
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(generatedPublicKey || "");
|
||||
alerts.success("Key copied to clipboard");
|
||||
setKeyCopied(true);
|
||||
setTimeout(() => setKeyCopied(false), 2000);
|
||||
}}
|
||||
colorPalette={keyCopied ? "green" : undefined}
|
||||
>
|
||||
Copy
|
||||
{keyCopied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
{hostKeyWarning && (
|
||||
<Box p={3} borderWidth={1} borderRadius="md" borderColor="yellow.400" bg="yellow.subtle">
|
||||
<CText fontSize="sm" color="yellow.700">
|
||||
<strong>Host key scan failed:</strong> {hostKeyWarning}
|
||||
</CText>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
@@ -288,6 +263,17 @@ const SftpConfigSection = ({
|
||||
defaultValue={"22"}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Known Hosts File"
|
||||
helperText="Optional: Path to a known_hosts file for host key verification. Populated automatically by Setup Keys."
|
||||
>
|
||||
<Input
|
||||
placeholder="/home/user/.ssh/known_hosts"
|
||||
value={knownHostsPath}
|
||||
onChange={(e) => onChangeKnownHostsPath(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -304,12 +290,14 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
|
||||
: toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true }),
|
||||
);
|
||||
|
||||
// SFTP specific state
|
||||
// SFTP specific state
|
||||
const [sftpIdentityFile, setSftpIdentityFile] = useState("");
|
||||
const [sftpPort, setSftpPort] = useState<number | null>(null);
|
||||
const [sftpKnownHostsPath, setSftpKnownHostsPath] = useState("");
|
||||
|
||||
// Ref to read current flags without making them a useEffect dependency
|
||||
const flagsRef = useRef<string[]>([]);
|
||||
|
||||
const [confirmation, setConfirmation] = useState<ConfirmationState>({
|
||||
open: false,
|
||||
title: "",
|
||||
@@ -323,11 +311,30 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
|
||||
? toJson(RepoSchema, template, { alwaysEmitImplicit: true })
|
||||
: toJson(RepoSchema, repoDefaults, { alwaysEmitImplicit: true }),
|
||||
);
|
||||
// Reset SFTP fields when template changes (or is null)
|
||||
if (!template) {
|
||||
setSftpIdentityFile("");
|
||||
setSftpPort(null);
|
||||
setSftpKnownHostsPath("");
|
||||
|
||||
setSftpIdentityFile("");
|
||||
setSftpPort(null);
|
||||
setSftpKnownHostsPath("");
|
||||
|
||||
if (template?.uri?.startsWith("sftp:")) {
|
||||
// Populate SFTP fields by parsing the existing sftp.args flag
|
||||
const sftpArgsFlag = (template.flags || []).find(
|
||||
(f) => f.includes("sftp.args") || f.includes("sftp.command"),
|
||||
);
|
||||
if (sftpArgsFlag) {
|
||||
const argsMatch = sftpArgsFlag.match(/sftp\.args=['"]?(.+?)['"]?\s*$/);
|
||||
if (argsMatch) {
|
||||
const argsStr = argsMatch[1].replace(/^'|'$/g, "");
|
||||
const identityMatch = argsStr.match(/-i\s+["']?([^\s"']+)["']?/);
|
||||
if (identityMatch) setSftpIdentityFile(identityMatch[1]);
|
||||
const portMatch = argsStr.match(/-p\s+(\d+)/);
|
||||
if (portMatch) setSftpPort(parseInt(portMatch[1], 10));
|
||||
const knownHostsMatch = argsStr.match(
|
||||
/-oUserKnownHostsFile=["']?([^\s"']+)["']?/,
|
||||
);
|
||||
if (knownHostsMatch) setSftpKnownHostsPath(knownHostsMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
@@ -353,49 +360,45 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
|
||||
return curr;
|
||||
};
|
||||
|
||||
// Logic to update flags based on SFTP inputs
|
||||
useEffect(() => {
|
||||
// If we are editing, we don't touch the flags. The user can edit them manually.
|
||||
if (template) {
|
||||
return;
|
||||
}
|
||||
// Keep flagsRef in sync with latest formData.flags so the SFTP effect can
|
||||
// read the current value without flags being a reactive dependency.
|
||||
flagsRef.current = (formData.flags as string[]) || [];
|
||||
|
||||
// Keep sftp.args flag in sync with the SFTP config fields.
|
||||
useEffect(() => {
|
||||
const uri = getField(["uri"]);
|
||||
if (!uri?.startsWith("sftp:")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFlags = getField(["flags"]) || [];
|
||||
// Read flags via ref so this effect does not re-run whenever the user
|
||||
// edits the flags list (which would immediately erase empty rows).
|
||||
const currentFlags = flagsRef.current;
|
||||
const newFlags = currentFlags.filter(
|
||||
(f: string) =>
|
||||
f && !f.includes("sftp.args") && !f.includes("sftp.command"),
|
||||
);
|
||||
|
||||
// Always include -oBatchMode=yes; quote paths to handle spaces.
|
||||
let sftpArgs = "-oBatchMode=yes";
|
||||
let argsChanged = false;
|
||||
|
||||
if (sftpIdentityFile) {
|
||||
let cleanPath = sftpIdentityFile;
|
||||
if (cleanPath.startsWith("@")) {
|
||||
cleanPath = cleanPath.substring(1);
|
||||
}
|
||||
sftpArgs += ` -i ${cleanPath}`;
|
||||
argsChanged = true;
|
||||
sftpArgs += ` -i "${cleanPath}"`;
|
||||
}
|
||||
|
||||
if (sftpPort && sftpPort !== 0 && sftpPort !== 22) {
|
||||
sftpArgs += ` -p ${sftpPort}`;
|
||||
argsChanged = true;
|
||||
}
|
||||
|
||||
if (sftpKnownHostsPath) {
|
||||
sftpArgs += ` -oUserKnownHostsFile=${sftpKnownHostsPath}`;
|
||||
argsChanged = true;
|
||||
sftpArgs += ` -oUserKnownHostsFile="${sftpKnownHostsPath}"`;
|
||||
}
|
||||
|
||||
if (argsChanged) {
|
||||
newFlags.push(`--option=sftp.args='${sftpArgs}'`);
|
||||
}
|
||||
newFlags.push(`--option=sftp.args='${sftpArgs}'`);
|
||||
|
||||
const sortedCurrent = [...currentFlags].sort();
|
||||
const sortedNew = [...newFlags].sort();
|
||||
@@ -407,8 +410,9 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
|
||||
getField(["uri"]),
|
||||
sftpIdentityFile,
|
||||
sftpPort,
|
||||
template,
|
||||
getField(["flags"]),
|
||||
sftpKnownHostsPath,
|
||||
// flags intentionally omitted: flagsRef avoids a circular dep where any
|
||||
// user edit to flags would re-trigger the effect and erase empty rows.
|
||||
]);
|
||||
|
||||
if (!config) return null;
|
||||
@@ -784,13 +788,14 @@ export const AddRepoModal = ({ template }: { template: Repo | null }) => {
|
||||
</Field>
|
||||
|
||||
{/* SFTP Specific Fields */}
|
||||
{getField(["uri"])?.startsWith("sftp:") && !template && (
|
||||
{getField(["uri"])?.startsWith("sftp:") && (
|
||||
<SftpConfigSection
|
||||
uri={getField(["uri"])}
|
||||
identityFile={sftpIdentityFile}
|
||||
onChangeIdentityFile={setSftpIdentityFile}
|
||||
port={sftpPort}
|
||||
onChangePort={setSftpPort}
|
||||
knownHostsPath={sftpKnownHostsPath}
|
||||
onChangeKnownHostsPath={setSftpKnownHostsPath}
|
||||
isWindows={isWindows}
|
||||
/>
|
||||
|
||||
@@ -270,7 +270,6 @@ export const SnapshotBrowser = ({
|
||||
cursor="pointer"
|
||||
onClick={(e: any) => {
|
||||
e.stopPropagation();
|
||||
console.log("SnapshotBrowser onClick: " + node.key);
|
||||
if (loadingKeys.has(node.key)) return;
|
||||
const newExpanded = new Set(expandedKeys);
|
||||
if (newExpanded.has(node.key)) {
|
||||
|
||||
Reference in New Issue
Block a user