From d3db4c7279fa43bf3a5d1a6afc8fcaa386079e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 23 Sep 2026 13:45:37 +0100 Subject: [PATCH] feat: preview sorted storage usage bars (#3867) --- .../system/storage/gocache/provider.go | 17 +- .../system/storage/gocache/provider_test.go | 39 ++- .../system/storage/tempstore/provider.go | 45 +++- .../system/storage/tempstore/provider_test.go | 66 +++++ .../system/storage/storage-action-button.tsx | 19 +- .../storage-overview-card-bars.test.tsx | 211 +++++++++++++++ .../storage/storage-overview-card.test.tsx | 58 +++- .../system/storage/storage-overview-card.tsx | 131 ++++++++- .../storage-overview-resources.test.ts | 170 ++++++++++++ .../storage/storage-overview-resources.ts | 181 +++++++++---- apps/web/e2e/helpers/storage-maintenance.ts | 177 ++++++++++++- .../mobile-storage-analysis-bars.spec.ts | 64 +++++ .../mobile-storage-temporary-folders.spec.ts | 2 + .../system/storage-analysis-bars.spec.ts | 228 ++++++++++++++++ .../system/storage-temporary-folders.spec.ts | 2 + apps/web/src/locales/en/system.json | 2 + apps/web/src/locales/ja/system.json | 2 + apps/web/src/locales/pseudo/system.json | 2 + apps/web/src/locales/pt-pt/system.json | 2 + apps/web/src/locales/zh-cn/system.json | 2 + apps/web/src/locales/zh-hk/system.json | 2 + apps/web/src/locales/zh-tw/system.json | 2 + .../storage-analysis-presentation/plan.md | 249 ++++++++++++++++++ .../task-01-timeout-feedback.md | 139 ++++++++++ .../task-02-sorted-storage-bars.md | 172 ++++++++++++ docs/plans/storage-temporary-folders/plan.md | 3 + docs/public/operations.md | 12 +- .../requirements/storage-maintenance.md | 27 +- .../storage-analysis-presentation.md | 173 ++++++++++++ .../storage-temporary-folders.md | 7 +- 30 files changed, 2122 insertions(+), 84 deletions(-) create mode 100644 apps/web/components/settings/system/storage/storage-overview-card-bars.test.tsx create mode 100644 apps/web/components/settings/system/storage/storage-overview-resources.test.ts create mode 100644 apps/web/e2e/tests/system/mobile-storage-analysis-bars.spec.ts create mode 100644 apps/web/e2e/tests/system/storage-analysis-bars.spec.ts create mode 100644 docs/plans/storage-analysis-presentation/plan.md create mode 100644 docs/plans/storage-analysis-presentation/task-01-timeout-feedback.md create mode 100644 docs/plans/storage-analysis-presentation/task-02-sorted-storage-bars.md create mode 100644 docs/specs/system-page/system-design/storage-analysis-presentation.md diff --git a/apps/backend/internal/system/storage/gocache/provider.go b/apps/backend/internal/system/storage/gocache/provider.go index 35fa702112..ac57e0580e 100644 --- a/apps/backend/internal/system/storage/gocache/provider.go +++ b/apps/backend/internal/system/storage/gocache/provider.go @@ -56,12 +56,14 @@ type Provider struct { // Analysis describes the configured cache without changing it. type Analysis struct { - Path string `json:"path"` - SizeBytes int64 `json:"size_bytes"` - Owned bool `json:"owned"` - Enabled bool `json:"enabled"` - UnmanagedPath string `json:"unmanaged_path,omitempty"` - UnmanagedSizeBytes int64 `json:"unmanaged_size_bytes,omitempty"` + Path string `json:"path"` + SizeBytes int64 `json:"size_bytes"` + Owned bool `json:"owned"` + Enabled bool `json:"enabled"` + UnmanagedPath string `json:"unmanaged_path,omitempty"` + // A nil size means that the distinct user cache was not measured. A pointer + // preserves an explicitly measured zero in the successful response. + UnmanagedSizeBytes *int64 `json:"unmanaged_size_bytes,omitempty"` } // CleanupResult describes one cache rotation. @@ -154,7 +156,8 @@ func (p *Provider) Analyze(ctx context.Context) (Analysis, error) { return Analysis{}, fmt.Errorf("measure Go cache: %w", err) } analysis.UnmanagedPath = unmanagedPath - analysis.UnmanagedSizeBytes = measurements[1].Bytes + unmanagedSizeBytes := measurements[1].Bytes + analysis.UnmanagedSizeBytes = &unmanagedSizeBytes return analysis, nil } diff --git a/apps/backend/internal/system/storage/gocache/provider_test.go b/apps/backend/internal/system/storage/gocache/provider_test.go index b494665263..c1041c54a6 100644 --- a/apps/backend/internal/system/storage/gocache/provider_test.go +++ b/apps/backend/internal/system/storage/gocache/provider_test.go @@ -7,16 +7,21 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "time" "github.com/kandev/kandev/internal/system/storage" ) +func int64Pointer(value int64) *int64 { + return &value +} + func TestAnalysisJSONUsesStorageAPISnakeCase(t *testing.T) { encoded, err := json.Marshal(Analysis{ Path: "/cache", SizeBytes: 42, Owned: true, Enabled: false, - UnmanagedPath: "/user-cache", UnmanagedSizeBytes: 24, + UnmanagedPath: "/user-cache", UnmanagedSizeBytes: int64Pointer(24), }) if err != nil { t.Fatalf("Marshal Analysis: %v", err) @@ -27,6 +32,36 @@ func TestAnalysisJSONUsesStorageAPISnakeCase(t *testing.T) { } } +func TestAnalyzeSerializesMeasuredZeroForUnmanagedCache(t *testing.T) { + home := t.TempDir() + userCache := filepath.Join(t.TempDir(), "go-build") + if err := os.MkdirAll(userCache, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("GOCACHE", userCache) + provider := New(Config{ + HomeDir: home, + TrashDir: filepath.Join(home, "trash"), + Settings: staticSettings{settings: storage.DefaultSettings()}, + }) + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if analysis.UnmanagedPath != userCache || analysis.UnmanagedSizeBytes == nil || *analysis.UnmanagedSizeBytes != 0 { + t.Fatalf("analysis = %#v, want an explicit measured zero", analysis) + } + + encoded, err := json.Marshal(analysis) + if err != nil { + t.Fatalf("Marshal Analysis: %v", err) + } + if got := string(encoded); !strings.Contains(got, `"unmanaged_size_bytes":0`) { + t.Fatalf("serialized analysis = %s, want unmanaged_size_bytes zero", encoded) + } +} + func TestCleanupResultJSONUsesStorageAPISnakeCase(t *testing.T) { encoded, err := json.Marshal(CleanupResult{ Path: "/cache", BytesBefore: 100, BytesAfter: 20, ReclaimedBytes: 80, @@ -168,7 +203,7 @@ func TestAnalyzeReportsUnmanagedDefaultGoCacheReadOnly(t *testing.T) { t.Fatalf("Analyze: %v", err) } if analysis.UnmanagedPath != userCache || - analysis.UnmanagedSizeBytes != int64(len("user cache bytes")) { + analysis.UnmanagedSizeBytes == nil || *analysis.UnmanagedSizeBytes != int64(len("user cache bytes")) { t.Fatalf("analysis = %#v, want unmanaged cache %s", analysis, userCache) } if _, err := os.Stat(artifact); err != nil { diff --git a/apps/backend/internal/system/storage/tempstore/provider.go b/apps/backend/internal/system/storage/tempstore/provider.go index fdc4a3a7ef..31e18fedde 100644 --- a/apps/backend/internal/system/storage/tempstore/provider.go +++ b/apps/backend/internal/system/storage/tempstore/provider.go @@ -473,22 +473,20 @@ func measurementFromResult( deadline bool, ) RootMeasurement { measurement.SkippedCount = result.SkippedCount - measurement.Warnings = appendBounded(nil, result.Warnings, 10) + measurement.Warnings = appendDistinctBounded(nil, result.Warnings, 10) if result.Err != nil { if deadline && errors.Is(result.Err, context.DeadlineExceeded) { size := result.Bytes measurement.Status = StatusPartial measurement.SizeBytes = &size measurement.Reason = reasonDeadline - measurement.Warnings = appendBounded( - measurement.Warnings, []string{result.Err.Error()}, 10, - ) + measurement.Warnings = appendErrorWarnings(measurement.Warnings, result.Err, 10) return measurement } measurement.Status = StatusUnavailable measurement.SizeBytes = nil measurement.Reason = reasonMeasurementUnavailable - measurement.Warnings = appendBounded(measurement.Warnings, []string{result.Err.Error()}, 10) + measurement.Warnings = appendErrorWarnings(measurement.Warnings, result.Err, 10) return measurement } size := result.Bytes @@ -518,7 +516,7 @@ func summarize(analysis Analysis) Analysis { if root.SizeBytes != nil { total += *root.SizeBytes } - analysis.Warnings = appendBounded(analysis.Warnings, root.Warnings, 10) + analysis.Warnings = appendDistinctBounded(analysis.Warnings, root.Warnings, 10) } switch { case measured+partial > 0: @@ -549,18 +547,47 @@ func summarize(analysis Analysis) Analysis { return analysis } -func appendBounded(values, additions []string, limit int) []string { +func appendErrorWarnings(values []string, err error, limit int) []string { + if err == nil || len(values) >= limit { + return values + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + for _, child := range joined.Unwrap() { + values = appendErrorWarnings(values, child, limit) + } + return values + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return appendErrorWarnings(values, wrapped.Unwrap(), limit) + } + if errors.Is(err, context.DeadlineExceeded) { + return values + } + return appendDistinctBounded(values, []string{err.Error()}, limit) +} + +func appendDistinctBounded(values, additions []string, limit int) []string { for _, addition := range additions { if len(values) >= limit { break } - if addition != "" { - values = append(values, addition) + if addition == "" || containsWarning(values, addition) { + continue } + values = append(values, addition) } return values } +func containsWarning(values []string, candidate string) bool { + for _, value := range values { + if value == candidate { + return true + } + } + return false +} + func sameMount(left, right string, windows bool) bool { if windows { return strings.EqualFold(left, right) diff --git a/apps/backend/internal/system/storage/tempstore/provider_test.go b/apps/backend/internal/system/storage/tempstore/provider_test.go index d558329605..25f03a7225 100644 --- a/apps/backend/internal/system/storage/tempstore/provider_test.go +++ b/apps/backend/internal/system/storage/tempstore/provider_test.go @@ -3,6 +3,7 @@ package tempstore import ( "context" "errors" + "fmt" "os" "path/filepath" "testing" @@ -287,3 +288,68 @@ func TestAnalyzeTemporaryDeadlineReturnsPartialSample(t *testing.T) { t.Fatalf("analysis = %#v, want deadline reason", analysis) } } + +func TestAnalyzeTemporaryDeadlineNormalizesJoinedWarnings(t *testing.T) { + firstRoot := t.TempDir() + secondRoot := t.TempDir() + provider := NewProvider(Config{ + GOOS: "linux", + Mounts: testMountReader{}, + Deadline: time.Nanosecond, + RootResolver: func(context.Context) ([]RootCandidate, error) { + return []RootCandidate{ + {RequestedPath: firstRoot}, + {RequestedPath: secondRoot}, + }, nil + }, + Scanner: testScanner{measure: func( + ctx context.Context, + _ []filescan.Root, + _ filescan.MeasureOptions, + _ func(filescan.Progress), + ) []filescan.Result { + <-ctx.Done() + return []filescan.Result{ + { + Bytes: 17, + SkippedCount: 2, + Err: fmt.Errorf( + "wrapped scan result: %w", + errors.Join(context.DeadlineExceeded, errors.New("permission denied")), + ), + }, + { + Bytes: 23, + SkippedCount: 3, + Err: errors.Join( + context.DeadlineExceeded, + errors.New("permission denied"), + errors.New("root disappeared"), + ), + }, + } + }}, + }) + + analysis, err := provider.Analyze(context.Background()) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if analysis.Status != StatusPartial || analysis.SizeBytes == nil || *analysis.SizeBytes != 40 { + t.Fatalf("analysis = %#v, want a 40-byte partial sample", analysis) + } + if analysis.Warnings == nil || len(analysis.Warnings) != 2 { + t.Fatalf("analysis warnings = %#v, want two distinct diagnostics", analysis.Warnings) + } + if analysis.Warnings[0] != "permission denied" || analysis.Warnings[1] != "root disappeared" { + t.Fatalf("analysis warnings = %#v, want normalized diagnostics", analysis.Warnings) + } + for _, warning := range analysis.Warnings { + if warning == context.DeadlineExceeded.Error() { + t.Fatalf("analysis warnings contain the deadline marker: %#v", analysis.Warnings) + } + } + if analysis.Roots[0].SkippedCount != 2 || analysis.Roots[1].SkippedCount != 3 { + t.Fatalf("root skipped counts = %#v, want 2 and 3", analysis.Roots) + } +} diff --git a/apps/web/components/settings/system/storage/storage-action-button.tsx b/apps/web/components/settings/system/storage/storage-action-button.tsx index 4a30ccd258..a9478d056a 100644 --- a/apps/web/components/settings/system/storage/storage-action-button.tsx +++ b/apps/web/components/settings/system/storage/storage-action-button.tsx @@ -3,18 +3,29 @@ import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { settingsActionClassName } from "@/components/settings/settings-control"; -type Props = ComponentProps & { disabledReason?: string }; +type Props = ComponentProps & { disabledReason?: string; focusId?: string }; -export function StorageActionButton({ disabledReason, disabled, className, ...props }: Props) { +export function StorageActionButton({ + disabledReason, + disabled, + className, + focusId, + ...props +}: Props) { const isDisabled = disabled || Boolean(disabledReason); const button = ( -