diff --git a/CHANGELOG.md b/CHANGELOG.md index 63861827f4..3f30e3c497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ - Include system CA roots and a writable `/tmp` in scratch container images for HTTPS requests and large multipart uploads, while retaining the non-root runtime and explicit private-CA configuration (PR #84). - - Upgrade the Swagger generator to v0.36.7 and runtime/server-middleware to v0.33.4. Regenerate array context validators so zero elements do not stop validation of later elements. Preserve the customized server and configuration @@ -36,6 +35,32 @@ settings. Keep the maintained SILO, PostgreSQL, browser, artifact and reproducibility checks. Exercise observer renewal with production lease defaults and synchronization against the database deadline. +- Start folder-size calculations and decryption-key copying when Console is + opened over plain HTTP, where browsers omit `crypto.randomUUID` and the async + Clipboard API. Previously both buttons silently did nothing. +- Let `console usage-worker` trust the same roots as `console server`: it + accepts `--certs-dir`, trusts `certs/CAs` and Console's public certificate, + and applies `CONSOLE_MINIO_SERVER_TLS_SKIP_VERIFY` to the SILO endpoint only. + The worker only reads that directory; it neither creates it nor loads + `private.key`. +- Charge `CONSOLE_USAGE_SCAN_TIMEOUT` for listing time summed across scan + slices, not for time a bucket spends queued behind others. Many large buckets + no longer all end `limited` together. Buckets left limited by the earlier + accounting resume with a full budget on their next retry; stop older usage + workers before starting 2.7.0 workers so the budget is accumulated + consistently. +- Return scans interrupted by worker shutdown, including during a database + write, to the queue with their checkpoint instead of showing them as failed. +- Forget a deleted bucket's saved totals on `s3:BucketRemoved`, including after + a force delete that emits no object events, so a bucket re-created under the + same name never shows them. Deletions that cannot be persisted at once are + retried. +- Without the usage database, retry a busy scanner (HTTP 429) with backoff for + about 30 seconds before reporting a bucket as failed. +- Reject malformed or truncated multipart uploads with an error instead of a + request-handler panic. +- Never treat a canceled bucket-usage authorization probe as authorized, and + build the share-link expiry from Luxon's locale-independent ISO output. ## Release v2.6.0 diff --git a/README.md b/README.md index d03512ba9e..a8d434c0ba 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,11 @@ ## SILO Console 2.7.0 -This source prepares **2.7.0**: ten UI languages and Arabic RTL, independently -cached language assets, container HTTPS and temporary-file fixes, and updated -Go dependencies and API documentation. Release artifacts are prepared as a -GitHub draft; [v2.6.0](https://github.com/pgsty/silo-console/releases/tag/v2.6.0) -remains the latest published stable release until that draft is published. +This source is **2.7.0**: ten UI languages and Arabic RTL, independently cached +language assets, container HTTPS and temporary-file fixes, usage-worker TLS and +scheduling fixes, folder sizes over plain HTTP, and updated Go dependencies and +API documentation. See [GitHub Releases](https://github.com/pgsty/silo-console/releases) +for publication status. [English](README.md) · [简体中文](README_ZH.md) diff --git a/README_ZH.md b/README_ZH.md index df5498d587..faa564ac54 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -10,9 +10,9 @@ SILO 对象存储的 Web 管理控制台,由 Pigsty 社区维护。前端使 ## SILO Console 2.7.0 -当前源码准备 **2.7.0**:十种语言与阿拉伯语 RTL、独立缓存的语言资源、容器 HTTPS 与临时文件修复,以及 Go 依赖和 API 文档更新。 -制品通过标签流水线准备为 GitHub Draft Release;草稿公开发布前,最新正式版仍为 -[v2.6.0](https://github.com/pgsty/silo-console/releases/tag/v2.6.0)。 +本源码为 **2.7.0**:十种语言与阿拉伯语 RTL、独立缓存的语言资源、容器 HTTPS 与临时文件修复、用量后台任务的 TLS 与调度修复、 +普通 HTTP 下的目录大小计算,以及 Go 依赖和 API 文档更新。 +发布状态见 [GitHub Releases](https://github.com/pgsty/silo-console/releases)。 **2.7.0** 使用 **Go 1.27.3**、**silo-pkg v3.16.0** 与 **mcli 20261109 正式版**。 MC 的 `RELEASE.2026-11-09T00-00-00Z` 是日历标签,Go 用伪版本 diff --git a/api/bucket_usage.go b/api/bucket_usage.go index c0030928da..0b0bd1963c 100644 --- a/api/bucket_usage.go +++ b/api/bucket_usage.go @@ -108,7 +108,9 @@ func authorizeBucketUsage(ctx context.Context, client usage.ListingClient, bucke if open { return first.Err } - return nil + // minio-go closes the channel without an error when ctx is canceled; + // that must not read as an authorized, empty listing. + return ctx.Err() } } diff --git a/api/bucket_usage_authorize_test.go b/api/bucket_usage_authorize_test.go new file mode 100644 index 0000000000..a22d2a0651 --- /dev/null +++ b/api/bucket_usage_authorize_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Pigsty +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package api + +import ( + "context" + "testing" + + "github.com/minio/minio-go/v7" +) + +// closedListing models minio-go on a canceled context: the channel closes +// without an error object. +type closedListing struct{} + +func (closedListing) ListObjects(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { + objects := make(chan minio.ObjectInfo) + close(objects) + return objects +} + +func TestAuthorizeBucketUsageDoesNotAuthorizeACanceledProbe(t *testing.T) { + if err := authorizeBucketUsage(context.Background(), closedListing{}, "empty", false); err != nil { + t.Fatalf("an empty, readable bucket must be authorized: %v", err) + } + canceled, cancel := context.WithCancel(context.Background()) + cancel() + // Either select branch may win; both must refuse. + for range 100 { + if err := authorizeBucketUsage(canceled, closedListing{}, "bucket", true); err == nil { + t.Fatal("a canceled probe was treated as authorized") + } + } +} diff --git a/api/tls.go b/api/tls.go index 8ef31ab6c3..b5e3864cfe 100644 --- a/api/tls.go +++ b/api/tls.go @@ -122,6 +122,13 @@ var ( insecureMinIOTransport *http.Transport ) +// MinIOEndpointTransport exposes the SILO endpoint policy of +// PrepareSTSClientTransport, without the per-request client address, to +// processes that act on their own behalf, such as the usage worker. +func MinIOEndpointTransport() http.RoundTripper { + return minioEndpointTransport() +} + // minioEndpointTransport returns GlobalTransport unless the operator explicitly // enabled the SILO endpoint compatibility switch for an HTTPS endpoint. func minioEndpointTransport() http.RoundTripper { diff --git a/api/user_objects.go b/api/user_objects.go index 05aac0ba94..d2f8e13372 100644 --- a/api/user_objects.go +++ b/api/user_objects.go @@ -1014,9 +1014,15 @@ func uploadFiles(ctx context.Context, client MinioClient, params objectApi.PostB for { p, err := mr.NextPart() + // Only an unwrapped io.EOF marks the closing boundary. A truncated + // body yields a wrapped EOF and malformed headers other errors; none + // of them return a part. if err == io.EOF { break } + if err != nil { + return err + } size, err := strconv.ParseInt(p.FormName(), 10, 64) if err != nil { diff --git a/api/user_objects_upload_test.go b/api/user_objects_upload_test.go new file mode 100644 index 0000000000..bfef53a130 --- /dev/null +++ b/api/user_objects_upload_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Pigsty +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package api + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + objectApi "github.com/minio/console/api/operations/object" + "github.com/minio/minio-go/v7" +) + +func TestUploadFilesRejectsMalformedMultipartWithoutPanic(t *testing.T) { + var uploaded []string + minioPutObjectMock = func(_ context.Context, bucket, object string, reader io.Reader, size int64, _ minio.PutObjectOptions) (minio.UploadInfo, error) { + data, err := io.ReadAll(reader) + if err != nil { + return minio.UploadInfo{}, err + } + uploaded = append(uploaded, bucket+"/"+object+"="+string(data)) + return minio.UploadInfo{Size: size}, nil + } + t.Cleanup(func() { minioPutObjectMock = nil }) + + params := func(body string) objectApi.PostBucketsBucketNameObjectsUploadParams { + r := httptest.NewRequest(http.MethodPost, "/api/v1/buckets/b/objects/upload", strings.NewReader(body)) + r.Header.Set("Content-Type", "multipart/form-data; boundary=X") + prefix := "/dir/a.txt" + return objectApi.PostBucketsBucketNameObjectsUploadParams{HTTPRequest: r, BucketName: "b", Prefix: &prefix} + } + const part = "--X\r\nContent-Disposition: form-data; name=\"5\"; filename=\"a.txt\"\r\n\r\nhello\r\n" + + if err := uploadFiles(context.Background(), minioClientMock{}, params(part+"--X--\r\n")); err != nil { + t.Fatalf("valid upload: %v", err) + } + if len(uploaded) != 1 || uploaded[0] != "b/dir/a.txt=hello" { + t.Fatalf("valid upload stored %q", uploaded) + } + + for name, body := range map[string]string{ + // Each of these used to return a nil part and panic. + "malformed part header": "--X\r\nnot a header line\r\n\r\nhello\r\n--X--\r\n", + "truncated after boundary": part + "--X", + "empty body": "", + "no boundary": "garbage", + } { + t.Run(name, func(t *testing.T) { + uploaded = nil + if err := uploadFiles(context.Background(), minioClientMock{}, params(body)); err == nil { + t.Fatalf("expected an error, stored %q", uploaded) + } + }) + } +} diff --git a/cmd/console/usage_worker.go b/cmd/console/usage_worker.go index b51f72e210..59f565ffdc 100644 --- a/cmd/console/usage_worker.go +++ b/cmd/console/usage_worker.go @@ -5,24 +5,58 @@ package main import ( "context" + "fmt" "os" "os/signal" + "path/filepath" "syscall" "github.com/minio/cli" + "github.com/minio/console/api" + "github.com/minio/console/pkg/certs" "github.com/minio/console/pkg/usage" ) var usageWorkerCmd = cli.Command{ Name: "usage-worker", Usage: "Observe storage changes and refresh durable bucket-usage metadata", - Action: func(_ *cli.Context) error { + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "certs-dir", + Value: certs.GlobalCertsCADir.Get(), + Usage: "path to certs directory; certificates in its CAs directory are trusted for the storage endpoint", + }, + }, + Action: func(cliCtx *cli.Context) error { config, err := usage.LoadConfig(true) if err != nil { return err } + if err = loadUsageWorkerTLS(cliCtx, &config); err != nil { + return err + } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() return usage.RunWorker(ctx, config) }, } + +// loadUsageWorkerTLS gives the worker the server's outbound trust: system +// roots, certs/CAs and Console's public certificate, with the endpoint-scoped +// CONSOLE_MINIO_SERVER_TLS_SKIP_VERIFY policy applied to the SILO endpoint. +// The worker serves no TLS, so it neither creates the certs directory nor +// loads a private key; a read-only or key-protected certs directory works. +func loadUsageWorkerTLS(ctx *cli.Context, config *usage.Config) error { + dir, _, err := certs.ResolveConfigDirFromCtx(ctx, "certs-dir", certs.DefaultCertsDir.Get) + if err != nil { + return err + } + certs.GlobalCertsDir = dir + certs.GlobalCertsCADir = &certs.ConfigDir{Path: filepath.Join(dir.Get(), certs.CertsCADir)} + if api.GlobalRootCAs, err = certs.GetTrustedRootCAs(); err != nil { + return fmt.Errorf("unable to load trusted certificates at %s: failed with %w", dir.Get(), err) + } + api.ApplyGlobalRootCAs() + config.Transport = api.MinIOEndpointTransport() + return nil +} diff --git a/cmd/console/usage_worker_tls_test.go b/cmd/console/usage_worker_tls_test.go new file mode 100644 index 0000000000..d1d9f80fa6 --- /dev/null +++ b/cmd/console/usage_worker_tls_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Pigsty +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package main + +import ( + "context" + "crypto/x509" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/minio/console/api" + "github.com/minio/console/pkg/certs" + "github.com/minio/console/pkg/usage" +) + +func storageServer(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(``)) + })) + t.Cleanup(server.Close) + return server +} + +// listBuckets makes one request with the worker's S3 client, built exactly as +// RunWorker builds it from the loaded configuration. +func listBuckets(t *testing.T, endpoint string, args ...string) error { + t.Helper() + // A pooled connection keeps the trust it was verified with. + api.GlobalTransport.CloseIdleConnections() + config := usage.Config{Endpoint: endpoint, AccessKey: "worker", SecretKey: "worker-secret"} + if err := loadUsageWorkerTLS(serverCLIContext(t, args...), &config); err != nil { + t.Fatalf("loadUsageWorkerTLS: %v", err) + } + client, err := config.NewS3Client() + if err != nil { + t.Fatalf("NewS3Client: %v", err) + } + _, err = client.ListBuckets(context.Background()) + return err +} + +func isUnknownAuthority(err error) bool { + var unknownAuthority x509.UnknownAuthorityError + return errors.As(err, &unknownAuthority) +} + +func TestUsageWorkerTrustsConsoleCAsAndEndpointPolicy(t *testing.T) { + preserveStartupTLSGlobals(t) + // Tests dial their own servers; a proxy would turn TLS into CONNECT. + api.GlobalTransport.Proxy = nil + silo, other := storageServer(t), storageServer(t) + + t.Run("CAs directory", func(t *testing.T) { + certsDir := t.TempDir() + writeCertificatePEM(t, filepath.Join(certsDir, certs.CertsCADir, "silo.crt"), silo.Certificate()) + if err := listBuckets(t, silo.URL, "--certs-dir", certsDir); err != nil { + t.Fatalf("worker did not trust the configured CA: %v", err) + } + }) + + t.Run("without the CA", func(t *testing.T) { + if err := listBuckets(t, silo.URL, "--certs-dir", t.TempDir()); !isUnknownAuthority(err) { + t.Fatalf("expected x509.UnknownAuthorityError, got %v", err) + } + }) + + t.Run("missing certs directory is not created", func(t *testing.T) { + certsDir := filepath.Join(t.TempDir(), "absent") + if err := listBuckets(t, other.URL, "--certs-dir", certsDir); !isUnknownAuthority(err) { + t.Fatalf("expected x509.UnknownAuthorityError, got %v", err) + } + if _, err := os.Stat(certsDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("the worker created its certs directory: %v", err) + } + }) + + t.Run("an unreadable server key does not block the worker", func(t *testing.T) { + certsDir := t.TempDir() + writeCertificatePEM(t, filepath.Join(certsDir, certs.CertsCADir, "silo.crt"), silo.Certificate()) + writeCertificatePEM(t, filepath.Join(certsDir, certs.PublicCertFile), other.Certificate()) + key := filepath.Join(certsDir, certs.PrivateKeyFile) + if err := os.WriteFile(key, []byte("not readable"), 0o000); err != nil { + t.Fatal(err) + } + if err := listBuckets(t, silo.URL, "--certs-dir", certsDir); err != nil { + t.Fatalf("worker failed with a key it does not need: %v", err) + } + }) + + t.Run("skip-verify exempts only the configured endpoint", func(t *testing.T) { + t.Setenv(api.ConsoleMinIOServer, silo.URL) + t.Setenv(api.ConsoleMinIOServerTLSSkipVerify, "on") + if err := listBuckets(t, silo.URL, "--certs-dir", t.TempDir()); err != nil { + t.Fatalf("skip-verify was not applied to the SILO endpoint: %v", err) + } + if err := listBuckets(t, other.URL, "--certs-dir", t.TempDir()); !isUnknownAuthority(err) { + t.Fatalf("skip-verify leaked to another origin: %v", err) + } + }) +} diff --git a/docs/Embedding.md b/docs/Embedding.md index 2875968e6d..778a814007 100644 --- a/docs/Embedding.md +++ b/docs/Embedding.md @@ -4,7 +4,7 @@ Console 2.7.0 source uses silo-pkg v3.16.0 and the published mcli 20261109 source, recorded in `hack/deps-release.json`, with Go 1.27.3 and upstream minio-go `v7.4.2-0.20261025165926-320af050c445`. This SDK revision includes post-v7.4.1 fixes; it is not a tagged v7.4.2 release. -The candidate retains the fixed `golang.org/x/net v0.62.0` and +It retains the fixed `golang.org/x/net v0.62.0` and `golang.org/x/crypto v0.58.1-0.20261009181718-2f1f834330b2` revisions already used by Console v2.6.0. Use the README replacement block from the Console revision being embedded in a SILO server. @@ -13,13 +13,13 @@ MC resolves to `v0.0.0-20261109021353-e1d4cc5abfe0`, the source commit `e1d4cc5abfe00b674eb36347399e7398990be467` tagged `RELEASE.2026-11-09T00-00-00Z`. The calendar tag is not Go SemVer. silo-pkg v3.16.0 resolves to `b7aeea85e84e8888d6ab424c7123a1297711bc3f`. -These are released dependencies. Console 2.7.0 artifacts are prepared as a -GitHub draft before public release; check the release's publication status. +These are released dependencies; see GitHub Releases for Console's own +publication status. Published Console v2.6.0 used silo-pkg v3.15.2 and mcli 20261026; its [dependency record](https://github.com/pgsty/silo-console/blob/v2.6.0/hack/deps-release.json) remains attached to that release. -The candidate's generator is go-swagger v0.36.7; its linked runtime and +The generator is go-swagger v0.36.7; its linked runtime and server-middleware are v0.33.4. Generated array context validators now continue past zero elements. The current schema has no `readOnly` constraints, so this does not introduce new contextual request restrictions. The default generated diff --git a/docs/FolderSizes.md b/docs/FolderSizes.md index 85a2738227..81d1c1cf81 100644 --- a/docs/FolderSizes.md +++ b/docs/FolderSizes.md @@ -6,7 +6,7 @@ The operator guide lives on the SILO documentation site: - [中文:用量扫描、权限、限制与可选后台任务](https://silo.pgsty.com/zh/administration/console/managing-objects/#usage-scans) Usage scans shipped in [Console 2.6.0](https://github.com/pgsty/silo-console/releases/tag/v2.6.0) -and remain available in the 2.7.0 candidate. The optional worker and the matching +and remain available in 2.7.0. The optional worker and the matching server APIs require coordinated SILO support; Server 20261026 does not acquire the feature merely because this source tree contains it. The implementation and original guide were contributed by Urbanize in diff --git a/docs/README.md b/docs/README.md index 568b44662c..e7466852c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,6 @@ # Docs -These pages describe the Console v2.7.0 candidate source. Console v2.6.0 remains -the latest published release while the v2.7.0 GitHub draft is being prepared. +These pages describe the Console v2.7.0 source. Published binaries, packages and release status are available on [GitHub Releases](https://github.com/pgsty/silo-console/releases). See [CHANGELOG.md](../CHANGELOG.md) and the diff --git a/docs/TLS.md b/docs/TLS.md index 747e6fef46..3162c4ebe3 100644 --- a/docs/TLS.md +++ b/docs/TLS.md @@ -47,6 +47,12 @@ trust store is the system root store plus every certificate under `--tls-ca` file when it is set. Console's own serving certificates are added to that pool as well, so a Console that talks to itself trusts itself. +The optional `console usage-worker` uses the same trust store and accepts the +same `--certs-dir` flag. It only reads that directory: unlike `console server` +it does not create it and never loads `private.key`, so a read-only directory +or a key the worker cannot read is fine. Its storage requests follow the SILO endpoint policy +described below, including `CONSOLE_MINIO_SERVER_TLS_SKIP_VERIFY`. + Embedded in SILO, Console uses the server's pool (`certs/CAs` plus the server's own public certificates) and dials the address the server advertises to it: `MINIO_SERVER_URL` when set, otherwise `https://127.0.0.1:`. The server diff --git a/pkg/certs/certs.go b/pkg/certs/certs.go index f9b0c4d4bb..119dfb9616 100644 --- a/pkg/certs/certs.go +++ b/pkg/certs/certs.go @@ -128,6 +128,19 @@ func MkdirAllIgnorePerm(path string) error { } func NewConfigDirFromCtx(ctx *cli.Context, option string, getDefaultDir func() string) (*ConfigDir, bool, error) { + configDir, dirSet, err := ResolveConfigDirFromCtx(ctx, option, getDefaultDir) + if err != nil { + return nil, false, err + } + if err = MkdirAllIgnorePerm(configDir.Path); err != nil { + return nil, false, fmt.Errorf("%w: Unable to create directory specified %s=%s", err, option, configDir.Path) + } + return configDir, dirSet, nil +} + +// ResolveConfigDirFromCtx resolves a directory option like NewConfigDirFromCtx +// without creating it, for processes that only read the directory. +func ResolveConfigDirFromCtx(ctx *cli.Context, option string, getDefaultDir func() string) (*ConfigDir, bool, error) { var dir string var dirSet bool @@ -164,9 +177,6 @@ func NewConfigDirFromCtx(ctx *cli.Context, option string, getDefaultDir func() s if err != nil { return nil, false, fmt.Errorf("%w: Unable to fetch absolute path for %s=%s", err, option, dir) } - if err = MkdirAllIgnorePerm(dirAbs); err != nil { - return nil, false, fmt.Errorf("%w: Unable to create directory specified %s=%s", err, option, dir) - } return &ConfigDir{Path: dirAbs}, dirSet, nil } @@ -315,6 +325,32 @@ func GetAllCertificatesAndCAs() (*x509.CertPool, []*x509.Certificate, *xcerts.Ma return rootCAs, publicCerts, certsManager, nil } +// GetTrustedRootCAs returns the pool Console verifies peers with: the system +// roots, every certificate under certs/CAs and Console's own public.crt when +// present. Unlike GetAllCertificatesAndCAs it never loads a private key, so a +// process that only makes outbound connections, such as the usage worker, +// needs neither a readable key nor its password. An unreadable public.crt is +// skipped like unreadable files under certs/CAs. +func GetTrustedRootCAs() (*x509.CertPool, error) { + rootCAs, err := xcerts.GetRootCAs(GlobalCertsCADir.Get()) + if err != nil { + return nil, err + } + if rootCAs == nil { + rootCAs = &x509.CertPool{} + } + if isFile(getPublicCertFile()) { + publicCerts, err := ParsePublicCertFile(getPublicCertFile()) + if err != nil && !errors.Is(err, os.ErrPermission) { + return nil, err + } + for _, publicCert := range publicCerts { + rootCAs.AddCert(publicCert) + } + } + return rootCAs, nil +} + // EnsureCertAndKey checks if both client certificate and key paths are provided func EnsureCertAndKey(clientCert, clientKey string) error { if (clientCert != "" && clientKey == "") || diff --git a/pkg/usage/bucket_events_integration_test.go b/pkg/usage/bucket_events_integration_test.go new file mode 100644 index 0000000000..bba1094cc6 --- /dev/null +++ b/pkg/usage/bucket_events_integration_test.go @@ -0,0 +1,143 @@ +//go:build integration + +// Copyright (c) 2026 Pigsty +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package usage + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/minio/minio-go/v7" +) + +func TestRemovedBucketForgetsTotalsAndFencesTheActiveScan(t *testing.T) { + s := integrationStore(t) + ctx := context.Background() + if err := s.Queue(ctx, "recreated", true, 0, 0, nil); err != nil { + t.Fatal(err) + } + first, err := s.Claim(ctx, time.Minute) + if err != nil { + t.Fatal(err) + } + if err = s.Complete(ctx, first, observation(5)); err != nil { + t.Fatal(err) + } + if err = s.Queue(ctx, "recreated", true, 0, 0, nil); err != nil { + t.Fatal(err) + } + active, err := s.Claim(ctx, time.Minute) + if err != nil { + t.Fatal(err) + } + if err = s.Removed(ctx, "recreated", 0); err != nil { + t.Fatal(err) + } + record, err := s.Get(ctx, "recreated") + if err != nil { + t.Fatal(err) + } + if record.State != "queued" || record.LastGood != nil || record.LastCompletedAt != nil || record.Progress != nil || record.Checkpoint != nil { + t.Fatalf("a deleted bucket kept its totals: %+v", record) + } + if err = s.Complete(ctx, active, observation(9)); !errors.Is(err, ErrLeaseLost) { + t.Fatalf("a scan of the deleted bucket published its totals: %v", err) + } + // The re-created bucket is measured afresh. + rescan, err := s.Claim(ctx, time.Minute) + if err != nil { + t.Fatal(err) + } + c := scanBudgetConfig(t) + source := listingFunc(func(ctx context.Context, _ string, _ minio.ListObjectsOptions) <-chan minio.ObjectInfo { + objects := make(chan minio.ObjectInfo, 1) + objects <- minio.ObjectInfo{Key: "new", Size: 2, IsLatest: true} + close(objects) + return objects + }) + if err = s.RunSlice(ctx, source, rescan, c); err != nil { + t.Fatal(err) + } + record, err = s.Get(ctx, "recreated") + if err != nil { + t.Fatal(err) + } + if record.State != "complete" || record.LastGood == nil || record.LastGood.Totals.CurrentBytes != "2" { + t.Fatalf("the re-created bucket was not measured afresh: %+v", record) + } + // Removing a bucket that was never seen is a no-op. + if err = s.Removed(ctx, "never-seen", 0); err != nil { + t.Fatal(err) + } + if _, err = s.Get(ctx, "never-seen"); err == nil { + t.Fatal("removal created a row for an unknown bucket") + } +} + +// A bucket left limited keeps its totals and an exhausted checkpoint, and +// automatic re-queueing keeps both. If the deletion cannot be persisted at +// first, it must survive for a later flush instead of being dropped when the +// observer restarts. +func TestUnpersistedBucketRemovalIsRetried(t *testing.T) { + s := integrationStore(t) + ctx := context.Background() + if err := s.Queue(ctx, "removed-limited", true, 0, 0, nil); err != nil { + t.Fatal(err) + } + job, err := s.Claim(ctx, time.Minute) + if err != nil { + t.Fatal(err) + } + if err = s.Complete(ctx, job, observation(5)); err != nil { + t.Fatal(err) + } + if err = s.Queue(ctx, "removed-limited", true, 0, 0, nil); err != nil { + t.Fatal(err) + } + if job, err = s.Claim(ctx, time.Minute); err != nil { + t.Fatal(err) + } + spent := observation(1) + if err = s.SaveCheckpoint(ctx, job, Checkpoint{Generation: job.Generation, LastKey: "a", ListingTime: time.Hour, Snapshot: spent}, spent, time.Minute); err != nil { + t.Fatal(err) + } + if err = s.Fail(ctx, job, "duration", true, time.Hour); err != nil { + t.Fatal(err) + } + + c := scanBudgetConfig(t) + pending := &pendingChanges{buckets: map[string]pendingChange{}} + pending.add("removed-limited", pendingChange{at: time.Now().UTC(), removed: true}) + unavailable, err := pgxpool.New(ctx, os.Getenv("TEST_USAGE_DATABASE_URL")) + if err != nil { + t.Fatal(err) + } + unavailable.Close() + if flushPending(ctx, &Store{pool: unavailable, scope: s.scope}, pending, c) { + t.Fatal("a closed database accepted the deletion") + } + // Reconnect reconciliation alone keeps the limited row and its totals. + if err = s.Queue(ctx, "removed-limited", false, 0, 0, nil); err != nil { + t.Fatal(err) + } + if !flushPending(ctx, s, pending, c) { + t.Fatal("the retained deletion was not persisted") + } + record, err := s.Get(ctx, "removed-limited") + if err != nil { + t.Fatal(err) + } + if record.State != "queued" || record.LastGood != nil || record.Checkpoint != nil { + t.Fatalf("the deleted bucket's results survived: %+v", record) + } +} diff --git a/pkg/usage/checkpoint.go b/pkg/usage/checkpoint.go index e8881eebfa..b2d7d34ac3 100644 --- a/pkg/usage/checkpoint.go +++ b/pkg/usage/checkpoint.go @@ -15,16 +15,24 @@ import ( // StartAfter key for version listings but no public version-ID resume marker. // Saving an intermediate version as the cursor would silently skip its peers. // This object is private worker state and never returned to a browser. +// +// ListingTime is the scan budget already used: listing time summed over the +// scan's slices, excluding time spent queued behind other buckets. +// Checkpoints written before this field existed decode it as zero. type Checkpoint struct { - Generation int64 `json:"generation"` - LastKey string `json:"last_key"` - Snapshot Snapshot `json:"snapshot"` + Generation int64 `json:"generation"` + LastKey string `json:"last_key"` + ListingTime time.Duration `json:"listing_time_ns,omitempty"` + Snapshot Snapshot `json:"snapshot"` } func (c Checkpoint) Validate() error { if c.Generation < 1 { return errors.New("invalid usage checkpoint generation") } + if c.ListingTime < 0 { + return errors.New("invalid usage checkpoint listing time") + } if err := c.Snapshot.Validate(); err != nil { return err } diff --git a/pkg/usage/config.go b/pkg/usage/config.go index c972d4629d..3b6fb8e32a 100644 --- a/pkg/usage/config.go +++ b/pkg/usage/config.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net" + "net/http" "net/url" "os" "strconv" @@ -38,6 +39,10 @@ type Config struct { FullReconcileInterval time.Duration RetryInterval time.Duration MaxRetryInterval time.Duration + // Transport carries the caller's TLS trust and endpoint policy to every + // storage request. It is never read from the environment; nil keeps the + // minio-go default, which trusts only the system roots. + Transport http.RoundTripper } func LoadConfig(worker bool) (Config, error) { diff --git a/pkg/usage/event_transport_test.go b/pkg/usage/event_transport_test.go index ef5bd12224..ca0085291d 100644 --- a/pkg/usage/event_transport_test.go +++ b/pkg/usage/event_transport_test.go @@ -9,6 +9,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/minio/minio-go/v7/pkg/notification" ) @@ -76,6 +77,30 @@ func TestChangeEventsQueueBucketsWithoutCollectingRequestOrObjectData(t *testing if _, at, ok = changedBucket(event); !ok || at.IsZero() { t.Fatal("bad source timestamp silently dropped a real deletion") } + // A force delete emits only the bucket event. + for _, name := range []string{"s3:BucketCreated:*", "s3:BucketRemoved:*"} { + event.EventName = name + if bucket, _, ok := changedBucket(event); !ok || bucket != "changed" { + t.Fatalf("%s did not invalidate the bucket", name) + } + } +} + +func TestPendingChangesKeepADeletionFollowedByRecreation(t *testing.T) { + pending := &pendingChanges{buckets: map[string]pendingChange{}} + removed := time.Date(2026, 11, 9, 0, 0, 0, 0, time.UTC) + pending.add("b", pendingChange{at: removed, removed: true}) + pending.add("b", pendingChange{at: removed.Add(time.Second)}) + pending.add("b", pendingChange{at: removed.Add(-time.Second)}) + batch := pending.take() + if change := batch["b"]; !change.removed || !change.at.Equal(removed.Add(time.Second)) { + t.Fatalf("coalescing lost the deletion or the newest time: %+v", change) + } + pending.add("b", pendingChange{at: removed.Add(2 * time.Second)}) + pending.restore(batch) + if change := pending.take()["b"]; !change.removed || !change.at.Equal(removed.Add(2*time.Second)) { + t.Fatalf("restore lost the deletion or a newer arrival: %+v", change) + } } func TestRetryBackoffIsBounded(t *testing.T) { diff --git a/pkg/usage/scan.go b/pkg/usage/scan.go index 2bfc9d125b..685e6d39ae 100644 --- a/pkg/usage/scan.go +++ b/pkg/usage/scan.go @@ -28,7 +28,7 @@ func (c Config) NewS3Client() (*minio.Client, error) { if err != nil { return nil, errors.New("invalid usage storage endpoint") } - return minio.New(endpoint.Host, &minio.Options{Secure: endpoint.Scheme == "https", Creds: credentials.NewStaticV4(c.AccessKey, c.SecretKey, c.SessionToken)}) + return minio.New(endpoint.Host, &minio.Options{Secure: endpoint.Scheme == "https", Creds: credentials.NewStaticV4(c.AccessKey, c.SecretKey, c.SessionToken), Transport: c.Transport}) } func (s Stats) counts() (Counts, error) { @@ -54,9 +54,11 @@ func scanFailure(err error) *ScanFailure { // groups advance the checkpoint. A complete snapshot is published only when // the SDK reaches the end without a listing error or canceled context. func (s *Store) RunSlice(ctx context.Context, client ListingClient, job Record, c Config) error { - started := time.Now().UTC() + sliceStarted := time.Now() + started := sliceStarted.UTC() counts := Counts{} lastKey := "" + spent := time.Duration(0) if job.Checkpoint != nil { var err error counts, err = job.Checkpoint.Snapshot.Totals.counts() @@ -65,6 +67,7 @@ func (s *Store) RunSlice(ctx context.Context, client ListingClient, job Record, } started = job.Checkpoint.Snapshot.StartedAt lastKey = job.Checkpoint.LastKey + spent = job.Checkpoint.ListingTime } snapshot := func() Snapshot { return Snapshot{Totals: counts.Stats(), ScannedEntries: counts.CurrentObjects + counts.RetainedVersions + counts.DeleteMarkers, StartedAt: started, UpdatedAt: time.Now().UTC()} @@ -75,18 +78,21 @@ func (s *Store) RunSlice(ctx context.Context, client ListingClient, job Record, generation = job.Checkpoint.Generation } safe := func() Checkpoint { - return Checkpoint{Generation: generation, LastKey: safeKey, Snapshot: Snapshot{Totals: safeCounts.Stats(), ScannedEntries: safeCounts.CurrentObjects + safeCounts.RetainedVersions + safeCounts.DeleteMarkers, StartedAt: started, UpdatedAt: time.Now().UTC()}} + return Checkpoint{Generation: generation, LastKey: safeKey, ListingTime: spent + time.Since(sliceStarted), Snapshot: Snapshot{Totals: safeCounts.Stats(), ScannedEntries: safeCounts.CurrentObjects + safeCounts.RetainedVersions + safeCounts.DeleteMarkers, StartedAt: started, UpdatedAt: time.Now().UTC()}} } - // A fair slice resumes the same scan, including its original wall-clock - // budget. Starting a fresh timeout here lets large buckets run indefinitely. - deadline := started.Add(c.ScanTimeout) + // A fair slice resumes the same scan with the budget it has left. The + // budget counts listing time summed over slices, not the wall clock since + // the scan started: time spent queued while other buckets take their + // turns must not exhaust it, but a fresh timeout per slice would let a + // large bucket run indefinitely. + remaining := c.ScanTimeout - spent if ctx.Err() != nil { return &ScanFailure{Code: "worker_shutdown"} } - if !time.Now().Before(deadline) { + if remaining <= 0 { return &ScanFailure{Code: "duration", Limited: true} } - scanCtx, cancel := context.WithDeadline(ctx, deadline) + scanCtx, cancel := context.WithTimeout(ctx, remaining) objects := client.ListObjects(scanCtx, job.Bucket, minio.ListObjectsOptions{Recursive: true, WithVersions: true, StartAfter: lastKey, MaxKeys: 1000}) defer func() { cancel() @@ -99,7 +105,6 @@ func (s *Store) RunSlice(ctx context.Context, client ListingClient, job Record, } ticker := time.NewTicker(interval) defer ticker.Stop() - sliceStarted := time.Now() currentKey := "" persist := func() error { operation, cancel := context.WithTimeout(ctx, c.Heartbeat) @@ -126,6 +131,11 @@ func (s *Store) RunSlice(ctx context.Context, client ListingClient, job Record, if ctx.Err() != nil { return &ScanFailure{Code: "worker_shutdown"} } + // Record the exhausted budget, as the other duration + // exits do, so a retry ends limited without listing. + if err := persist(); err != nil { + return err + } return &ScanFailure{Code: "duration", Limited: true} } return s.Complete(ctx, job, snapshot(), c.MinScanInterval) diff --git a/pkg/usage/scan_budget_integration_test.go b/pkg/usage/scan_budget_integration_test.go new file mode 100644 index 0000000000..4e9eed09a7 --- /dev/null +++ b/pkg/usage/scan_budget_integration_test.go @@ -0,0 +1,297 @@ +//go:build integration + +// Copyright (c) 2026 Pigsty +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +package usage + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/minio/minio-go/v7" +) + +// slowListing returns 30 keys per bucket, each after delay, resuming after +// StartAfter like the SDK. +func slowListing(delay time.Duration) ListingClient { + return listingFunc(func(ctx context.Context, _ string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { + objects := make(chan minio.ObjectInfo) + go func() { + defer close(objects) + for i := range 30 { + key := fmt.Sprintf("k%03d", i) + if key <= opts.StartAfter { + continue + } + select { + case <-ctx.Done(): + return + case <-time.After(delay): + } + select { + case <-ctx.Done(): + return + case objects <- minio.ObjectInfo{Key: key, Size: 1, IsLatest: true}: + } + } + }() + return objects + }) +} + +func scanBudgetConfig(t *testing.T) Config { + t.Helper() + c, err := ParseConfig(nil, false) + if err != nil { + t.Fatal(err) + } + c.ScanTimeout = 3 * time.Second + c.ScanSlice = 100 * time.Millisecond + c.PollInterval = 20 * time.Millisecond + c.MinScanInterval = 0 + return c +} + +// Each bucket needs about 1–1.5 s of listing, well within its 3 s budget, but +// the four together need over 4 s of a single scan worker's time. Charging +// the wall clock since each scan started, as earlier releases did, ended all +// four limited because each bucket also waited for the others' slices. +func TestScanBudgetExcludesTimeQueuedBehindOtherBuckets(t *testing.T) { + s := integrationStore(t) + c := scanBudgetConfig(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + buckets := []string{"budget-a", "budget-b", "budget-c", "budget-d"} + for _, bucket := range buckets { + if err := s.Queue(ctx, bucket, true, 0, 0, nil); err != nil { + t.Fatal(err) + } + } + done := make(chan struct{}) + go func() { defer close(done); runScanWorker(ctx, s, slowListing(30*time.Millisecond), c) }() + defer func() { cancel(); <-done }() + deadline := time.Now().Add(30 * time.Second) + for { + finished := 0 + for _, bucket := range buckets { + record, err := s.Get(ctx, bucket) + if err != nil { + t.Fatal(err) + } + switch record.State { + case "complete": + if record.LastGood == nil || record.LastGood.Totals.CurrentObjects != 30 { + t.Fatalf("%s completed with wrong totals: %+v", bucket, record.LastGood) + } + finished++ + case "limited", "error": + t.Fatalf("%s ended %s/%s although its own listing fits the budget", bucket, record.State, record.ErrorCode) + } + } + if finished == len(buckets) { + return + } + if time.Now().After(deadline) { + t.Fatal("scans did not finish") + } + time.Sleep(50 * time.Millisecond) + } +} + +// The budget still bounds a single bucket whose own listing is too long. +func TestScanBudgetLimitsABucketWhoseListingExceedsIt(t *testing.T) { + s := integrationStore(t) + c := scanBudgetConfig(t) + c.ScanTimeout = 500 * time.Millisecond + ctx := context.Background() + if err := s.Queue(ctx, "budget-long", true, 0, 0, nil); err != nil { + t.Fatal(err) + } + source := slowListing(50 * time.Millisecond) // 1.5 s of listing + for slices := 0; ; slices++ { + if slices > 50 { + t.Fatal("scan neither completed nor reached its budget") + } + job, err := s.Claim(ctx, time.Minute) + if err != nil { + t.Fatal(err) + } + err = s.RunSlice(ctx, source, job, c) + if err == nil { + record, _ := s.Get(ctx, "budget-long") + if record.State == "complete" { + t.Fatal("a listing longer than the budget completed") + } + continue + } + var failure *ScanFailure + if !errors.As(err, &failure) || failure.Code != "duration" || !failure.Limited { + t.Fatalf("expected limited/duration, got %v", err) + } + if err = s.Fail(ctx, job, failure.Code, true, 0); err != nil { + t.Fatal(err) + } + record, err := s.Get(ctx, "budget-long") + if err != nil { + t.Fatal(err) + } + if record.Checkpoint == nil || record.Checkpoint.ListingTime < c.ScanTimeout { + t.Fatalf("limited checkpoint must record the exhausted budget: %+v", record.Checkpoint) + } + return + } +} + +// A worker stopping during a scan (a rolling restart) must not show the +// bucket as failed or delay it by a retry backoff. +func TestWorkerShutdownRequeuesScanWithItsCheckpoint(t *testing.T) { + s := integrationStore(t) + c := scanBudgetConfig(t) + c.ProgressInterval = 50 * time.Millisecond + c.ScanSlice = time.Minute + c.RetryInterval = time.Hour + c.MaxRetryInterval = time.Hour + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := s.Queue(ctx, "budget-restart", true, 0, 0, nil); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { defer close(done); runScanWorker(ctx, s, slowListing(100*time.Millisecond), c) }() + deadline := time.Now().Add(10 * time.Second) + for { + record, err := s.Get(context.Background(), "budget-restart") + if err != nil { + t.Fatal(err) + } + if record.State == "running" && record.Checkpoint != nil && record.Checkpoint.LastKey != "" { + break + } + if time.Now().After(deadline) { + t.Fatalf("scan never saved a checkpoint: %+v", record) + } + time.Sleep(20 * time.Millisecond) + } + cancel() + <-done + record, err := s.Get(context.Background(), "budget-restart") + if err != nil { + t.Fatal(err) + } + if record.State != "queued" || record.ErrorCode != "" || record.Checkpoint == nil { + t.Fatalf("shutdown must requeue the scan with its checkpoint: %+v", record) + } + resumed, err := s.Claim(context.Background(), time.Minute) + if err != nil { + t.Fatalf("the next worker cannot resume at once: %v", err) + } + if resumed.Bucket != "budget-restart" || resumed.Checkpoint == nil || resumed.Checkpoint.LastKey != record.Checkpoint.LastKey { + t.Fatalf("resumed without the saved cursor: %+v", resumed) + } +} + +// When the budget runs out, the SDK closes the listing channel at the same +// time as the scan context expires, and select may take either. Both exits +// must record the exhausted budget, or a retry lists again. +func TestBudgetExhaustionIsRecordedOnEveryExit(t *testing.T) { + s := integrationStore(t) + c := scanBudgetConfig(t) + c.ScanTimeout = 20 * time.Millisecond + c.ScanSlice = time.Minute + ctx := context.Background() + // A steady stream keeps the scan loop busy until the budget expires; the + // lister then closes its channel like the SDK does, so both the closed + // channel and the expired context are ready. No progress tick persists + // the budget earlier. + busy := listingFunc(func(ctx context.Context, _ string, _ minio.ListObjectsOptions) <-chan minio.ObjectInfo { + objects := make(chan minio.ObjectInfo) + go func() { + defer close(objects) + for i := 0; ; i++ { + select { + case <-ctx.Done(): + return + case objects <- minio.ObjectInfo{Key: fmt.Sprintf("k%09d", i), Size: 1, IsLatest: true}: + } + } + }() + return objects + }) + for i := range 40 { + bucket := fmt.Sprintf("budget-exit-%02d", i) + if err := s.Queue(ctx, bucket, true, 0, 0, nil); err != nil { + t.Fatal(err) + } + job, err := s.Claim(ctx, time.Minute) + if err != nil { + t.Fatal(err) + } + err = s.RunSlice(ctx, busy, job, c) + var failure *ScanFailure + if job.Bucket != bucket || !errors.As(err, &failure) || failure.Code != "duration" { + t.Fatalf("expected limited/duration for %s, got %s: %v", bucket, job.Bucket, err) + } + // Keep earlier limited rows out of later claims. + if err = s.Fail(ctx, job, failure.Code, true, time.Hour); err != nil { + t.Fatal(err) + } + record, err := s.Get(ctx, bucket) + if err != nil { + t.Fatal(err) + } + if record.Checkpoint == nil || record.Checkpoint.ListingTime < c.ScanTimeout { + t.Fatalf("attempt %d left the spent budget unrecorded: %+v", i, record.Checkpoint) + } + } +} + +// A shutdown that cancels a checkpoint, yield or completion write surfaces as +// a database error rather than worker_shutdown; the job must still be handed +// back at once instead of waiting for its lease to expire. +func TestShutdownDuringADatabaseWriteStillRequeues(t *testing.T) { + s := integrationStore(t) + c := scanBudgetConfig(t) + ctx := context.Background() + for _, bucket := range []string{"budget-canceled-write", "budget-upstream"} { + if err := s.Queue(ctx, bucket, true, 0, 0, nil); err != nil { + t.Fatal(err) + } + } + job, err := s.Claim(ctx, time.Minute) + if err != nil { + t.Fatal(err) + } + stopping, stop := context.WithCancel(ctx) + stop() + settleScan(stopping, s, job, fmt.Errorf("save checkpoint: %w", context.Canceled), c) + record, err := s.Get(ctx, job.Bucket) + if err != nil { + t.Fatal(err) + } + if record.State != "queued" || record.ErrorCode != "" { + t.Fatalf("an interrupted write left the job %s/%s", record.State, record.ErrorCode) + } + // A failure while the worker keeps running is still recorded. + other, err := s.Claim(ctx, time.Minute) + if err != nil { + t.Fatal(err) + } + if other.Bucket == job.Bucket { + other, err = s.Claim(ctx, time.Minute) + if err != nil { + t.Fatal(err) + } + } + settleScan(ctx, s, other, &ScanFailure{Code: "upstream"}, c) + if record, err = s.Get(ctx, other.Bucket); err != nil || record.State != "error" || record.ErrorCode != "upstream" { + t.Fatalf("a scan failure was not recorded: %+v %v", record, err) + } +} diff --git a/pkg/usage/store.go b/pkg/usage/store.go index 0665dd825b..eb9dafa7b5 100644 --- a/pkg/usage/store.go +++ b/pkg/usage/store.go @@ -148,10 +148,10 @@ func (s *Store) Migrate(ctx context.Context) error { // Repeated manual clicks join an existing job rather than creating a backlog. // // A failed or limited job keeps its checkpoint so automatic retries never -// restart an over-budget listing. That checkpoint also carries the original -// scan deadline, so resuming it after a limit can only end limited again. An -// explicit refresh therefore discards it and starts a new scan with a new -// budget; automatic events and reconciliation leave it in place. +// restart an over-budget listing. That checkpoint also carries the listing +// time already spent, so resuming it after a limit can only end limited +// again. An explicit refresh therefore discards it and starts a new scan with +// a new budget; automatic events and reconciliation leave it in place. func (s *Store) Queue(ctx context.Context, bucket string, manual bool, debounce, spacing time.Duration, eventAt *time.Time) error { if debounce < 0 || spacing < 0 { return errors.New("usage queue delays must not be negative") @@ -324,6 +324,40 @@ func (s *Store) Fail(ctx context.Context, job Record, code string, limited bool, return nil } +// Release hands a scan interrupted by worker shutdown back to the queue. It +// keeps the checkpoint and visible progress so the next worker resumes at +// once, and it is neither recorded as an error nor counted as an attempt, +// so a rolling restart does not show buckets as failed. +func (s *Store) Release(ctx context.Context, job Record) error { + command, err := s.pool.Exec(ctx, `UPDATE console_usage_buckets SET state='queued',error_code='',lease_token=NULL,lease_until=NULL, + next_run_at=clock_timestamp(),attempts=GREATEST(attempts-1,0) + WHERE scope=$1 AND bucket=$2 AND lease_token=$3 AND state='running' AND lease_until>clock_timestamp()`, s.scope, job.Bucket, job.LeaseToken) + if err != nil { + return err + } + if command.RowsAffected() != 1 { + return ErrLeaseLost + } + return nil +} + +// Removed forgets a deleted bucket's results at once. A force delete removes +// every object but emits only s3:BucketRemoved, so without this a bucket +// re-created under the same name, possibly by another owner, would show the +// old bucket's totals until the next full reconciliation. A scan in progress +// loses its lease. The queued rescan then either retires the row through +// MissingBucket or measures the re-created bucket. +func (s *Store) Removed(ctx context.Context, bucket string, debounce time.Duration) error { + if bucket == "" || len(bucket) > 63 || debounce < 0 { + return errors.New("invalid usage bucket") + } + _, err := s.pool.Exec(ctx, `UPDATE console_usage_buckets SET state='queued',generation=generation+1, + last_good=NULL,progress=NULL,checkpoint=NULL,last_completed_at=NULL,lease_token=NULL,lease_until=NULL, + attempts=0,error_code='',priority=GREATEST(priority,10),next_run_at=clock_timestamp()+$3::bigint*interval '1 millisecond' + WHERE scope=$1 AND bucket=$2`, s.scope, bucket, debounce.Milliseconds()) + return err +} + // Discover queues new buckets without invalidating healthy existing results. // Frequent inventory checks are cheap; full safety-net scans are independent. func (s *Store) Discover(ctx context.Context, bucket string, debounce time.Duration) error { diff --git a/pkg/usage/worker.go b/pkg/usage/worker.go index 4c45e0bce7..7c5da7625e 100644 --- a/pkg/usage/worker.go +++ b/pkg/usage/worker.go @@ -99,34 +99,47 @@ func runScanWorker(ctx context.Context, s *Store, client ListingClient, c Config } continue } - err = s.RunSlice(ctx, client, job, c) - if err == nil { - continue - } - if errors.Is(err, ErrLeaseLost) { - continue - } - var failure *ScanFailure - if errors.As(err, &failure) { - if failure.Code == "not_found" { - operation, stop := context.WithTimeout(ctx, c.Heartbeat) - _ = s.MissingBucket(operation, job) - stop() - continue - } - operation, cancel := context.WithTimeout(context.Background(), c.Heartbeat) - persistErr := s.Fail(operation, job, failure.Code, failure.Limited, c.retry(job.Attempts)) - cancel() - if persistErr != nil && !errors.Is(persistErr, ErrLeaseLost) { - log.Print("usage worker: state store unavailable while recording scan failure") - } - if failure.Code != "worker_shutdown" { - log.Printf("usage worker: bucket=%q scan=%s", job.Bucket, failure.Code) - } - } else if ctx.Err() == nil { - log.Print("usage worker: scan interrupted by state store failure; lease recovery will resume it") + settleScan(ctx, s, job, s.RunSlice(ctx, client, job, c), c) + } +} + +// settleScan records how a claimed slice ended. A stopping worker hands its +// job back with Release whatever interrupted it: the shutdown check inside +// RunSlice, or a database write canceled with the worker's context. The +// lease guard makes Release a no-op for a job that was already completed, +// yielded or taken over. +func settleScan(ctx context.Context, s *Store, job Record, err error, c Config) { + if err == nil || errors.Is(err, ErrLeaseLost) { + return + } + var failure *ScanFailure + isFailure := errors.As(err, &failure) + if ctx.Err() != nil || (isFailure && failure.Code == "worker_shutdown") { + operation, cancel := context.WithTimeout(context.Background(), c.Heartbeat) + releaseErr := s.Release(operation, job) + cancel() + if releaseErr != nil && !errors.Is(releaseErr, ErrLeaseLost) { + log.Print("usage worker: state store unavailable while releasing an interrupted scan; lease recovery will resume it") } + return } + if !isFailure { + log.Print("usage worker: scan interrupted by state store failure; lease recovery will resume it") + return + } + if failure.Code == "not_found" { + operation, stop := context.WithTimeout(ctx, c.Heartbeat) + _ = s.MissingBucket(operation, job) + stop() + return + } + operation, cancel := context.WithTimeout(context.Background(), c.Heartbeat) + persistErr := s.Fail(operation, job, failure.Code, failure.Limited, c.retry(job.Attempts)) + cancel() + if persistErr != nil && !errors.Is(persistErr, ErrLeaseLost) { + log.Print("usage worker: state store unavailable while recording scan failure") + } + log.Printf("usage worker: bucket=%q scan=%s", job.Bucket, failure.Code) } func reconcile(ctx context.Context, s *Store, client *minio.Client, c Config, full bool) error { @@ -151,7 +164,13 @@ func reconcile(ctx context.Context, s *Store, client *minio.Client, c Config, fu func runObserver(ctx context.Context, s *Store, c Config) { owner := uuid.NewString() + // Received changes that could not be persisted outlive an observer lease. + // Reconnect reconciliation re-queues every bucket, but it cannot tell that + // a bucket was deleted and re-created, so a lost deletion would leave the + // old bucket's totals in place. + pending := &pendingChanges{buckets: map[string]pendingChange{}} for ctx.Err() == nil { + flushPending(ctx, s, pending, c) operation, cancel := context.WithTimeout(ctx, c.Heartbeat) acquired, err := s.AcquireObserver(operation, owner, c.Lease) cancel() @@ -161,14 +180,14 @@ func runObserver(ctx context.Context, s *Store, c Config) { } continue } - observeLease(ctx, s, c, owner) + observeLease(ctx, s, c, owner, pending) if !wait(ctx, c.RetryInterval) { return } } } -func observeLease(ctx context.Context, s *Store, c Config, owner string) { +func observeLease(ctx context.Context, s *Store, c Config, owner string, pending *pendingChanges) { streamCtx, cancel := context.WithCancel(ctx) ctx = streamCtx // Inventory may take longer than a lease. Renew independently of the event @@ -212,10 +231,14 @@ func observeLease(ctx context.Context, s *Store, c Config, owner string) { <-streamDone } }() - base, err := minio.DefaultTransport(strings.HasPrefix(c.Endpoint, "https://")) - if err != nil { - log.Print("usage observer: transport configuration invalid") - return + base := c.Transport + if base == nil { + defaultTransport, err := minio.DefaultTransport(strings.HasPrefix(c.Endpoint, "https://")) + if err != nil { + log.Print("usage observer: transport configuration invalid") + return + } + base = defaultTransport } transport := &eventTransport{base: base, wake: make(chan struct{}, 1)} endpoint, _ := url.Parse(c.Endpoint) @@ -223,13 +246,14 @@ func observeLease(ctx context.Context, s *Store, c Config, owner string) { if err != nil { return } - events = client.ListenNotification(streamCtx, "", "", []string{"s3:ObjectCreated:*", "s3:ObjectRemoved:*"}) + // Bucket events matter too: a force delete removes every object but + // emits only s3:BucketRemoved. + events = client.ListenNotification(streamCtx, "", "", []string{"s3:ObjectCreated:*", "s3:ObjectRemoved:*", "s3:BucketCreated:*", "s3:BucketRemoved:*"}) // The storage server drops notifications for a subscriber that stops // reading, without closing the stream. Consume the stream continuously, so // reconciliation and database writes on the loop below cannot lose changes // while coverage still reports a reconciled connection. Pending work is one // entry per bucket, bounded by the inventory rather than the event rate. - pending := &pendingChanges{buckets: map[string]time.Time{}} streamDone = make(chan struct{}) go func() { defer close(streamDone) @@ -242,7 +266,7 @@ func observeLease(ctx context.Context, s *Store, c Config, owner string) { } for _, event := range info.Records { if bucket, at, ok := changedBucket(event); ok { - pending.add(bucket, at) + pending.add(bucket, pendingChange{at: at, removed: strings.HasPrefix(event.EventName, "s3:BucketRemoved:")}) } } } @@ -257,27 +281,7 @@ func observeLease(ctx context.Context, s *Store, c Config, owner string) { defer flush.Stop() connected := false var observedEpoch uint64 - flushEvents := func() bool { - batch := pending.take() - for bucket, at := range batch { - parent := ctx - if ctx.Err() != nil { - parent = context.Background() - } - operation, stop := context.WithTimeout(parent, c.Heartbeat) - err := s.Queue(operation, bucket, false, c.EventDebounce, c.MinScanInterval, &at) - stop() - if err != nil { - pending.restore(batch) - if ctx.Err() == nil { - log.Print("usage observer: failed to persist changes; reconnect reconciliation required") - } - return false - } - delete(batch, bucket) - } - return true - } + flushEvents := func() bool { return flushPending(ctx, s, pending, c) } doReconcile := func(full bool) bool { operation, stop := context.WithTimeout(ctx, c.ScanTimeout) defer stop() @@ -353,33 +357,77 @@ func observeLease(ctx context.Context, s *Store, c Config, owner string) { } } +// flushPending persists received changes: a deletion first clears the +// bucket's results, then the bucket is queued like any other change. What +// cannot be persisted is restored for the next attempt. During shutdown the +// writes use a fresh context so received changes are not dropped. +func flushPending(ctx context.Context, s *Store, pending *pendingChanges, c Config) bool { + batch := pending.take() + for bucket, change := range batch { + parent := ctx + if ctx.Err() != nil { + parent = context.Background() + } + operation, stop := context.WithTimeout(parent, c.Heartbeat) + var err error + if change.removed { + err = s.Removed(operation, bucket, c.EventDebounce) + } + if err == nil { + err = s.Queue(operation, bucket, false, c.EventDebounce, c.MinScanInterval, &change.at) + } + stop() + if err != nil { + pending.restore(batch) + if ctx.Err() == nil { + log.Print("usage observer: failed to persist changes; they are kept for the next attempt") + } + return false + } + delete(batch, bucket) + } + return true +} + +// pendingChange is the newest event time for a bucket and whether the +// bucket was deleted since the last flush. +type pendingChange struct { + at time.Time + removed bool +} + // pendingChanges coalesces received notifications into one invalidation per -// bucket until the observer loop persists them. +// bucket until the observer loop persists them. A deletion is kept even when +// a later event, such as re-creating the bucket, arrives in the same batch. type pendingChanges struct { sync.Mutex - buckets map[string]time.Time + buckets map[string]pendingChange } -func (p *pendingChanges) add(bucket string, at time.Time) { +func (p *pendingChanges) add(bucket string, change pendingChange) { p.Lock() defer p.Unlock() - if previous, exists := p.buckets[bucket]; !exists || at.After(previous) { - p.buckets[bucket] = at + if previous, exists := p.buckets[bucket]; exists { + if previous.at.After(change.at) { + change.at = previous.at + } + change.removed = change.removed || previous.removed } + p.buckets[bucket] = change } -func (p *pendingChanges) take() map[string]time.Time { +func (p *pendingChanges) take() map[string]pendingChange { p.Lock() defer p.Unlock() batch := p.buckets - p.buckets = map[string]time.Time{} + p.buckets = map[string]pendingChange{} return batch } // restore returns unpersisted changes without discarding newer arrivals. -func (p *pendingChanges) restore(batch map[string]time.Time) { - for bucket, at := range batch { - p.add(bucket, at) +func (p *pendingChanges) restore(batch map[string]pendingChange) { + for bucket, change := range batch { + p.add(bucket, change) } } @@ -390,6 +438,8 @@ func changedBucket(event notification.Event) (string, time.Time, bool) { switch { case strings.HasPrefix(event.EventName, "s3:ObjectCreated:"): case strings.HasPrefix(event.EventName, "s3:ObjectRemoved:"): + case strings.HasPrefix(event.EventName, "s3:BucketCreated:"): + case strings.HasPrefix(event.EventName, "s3:BucketRemoved:"): default: return "", time.Time{}, false } diff --git a/pkg/usage/worker_regression_integration_test.go b/pkg/usage/worker_regression_integration_test.go index f5ad3e0294..7a668af482 100644 --- a/pkg/usage/worker_regression_integration_test.go +++ b/pkg/usage/worker_regression_integration_test.go @@ -18,7 +18,7 @@ import ( "github.com/minio/minio-go/v7" ) -func TestScanDeadlineSurvivesCheckpoint(t *testing.T) { +func TestScanBudgetSurvivesCheckpoint(t *testing.T) { s := integrationStore(t) ctx := context.Background() if err := s.Queue(ctx, "deadline-bucket", true, 0, 0, nil); err != nil { @@ -30,7 +30,7 @@ func TestScanDeadlineSurvivesCheckpoint(t *testing.T) { } snapshot := observation(10) snapshot.StartedAt = time.Now().Add(-time.Hour) - checkpoint := Checkpoint{Generation: job.Generation, LastKey: "a", Snapshot: snapshot} + checkpoint := Checkpoint{Generation: job.Generation, LastKey: "a", ListingTime: time.Hour, Snapshot: snapshot} if err := s.Yield(ctx, job, checkpoint, snapshot); err != nil { t.Fatal(err) } @@ -65,7 +65,7 @@ func TestScanDeadlineSurvivesCheckpoint(t *testing.T) { var failure *ScanFailure if !errors.As(err, &failure) || failure.Code != "duration" || !failure.Limited { record, _ := s.Get(ctx, "deadline-bucket") - t.Fatalf("expired full-scan deadline must remain limited after resume; err=%v state=%s", err, record.State) + t.Fatalf("an exhausted listing budget must remain limited after resume; err=%v state=%s", err, record.State) } if listed { t.Fatal("an expired checkpoint must not start another S3 listing") @@ -102,7 +102,7 @@ func TestManualRefreshRestartsLimitedScanWithFreshBudget(t *testing.T) { } stale := observation(10) stale.StartedAt = time.Now().Add(-time.Hour) - if err = s.Yield(ctx, job, Checkpoint{Generation: job.Generation, LastKey: "a", Snapshot: stale}, stale); err != nil { + if err = s.Yield(ctx, job, Checkpoint{Generation: job.Generation, LastKey: "a", ListingTime: time.Hour, Snapshot: stale}, stale); err != nil { t.Fatal(err) } var starts []string @@ -267,7 +267,10 @@ func TestObserverRenewsLeaseDuringInventory(t *testing.T) { } ctx, cancel := context.WithCancel(t.Context()) done := make(chan struct{}) - go func() { defer close(done); observeLease(ctx, s, c, "review-observer") }() + go func() { + defer close(done) + observeLease(ctx, s, c, "review-observer", &pendingChanges{buckets: map[string]pendingChange{}}) + }() defer func() { cancel(); <-done }() select { case <-inventoryStarted: @@ -372,7 +375,10 @@ func TestObserverKeepsReadingEventsDuringSlowReconciliation(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) - go func() { defer close(done); observeLease(ctx, s, c, "draining-observer") }() + go func() { + defer close(done) + observeLease(ctx, s, c, "draining-observer", &pendingChanges{buckets: map[string]pendingChange{}}) + }() defer func() { cancel(); <-done }() deadline := time.Now().Add(30 * time.Second) for { diff --git a/web-app/build/.vite/manifest.json.gz b/web-app/build/.vite/manifest.json.gz index f1a2d67575..5837747f63 100644 Binary files a/web-app/build/.vite/manifest.json.gz and b/web-app/build/.vite/manifest.json.gz differ diff --git a/web-app/build/assets/AccessDetailsPanel-Bn8j2mPU.js.gz b/web-app/build/assets/AccessDetailsPanel-Bn8j2mPU.js.gz deleted file mode 100644 index 802d543c20..0000000000 Binary files a/web-app/build/assets/AccessDetailsPanel-Bn8j2mPU.js.gz and /dev/null differ diff --git a/web-app/build/assets/AccessDetailsPanel-qbz3xjzX.js.gz b/web-app/build/assets/AccessDetailsPanel-qbz3xjzX.js.gz new file mode 100644 index 0000000000..d90f9719e9 Binary files /dev/null and b/web-app/build/assets/AccessDetailsPanel-qbz3xjzX.js.gz differ diff --git a/web-app/build/assets/AccessRulePanel-CuGZ7JBA.js.gz b/web-app/build/assets/AccessRulePanel-CuGZ7JBA.js.gz new file mode 100644 index 0000000000..cce1627f96 Binary files /dev/null and b/web-app/build/assets/AccessRulePanel-CuGZ7JBA.js.gz differ diff --git a/web-app/build/assets/AccessRulePanel-DNOBSphi.js.gz b/web-app/build/assets/AccessRulePanel-DNOBSphi.js.gz deleted file mode 100644 index e1bf158757..0000000000 Binary files a/web-app/build/assets/AccessRulePanel-DNOBSphi.js.gz and /dev/null differ diff --git a/web-app/build/assets/Account-CJCou4g3.js.gz b/web-app/build/assets/Account-CJCou4g3.js.gz new file mode 100644 index 0000000000..8509a84fd8 Binary files /dev/null and b/web-app/build/assets/Account-CJCou4g3.js.gz differ diff --git a/web-app/build/assets/Account-CfAelSJk.js.gz b/web-app/build/assets/Account-CfAelSJk.js.gz deleted file mode 100644 index 02bfc07a25..0000000000 Binary files a/web-app/build/assets/Account-CfAelSJk.js.gz and /dev/null differ diff --git a/web-app/build/assets/AccountUtils-DpUrvAVT.js.gz b/web-app/build/assets/AccountUtils-DpUrvAVT.js.gz deleted file mode 100644 index 98938240ae..0000000000 Binary files a/web-app/build/assets/AccountUtils-DpUrvAVT.js.gz and /dev/null differ diff --git a/web-app/build/assets/AccountUtils-w4TVUx55.js.gz b/web-app/build/assets/AccountUtils-w4TVUx55.js.gz new file mode 100644 index 0000000000..6ace741f9f Binary files /dev/null and b/web-app/build/assets/AccountUtils-w4TVUx55.js.gz differ diff --git a/web-app/build/assets/AddAccessRule-CGAuhgvI.js.gz b/web-app/build/assets/AddAccessRule-CGAuhgvI.js.gz new file mode 100644 index 0000000000..8e5c37f27d Binary files /dev/null and b/web-app/build/assets/AddAccessRule-CGAuhgvI.js.gz differ diff --git a/web-app/build/assets/AddAccessRule-s8c4ASmD.js.gz b/web-app/build/assets/AddAccessRule-s8c4ASmD.js.gz deleted file mode 100644 index d3847d7cd7..0000000000 Binary files a/web-app/build/assets/AddAccessRule-s8c4ASmD.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddBucket-BSHQQM9o.js.gz b/web-app/build/assets/AddBucket-BSHQQM9o.js.gz new file mode 100644 index 0000000000..d786142b9a Binary files /dev/null and b/web-app/build/assets/AddBucket-BSHQQM9o.js.gz differ diff --git a/web-app/build/assets/AddBucket-CY7SsPzq.js.gz b/web-app/build/assets/AddBucket-CY7SsPzq.js.gz deleted file mode 100644 index 6c7ef0a4ea..0000000000 Binary files a/web-app/build/assets/AddBucket-CY7SsPzq.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddBucketReplication-B2Xt4ocq.js.gz b/web-app/build/assets/AddBucketReplication-B2Xt4ocq.js.gz new file mode 100644 index 0000000000..f1c818f71c Binary files /dev/null and b/web-app/build/assets/AddBucketReplication-B2Xt4ocq.js.gz differ diff --git a/web-app/build/assets/AddBucketReplication-D25Z67xS.js.gz b/web-app/build/assets/AddBucketReplication-D25Z67xS.js.gz deleted file mode 100644 index 21d6e2c615..0000000000 Binary files a/web-app/build/assets/AddBucketReplication-D25Z67xS.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddBucketTagModal-DT0z1KwZ.js.gz b/web-app/build/assets/AddBucketTagModal-DT0z1KwZ.js.gz deleted file mode 100644 index 179d37258b..0000000000 Binary files a/web-app/build/assets/AddBucketTagModal-DT0z1KwZ.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddBucketTagModal-K9ZSYaov.js.gz b/web-app/build/assets/AddBucketTagModal-K9ZSYaov.js.gz new file mode 100644 index 0000000000..7145513d57 Binary files /dev/null and b/web-app/build/assets/AddBucketTagModal-K9ZSYaov.js.gz differ diff --git a/web-app/build/assets/AddEvent-Df6XNy42.js.gz b/web-app/build/assets/AddEvent-Df6XNy42.js.gz new file mode 100644 index 0000000000..03eb62d178 Binary files /dev/null and b/web-app/build/assets/AddEvent-Df6XNy42.js.gz differ diff --git a/web-app/build/assets/AddEvent-b07MOIzL.js.gz b/web-app/build/assets/AddEvent-b07MOIzL.js.gz deleted file mode 100644 index b65f769eea..0000000000 Binary files a/web-app/build/assets/AddEvent-b07MOIzL.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddEventDestination-DijCTKmw.js.gz b/web-app/build/assets/AddEventDestination-DijCTKmw.js.gz deleted file mode 100644 index 661e040dfb..0000000000 Binary files a/web-app/build/assets/AddEventDestination-DijCTKmw.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddEventDestination-aQqEk2BF.js.gz b/web-app/build/assets/AddEventDestination-aQqEk2BF.js.gz new file mode 100644 index 0000000000..cbd6c42fb5 Binary files /dev/null and b/web-app/build/assets/AddEventDestination-aQqEk2BF.js.gz differ diff --git a/web-app/build/assets/AddGroupScreen-DxeJ5wMF.js.gz b/web-app/build/assets/AddGroupScreen-DxeJ5wMF.js.gz deleted file mode 100644 index 7634ca8140..0000000000 Binary files a/web-app/build/assets/AddGroupScreen-DxeJ5wMF.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddGroupScreen-zoIm6yQd.js.gz b/web-app/build/assets/AddGroupScreen-zoIm6yQd.js.gz new file mode 100644 index 0000000000..8f9fe73259 Binary files /dev/null and b/web-app/build/assets/AddGroupScreen-zoIm6yQd.js.gz differ diff --git a/web-app/build/assets/AddIDPConfigurationHelpbox-BnwD4qHH.js.gz b/web-app/build/assets/AddIDPConfigurationHelpbox-BnwD4qHH.js.gz new file mode 100644 index 0000000000..bd7e50b294 Binary files /dev/null and b/web-app/build/assets/AddIDPConfigurationHelpbox-BnwD4qHH.js.gz differ diff --git a/web-app/build/assets/AddIDPConfigurationHelpbox-Q9TkQb59.js.gz b/web-app/build/assets/AddIDPConfigurationHelpbox-Q9TkQb59.js.gz deleted file mode 100644 index daf0d87d43..0000000000 Binary files a/web-app/build/assets/AddIDPConfigurationHelpbox-Q9TkQb59.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddIDPOpenIDConfiguration-C1G__ubL.js.gz b/web-app/build/assets/AddIDPOpenIDConfiguration-C1G__ubL.js.gz new file mode 100644 index 0000000000..17a897b5d3 Binary files /dev/null and b/web-app/build/assets/AddIDPOpenIDConfiguration-C1G__ubL.js.gz differ diff --git a/web-app/build/assets/AddIDPOpenIDConfiguration-uI_VPwkS.js.gz b/web-app/build/assets/AddIDPOpenIDConfiguration-uI_VPwkS.js.gz deleted file mode 100644 index 125cd282c1..0000000000 Binary files a/web-app/build/assets/AddIDPOpenIDConfiguration-uI_VPwkS.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddKey-B534kTJY.js.gz b/web-app/build/assets/AddKey-B534kTJY.js.gz new file mode 100644 index 0000000000..dd2d6877d1 Binary files /dev/null and b/web-app/build/assets/AddKey-B534kTJY.js.gz differ diff --git a/web-app/build/assets/AddKey-C3kOUl26.js.gz b/web-app/build/assets/AddKey-C3kOUl26.js.gz deleted file mode 100644 index 345ab5cab1..0000000000 Binary files a/web-app/build/assets/AddKey-C3kOUl26.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddPolicyScreen-CLsVcbV8.js.gz b/web-app/build/assets/AddPolicyScreen-CLsVcbV8.js.gz new file mode 100644 index 0000000000..15a67c8b0a Binary files /dev/null and b/web-app/build/assets/AddPolicyScreen-CLsVcbV8.js.gz differ diff --git a/web-app/build/assets/AddPolicyScreen-e2sAHz1i.js.gz b/web-app/build/assets/AddPolicyScreen-e2sAHz1i.js.gz deleted file mode 100644 index 61067b539a..0000000000 Binary files a/web-app/build/assets/AddPolicyScreen-e2sAHz1i.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddReplicationModal-DG2Uf1yQ.js.gz b/web-app/build/assets/AddReplicationModal-DG2Uf1yQ.js.gz deleted file mode 100644 index be70664e09..0000000000 Binary files a/web-app/build/assets/AddReplicationModal-DG2Uf1yQ.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddReplicationModal-DfhQ24xa.js.gz b/web-app/build/assets/AddReplicationModal-DfhQ24xa.js.gz new file mode 100644 index 0000000000..746a68f7fa Binary files /dev/null and b/web-app/build/assets/AddReplicationModal-DfhQ24xa.js.gz differ diff --git a/web-app/build/assets/AddReplicationSites-BPrvVXkH.js.gz b/web-app/build/assets/AddReplicationSites-BPrvVXkH.js.gz deleted file mode 100644 index 3630d93b47..0000000000 Binary files a/web-app/build/assets/AddReplicationSites-BPrvVXkH.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddReplicationSites-DOySVIah.js.gz b/web-app/build/assets/AddReplicationSites-DOySVIah.js.gz new file mode 100644 index 0000000000..cf0c73891c Binary files /dev/null and b/web-app/build/assets/AddReplicationSites-DOySVIah.js.gz differ diff --git a/web-app/build/assets/AddServiceAccountScreen-DcXcQfQ-.js.gz b/web-app/build/assets/AddServiceAccountScreen-DcXcQfQ-.js.gz new file mode 100644 index 0000000000..c3eeaff0c4 Binary files /dev/null and b/web-app/build/assets/AddServiceAccountScreen-DcXcQfQ-.js.gz differ diff --git a/web-app/build/assets/AddServiceAccountScreen-VzBqUeb_.js.gz b/web-app/build/assets/AddServiceAccountScreen-VzBqUeb_.js.gz deleted file mode 100644 index 5f88be52bf..0000000000 Binary files a/web-app/build/assets/AddServiceAccountScreen-VzBqUeb_.js.gz and /dev/null differ diff --git a/web-app/build/assets/AddTierConfiguration-DkHqA-_O.js.gz b/web-app/build/assets/AddTierConfiguration-DkHqA-_O.js.gz new file mode 100644 index 0000000000..610a5e68d1 Binary files /dev/null and b/web-app/build/assets/AddTierConfiguration-DkHqA-_O.js.gz differ diff --git a/web-app/build/assets/AddTierConfiguration-fjMFtCvJ.js.gz b/web-app/build/assets/AddTierConfiguration-fjMFtCvJ.js.gz deleted file mode 100644 index ba0906a718..0000000000 Binary files a/web-app/build/assets/AddTierConfiguration-fjMFtCvJ.js.gz and /dev/null differ diff --git a/web-app/build/assets/BrowserHandler-B7ZEUTDj.js.gz b/web-app/build/assets/BrowserHandler-B7ZEUTDj.js.gz new file mode 100644 index 0000000000..b7bdfedb2a Binary files /dev/null and b/web-app/build/assets/BrowserHandler-B7ZEUTDj.js.gz differ diff --git a/web-app/build/assets/BrowserHandler-BlI-I8M9.js.gz b/web-app/build/assets/BrowserHandler-BlI-I8M9.js.gz deleted file mode 100644 index 59e1107955..0000000000 Binary files a/web-app/build/assets/BrowserHandler-BlI-I8M9.js.gz and /dev/null differ diff --git a/web-app/build/assets/BucketDetails-BfxHA_Dt.js.gz b/web-app/build/assets/BucketDetails-BfxHA_Dt.js.gz deleted file mode 100644 index 6c8e8e7316..0000000000 Binary files a/web-app/build/assets/BucketDetails-BfxHA_Dt.js.gz and /dev/null differ diff --git a/web-app/build/assets/BucketDetails-DRmuy8tv.js.gz b/web-app/build/assets/BucketDetails-DRmuy8tv.js.gz new file mode 100644 index 0000000000..ef165d167a Binary files /dev/null and b/web-app/build/assets/BucketDetails-DRmuy8tv.js.gz differ diff --git a/web-app/build/assets/BucketEventsPanel-00E16WRl.js.gz b/web-app/build/assets/BucketEventsPanel-00E16WRl.js.gz new file mode 100644 index 0000000000..8b7049a6d9 Binary files /dev/null and b/web-app/build/assets/BucketEventsPanel-00E16WRl.js.gz differ diff --git a/web-app/build/assets/BucketEventsPanel-DPgRVNCi.js.gz b/web-app/build/assets/BucketEventsPanel-DPgRVNCi.js.gz deleted file mode 100644 index 2f42787fff..0000000000 Binary files a/web-app/build/assets/BucketEventsPanel-DPgRVNCi.js.gz and /dev/null differ diff --git a/web-app/build/assets/BucketLifecyclePanel-BW7g5UMk.js.gz b/web-app/build/assets/BucketLifecyclePanel-BW7g5UMk.js.gz deleted file mode 100644 index fa9bb1ad57..0000000000 Binary files a/web-app/build/assets/BucketLifecyclePanel-BW7g5UMk.js.gz and /dev/null differ diff --git a/web-app/build/assets/BucketLifecyclePanel-DIB5qB-2.js.gz b/web-app/build/assets/BucketLifecyclePanel-DIB5qB-2.js.gz new file mode 100644 index 0000000000..edf645bb83 Binary files /dev/null and b/web-app/build/assets/BucketLifecyclePanel-DIB5qB-2.js.gz differ diff --git a/web-app/build/assets/BucketPageHeader-CFwHnqtD.js.gz b/web-app/build/assets/BucketPageHeader-CFwHnqtD.js.gz new file mode 100644 index 0000000000..ac4dfc9004 Binary files /dev/null and b/web-app/build/assets/BucketPageHeader-CFwHnqtD.js.gz differ diff --git a/web-app/build/assets/BucketPageHeader-DaqfKzZx.js.gz b/web-app/build/assets/BucketPageHeader-DaqfKzZx.js.gz deleted file mode 100644 index 4cac120612..0000000000 Binary files a/web-app/build/assets/BucketPageHeader-DaqfKzZx.js.gz and /dev/null differ diff --git a/web-app/build/assets/BucketReplicationPanel-Cd-nPY7F.js.gz b/web-app/build/assets/BucketReplicationPanel-Cd-nPY7F.js.gz deleted file mode 100644 index 0cec9d9463..0000000000 Binary files a/web-app/build/assets/BucketReplicationPanel-Cd-nPY7F.js.gz and /dev/null differ diff --git a/web-app/build/assets/BucketReplicationPanel-DDi8n7dq.js.gz b/web-app/build/assets/BucketReplicationPanel-DDi8n7dq.js.gz new file mode 100644 index 0000000000..c1a2476fbe Binary files /dev/null and b/web-app/build/assets/BucketReplicationPanel-DDi8n7dq.js.gz differ diff --git a/web-app/build/assets/BucketSummaryPanel-BDC-8bzA.js.gz b/web-app/build/assets/BucketSummaryPanel-BDC-8bzA.js.gz deleted file mode 100644 index a71e24143d..0000000000 Binary files a/web-app/build/assets/BucketSummaryPanel-BDC-8bzA.js.gz and /dev/null differ diff --git a/web-app/build/assets/BucketSummaryPanel-CmYoFmdb.js.gz b/web-app/build/assets/BucketSummaryPanel-CmYoFmdb.js.gz new file mode 100644 index 0000000000..4b98a64dcb Binary files /dev/null and b/web-app/build/assets/BucketSummaryPanel-CmYoFmdb.js.gz differ diff --git a/web-app/build/assets/BucketTags-CH9XQvJO.js.gz b/web-app/build/assets/BucketTags-CH9XQvJO.js.gz new file mode 100644 index 0000000000..cf7aace1fb Binary files /dev/null and b/web-app/build/assets/BucketTags-CH9XQvJO.js.gz differ diff --git a/web-app/build/assets/BucketTags-DWhZx8TH.js.gz b/web-app/build/assets/BucketTags-DWhZx8TH.js.gz deleted file mode 100644 index d95790359c..0000000000 Binary files a/web-app/build/assets/BucketTags-DWhZx8TH.js.gz and /dev/null differ diff --git a/web-app/build/assets/Buckets-BTCFpwqd.js.gz b/web-app/build/assets/Buckets-BTCFpwqd.js.gz new file mode 100644 index 0000000000..e42f5e8dc5 Binary files /dev/null and b/web-app/build/assets/Buckets-BTCFpwqd.js.gz differ diff --git a/web-app/build/assets/Buckets-D1vHd4Fp.js.gz b/web-app/build/assets/Buckets-D1vHd4Fp.js.gz deleted file mode 100644 index 7b8f42d271..0000000000 Binary files a/web-app/build/assets/Buckets-D1vHd4Fp.js.gz and /dev/null differ diff --git a/web-app/build/assets/BulkAddToGroup-BM-Yo2Gd.js.gz b/web-app/build/assets/BulkAddToGroup-BM-Yo2Gd.js.gz new file mode 100644 index 0000000000..2ab19ce333 Binary files /dev/null and b/web-app/build/assets/BulkAddToGroup-BM-Yo2Gd.js.gz differ diff --git a/web-app/build/assets/BulkAddToGroup-DthRbVer.js.gz b/web-app/build/assets/BulkAddToGroup-DthRbVer.js.gz deleted file mode 100644 index 0dfff3b225..0000000000 Binary files a/web-app/build/assets/BulkAddToGroup-DthRbVer.js.gz and /dev/null differ diff --git a/web-app/build/assets/BulkLifecycleModal-CIN8vdIp.js.gz b/web-app/build/assets/BulkLifecycleModal-CIN8vdIp.js.gz new file mode 100644 index 0000000000..d69b585755 Binary files /dev/null and b/web-app/build/assets/BulkLifecycleModal-CIN8vdIp.js.gz differ diff --git a/web-app/build/assets/BulkLifecycleModal-znOgNya7.js.gz b/web-app/build/assets/BulkLifecycleModal-znOgNya7.js.gz deleted file mode 100644 index e3f92dbc09..0000000000 Binary files a/web-app/build/assets/BulkLifecycleModal-znOgNya7.js.gz and /dev/null differ diff --git a/web-app/build/assets/BulkReplicationModal-5F5shPWS.js.gz b/web-app/build/assets/BulkReplicationModal-5F5shPWS.js.gz deleted file mode 100644 index d661084a1e..0000000000 Binary files a/web-app/build/assets/BulkReplicationModal-5F5shPWS.js.gz and /dev/null differ diff --git a/web-app/build/assets/BulkReplicationModal-uzIPK8Ma.js.gz b/web-app/build/assets/BulkReplicationModal-uzIPK8Ma.js.gz new file mode 100644 index 0000000000..2e3359b7fd Binary files /dev/null and b/web-app/build/assets/BulkReplicationModal-uzIPK8Ma.js.gz differ diff --git a/web-app/build/assets/CliEquivalent-BAzZiD40.js.gz b/web-app/build/assets/CliEquivalent-BAzZiD40.js.gz deleted file mode 100644 index 89cd886bde..0000000000 Binary files a/web-app/build/assets/CliEquivalent-BAzZiD40.js.gz and /dev/null differ diff --git a/web-app/build/assets/CliEquivalent-BV0MzDFR.js.gz b/web-app/build/assets/CliEquivalent-BV0MzDFR.js.gz new file mode 100644 index 0000000000..1c9fb2cbae Binary files /dev/null and b/web-app/build/assets/CliEquivalent-BV0MzDFR.js.gz differ diff --git a/web-app/build/assets/CodeMirrorWrapper-Bz9IpJNX.js.gz b/web-app/build/assets/CodeMirrorWrapper-Bz9IpJNX.js.gz new file mode 100644 index 0000000000..888aaeac94 Binary files /dev/null and b/web-app/build/assets/CodeMirrorWrapper-Bz9IpJNX.js.gz differ diff --git a/web-app/build/assets/CodeMirrorWrapper-MZFrzgs3.js.gz b/web-app/build/assets/CodeMirrorWrapper-MZFrzgs3.js.gz deleted file mode 100644 index e1dd9beade..0000000000 Binary files a/web-app/build/assets/CodeMirrorWrapper-MZFrzgs3.js.gz and /dev/null differ diff --git a/web-app/build/assets/ComponentsScreen-DrpMPK77.js.gz b/web-app/build/assets/ComponentsScreen-DdnZPNEB.js.gz similarity index 92% rename from web-app/build/assets/ComponentsScreen-DrpMPK77.js.gz rename to web-app/build/assets/ComponentsScreen-DdnZPNEB.js.gz index 4d32e60f59..356df09300 100644 Binary files a/web-app/build/assets/ComponentsScreen-DrpMPK77.js.gz and b/web-app/build/assets/ComponentsScreen-DdnZPNEB.js.gz differ diff --git a/web-app/build/assets/ConfMySql-BjS9bDJw.js.gz b/web-app/build/assets/ConfMySql-BjS9bDJw.js.gz deleted file mode 100644 index e7ebab1c8b..0000000000 Binary files a/web-app/build/assets/ConfMySql-BjS9bDJw.js.gz and /dev/null differ diff --git a/web-app/build/assets/ConfMySql-DxC86fpv.js.gz b/web-app/build/assets/ConfMySql-DxC86fpv.js.gz new file mode 100644 index 0000000000..8921abc006 Binary files /dev/null and b/web-app/build/assets/ConfMySql-DxC86fpv.js.gz differ diff --git a/web-app/build/assets/ConfPostgres-8kfLIR3p.js.gz b/web-app/build/assets/ConfPostgres-8kfLIR3p.js.gz new file mode 100644 index 0000000000..bc402c1943 Binary files /dev/null and b/web-app/build/assets/ConfPostgres-8kfLIR3p.js.gz differ diff --git a/web-app/build/assets/ConfPostgres-CZ_EZjFY.js.gz b/web-app/build/assets/ConfPostgres-CZ_EZjFY.js.gz deleted file mode 100644 index 9a44c5e489..0000000000 Binary files a/web-app/build/assets/ConfPostgres-CZ_EZjFY.js.gz and /dev/null differ diff --git a/web-app/build/assets/ConfTargetGeneric-BIAzGNvb.js.gz b/web-app/build/assets/ConfTargetGeneric-BIAzGNvb.js.gz deleted file mode 100644 index ebc89d2e3f..0000000000 Binary files a/web-app/build/assets/ConfTargetGeneric-BIAzGNvb.js.gz and /dev/null differ diff --git a/web-app/build/assets/ConfTargetGeneric-BV_pvy2j.js.gz b/web-app/build/assets/ConfTargetGeneric-BV_pvy2j.js.gz new file mode 100644 index 0000000000..92e5e5fda7 Binary files /dev/null and b/web-app/build/assets/ConfTargetGeneric-BV_pvy2j.js.gz differ diff --git a/web-app/build/assets/ConfigurationOptions-Dl8CwI91.js.gz b/web-app/build/assets/ConfigurationOptions-Dl8CwI91.js.gz new file mode 100644 index 0000000000..fb44bfaa75 Binary files /dev/null and b/web-app/build/assets/ConfigurationOptions-Dl8CwI91.js.gz differ diff --git a/web-app/build/assets/ConfigurationOptions-Dt_EusE4.js.gz b/web-app/build/assets/ConfigurationOptions-Dt_EusE4.js.gz deleted file mode 100644 index ff53093546..0000000000 Binary files a/web-app/build/assets/ConfigurationOptions-Dt_EusE4.js.gz and /dev/null differ diff --git a/web-app/build/assets/ConsoleKBar-BpSg_38F.js.gz b/web-app/build/assets/ConsoleKBar-BpSg_38F.js.gz deleted file mode 100644 index 8b62f51430..0000000000 Binary files a/web-app/build/assets/ConsoleKBar-BpSg_38F.js.gz and /dev/null differ diff --git a/web-app/build/assets/ConsoleKBar-C4QsHk9C.js.gz b/web-app/build/assets/ConsoleKBar-C4QsHk9C.js.gz new file mode 100644 index 0000000000..5b30fe78b9 Binary files /dev/null and b/web-app/build/assets/ConsoleKBar-C4QsHk9C.js.gz differ diff --git a/web-app/build/assets/CreatePathModal-Bzo7-YGc.js.gz b/web-app/build/assets/CreatePathModal-Bzo7-YGc.js.gz deleted file mode 100644 index fffa186ffe..0000000000 Binary files a/web-app/build/assets/CreatePathModal-Bzo7-YGc.js.gz and /dev/null differ diff --git a/web-app/build/assets/CreatePathModal-CfRIGfhd.js.gz b/web-app/build/assets/CreatePathModal-CfRIGfhd.js.gz new file mode 100644 index 0000000000..058f9b0de3 Binary files /dev/null and b/web-app/build/assets/CreatePathModal-CfRIGfhd.js.gz differ diff --git a/web-app/build/assets/Dashboard-4TdJlcCa.js.gz b/web-app/build/assets/Dashboard-4TdJlcCa.js.gz deleted file mode 100644 index 6450e2e680..0000000000 Binary files a/web-app/build/assets/Dashboard-4TdJlcCa.js.gz and /dev/null differ diff --git a/web-app/build/assets/Dashboard-D0KXNAE6.js.gz b/web-app/build/assets/Dashboard-D0KXNAE6.js.gz new file mode 100644 index 0000000000..e76e570b87 Binary files /dev/null and b/web-app/build/assets/Dashboard-D0KXNAE6.js.gz differ diff --git a/web-app/build/assets/DeleteBucket-B68JFugO.js.gz b/web-app/build/assets/DeleteBucket-B68JFugO.js.gz new file mode 100644 index 0000000000..f6766f55e0 Binary files /dev/null and b/web-app/build/assets/DeleteBucket-B68JFugO.js.gz differ diff --git a/web-app/build/assets/DeleteBucket-BiPPnlG-.js.gz b/web-app/build/assets/DeleteBucket-BiPPnlG-.js.gz deleted file mode 100644 index 9b4d584113..0000000000 Binary files a/web-app/build/assets/DeleteBucket-BiPPnlG-.js.gz and /dev/null differ diff --git a/web-app/build/assets/DeleteBucketTagModal-3QmS1BnY.js.gz b/web-app/build/assets/DeleteBucketTagModal-3QmS1BnY.js.gz new file mode 100644 index 0000000000..cb3e8b5b25 Binary files /dev/null and b/web-app/build/assets/DeleteBucketTagModal-3QmS1BnY.js.gz differ diff --git a/web-app/build/assets/DeleteBucketTagModal-BBNCIFOJ.js.gz b/web-app/build/assets/DeleteBucketTagModal-BBNCIFOJ.js.gz deleted file mode 100644 index 94845fcee4..0000000000 Binary files a/web-app/build/assets/DeleteBucketTagModal-BBNCIFOJ.js.gz and /dev/null differ diff --git a/web-app/build/assets/DeleteEvent-CnCK7woX.js.gz b/web-app/build/assets/DeleteEvent-CnCK7woX.js.gz new file mode 100644 index 0000000000..12018a989d Binary files /dev/null and b/web-app/build/assets/DeleteEvent-CnCK7woX.js.gz differ diff --git a/web-app/build/assets/DeleteEvent-ptoTnZ3P.js.gz b/web-app/build/assets/DeleteEvent-ptoTnZ3P.js.gz deleted file mode 100644 index aee4f41167..0000000000 Binary files a/web-app/build/assets/DeleteEvent-ptoTnZ3P.js.gz and /dev/null differ diff --git a/web-app/build/assets/DeleteMultipleObjects-B3O4QMIV.js.gz b/web-app/build/assets/DeleteMultipleObjects-B3O4QMIV.js.gz new file mode 100644 index 0000000000..fa19b92558 Binary files /dev/null and b/web-app/build/assets/DeleteMultipleObjects-B3O4QMIV.js.gz differ diff --git a/web-app/build/assets/DeleteMultipleObjects-ejVvqVGb.js.gz b/web-app/build/assets/DeleteMultipleObjects-ejVvqVGb.js.gz deleted file mode 100644 index d2456efbff..0000000000 Binary files a/web-app/build/assets/DeleteMultipleObjects-ejVvqVGb.js.gz and /dev/null differ diff --git a/web-app/build/assets/DeleteReplicationRule-BnCfZVbM.js.gz b/web-app/build/assets/DeleteReplicationRule-BnCfZVbM.js.gz deleted file mode 100644 index ff78ea3295..0000000000 Binary files a/web-app/build/assets/DeleteReplicationRule-BnCfZVbM.js.gz and /dev/null differ diff --git a/web-app/build/assets/DeleteReplicationRule-DxN-Vrae.js.gz b/web-app/build/assets/DeleteReplicationRule-DxN-Vrae.js.gz new file mode 100644 index 0000000000..a64a8101ad Binary files /dev/null and b/web-app/build/assets/DeleteReplicationRule-DxN-Vrae.js.gz differ diff --git a/web-app/build/assets/EditAccessRule-CMRDoH-o.js.gz b/web-app/build/assets/EditAccessRule-CMRDoH-o.js.gz deleted file mode 100644 index f19e0287d7..0000000000 Binary files a/web-app/build/assets/EditAccessRule-CMRDoH-o.js.gz and /dev/null differ diff --git a/web-app/build/assets/EditAccessRule-CbYB_BL5.js.gz b/web-app/build/assets/EditAccessRule-CbYB_BL5.js.gz new file mode 100644 index 0000000000..eb5cec5dac Binary files /dev/null and b/web-app/build/assets/EditAccessRule-CbYB_BL5.js.gz differ diff --git a/web-app/build/assets/EditBucketReplication-Cqn3hoyB.js.gz b/web-app/build/assets/EditBucketReplication-Cqn3hoyB.js.gz new file mode 100644 index 0000000000..93e318fda8 Binary files /dev/null and b/web-app/build/assets/EditBucketReplication-Cqn3hoyB.js.gz differ diff --git a/web-app/build/assets/EditBucketReplication-CrooLCml.js.gz b/web-app/build/assets/EditBucketReplication-CrooLCml.js.gz deleted file mode 100644 index 109952118d..0000000000 Binary files a/web-app/build/assets/EditBucketReplication-CrooLCml.js.gz and /dev/null differ diff --git a/web-app/build/assets/EditReplicationModal-BVmc0Qqp.js.gz b/web-app/build/assets/EditReplicationModal-BVmc0Qqp.js.gz new file mode 100644 index 0000000000..68663af33f Binary files /dev/null and b/web-app/build/assets/EditReplicationModal-BVmc0Qqp.js.gz differ diff --git a/web-app/build/assets/EditReplicationModal-J0Meb1GJ.js.gz b/web-app/build/assets/EditReplicationModal-J0Meb1GJ.js.gz deleted file mode 100644 index 1e49dda517..0000000000 Binary files a/web-app/build/assets/EditReplicationModal-J0Meb1GJ.js.gz and /dev/null differ diff --git a/web-app/build/assets/EnableBucketEncryption-BClmoe4C.js.gz b/web-app/build/assets/EnableBucketEncryption-BClmoe4C.js.gz new file mode 100644 index 0000000000..81377bcb63 Binary files /dev/null and b/web-app/build/assets/EnableBucketEncryption-BClmoe4C.js.gz differ diff --git a/web-app/build/assets/EnableBucketEncryption-BYzTi60M.js.gz b/web-app/build/assets/EnableBucketEncryption-BYzTi60M.js.gz deleted file mode 100644 index 8d41d66df0..0000000000 Binary files a/web-app/build/assets/EnableBucketEncryption-BYzTi60M.js.gz and /dev/null differ diff --git a/web-app/build/assets/EnableQuota-Ct3y5N_E.js.gz b/web-app/build/assets/EnableQuota-Ct3y5N_E.js.gz deleted file mode 100644 index b49d7cfadc..0000000000 Binary files a/web-app/build/assets/EnableQuota-Ct3y5N_E.js.gz and /dev/null differ diff --git a/web-app/build/assets/EnableQuota-DeFKUETx.js.gz b/web-app/build/assets/EnableQuota-DeFKUETx.js.gz new file mode 100644 index 0000000000..d2637c2726 Binary files /dev/null and b/web-app/build/assets/EnableQuota-DeFKUETx.js.gz differ diff --git a/web-app/build/assets/EnableVersioningModal-Bt0edLxd.js.gz b/web-app/build/assets/EnableVersioningModal-Bt0edLxd.js.gz new file mode 100644 index 0000000000..28827a793e Binary files /dev/null and b/web-app/build/assets/EnableVersioningModal-Bt0edLxd.js.gz differ diff --git a/web-app/build/assets/EnableVersioningModal-fGUP2_sE.js.gz b/web-app/build/assets/EnableVersioningModal-fGUP2_sE.js.gz deleted file mode 100644 index 1551bd369b..0000000000 Binary files a/web-app/build/assets/EnableVersioningModal-fGUP2_sE.js.gz and /dev/null differ diff --git a/web-app/build/assets/ErrorLogs-B6yxvy7I.js.gz b/web-app/build/assets/ErrorLogs-B6yxvy7I.js.gz new file mode 100644 index 0000000000..254673e301 Binary files /dev/null and b/web-app/build/assets/ErrorLogs-B6yxvy7I.js.gz differ diff --git a/web-app/build/assets/ErrorLogs-CwDLNem4.js.gz b/web-app/build/assets/ErrorLogs-CwDLNem4.js.gz deleted file mode 100644 index 7e7bf30935..0000000000 Binary files a/web-app/build/assets/ErrorLogs-CwDLNem4.js.gz and /dev/null differ diff --git a/web-app/build/assets/EventDestinations-BmZOCES6.js.gz b/web-app/build/assets/EventDestinations-BmZOCES6.js.gz deleted file mode 100644 index 5e294d4305..0000000000 Binary files a/web-app/build/assets/EventDestinations-BmZOCES6.js.gz and /dev/null differ diff --git a/web-app/build/assets/EventDestinations-g1i16vNv.js.gz b/web-app/build/assets/EventDestinations-g1i16vNv.js.gz new file mode 100644 index 0000000000..41c5724854 Binary files /dev/null and b/web-app/build/assets/EventDestinations-g1i16vNv.js.gz differ diff --git a/web-app/build/assets/EventTypeSelector-D8pnKkU6.js.gz b/web-app/build/assets/EventTypeSelector-D8pnKkU6.js.gz new file mode 100644 index 0000000000..d2c992b018 Binary files /dev/null and b/web-app/build/assets/EventTypeSelector-D8pnKkU6.js.gz differ diff --git a/web-app/build/assets/EventTypeSelector-DJuRZbLN.js.gz b/web-app/build/assets/EventTypeSelector-DJuRZbLN.js.gz deleted file mode 100644 index 6ae283ea51..0000000000 Binary files a/web-app/build/assets/EventTypeSelector-DJuRZbLN.js.gz and /dev/null differ diff --git a/web-app/build/assets/FilteredEmptyState-pjAuzuwb.js.gz b/web-app/build/assets/FilteredEmptyState-pjAuzuwb.js.gz deleted file mode 100644 index 37d416cf21..0000000000 Binary files a/web-app/build/assets/FilteredEmptyState-pjAuzuwb.js.gz and /dev/null differ diff --git a/web-app/build/assets/FilteredEmptyState-qp8rBYxn.js.gz b/web-app/build/assets/FilteredEmptyState-qp8rBYxn.js.gz new file mode 100644 index 0000000000..5c81939564 Binary files /dev/null and b/web-app/build/assets/FilteredEmptyState-qp8rBYxn.js.gz differ diff --git a/web-app/build/assets/Groups-C2cfqdpF.js.gz b/web-app/build/assets/Groups-C2cfqdpF.js.gz new file mode 100644 index 0000000000..b725e5b6ad Binary files /dev/null and b/web-app/build/assets/Groups-C2cfqdpF.js.gz differ diff --git a/web-app/build/assets/Groups-H8W897oI.js.gz b/web-app/build/assets/Groups-H8W897oI.js.gz deleted file mode 100644 index 39b60a3bf0..0000000000 Binary files a/web-app/build/assets/Groups-H8W897oI.js.gz and /dev/null differ diff --git a/web-app/build/assets/GroupsDetails-CDpyDinR.js.gz b/web-app/build/assets/GroupsDetails-CDpyDinR.js.gz deleted file mode 100644 index e7552eaf15..0000000000 Binary files a/web-app/build/assets/GroupsDetails-CDpyDinR.js.gz and /dev/null differ diff --git a/web-app/build/assets/GroupsDetails-ji2WiZ_P.js.gz b/web-app/build/assets/GroupsDetails-ji2WiZ_P.js.gz new file mode 100644 index 0000000000..d0583e7e6a Binary files /dev/null and b/web-app/build/assets/GroupsDetails-ji2WiZ_P.js.gz differ diff --git a/web-app/build/assets/HealthInfo-Cf7YiffP.js.gz b/web-app/build/assets/HealthInfo-Cf7YiffP.js.gz deleted file mode 100644 index 7283728e81..0000000000 Binary files a/web-app/build/assets/HealthInfo-Cf7YiffP.js.gz and /dev/null differ diff --git a/web-app/build/assets/HealthInfo-Dr7sjLQJ.js.gz b/web-app/build/assets/HealthInfo-Dr7sjLQJ.js.gz new file mode 100644 index 0000000000..3ea198dcde Binary files /dev/null and b/web-app/build/assets/HealthInfo-Dr7sjLQJ.js.gz differ diff --git a/web-app/build/assets/IDPLDAPConfigurationDetails-DNWD-1xe.js.gz b/web-app/build/assets/IDPLDAPConfigurationDetails-DNWD-1xe.js.gz deleted file mode 100644 index db2523d4eb..0000000000 Binary files a/web-app/build/assets/IDPLDAPConfigurationDetails-DNWD-1xe.js.gz and /dev/null differ diff --git a/web-app/build/assets/IDPLDAPConfigurationDetails-DUXUGfO0.js.gz b/web-app/build/assets/IDPLDAPConfigurationDetails-DUXUGfO0.js.gz new file mode 100644 index 0000000000..2a0da0a93a Binary files /dev/null and b/web-app/build/assets/IDPLDAPConfigurationDetails-DUXUGfO0.js.gz differ diff --git a/web-app/build/assets/IDPOpenIDConfigurationDetails-WA3qBXFP.js.gz b/web-app/build/assets/IDPOpenIDConfigurationDetails-WA3qBXFP.js.gz deleted file mode 100644 index b0bc40553e..0000000000 Binary files a/web-app/build/assets/IDPOpenIDConfigurationDetails-WA3qBXFP.js.gz and /dev/null differ diff --git a/web-app/build/assets/IDPOpenIDConfigurationDetails-Wn_D__WV.js.gz b/web-app/build/assets/IDPOpenIDConfigurationDetails-Wn_D__WV.js.gz new file mode 100644 index 0000000000..1de1bccf86 Binary files /dev/null and b/web-app/build/assets/IDPOpenIDConfigurationDetails-Wn_D__WV.js.gz differ diff --git a/web-app/build/assets/IDPOpenIDConfigurations-BkEDdb0g.js.gz b/web-app/build/assets/IDPOpenIDConfigurations-BkEDdb0g.js.gz deleted file mode 100644 index 484ca8dd51..0000000000 Binary files a/web-app/build/assets/IDPOpenIDConfigurations-BkEDdb0g.js.gz and /dev/null differ diff --git a/web-app/build/assets/IDPOpenIDConfigurations-lEPmE3-t.js.gz b/web-app/build/assets/IDPOpenIDConfigurations-lEPmE3-t.js.gz new file mode 100644 index 0000000000..467d070b9a Binary files /dev/null and b/web-app/build/assets/IDPOpenIDConfigurations-lEPmE3-t.js.gz differ diff --git a/web-app/build/assets/IconsScreen-B9jjA3-L.js.gz b/web-app/build/assets/IconsScreen-B9jjA3-L.js.gz deleted file mode 100644 index d63cdaa3d0..0000000000 Binary files a/web-app/build/assets/IconsScreen-B9jjA3-L.js.gz and /dev/null differ diff --git a/web-app/build/assets/IconsScreen-D-8cP1PE.js.gz b/web-app/build/assets/IconsScreen-D-8cP1PE.js.gz new file mode 100644 index 0000000000..10262861e3 Binary files /dev/null and b/web-app/build/assets/IconsScreen-D-8cP1PE.js.gz differ diff --git a/web-app/build/assets/Inspect-DInyR1qg.js.gz b/web-app/build/assets/Inspect-DInyR1qg.js.gz new file mode 100644 index 0000000000..322d8786a3 Binary files /dev/null and b/web-app/build/assets/Inspect-DInyR1qg.js.gz differ diff --git a/web-app/build/assets/Inspect-z11H0H5H.js.gz b/web-app/build/assets/Inspect-z11H0H5H.js.gz deleted file mode 100644 index 2c5b931163..0000000000 Binary files a/web-app/build/assets/Inspect-z11H0H5H.js.gz and /dev/null differ diff --git a/web-app/build/assets/KMSRoutes-Bsyqi2te.js.gz b/web-app/build/assets/KMSRoutes-Bsyqi2te.js.gz deleted file mode 100644 index 313501493e..0000000000 Binary files a/web-app/build/assets/KMSRoutes-Bsyqi2te.js.gz and /dev/null differ diff --git a/web-app/build/assets/KMSRoutes-DGOdFA14.js.gz b/web-app/build/assets/KMSRoutes-DGOdFA14.js.gz new file mode 100644 index 0000000000..ed36a9c888 Binary files /dev/null and b/web-app/build/assets/KMSRoutes-DGOdFA14.js.gz differ diff --git a/web-app/build/assets/License-CZV8gIh9.js.gz b/web-app/build/assets/License-CZV8gIh9.js.gz deleted file mode 100644 index c235af0b6a..0000000000 Binary files a/web-app/build/assets/License-CZV8gIh9.js.gz and /dev/null differ diff --git a/web-app/build/assets/License-CfcnUcoc.js.gz b/web-app/build/assets/License-CfcnUcoc.js.gz new file mode 100644 index 0000000000..fc00d4232c Binary files /dev/null and b/web-app/build/assets/License-CfcnUcoc.js.gz differ diff --git a/web-app/build/assets/ListBuckets-DH2wI3PU.js.gz b/web-app/build/assets/ListBuckets-DH2wI3PU.js.gz new file mode 100644 index 0000000000..080ef5c14e Binary files /dev/null and b/web-app/build/assets/ListBuckets-DH2wI3PU.js.gz differ diff --git a/web-app/build/assets/ListBuckets-DyHS8Q8M.js.gz b/web-app/build/assets/ListBuckets-DyHS8Q8M.js.gz deleted file mode 100644 index be90309e8c..0000000000 Binary files a/web-app/build/assets/ListBuckets-DyHS8Q8M.js.gz and /dev/null differ diff --git a/web-app/build/assets/ListEventDestinations-CtW2lZO1.js.gz b/web-app/build/assets/ListEventDestinations-CtW2lZO1.js.gz new file mode 100644 index 0000000000..d1f424c562 Binary files /dev/null and b/web-app/build/assets/ListEventDestinations-CtW2lZO1.js.gz differ diff --git a/web-app/build/assets/ListEventDestinations-D69olgOT.js.gz b/web-app/build/assets/ListEventDestinations-D69olgOT.js.gz deleted file mode 100644 index 90899b0ca6..0000000000 Binary files a/web-app/build/assets/ListEventDestinations-D69olgOT.js.gz and /dev/null differ diff --git a/web-app/build/assets/ListKeys-CVlWZ5ls.js.gz b/web-app/build/assets/ListKeys-CVlWZ5ls.js.gz new file mode 100644 index 0000000000..62c3960c43 Binary files /dev/null and b/web-app/build/assets/ListKeys-CVlWZ5ls.js.gz differ diff --git a/web-app/build/assets/ListKeys-C_7WyENF.js.gz b/web-app/build/assets/ListKeys-C_7WyENF.js.gz deleted file mode 100644 index 664baca15e..0000000000 Binary files a/web-app/build/assets/ListKeys-C_7WyENF.js.gz and /dev/null differ diff --git a/web-app/build/assets/ListPolicies-Ckz-Oh0p.js.gz b/web-app/build/assets/ListPolicies-Ckz-Oh0p.js.gz deleted file mode 100644 index efe704518e..0000000000 Binary files a/web-app/build/assets/ListPolicies-Ckz-Oh0p.js.gz and /dev/null differ diff --git a/web-app/build/assets/ListPolicies-g07fyyFf.js.gz b/web-app/build/assets/ListPolicies-g07fyyFf.js.gz new file mode 100644 index 0000000000..144fe1414b Binary files /dev/null and b/web-app/build/assets/ListPolicies-g07fyyFf.js.gz differ diff --git a/web-app/build/assets/ListTiersConfiguration-BAQSJ50b.js.gz b/web-app/build/assets/ListTiersConfiguration-BAQSJ50b.js.gz new file mode 100644 index 0000000000..e6f9dc7e25 Binary files /dev/null and b/web-app/build/assets/ListTiersConfiguration-BAQSJ50b.js.gz differ diff --git a/web-app/build/assets/ListTiersConfiguration-CGN9AYl7.js.gz b/web-app/build/assets/ListTiersConfiguration-CGN9AYl7.js.gz deleted file mode 100644 index 407ff44d6b..0000000000 Binary files a/web-app/build/assets/ListTiersConfiguration-CGN9AYl7.js.gz and /dev/null differ diff --git a/web-app/build/assets/LoginCallback-Bl9QsJ0f.js.gz b/web-app/build/assets/LoginCallback-Bl9QsJ0f.js.gz deleted file mode 100644 index 6e4db66526..0000000000 Binary files a/web-app/build/assets/LoginCallback-Bl9QsJ0f.js.gz and /dev/null differ diff --git a/web-app/build/assets/LoginCallback-DJEYeQVZ.js.gz b/web-app/build/assets/LoginCallback-DJEYeQVZ.js.gz new file mode 100644 index 0000000000..cfe9c89bea Binary files /dev/null and b/web-app/build/assets/LoginCallback-DJEYeQVZ.js.gz differ diff --git a/web-app/build/assets/LogoutPage-1td9pWU_.js.gz b/web-app/build/assets/LogoutPage-1td9pWU_.js.gz deleted file mode 100644 index 1ec09173e7..0000000000 Binary files a/web-app/build/assets/LogoutPage-1td9pWU_.js.gz and /dev/null differ diff --git a/web-app/build/assets/LogoutPage-BQMigwHT.js.gz b/web-app/build/assets/LogoutPage-BQMigwHT.js.gz new file mode 100644 index 0000000000..c7ab247188 Binary files /dev/null and b/web-app/build/assets/LogoutPage-BQMigwHT.js.gz differ diff --git a/web-app/build/assets/LogsSearchMain-_PwRPdYH.js.gz b/web-app/build/assets/LogsSearchMain-_PwRPdYH.js.gz deleted file mode 100644 index fa44fde48a..0000000000 Binary files a/web-app/build/assets/LogsSearchMain-_PwRPdYH.js.gz and /dev/null differ diff --git a/web-app/build/assets/LogsSearchMain-mOkJWyLs.js.gz b/web-app/build/assets/LogsSearchMain-mOkJWyLs.js.gz new file mode 100644 index 0000000000..9782ef0a97 Binary files /dev/null and b/web-app/build/assets/LogsSearchMain-mOkJWyLs.js.gz differ diff --git a/web-app/build/assets/ObjectBrowser-D_Ka6YFA.js.gz b/web-app/build/assets/ObjectBrowser-D_Ka6YFA.js.gz deleted file mode 100644 index d9bdb858a6..0000000000 Binary files a/web-app/build/assets/ObjectBrowser-D_Ka6YFA.js.gz and /dev/null differ diff --git a/web-app/build/assets/ObjectBrowser-da3rD5EL.js.gz b/web-app/build/assets/ObjectBrowser-da3rD5EL.js.gz new file mode 100644 index 0000000000..ef32387064 Binary files /dev/null and b/web-app/build/assets/ObjectBrowser-da3rD5EL.js.gz differ diff --git a/web-app/build/assets/ObjectManager-BkpjHysq.js.gz b/web-app/build/assets/ObjectManager-BkpjHysq.js.gz deleted file mode 100644 index 65bbcae42a..0000000000 Binary files a/web-app/build/assets/ObjectManager-BkpjHysq.js.gz and /dev/null differ diff --git a/web-app/build/assets/ObjectManager-DgfxT0IA.js.gz b/web-app/build/assets/ObjectManager-DgfxT0IA.js.gz new file mode 100644 index 0000000000..0e9dccedc1 Binary files /dev/null and b/web-app/build/assets/ObjectManager-DgfxT0IA.js.gz differ diff --git a/web-app/build/assets/Overview-C7ToLKe-.js.gz b/web-app/build/assets/Overview-C7ToLKe-.js.gz deleted file mode 100644 index be62cb3606..0000000000 Binary files a/web-app/build/assets/Overview-C7ToLKe-.js.gz and /dev/null differ diff --git a/web-app/build/assets/Overview-C9vMenkN.js.gz b/web-app/build/assets/Overview-C9vMenkN.js.gz new file mode 100644 index 0000000000..35b77c711c Binary files /dev/null and b/web-app/build/assets/Overview-C9vMenkN.js.gz differ diff --git a/web-app/build/assets/PanelTitle-BawI_bpb.js.gz b/web-app/build/assets/PanelTitle-BawI_bpb.js.gz deleted file mode 100644 index a2f00308e8..0000000000 Binary files a/web-app/build/assets/PanelTitle-BawI_bpb.js.gz and /dev/null differ diff --git a/web-app/build/assets/PanelTitle-DYhlC3Mu.js.gz b/web-app/build/assets/PanelTitle-DYhlC3Mu.js.gz new file mode 100644 index 0000000000..faf2831708 Binary files /dev/null and b/web-app/build/assets/PanelTitle-DYhlC3Mu.js.gz differ diff --git a/web-app/build/assets/Policies-B4Cz6-gI.js.gz b/web-app/build/assets/Policies-B4Cz6-gI.js.gz deleted file mode 100644 index ac59a44cdc..0000000000 Binary files a/web-app/build/assets/Policies-B4Cz6-gI.js.gz and /dev/null differ diff --git a/web-app/build/assets/Policies-CkOH_5NZ.js.gz b/web-app/build/assets/Policies-CkOH_5NZ.js.gz new file mode 100644 index 0000000000..5f1e32c50d Binary files /dev/null and b/web-app/build/assets/Policies-CkOH_5NZ.js.gz differ diff --git a/web-app/build/assets/PolicyDetails-HQKzhLE9.js.gz b/web-app/build/assets/PolicyDetails-HQKzhLE9.js.gz deleted file mode 100644 index fa9cf55a44..0000000000 Binary files a/web-app/build/assets/PolicyDetails-HQKzhLE9.js.gz and /dev/null differ diff --git a/web-app/build/assets/PolicyDetails-U89o1I_A.js.gz b/web-app/build/assets/PolicyDetails-U89o1I_A.js.gz new file mode 100644 index 0000000000..cf52279188 Binary files /dev/null and b/web-app/build/assets/PolicyDetails-U89o1I_A.js.gz differ diff --git a/web-app/build/assets/PreviewCsv-BkgABCcK.js.gz b/web-app/build/assets/PreviewCsv-BkgABCcK.js.gz deleted file mode 100644 index 30ccf5fcdd..0000000000 Binary files a/web-app/build/assets/PreviewCsv-BkgABCcK.js.gz and /dev/null differ diff --git a/web-app/build/assets/PreviewCsv-CV1hKug2.js.gz b/web-app/build/assets/PreviewCsv-CV1hKug2.js.gz new file mode 100644 index 0000000000..c26c6c0e53 Binary files /dev/null and b/web-app/build/assets/PreviewCsv-CV1hKug2.js.gz differ diff --git a/web-app/build/assets/PreviewFileContent-BKZf9BV-.js.gz b/web-app/build/assets/PreviewFileContent-BKZf9BV-.js.gz deleted file mode 100644 index 73c298ee57..0000000000 Binary files a/web-app/build/assets/PreviewFileContent-BKZf9BV-.js.gz and /dev/null differ diff --git a/web-app/build/assets/PreviewFileContent-DE3FwZbA.js.gz b/web-app/build/assets/PreviewFileContent-DE3FwZbA.js.gz new file mode 100644 index 0000000000..20e77340fc Binary files /dev/null and b/web-app/build/assets/PreviewFileContent-DE3FwZbA.js.gz differ diff --git a/web-app/build/assets/PreviewMarkdown-D0fF9zkT.js.gz b/web-app/build/assets/PreviewMarkdown-D0fF9zkT.js.gz deleted file mode 100644 index 35d8737ae5..0000000000 Binary files a/web-app/build/assets/PreviewMarkdown-D0fF9zkT.js.gz and /dev/null differ diff --git a/web-app/build/assets/PreviewMarkdown-D_SXYbAb.js.gz b/web-app/build/assets/PreviewMarkdown-D_SXYbAb.js.gz new file mode 100644 index 0000000000..7650e26da5 Binary files /dev/null and b/web-app/build/assets/PreviewMarkdown-D_SXYbAb.js.gz differ diff --git a/web-app/build/assets/PreviewText-3UbaCnNJ.js.gz b/web-app/build/assets/PreviewText-3UbaCnNJ.js.gz new file mode 100644 index 0000000000..dc60711970 Binary files /dev/null and b/web-app/build/assets/PreviewText-3UbaCnNJ.js.gz differ diff --git a/web-app/build/assets/PreviewText-DCO3K_36.js.gz b/web-app/build/assets/PreviewText-DCO3K_36.js.gz deleted file mode 100644 index ff47cdc818..0000000000 Binary files a/web-app/build/assets/PreviewText-DCO3K_36.js.gz and /dev/null differ diff --git a/web-app/build/assets/Profile-BZY7169c.js.gz b/web-app/build/assets/Profile-BZY7169c.js.gz deleted file mode 100644 index 05761ea8a0..0000000000 Binary files a/web-app/build/assets/Profile-BZY7169c.js.gz and /dev/null differ diff --git a/web-app/build/assets/Profile-TnFWBVll.js.gz b/web-app/build/assets/Profile-TnFWBVll.js.gz new file mode 100644 index 0000000000..601528d961 Binary files /dev/null and b/web-app/build/assets/Profile-TnFWBVll.js.gz differ diff --git a/web-app/build/assets/RewindEnable-BazTSHc1.js.gz b/web-app/build/assets/RewindEnable-BazTSHc1.js.gz new file mode 100644 index 0000000000..9dedc9449b Binary files /dev/null and b/web-app/build/assets/RewindEnable-BazTSHc1.js.gz differ diff --git a/web-app/build/assets/RewindEnable-CZPx75CP.js.gz b/web-app/build/assets/RewindEnable-CZPx75CP.js.gz deleted file mode 100644 index 40c4f900eb..0000000000 Binary files a/web-app/build/assets/RewindEnable-CZPx75CP.js.gz and /dev/null differ diff --git a/web-app/build/assets/SSOLogin-5jHX6KSi.js.gz b/web-app/build/assets/SSOLogin-5jHX6KSi.js.gz new file mode 100644 index 0000000000..1f63d94853 Binary files /dev/null and b/web-app/build/assets/SSOLogin-5jHX6KSi.js.gz differ diff --git a/web-app/build/assets/SSOLogin-DTfLb_eT.js.gz b/web-app/build/assets/SSOLogin-DTfLb_eT.js.gz deleted file mode 100644 index 807a4c2015..0000000000 Binary files a/web-app/build/assets/SSOLogin-DTfLb_eT.js.gz and /dev/null differ diff --git a/web-app/build/assets/SetAccessPolicy-B86PZTlV.js.gz b/web-app/build/assets/SetAccessPolicy-B86PZTlV.js.gz deleted file mode 100644 index 1aabec1979..0000000000 Binary files a/web-app/build/assets/SetAccessPolicy-B86PZTlV.js.gz and /dev/null differ diff --git a/web-app/build/assets/SetAccessPolicy-B8sI3Y22.js.gz b/web-app/build/assets/SetAccessPolicy-B8sI3Y22.js.gz new file mode 100644 index 0000000000..22a34bd263 Binary files /dev/null and b/web-app/build/assets/SetAccessPolicy-B8sI3Y22.js.gz differ diff --git a/web-app/build/assets/SetPolicy-D5dyW2sp.js.gz b/web-app/build/assets/SetPolicy-D5dyW2sp.js.gz new file mode 100644 index 0000000000..02173a7627 Binary files /dev/null and b/web-app/build/assets/SetPolicy-D5dyW2sp.js.gz differ diff --git a/web-app/build/assets/SetPolicy-DCoTTphk.js.gz b/web-app/build/assets/SetPolicy-DCoTTphk.js.gz deleted file mode 100644 index b686c7b595..0000000000 Binary files a/web-app/build/assets/SetPolicy-DCoTTphk.js.gz and /dev/null differ diff --git a/web-app/build/assets/SetRetentionConfig-B70XSZps.js.gz b/web-app/build/assets/SetRetentionConfig-B70XSZps.js.gz new file mode 100644 index 0000000000..4c192c663f Binary files /dev/null and b/web-app/build/assets/SetRetentionConfig-B70XSZps.js.gz differ diff --git a/web-app/build/assets/SetRetentionConfig-Ci54N2Ws.js.gz b/web-app/build/assets/SetRetentionConfig-Ci54N2Ws.js.gz deleted file mode 100644 index 3457177ff5..0000000000 Binary files a/web-app/build/assets/SetRetentionConfig-Ci54N2Ws.js.gz and /dev/null differ diff --git a/web-app/build/assets/SiteReplication-CBgP2eM4.js.gz b/web-app/build/assets/SiteReplication-CBgP2eM4.js.gz deleted file mode 100644 index b1beb912ae..0000000000 Binary files a/web-app/build/assets/SiteReplication-CBgP2eM4.js.gz and /dev/null differ diff --git a/web-app/build/assets/SiteReplication-Cgb7PmU_.js.gz b/web-app/build/assets/SiteReplication-Cgb7PmU_.js.gz new file mode 100644 index 0000000000..dbd907d8dc Binary files /dev/null and b/web-app/build/assets/SiteReplication-Cgb7PmU_.js.gz differ diff --git a/web-app/build/assets/SiteReplicationStatus-Bo7v1PoP.js.gz b/web-app/build/assets/SiteReplicationStatus-Bo7v1PoP.js.gz deleted file mode 100644 index e5997ee9ac..0000000000 Binary files a/web-app/build/assets/SiteReplicationStatus-Bo7v1PoP.js.gz and /dev/null differ diff --git a/web-app/build/assets/SiteReplicationStatus-CZhOTWoj.js.gz b/web-app/build/assets/SiteReplicationStatus-CZhOTWoj.js.gz new file mode 100644 index 0000000000..a1901839cf Binary files /dev/null and b/web-app/build/assets/SiteReplicationStatus-CZhOTWoj.js.gz differ diff --git a/web-app/build/assets/Speedtest-BjLHZr8A.js.gz b/web-app/build/assets/Speedtest-BjLHZr8A.js.gz deleted file mode 100644 index 18305f9093..0000000000 Binary files a/web-app/build/assets/Speedtest-BjLHZr8A.js.gz and /dev/null differ diff --git a/web-app/build/assets/Speedtest-BxZgg0es.js.gz b/web-app/build/assets/Speedtest-BxZgg0es.js.gz new file mode 100644 index 0000000000..aa16f09efd Binary files /dev/null and b/web-app/build/assets/Speedtest-BxZgg0es.js.gz differ diff --git a/web-app/build/assets/Status-CouoBVdD.js.gz b/web-app/build/assets/Status-CouoBVdD.js.gz new file mode 100644 index 0000000000..c98103d49a Binary files /dev/null and b/web-app/build/assets/Status-CouoBVdD.js.gz differ diff --git a/web-app/build/assets/Status-D9P-yHBD.js.gz b/web-app/build/assets/Status-D9P-yHBD.js.gz deleted file mode 100644 index d6e8a7eba9..0000000000 Binary files a/web-app/build/assets/Status-D9P-yHBD.js.gz and /dev/null differ diff --git a/web-app/build/assets/TierTypeSelector-BJSoSR41.js.gz b/web-app/build/assets/TierTypeSelector-BJSoSR41.js.gz new file mode 100644 index 0000000000..c89fa10c08 Binary files /dev/null and b/web-app/build/assets/TierTypeSelector-BJSoSR41.js.gz differ diff --git a/web-app/build/assets/TierTypeSelector-BkyHChrR.js.gz b/web-app/build/assets/TierTypeSelector-BkyHChrR.js.gz deleted file mode 100644 index c8649b797e..0000000000 Binary files a/web-app/build/assets/TierTypeSelector-BkyHChrR.js.gz and /dev/null differ diff --git a/web-app/build/assets/Tools-CNJwXv1q.js.gz b/web-app/build/assets/Tools-CNJwXv1q.js.gz deleted file mode 100644 index ad67acd4bf..0000000000 Binary files a/web-app/build/assets/Tools-CNJwXv1q.js.gz and /dev/null differ diff --git a/web-app/build/assets/Tools-rZPgIiLM.js.gz b/web-app/build/assets/Tools-rZPgIiLM.js.gz new file mode 100644 index 0000000000..89d85ba2e4 Binary files /dev/null and b/web-app/build/assets/Tools-rZPgIiLM.js.gz differ diff --git a/web-app/build/assets/Trace-C01p9rLK.js.gz b/web-app/build/assets/Trace-C01p9rLK.js.gz new file mode 100644 index 0000000000..30cd080a2d Binary files /dev/null and b/web-app/build/assets/Trace-C01p9rLK.js.gz differ diff --git a/web-app/build/assets/Trace-ChzeHata.js.gz b/web-app/build/assets/Trace-ChzeHata.js.gz deleted file mode 100644 index 5862f8fe3f..0000000000 Binary files a/web-app/build/assets/Trace-ChzeHata.js.gz and /dev/null differ diff --git a/web-app/build/assets/UpdateTierCredentialsModal-ChvnjHRE.js.gz b/web-app/build/assets/UpdateTierCredentialsModal-ChvnjHRE.js.gz new file mode 100644 index 0000000000..8947bd759e Binary files /dev/null and b/web-app/build/assets/UpdateTierCredentialsModal-ChvnjHRE.js.gz differ diff --git a/web-app/build/assets/UpdateTierCredentialsModal-DAMXw9I7.js.gz b/web-app/build/assets/UpdateTierCredentialsModal-DAMXw9I7.js.gz deleted file mode 100644 index f356e47f29..0000000000 Binary files a/web-app/build/assets/UpdateTierCredentialsModal-DAMXw9I7.js.gz and /dev/null differ diff --git a/web-app/build/assets/Users-CKok5nEq.js.gz b/web-app/build/assets/Users-CKok5nEq.js.gz deleted file mode 100644 index 0bbd47ff7f..0000000000 Binary files a/web-app/build/assets/Users-CKok5nEq.js.gz and /dev/null differ diff --git a/web-app/build/assets/Users-Cz4n9i96.js.gz b/web-app/build/assets/Users-Cz4n9i96.js.gz new file mode 100644 index 0000000000..2eeebfa371 Binary files /dev/null and b/web-app/build/assets/Users-Cz4n9i96.js.gz differ diff --git a/web-app/build/assets/Watch-BCM98jI6.js.gz b/web-app/build/assets/Watch-BCM98jI6.js.gz deleted file mode 100644 index 95ac1bee54..0000000000 Binary files a/web-app/build/assets/Watch-BCM98jI6.js.gz and /dev/null differ diff --git a/web-app/build/assets/Watch-Cz3Smi4G.js.gz b/web-app/build/assets/Watch-Cz3Smi4G.js.gz new file mode 100644 index 0000000000..9abce21bf6 Binary files /dev/null and b/web-app/build/assets/Watch-Cz3Smi4G.js.gz differ diff --git a/web-app/build/assets/copy-to-clipboard-SfaOv4Kn.js.gz b/web-app/build/assets/copy-to-clipboard-SfaOv4Kn.js.gz new file mode 100644 index 0000000000..8f678e4d60 Binary files /dev/null and b/web-app/build/assets/copy-to-clipboard-SfaOv4Kn.js.gz differ diff --git a/web-app/build/assets/index-D3Bfae5V.js.gz b/web-app/build/assets/index-D3Bfae5V.js.gz deleted file mode 100644 index ae67b7399e..0000000000 Binary files a/web-app/build/assets/index-D3Bfae5V.js.gz and /dev/null differ diff --git a/web-app/build/assets/index-DZPdmjev.js.gz b/web-app/build/assets/index-DZPdmjev.js.gz new file mode 100644 index 0000000000..1475781e04 Binary files /dev/null and b/web-app/build/assets/index-DZPdmjev.js.gz differ diff --git a/web-app/build/assets/inspectDownload-BMNtgwSJ.js.gz b/web-app/build/assets/inspectDownload-BMNtgwSJ.js.gz deleted file mode 100644 index 94f40323b2..0000000000 Binary files a/web-app/build/assets/inspectDownload-BMNtgwSJ.js.gz and /dev/null differ diff --git a/web-app/build/assets/inspectDownload-EGCmn29I.js.gz b/web-app/build/assets/inspectDownload-EGCmn29I.js.gz new file mode 100644 index 0000000000..3365c42bf6 Binary files /dev/null and b/web-app/build/assets/inspectDownload-EGCmn29I.js.gz differ diff --git a/web-app/build/assets/lib-BVN4wEer.js.gz b/web-app/build/assets/lib-BVN4wEer.js.gz deleted file mode 100644 index e9b414d12e..0000000000 Binary files a/web-app/build/assets/lib-BVN4wEer.js.gz and /dev/null differ diff --git a/web-app/build/assets/lib-BtWwCU9q.js.gz b/web-app/build/assets/lib-BtWwCU9q.js.gz new file mode 100644 index 0000000000..0898d27364 Binary files /dev/null and b/web-app/build/assets/lib-BtWwCU9q.js.gz differ diff --git a/web-app/build/assets/utils--e3qTLdP.js.gz b/web-app/build/assets/utils--e3qTLdP.js.gz deleted file mode 100644 index 081e27bd27..0000000000 Binary files a/web-app/build/assets/utils--e3qTLdP.js.gz and /dev/null differ diff --git a/web-app/build/assets/utils-3gqjf0lY.js.gz b/web-app/build/assets/utils-3gqjf0lY.js.gz new file mode 100644 index 0000000000..591a33c6ed Binary files /dev/null and b/web-app/build/assets/utils-3gqjf0lY.js.gz differ diff --git a/web-app/build/index.html b/web-app/build/index.html index 0c1cebf5b9..6735eb3a54 100644 --- a/web-app/build/index.html +++ b/web-app/build/index.html @@ -29,7 +29,7 @@ SILO Console - + diff --git a/web-app/e2e/bucket-usage-refresh.unit.ts b/web-app/e2e/bucket-usage-refresh.unit.ts index 4a1eab6ecd..2e240ed37e 100644 --- a/web-app/e2e/bucket-usage-refresh.unit.ts +++ b/web-app/e2e/bucket-usage-refresh.unit.ts @@ -235,6 +235,51 @@ test("without the persistent service, Refresh calculates current totals and neve expect(scans).toBe(1); }); +test("without the persistent service, a busy scanner (429) is retried instead of failing the bucket", async () => { + let scans = 0; + await page.route("**/api/v1/bucket-usage/*", (route) => + route.fulfill({ + json: { enabled: false, poll_after_ms: 250, buckets: [] }, + }), + ); + await page.route( + "**/api/v1/buckets/fixture-bucket/prefix-usage*", + (route) => { + scans++; + if (scans === 1) + return route.fulfill({ + status: 429, + json: { + code: 429, + message: "Other size calculations are running. Try again shortly.", + }, + }); + return route.fulfill({ + contentType: "application/x-ndjson", + body: + JSON.stringify({ + ...snapshot, + bucket: "fixture-bucket", + prefix: "", + mode: "versions", + status: "complete", + folders: [], + }) + "\n", + }); + }, + ); + await page.goto(base); + const row = page.getByRole("region", { name: "fixture-bucket" }); + await expect(page.getByRole("status")).toContainText( + "Automatic updates are not configured", + ); + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(row).toContainText("1.5 KiB"); + await expect(row).toContainText("Updated"); + await expect(row).not.toContainText("Update failed"); + expect(scans).toBe(2); +}); + for (const outcome of [ "outage", "limited", diff --git a/web-app/e2e/secure-context.unit.ts b/web-app/e2e/secure-context.unit.ts new file mode 100644 index 0000000000..5de917baff --- /dev/null +++ b/web-app/e2e/secure-context.unit.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Pigsty +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Embedded Console is commonly served over plain HTTP (http://:9001), where +// browsers omit secure-context APIs. A call to one of them throws before the +// surrounding request starts, so features silently do nothing. The share-link +// expiry must also survive browsers whose locale formats digits natively. + +import { expect, test } from "@playwright/test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Settings } from "luxon"; +import { shareExpiryTimestamp } from "../src/screens/Console/Common/FormComponents/DaysSelector/shareExpiry"; + +const roots = ["../src/", "../packages/mds/src/"].map((relative) => + fileURLToPath(new URL(relative, import.meta.url)), +); +const sources = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sources(path); + return /\.(ts|tsx)$/.test(entry.name) ? [path] : []; + }); + +test("source avoids APIs that plain-HTTP pages do not expose", () => { + const offenders = roots + .flatMap(sources) + .filter((path) => + /crypto\.randomUUID|crypto\.subtle|navigator\.clipboard/.test( + readFileSync(path, "utf8"), + ), + ); + expect(offenders).toEqual([]); +}); + +test("share expiry stays parseable under native-digit locales", () => { + const previous = Settings.defaultLocale; + try { + for (const locale of ["en-US", "ar", "ar-EG", "ar-SA", "fa", "zh-CN"]) { + Settings.defaultLocale = locale; + const value = shareExpiryTimestamp("2026-11-16T10:20:30.456+08:00"); + expect(value, locale).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d$/); + expect(Number.isNaN(new Date(`${value}`).getTime()), locale).toBe(false); + } + } finally { + Settings.defaultLocale = previous; + } +}); diff --git a/web-app/e2e/visual-polish.unit.ts b/web-app/e2e/visual-polish.unit.ts index de56c3435b..5e8ea9b02c 100644 --- a/web-app/e2e/visual-polish.unit.ts +++ b/web-app/e2e/visual-polish.unit.ts @@ -192,6 +192,23 @@ test("actual Inspect guards pristine submit and preserves encoded download and d page.getByRole("dialog", { name: "Inspect Decryption Key" }), ).toBeVisible(); await expect(page.locator("#inspect-dec-key")).toHaveValue("test-key"); + // polish.test is plain HTTP, where the async Clipboard API is absent. The + // copy action must still work, and stay the field's only overlay button. + const copyKey = page.locator( + ".textBoxContainer:has(#inspect-dec-key) .overlayAction button", + ); + await expect(copyKey).toHaveCount(1); + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + await copyKey.click(); + expect(pageErrors).toEqual([]); + await expect(page.locator("#inspect-dec-key")).toHaveAttribute( + "type", + "password", + ); + // Leave the button so its tooltip does not take the first Escape. + await page.mouse.move(0, 0); + await page.locator("#inspect-dec-key").focus(); await page.keyboard.press("Escape"); await expect(page.locator("#inspect_volume")).toHaveValue(""); await expect(page.locator('[aria-invalid="true"]')).toHaveCount(0); diff --git a/web-app/package.json b/web-app/package.json index c0c2a35d35..249c3d134d 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -7,6 +7,7 @@ "type": "module", "dependencies": { "@reduxjs/toolkit": "^1.9.7", + "copy-to-clipboard": "^3.3.3", "kbar": "^0.1.0-beta.48", "lodash": "^4.18.1", "luxon": "^3.7.2", diff --git a/web-app/src/screens/Console/Buckets/ListBuckets/useBucketUsage.tsx b/web-app/src/screens/Console/Buckets/ListBuckets/useBucketUsage.tsx index 25f20561ae..68f0963c4a 100644 --- a/web-app/src/screens/Console/Buckets/ListBuckets/useBucketUsage.tsx +++ b/web-app/src/screens/Console/Buckets/ListBuckets/useBucketUsage.tsx @@ -37,6 +37,24 @@ export const BucketUsageProvider = UsageContext.Provider; export const useBucketUsageView = () => useContext(UsageContext); const usagePending = (row: BucketUsageRecord) => row.state === "queued" || row.state === "running"; +// A busy server (429) is retried with jittered, capped backoff for about +// half a minute before the bucket is reported as failed. +const usageBusyAttempts = 8; +const usageBusyWait = (attempt: number, signal: AbortSignal) => + new Promise((resolve) => { + if (signal.aborted) return resolve(false); + const cap = Math.min(8000, 1000 * 2 ** (attempt - 1)); + const timer = setTimeout(finish, cap / 2 + (Math.random() * cap) / 2); + function finish() { + signal.removeEventListener("abort", abort); + resolve(!signal.aborted); + } + function abort() { + clearTimeout(timer); + finish(); + } + signal.addEventListener("abort", abort, { once: true }); + }); const usageStates: readonly string[] = [ "not_scanned", "queued", @@ -186,70 +204,83 @@ export function useBucketUsage(names: string[]): UsageView { }; }); publish(); - try { - let completed: BucketUsageSnapshot | undefined; - const response = await api.buckets.prefixUsage( - bucket, - { prefix: "", versions, folders: false }, - { signal: controller.signal }, - ); - await readPrefixUsageStream( - response, - { bucket, prefix: "", mode: versions ? "versions" : "current" }, - (frame) => { - const totals = { - ...frame.totals, - ...(versions - ? {} - : { - retained_bytes: undefined, - retained_versions: undefined, - delete_markers: undefined, - }), - }; - const snapshot = { - totals, - scanned_entries: frame.scanned_entries, - started_at: frame.started_at, - updated_at: frame.updated_at, - }; + for (let attempt = 1; ; attempt++) { + try { + let completed: BucketUsageSnapshot | undefined; + const response = await api.buckets.prefixUsage( + bucket, + { prefix: "", versions, folders: false }, + { signal: controller.signal }, + ); + await readPrefixUsageStream( + response, + { bucket, prefix: "", mode: versions ? "versions" : "current" }, + (frame) => { + const totals = { + ...frame.totals, + ...(versions + ? {} + : { + retained_bytes: undefined, + retained_versions: undefined, + delete_markers: undefined, + }), + }; + const snapshot = { + totals, + scanned_entries: frame.scanned_entries, + started_at: frame.started_at, + updated_at: frame.updated_at, + }; + row = { + ...row, + state: + frame.status === "scanning" || frame.status === "complete" + ? "running" + : frame.status === "failed" + ? "error" + : frame.status, + progress: snapshot, + }; + if (frame.status === "complete") completed = snapshot; + publish(); + }, + ); + // Only replace last-good after the entire stream validates. A + // malformed trailer or disconnect must not publish new totals. + if (completed) { row = { ...row, - state: - frame.status === "scanning" || frame.status === "complete" - ? "running" - : frame.status === "failed" - ? "error" - : frame.status, - progress: snapshot, + state: "complete", + last_good: completed, + last_completed_at: completed.updated_at, }; - if (frame.status === "complete") completed = snapshot; publish(); - }, - ); - // Only replace last-good after the entire stream validates. A - // malformed trailer or disconnect must not publish new totals. - if (completed) { + } + break; + } catch (error) { + const status = error instanceof Response ? error.status : 0; + // Prefix-usage slots are shared by every Console session, so a + // busy server answers 429. Wait and retry the same bucket + // instead of reporting it as failed. + if ( + status === 429 && + attempt < usageBusyAttempts && + (await usageBusyWait(attempt, controller.signal)) + ) + continue; row = { ...row, - state: "complete", - last_good: completed, - last_completed_at: completed.updated_at, + state: + status === 401 || status === 403 + ? "forbidden" + : status === 404 + ? "missing" + : "error", }; publish(); + break; } - } catch (error) { - const status = error instanceof Response ? error.status : 0; - row = { - ...row, - state: - status === 401 || status === 403 - ? "forbidden" - : status === 404 - ? "missing" - : "error", - }; - publish(); } } }; diff --git a/web-app/src/screens/Console/Common/FormComponents/DaysSelector/DaysSelector.tsx b/web-app/src/screens/Console/Common/FormComponents/DaysSelector/DaysSelector.tsx index dbd32a6dae..3be6102842 100644 --- a/web-app/src/screens/Console/Common/FormComponents/DaysSelector/DaysSelector.tsx +++ b/web-app/src/screens/Console/Common/FormComponents/DaysSelector/DaysSelector.tsx @@ -16,6 +16,7 @@ import React, { useEffect, useState } from "react"; import { DateTime } from "luxon"; +import { shareExpiryTimestamp } from "./shareExpiry"; import { Box, InputBox, InputLabel, LinkIcon } from "mds"; import { formatText, useT } from "i18n"; @@ -78,11 +79,10 @@ const DaysSelector = ({ }, [selectedDays, selectedHours, selectedMinutes]); useEffect(() => { - if (validDate && dateSelected) { - const formattedDate = DateTime.fromISO(dateSelected).toFormat( - "yyyy-MM-dd HH:mm:ss", - ); - onChange(formattedDate.split(" ").join("T"), true); + const formattedDate = + validDate && dateSelected ? shareExpiryTimestamp(dateSelected) : null; + if (formattedDate) { + onChange(formattedDate, true); } else { onChange("0000-00-00", false); } diff --git a/web-app/src/screens/Console/Common/FormComponents/DaysSelector/shareExpiry.ts b/web-app/src/screens/Console/Common/FormComponents/DaysSelector/shareExpiry.ts new file mode 100644 index 0000000000..af885c7516 --- /dev/null +++ b/web-app/src/screens/Console/Common/FormComponents/DaysSelector/shareExpiry.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Pigsty +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +import { DateTime } from "luxon"; + +// Returns a local timestamp ("yyyy-MM-ddTHH:mm:ss") that `new Date()` parses. +// Luxon's toFormat follows the browser locale and emits Arabic-Indic or +// Persian digits for ar-EG, ar-SA and fa, so use its technical ISO output. +export const shareExpiryTimestamp = (iso: string): string | null => + DateTime.fromISO(iso) + .startOf("second") + .toISO({ includeOffset: false, suppressMilliseconds: true }); diff --git a/web-app/src/screens/Console/ObjectBrowser/usePrefixUsage.ts b/web-app/src/screens/Console/ObjectBrowser/usePrefixUsage.ts index 898f7592c7..cb72b0f89c 100644 --- a/web-app/src/screens/Console/ObjectBrowser/usePrefixUsage.ts +++ b/web-app/src/screens/Console/ObjectBrowser/usePrefixUsage.ts @@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "api"; import type { PrefixUsageFrame } from "api/consoleApi"; import { readPrefixUsageStream } from "./prefixUsage"; -import { registerTaskControl, removeTrace } from "./transferManager"; +import { makeid, registerTaskControl, removeTrace } from "./transferManager"; import { useTaskSession } from "../Common/ObjectManager/useTaskSession"; type Scan = { @@ -40,7 +40,9 @@ export function usePrefixUsage( const request = ++serial.current; const controller = new AbortController(); active.current = controller; - const task = `prefix-usage-${crypto.randomUUID()}`; + // randomUUID exists only in secure contexts; embedded Console is + // often served over plain HTTP, so use the shared task-ID helper. + const task = `prefix-usage-${makeid(16)}`; registerTaskControl(task, { cancel: () => controller.abort() }); const current = () => serial.current === request && isCurrentSession(); setScan({ owner, prefix: target, running: true }); diff --git a/web-app/src/screens/Console/Tools/KeyRevealer.tsx b/web-app/src/screens/Console/Tools/KeyRevealer.tsx index 8bae2413c3..37bc4f2ae2 100644 --- a/web-app/src/screens/Console/Tools/KeyRevealer.tsx +++ b/web-app/src/screens/Console/Tools/KeyRevealer.tsx @@ -15,6 +15,7 @@ // along with this program. If not, see . import React, { useState } from "react"; +import copy from "copy-to-clipboard"; import { Button, CopyIcon, InputBox, Box, breakPoints } from "mds"; import { useT } from "i18n"; @@ -44,7 +45,9 @@ const KeyRevealer = ({ value }: { value: string }) => { value={value} overlayIcon={} readOnly={true} - overlayAction={() => navigator.clipboard.writeText(value)} + // The async Clipboard API exists only in secure contexts; Console is + // often served over plain HTTP, so use the execCommand fallback. + overlayAction={() => copy(value)} />