feat: preview sorted storage usage bars (#3867)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof Button> & { disabledReason?: string };
|
||||
type Props = ComponentProps<typeof Button> & { 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 = (
|
||||
<Button {...props} disabled={isDisabled} className={settingsActionClassName(className)} />
|
||||
<Button
|
||||
{...props}
|
||||
disabled={isDisabled}
|
||||
className={settingsActionClassName(className)}
|
||||
data-storage-focus-id={focusId}
|
||||
/>
|
||||
);
|
||||
if (!disabledReason) return button;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0} className="inline-flex">
|
||||
<span tabIndex={0} className="inline-flex" data-storage-focus-id={focusId}>
|
||||
{button}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StorageOverviewResponse } from "@/lib/types/system";
|
||||
import { StorageOverviewCard } from "./storage-overview-card";
|
||||
|
||||
const SYSTEM_TEMPORARY_RESOURCE_TEST_ID = "storage-resource-system-temporary";
|
||||
const SYSTEM_TEMPORARY_TRIGGER_TEST_ID = `${SYSTEM_TEMPORARY_RESOURCE_TEST_ID}-trigger`;
|
||||
const WORKSPACES_RESOURCE_TEST_ID = "storage-resource-workspaces";
|
||||
const WORKSPACES_TRIGGER_TEST_ID = `${WORKSPACES_RESOURCE_TEST_ID}-trigger`;
|
||||
const WORKSPACES_BAR_TEST_ID = `${WORKSPACES_RESOURCE_TEST_ID}-bar`;
|
||||
const WORKSPACES_BAR_FILL_TEST_ID = `${WORKSPACES_RESOURCE_TEST_ID}-bar-fill`;
|
||||
const SYSTEM_TEMPORARY_BAR_FILL_TEST_ID = `${SYSTEM_TEMPORARY_RESOURCE_TEST_ID}-bar-fill`;
|
||||
const TEMPORARY_ARTIFACTS_BAR_TEST_ID = "storage-resource-temporary-artifacts-bar";
|
||||
const GO_CACHE_TRIGGER_TEST_ID = "storage-resource-go-cache-trigger";
|
||||
const GO_CACHE_CLEAN_TEST_ID = "storage-go-cache-clean";
|
||||
const EXPANDED_ATTRIBUTE = "aria-expanded";
|
||||
const EXPANDED_ATTRIBUTE_VALUE = "true";
|
||||
const ACCORDION_CONTENT_SELECTOR = '[data-slot="accordion-content"]';
|
||||
|
||||
function expectExpandedContent(resourceTestId: string) {
|
||||
const content = screen.getByTestId(resourceTestId).querySelector(ACCORDION_CONTENT_SELECTOR);
|
||||
expect(content).not.toBeNull();
|
||||
expect(content?.getAttribute("data-state")).toBe("open");
|
||||
expect(content?.hasAttribute("hidden")).toBe(false);
|
||||
}
|
||||
|
||||
const barsOverview = {
|
||||
settings: {
|
||||
enabled: false,
|
||||
check_interval_hours: 24,
|
||||
idle_for_minutes: 10,
|
||||
orphan_grace_hours: 168,
|
||||
quarantine_retention_hours: 168,
|
||||
workspaces: { enabled: true, dependency_cleanup_enabled: false },
|
||||
kandev_containers: { enabled: true },
|
||||
go_cache: { enabled: false, max_bytes: 16106127360, adopted_path: "" },
|
||||
docker: {
|
||||
dedicated_daemon_acknowledged: false,
|
||||
build_cache_enabled: false,
|
||||
build_cache_keep_bytes: 10737418240,
|
||||
build_cache_unused_hours: 168,
|
||||
unused_images_enabled: false,
|
||||
unused_images_hours: 168,
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
managed_go_cache_path: "/data/cache/go-build",
|
||||
go_cache_adoption_available: true,
|
||||
temporary_artifacts_available: false,
|
||||
docker_available: false,
|
||||
docker_host: "",
|
||||
host_global_docker_cleanup_allowed: false,
|
||||
},
|
||||
summary: {
|
||||
workspaces: { total_bytes: 8 * 1024 ** 3, active_bytes: 0, candidate_bytes: 0 },
|
||||
go_cache: { path: "/data/cache/go-build", size_bytes: 0, owned: true, enabled: false },
|
||||
quarantine: { available: true, count: 0, size_bytes: 2 * 1024 ** 3 },
|
||||
temporary_artifacts: { available: false, warning: "registry unavailable" },
|
||||
system_temporary: {
|
||||
status: "partial",
|
||||
size_bytes: 4 * 1024 ** 3,
|
||||
included_in_total: false,
|
||||
roots: [],
|
||||
},
|
||||
docker: {
|
||||
available: false,
|
||||
build_cache_bytes: 0,
|
||||
unused_image_bytes: 0,
|
||||
managed_container_count: 0,
|
||||
managed_container_bytes: 0,
|
||||
},
|
||||
},
|
||||
analysis: {
|
||||
generation: 1,
|
||||
state: "ready",
|
||||
started_at: "2026-07-23T11:59:00Z",
|
||||
completed_at: "2026-07-23T12:00:00Z",
|
||||
duration_ms: 100,
|
||||
cache_ttl_seconds: 900,
|
||||
refresh_due_at: "2099-07-23T12:15:00Z",
|
||||
stale: false,
|
||||
error: null,
|
||||
partial_summary: null,
|
||||
progress: { completed_sources: 8, total_sources: 8, sources: {} },
|
||||
},
|
||||
analyzed_at: null,
|
||||
last_run: null,
|
||||
} satisfies StorageOverviewResponse;
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- related bar and focus regressions share one fixture.
|
||||
describe("StorageOverviewCard relative bars", () => {
|
||||
it("renders decorative relative bars and visible partial status", () => {
|
||||
render(<StorageOverviewCard overview={barsOverview} onRunGoCache={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("storage-analysis-bars-description").textContent).toContain(
|
||||
"Bars compare category sizes",
|
||||
);
|
||||
expect(screen.getByTestId(WORKSPACES_BAR_TEST_ID).getAttribute("aria-hidden")).toBe("true");
|
||||
expect(screen.getByTestId(WORKSPACES_BAR_TEST_ID).getAttribute("role")).toBeNull();
|
||||
expect(screen.getByTestId(WORKSPACES_BAR_FILL_TEST_ID).getAttribute("style")).toBe(
|
||||
"width: 100%;",
|
||||
);
|
||||
const partialBadge = screen.getByTestId(`${SYSTEM_TEMPORARY_RESOURCE_TEST_ID}-partial`);
|
||||
const systemTemporaryTitle = screen.getByTestId(`${SYSTEM_TEMPORARY_RESOURCE_TEST_ID}-title`);
|
||||
const systemTemporaryValue = screen.getByTestId("storage-analysis-source-system_temporary");
|
||||
expect(systemTemporaryTitle.contains(partialBadge)).toBe(true);
|
||||
expect(systemTemporaryValue.contains(partialBadge)).toBe(false);
|
||||
expect(screen.getByTestId(SYSTEM_TEMPORARY_BAR_FILL_TEST_ID).getAttribute("style")).toBe(
|
||||
"width: 50%;",
|
||||
);
|
||||
expect(screen.queryByTestId(TEMPORARY_ARTIFACTS_BAR_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps expanded rows and focus attached to stable resource IDs after reordering", () => {
|
||||
const { rerender } = render(
|
||||
<StorageOverviewCard overview={barsOverview} onRunGoCache={vi.fn()} />,
|
||||
);
|
||||
const systemTemporaryTrigger = screen.getByTestId(SYSTEM_TEMPORARY_TRIGGER_TEST_ID);
|
||||
const workspacesTrigger = screen.getByTestId(WORKSPACES_TRIGGER_TEST_ID);
|
||||
fireEvent.click(systemTemporaryTrigger);
|
||||
fireEvent.click(workspacesTrigger);
|
||||
systemTemporaryTrigger.focus();
|
||||
expect(systemTemporaryTrigger.getAttribute(EXPANDED_ATTRIBUTE)).toBe(EXPANDED_ATTRIBUTE_VALUE);
|
||||
expect(workspacesTrigger.getAttribute(EXPANDED_ATTRIBUTE)).toBe(EXPANDED_ATTRIBUTE_VALUE);
|
||||
expectExpandedContent(SYSTEM_TEMPORARY_RESOURCE_TEST_ID);
|
||||
expectExpandedContent(WORKSPACES_RESOURCE_TEST_ID);
|
||||
|
||||
rerender(
|
||||
<StorageOverviewCard
|
||||
overview={{
|
||||
...barsOverview,
|
||||
summary: {
|
||||
...barsOverview.summary,
|
||||
workspaces: { total_bytes: 1 * 1024 ** 3, active_bytes: 0, candidate_bytes: 0 },
|
||||
system_temporary: {
|
||||
...barsOverview.summary.system_temporary,
|
||||
size_bytes: 16 * 1024 ** 3,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onRunGoCache={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId(SYSTEM_TEMPORARY_TRIGGER_TEST_ID).getAttribute(EXPANDED_ATTRIBUTE),
|
||||
).toBe(EXPANDED_ATTRIBUTE_VALUE);
|
||||
expect(screen.getByTestId(WORKSPACES_TRIGGER_TEST_ID).getAttribute(EXPANDED_ATTRIBUTE)).toBe(
|
||||
EXPANDED_ATTRIBUTE_VALUE,
|
||||
);
|
||||
expectExpandedContent(SYSTEM_TEMPORARY_RESOURCE_TEST_ID);
|
||||
expectExpandedContent(WORKSPACES_RESOURCE_TEST_ID);
|
||||
expect(document.activeElement).toBe(screen.getByTestId(SYSTEM_TEMPORARY_TRIGGER_TEST_ID));
|
||||
});
|
||||
|
||||
it("keeps a focused expanded-row action attached after its row moves", () => {
|
||||
const actionOverview = {
|
||||
...barsOverview,
|
||||
summary: {
|
||||
...barsOverview.summary,
|
||||
go_cache: {
|
||||
...barsOverview.summary.go_cache,
|
||||
size_bytes: 16 * 1024 ** 3,
|
||||
owned: true,
|
||||
},
|
||||
},
|
||||
} satisfies StorageOverviewResponse;
|
||||
const { rerender } = render(
|
||||
<StorageOverviewCard overview={actionOverview} onRunGoCache={vi.fn()} />,
|
||||
{ wrapper: TooltipProvider },
|
||||
);
|
||||
|
||||
const goCacheTrigger = screen.getByTestId(GO_CACHE_TRIGGER_TEST_ID);
|
||||
fireEvent.click(goCacheTrigger);
|
||||
const cleanButton = screen.getByTestId(GO_CACHE_CLEAN_TEST_ID);
|
||||
cleanButton.focus();
|
||||
expect(goCacheTrigger.getAttribute(EXPANDED_ATTRIBUTE)).toBe(EXPANDED_ATTRIBUTE_VALUE);
|
||||
expect(document.activeElement).toBe(cleanButton);
|
||||
|
||||
rerender(
|
||||
<StorageOverviewCard
|
||||
overview={{
|
||||
...actionOverview,
|
||||
summary: {
|
||||
...actionOverview.summary,
|
||||
go_cache: {
|
||||
...actionOverview.summary.go_cache,
|
||||
size_bytes: 1,
|
||||
warning: "cache measurement warning",
|
||||
},
|
||||
system_temporary: {
|
||||
...actionOverview.summary.system_temporary,
|
||||
size_bytes: 20 * 1024 ** 3,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onRunGoCache={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(GO_CACHE_TRIGGER_TEST_ID).getAttribute(EXPANDED_ATTRIBUTE)).toBe(
|
||||
EXPANDED_ATTRIBUTE_VALUE,
|
||||
);
|
||||
const disabledCleanButton = screen.getByTestId(GO_CACHE_CLEAN_TEST_ID);
|
||||
expect(disabledCleanButton.parentElement?.getAttribute("tabindex")).toBe("0");
|
||||
expect(document.activeElement).toBe(disabledCleanButton.parentElement);
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,9 @@ const DATABASE_BACKUP_PATH = "/data/backups";
|
||||
const DATABASE_TRIGGER_TEST_ID = "storage-resource-database-trigger";
|
||||
const DATABASE_BACKUPS_TRIGGER_TEST_ID = "storage-resource-database-backups-trigger";
|
||||
const STORAGE_ANALYSIS_TOTAL_TEST_ID = "storage-analysis-total";
|
||||
const SYSTEM_TEMPORARY_RESOURCE_TEST_ID = "storage-resource-system-temporary";
|
||||
const SYSTEM_TEMPORARY_TRIGGER_TEST_ID = "storage-resource-system-temporary-trigger";
|
||||
const PERMISSION_DENIED_WARNING = "permission denied";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -208,20 +211,63 @@ describe("StorageOverviewCard system temporary resources", () => {
|
||||
|
||||
render(<StorageOverviewCard overview={overview} onRunGoCache={vi.fn()} />);
|
||||
|
||||
const trigger = screen.getByTestId("storage-resource-system-temporary-trigger");
|
||||
const trigger = screen.getByTestId(SYSTEM_TEMPORARY_TRIGGER_TEST_ID);
|
||||
expect(trigger.textContent).toContain("100 GB");
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByTestId("storage-resource-system-temporary").textContent).toContain("/tmp");
|
||||
expect(screen.getByTestId("storage-resource-system-temporary").textContent).toContain(
|
||||
"Partial",
|
||||
);
|
||||
expect(screen.getByTestId("storage-resource-system-temporary").textContent).toContain(
|
||||
expect(screen.getByTestId(SYSTEM_TEMPORARY_RESOURCE_TEST_ID).textContent).toContain("/tmp");
|
||||
expect(screen.getByTestId(SYSTEM_TEMPORARY_RESOURCE_TEST_ID).textContent).toContain("Partial");
|
||||
expect(screen.getByTestId(SYSTEM_TEMPORARY_RESOURCE_TEST_ID).textContent).toContain(
|
||||
"2 entries skipped",
|
||||
);
|
||||
expect(screen.getByTestId(STORAGE_ANALYSIS_TOTAL_TEST_ID).textContent).toContain(
|
||||
"Total counted: 14 GB",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows one localized timeout and preserves unrelated older diagnostics", () => {
|
||||
const overview = {
|
||||
...degradedOverview,
|
||||
summary: {
|
||||
...degradedOverview.summary,
|
||||
system_temporary: {
|
||||
status: "partial",
|
||||
size_bytes: 24,
|
||||
included_in_total: false,
|
||||
reason: "deadline",
|
||||
roots: [
|
||||
{
|
||||
requested_path: "/tmp",
|
||||
path: "/tmp",
|
||||
status: "partial",
|
||||
size_bytes: 24,
|
||||
skipped_count: 1,
|
||||
warnings: [
|
||||
"context deadline exceeded",
|
||||
"context deadline exceeded\ncontext deadline exceeded",
|
||||
PERMISSION_DENIED_WARNING,
|
||||
],
|
||||
},
|
||||
],
|
||||
warnings: [
|
||||
"context deadline exceeded",
|
||||
PERMISSION_DENIED_WARNING,
|
||||
PERMISSION_DENIED_WARNING,
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StorageOverviewResponse;
|
||||
|
||||
render(<StorageOverviewCard overview={overview} onRunGoCache={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId(SYSTEM_TEMPORARY_TRIGGER_TEST_ID));
|
||||
const resource = screen.getByTestId(SYSTEM_TEMPORARY_RESOURCE_TEST_ID);
|
||||
expect(resource.textContent).toContain("Scan timed out. Showing partial usage.");
|
||||
expect(resource.textContent).toContain(PERMISSION_DENIED_WARNING);
|
||||
expect(resource.textContent).not.toContain("context deadline exceeded");
|
||||
expect(resource.textContent?.match(/Scan timed out\. Showing partial usage\./g)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageOverviewCard database footprint", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kand
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { IconChartPie, IconTrash } from "@tabler/icons-react";
|
||||
import { useLayoutEffect, useRef, type FocusEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatDateTime,
|
||||
@@ -82,6 +83,44 @@ interface ResourceRowProps {
|
||||
onRunTemporaryArtifacts: () => void;
|
||||
}
|
||||
|
||||
function ResourceBar({ resource }: { resource: StorageResource }) {
|
||||
if (resource.sizeBytes === undefined) return null;
|
||||
return (
|
||||
<span
|
||||
className="order-3 block h-2 w-full min-w-0 basis-full overflow-hidden rounded-full bg-muted md:order-none md:col-start-2 md:w-auto md:basis-auto"
|
||||
data-testid={`storage-resource-${resource.id}-bar`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className="block h-full rounded-full bg-primary"
|
||||
data-testid={`storage-resource-${resource.id}-bar-fill`}
|
||||
style={{ width: `${resource.barPercent ?? 0}%` }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface FocusedStorageTarget {
|
||||
resourceId: string;
|
||||
element: HTMLElement;
|
||||
focusId: string;
|
||||
}
|
||||
|
||||
function focusedElementAfterReorder(
|
||||
container: HTMLElement,
|
||||
target: FocusedStorageTarget,
|
||||
): HTMLElement | null {
|
||||
const resource = Array.from(
|
||||
container.querySelectorAll<HTMLElement>("[data-storage-resource-id]"),
|
||||
).find((element) => element.dataset.storageResourceId === target.resourceId);
|
||||
if (!resource) return null;
|
||||
return (
|
||||
Array.from(resource.querySelectorAll<HTMLElement>("[data-storage-focus-id]")).find(
|
||||
(element) => element.dataset.storageFocusId === target.focusId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceRow({
|
||||
resource,
|
||||
goCacheCleanupDisabledReason,
|
||||
@@ -91,15 +130,35 @@ function ResourceRow({
|
||||
}: ResourceRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AccordionItem value={resource.id} data-testid={`storage-resource-${resource.id}`}>
|
||||
<AccordionItem
|
||||
value={resource.id}
|
||||
data-testid={`storage-resource-${resource.id}`}
|
||||
data-storage-resource-id={resource.id}
|
||||
>
|
||||
<AccordionTrigger
|
||||
className="min-h-11 items-center px-3 no-underline"
|
||||
className="min-h-7 cursor-pointer items-center px-3 no-underline max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
|
||||
data-testid={`storage-resource-${resource.id}-trigger`}
|
||||
data-storage-focus-id="trigger"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm">{resource.label}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 md:grid md:grid-cols-[minmax(0,1fr)_minmax(8rem,16rem)_7rem] md:items-center md:gap-3">
|
||||
<span
|
||||
className="block text-xs font-normal text-muted-foreground"
|
||||
className="flex min-w-0 flex-1 flex-wrap items-center gap-2 break-words text-sm"
|
||||
data-testid={`storage-resource-${resource.id}-title`}
|
||||
>
|
||||
<span className="min-w-0 break-words">{resource.label}</span>
|
||||
{resource.partial && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-normal"
|
||||
data-testid={`storage-resource-${resource.id}-partial`}
|
||||
>
|
||||
{t("system:storageSystemTemporaryPartial")}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<ResourceBar resource={resource} />
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-2 text-xs font-normal text-muted-foreground md:col-start-3 md:block md:min-w-0 md:w-full md:break-words md:text-right"
|
||||
data-testid={resource.source ? `storage-analysis-source-${resource.source}` : undefined}
|
||||
>
|
||||
{resource.value}
|
||||
@@ -125,6 +184,7 @@ function ResourceRow({
|
||||
disabledReason={goCacheCleanupDisabledReason}
|
||||
onClick={onRunGoCache}
|
||||
data-testid="storage-go-cache-clean"
|
||||
focusId="go-cache-clean"
|
||||
>
|
||||
<IconTrash className="size-4" /> {t("system:storageCleanGoCache")}
|
||||
</StorageActionButton>
|
||||
@@ -136,6 +196,7 @@ function ResourceRow({
|
||||
disabledReason={temporaryArtifactsCleanupDisabledReason}
|
||||
onClick={onRunTemporaryArtifacts}
|
||||
data-testid="storage-temporary-artifacts-clean"
|
||||
focusId="temporary-artifacts-clean"
|
||||
>
|
||||
<IconTrash className="size-4" /> {t("system:storageCleanTemporaryArtifacts")}
|
||||
</StorageActionButton>
|
||||
@@ -304,6 +365,9 @@ function StorageOverviewHeader({
|
||||
<p className="text-xs text-muted-foreground" data-testid="storage-analysis-scope">
|
||||
{t("system:storageAnalysisScope")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground" data-testid="storage-analysis-bars-description">
|
||||
{t("system:storageAnalysisBarsDescription")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<AnalysisStatusTime overview={overview} analyzedAt={analyzedAt} />
|
||||
<AnalysisTimingDisclosure overview={overview} />
|
||||
@@ -331,8 +395,63 @@ function StorageOverviewResources({
|
||||
onRunGoCache: () => void;
|
||||
onRunTemporaryArtifacts: () => void;
|
||||
}) {
|
||||
const resourcesRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusedTargetRef = useRef<FocusedStorageTarget | null>(null);
|
||||
const resourceOrderKey = resources.map((resource) => resource.id).join("\u0000");
|
||||
|
||||
const rememberFocusedTarget = (event: FocusEvent<HTMLDivElement>) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const container = resourcesRef.current;
|
||||
const resource = target.closest<HTMLElement>("[data-storage-resource-id]");
|
||||
if (!container || !resource || !container.contains(resource)) {
|
||||
focusedTargetRef.current = null;
|
||||
return;
|
||||
}
|
||||
const focusId = target.dataset.storageFocusId;
|
||||
if (!focusId) {
|
||||
focusedTargetRef.current = null;
|
||||
return;
|
||||
}
|
||||
focusedTargetRef.current = {
|
||||
resourceId: resource.dataset.storageResourceId ?? "",
|
||||
element: target,
|
||||
focusId,
|
||||
};
|
||||
};
|
||||
|
||||
const forgetFocusedTargetOutsideResources = (event: FocusEvent<HTMLDivElement>) => {
|
||||
const relatedTarget = event.relatedTarget;
|
||||
if (!(relatedTarget instanceof Node) || !resourcesRef.current?.contains(relatedTarget)) {
|
||||
focusedTargetRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = resourcesRef.current;
|
||||
const target = focusedTargetRef.current;
|
||||
if (!container || !target) return;
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement &&
|
||||
activeElement !== document.body &&
|
||||
(!container.contains(activeElement) || activeElement !== target.element)
|
||||
) {
|
||||
focusedTargetRef.current = null;
|
||||
return;
|
||||
}
|
||||
const element = focusedElementAfterReorder(container, target);
|
||||
if (element && document.activeElement !== element) element.focus();
|
||||
focusedTargetRef.current = null;
|
||||
}, [resourceOrderKey]);
|
||||
|
||||
return (
|
||||
<CardContent className="min-w-0">
|
||||
<CardContent
|
||||
ref={resourcesRef}
|
||||
className="min-w-0"
|
||||
onFocusCapture={rememberFocusedTarget}
|
||||
onBlurCapture={forgetFocusedTargetOutsideResources}
|
||||
>
|
||||
<Accordion type="multiple" className="min-w-0">
|
||||
{resources.map((resource) => (
|
||||
<ResourceRow
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StorageOverviewResponse, StorageSummary } from "@/lib/types/system";
|
||||
import {
|
||||
storageResources,
|
||||
type StorageResource,
|
||||
type Translate,
|
||||
} from "./storage-overview-resources";
|
||||
|
||||
const translate: Translate = (key) => key;
|
||||
const SYSTEM_TEMPORARY_ID = "system-temporary";
|
||||
const GO_CACHE_ID = "go-cache";
|
||||
const TEMPORARY_ARTIFACTS_ID = "temporary-artifacts";
|
||||
|
||||
function overview(summary: StorageSummary): StorageOverviewResponse {
|
||||
return {
|
||||
settings: {} as StorageOverviewResponse["settings"],
|
||||
capabilities: {
|
||||
managed_go_cache_path: "/cache/go-build",
|
||||
docker_host: "",
|
||||
} as StorageOverviewResponse["capabilities"],
|
||||
summary,
|
||||
analyzed_at: null,
|
||||
analysis: {
|
||||
state: "ready",
|
||||
partial_summary: null,
|
||||
progress: { sources: {} },
|
||||
} as StorageOverviewResponse["analysis"],
|
||||
last_run: null,
|
||||
};
|
||||
}
|
||||
|
||||
function resourceField(resource: StorageResource, field: string): unknown {
|
||||
return (resource as unknown as Record<string, unknown>)[field];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- the fixture covers the complete sorted resource set.
|
||||
describe("storageResources relative measurements", () => {
|
||||
it("sorts measured rows by bytes and scales them against the largest row", () => {
|
||||
const resources = storageResources(
|
||||
translate,
|
||||
overview({
|
||||
workspaces: { total_bytes: 80 },
|
||||
database: { status: "measured", size_bytes: 10, included_in_total: true },
|
||||
database_backups: { status: "measured", size_bytes: 10, included_in_total: true },
|
||||
quarantine: { available: true, count: 0, size_bytes: 0 },
|
||||
system_temporary: {
|
||||
status: "partial",
|
||||
size_bytes: 100,
|
||||
included_in_total: false,
|
||||
roots: [],
|
||||
},
|
||||
go_cache: { size_bytes: 0 },
|
||||
temporary_artifacts: { available: true },
|
||||
docker: {
|
||||
available: false,
|
||||
build_cache_bytes: 0,
|
||||
unused_image_bytes: 0,
|
||||
managed_container_count: 0,
|
||||
managed_container_bytes: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resources.map((resource) => resource.id)).toEqual([
|
||||
SYSTEM_TEMPORARY_ID,
|
||||
"workspaces",
|
||||
"database",
|
||||
"database-backups",
|
||||
"quarantine",
|
||||
GO_CACHE_ID,
|
||||
TEMPORARY_ARTIFACTS_ID,
|
||||
"managed-containers",
|
||||
"docker-image-layers",
|
||||
"docker-build-cache",
|
||||
"docker-unused-images",
|
||||
]);
|
||||
|
||||
const byId = new Map(resources.map((resource) => [resource.id, resource]));
|
||||
expect(resourceField(byId.get(SYSTEM_TEMPORARY_ID)!, "sizeBytes")).toBe(100);
|
||||
expect(resourceField(byId.get(SYSTEM_TEMPORARY_ID)!, "partial")).toBe(true);
|
||||
expect(resourceField(byId.get(SYSTEM_TEMPORARY_ID)!, "barPercent")).toBe(100);
|
||||
expect(resourceField(byId.get("workspaces")!, "barPercent")).toBe(80);
|
||||
expect(resourceField(byId.get("database")!, "barPercent")).toBe(10);
|
||||
expect(resourceField(byId.get("quarantine")!, "barPercent")).toBe(0);
|
||||
expect(resourceField(byId.get(TEMPORARY_ARTIFACTS_ID)!, "sizeBytes")).toBeUndefined();
|
||||
expect(resourceField(byId.get(TEMPORARY_ARTIFACTS_ID)!, "barPercent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps missing and invalid measurements distinct from measured zero", () => {
|
||||
const resources = storageResources(
|
||||
translate,
|
||||
overview({
|
||||
workspaces: {},
|
||||
database: { status: "measured", size_bytes: Number.NaN, included_in_total: true },
|
||||
database_backups: { status: "unavailable", included_in_total: false },
|
||||
quarantine: { available: true, count: 0, size_bytes: 0 },
|
||||
go_cache: { size_bytes: -1 },
|
||||
temporary_artifacts: { available: true, total_bytes: undefined },
|
||||
docker: {
|
||||
available: false,
|
||||
build_cache_bytes: 0,
|
||||
unused_image_bytes: 0,
|
||||
managed_container_count: 0,
|
||||
managed_container_bytes: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(resources.map((resource) => [resource.id, resource]));
|
||||
expect(resourceField(byId.get("quarantine")!, "sizeBytes")).toBe(0);
|
||||
expect(resourceField(byId.get("quarantine")!, "barPercent")).toBe(0);
|
||||
expect(resourceField(byId.get("workspaces")!, "sizeBytes")).toBeUndefined();
|
||||
expect(resourceField(byId.get("database")!, "sizeBytes")).toBeUndefined();
|
||||
expect(resourceField(byId.get(GO_CACHE_ID)!, "sizeBytes")).toBeUndefined();
|
||||
expect(resourceField(byId.get(TEMPORARY_ARTIFACTS_ID)!, "sizeBytes")).toBeUndefined();
|
||||
expect(byId.get("workspaces")?.value).toBe("system:storageUnavailableValue");
|
||||
});
|
||||
|
||||
it("maps every optional and subset measurement from numeric fields", () => {
|
||||
const resources = storageResources(
|
||||
translate,
|
||||
overview({
|
||||
workspaces: { total_bytes: 11 },
|
||||
database: { status: "measured", size_bytes: 10, included_in_total: true },
|
||||
database_backups: { status: "measured", size_bytes: 9, included_in_total: true },
|
||||
quarantine: { available: true, count: 1, size_bytes: 8 },
|
||||
system_temporary: {
|
||||
status: "measured",
|
||||
size_bytes: 7,
|
||||
included_in_total: false,
|
||||
roots: [],
|
||||
},
|
||||
go_cache: {
|
||||
size_bytes: 6,
|
||||
unmanaged_path: "/home/user/.cache/go-build",
|
||||
unmanaged_size_bytes: 5,
|
||||
},
|
||||
temporary_artifacts: { available: true, total_bytes: 4 },
|
||||
docker: {
|
||||
available: true,
|
||||
managed_container_count: 1,
|
||||
managed_container_bytes: 3,
|
||||
image_layer_bytes: 2,
|
||||
build_cache_bytes: 1,
|
||||
unused_image_bytes: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const sizes = new Map(
|
||||
resources.map((resource) => [resource.id, resourceField(resource, "sizeBytes")]),
|
||||
);
|
||||
expect(sizes).toEqual(
|
||||
new Map([
|
||||
["workspaces", 11],
|
||||
["database", 10],
|
||||
["database-backups", 9],
|
||||
["quarantine", 8],
|
||||
[SYSTEM_TEMPORARY_ID, 7],
|
||||
[GO_CACHE_ID, 6],
|
||||
["unmanaged-go-cache", 5],
|
||||
["temporary-artifacts", 4],
|
||||
["managed-containers", 3],
|
||||
["docker-image-layers", 2],
|
||||
["docker-build-cache", 1],
|
||||
["docker-unused-images", 0],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -27,9 +27,20 @@ export interface StorageResource {
|
||||
detailLines?: string[];
|
||||
warning?: string;
|
||||
source?: string;
|
||||
sizeBytes?: number;
|
||||
barPercent?: number;
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_UNAVAILABLE_VALUE_KEY = "system:storageUnavailableValue";
|
||||
// i18n-exempt: stable backend error marker from older storage snapshots.
|
||||
const LEGACY_DEADLINE_WARNING = "context deadline exceeded";
|
||||
const TEMPORARY_WARNING_LIMIT = 10;
|
||||
|
||||
function measuredBytes(value: number | undefined): number | undefined {
|
||||
if (value === undefined || !Number.isFinite(value) || value < 0) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
function analysisSourceText(t: Translate, progress?: StorageSourceProgress): string {
|
||||
if (!progress || progress.state === "pending") {
|
||||
@@ -73,27 +84,34 @@ function quarantineResource(t: Translate, summary: StorageQuarantineSummary): St
|
||||
warning: summary.warning,
|
||||
};
|
||||
}
|
||||
const sizeBytes = measuredBytes(summary.size_bytes);
|
||||
return {
|
||||
id: "quarantine",
|
||||
label: t("system:storageQuarantinedResources"),
|
||||
value: formatGigabytes(summary.size_bytes),
|
||||
value: sizeBytes === undefined ? t(STORAGE_UNAVAILABLE_VALUE_KEY) : formatGigabytes(sizeBytes),
|
||||
detail: t("system:storageQuarantineMovedAside", { count: summary.count }),
|
||||
sizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function dockerMeasurement(
|
||||
t: Translate,
|
||||
available: boolean,
|
||||
value: string,
|
||||
value: number | undefined,
|
||||
detail: string,
|
||||
): Pick<StorageResource, "value" | "detail"> {
|
||||
): Pick<StorageResource, "value" | "detail" | "sizeBytes"> {
|
||||
if (!available) {
|
||||
return {
|
||||
value: t(STORAGE_UNAVAILABLE_VALUE_KEY),
|
||||
detail: t("system:storageDockerUnmeasured"),
|
||||
};
|
||||
}
|
||||
return { value, detail };
|
||||
const sizeBytes = measuredBytes(value);
|
||||
return {
|
||||
value: sizeBytes === undefined ? t(STORAGE_UNAVAILABLE_VALUE_KEY) : formatGigabytes(sizeBytes),
|
||||
detail,
|
||||
sizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function temporaryArtifactsResource(
|
||||
@@ -110,6 +128,7 @@ function temporaryArtifactsResource(
|
||||
warning: warnings || undefined,
|
||||
};
|
||||
}
|
||||
const sizeBytes = measuredBytes(summary.total_bytes);
|
||||
const stale = summary.stale_count ?? 0;
|
||||
const active = summary.active_count ?? 0;
|
||||
const protectedCount = summary.protected_count ?? 0;
|
||||
@@ -122,10 +141,7 @@ function temporaryArtifactsResource(
|
||||
return {
|
||||
id: TEMPORARY_ARTIFACTS_RESOURCE_ID,
|
||||
label: t("system:storageTemporaryArtifacts"),
|
||||
value:
|
||||
summary.total_bytes === undefined
|
||||
? t(STORAGE_UNAVAILABLE_VALUE_KEY)
|
||||
: formatGigabytes(summary.total_bytes),
|
||||
value: sizeBytes === undefined ? t(STORAGE_UNAVAILABLE_VALUE_KEY) : formatGigabytes(sizeBytes),
|
||||
detail: [
|
||||
t("system:storageTemporaryArtifactsStaleCount", { count: stale }),
|
||||
staleBytes,
|
||||
@@ -135,6 +151,7 @@ function temporaryArtifactsResource(
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
warning: warnings || undefined,
|
||||
sizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,16 +163,19 @@ function workspaceResource(
|
||||
if (!workspace) {
|
||||
return pendingStorageResource(t, "workspaces", t("system:storageTaskWorkspaces"), progress);
|
||||
}
|
||||
const sizeBytes =
|
||||
workspace.available === false ? undefined : measuredBytes(workspace.total_bytes);
|
||||
return {
|
||||
id: "workspaces",
|
||||
label: t("system:storageTaskWorkspaces"),
|
||||
value: formatGigabytes(workspace.total_bytes ?? 0),
|
||||
value: sizeBytes === undefined ? t(STORAGE_UNAVAILABLE_VALUE_KEY) : formatGigabytes(sizeBytes),
|
||||
detail: t("system:storageWorkspacesDetail", {
|
||||
reclaimable: formatGigabytes(workspace.candidate_bytes ?? 0),
|
||||
active: formatGigabytes(workspace.active_bytes ?? 0),
|
||||
}),
|
||||
warning: workspace.warning,
|
||||
source: "workspaces",
|
||||
sizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,6 +241,7 @@ function databaseResource(
|
||||
source: options.source,
|
||||
};
|
||||
case "measured": {
|
||||
const sizeBytes = measuredBytes(measurement.size_bytes);
|
||||
const detail = [
|
||||
t(options.detailKey),
|
||||
measurement.included_in_total === false
|
||||
@@ -237,12 +258,11 @@ function databaseResource(
|
||||
id: options.id,
|
||||
label: options.label,
|
||||
value:
|
||||
measurement.size_bytes === undefined
|
||||
? t(STORAGE_UNAVAILABLE_VALUE_KEY)
|
||||
: formatGigabytes(measurement.size_bytes),
|
||||
sizeBytes === undefined ? t(STORAGE_UNAVAILABLE_VALUE_KEY) : formatGigabytes(sizeBytes),
|
||||
detail,
|
||||
warning: measurement.warning,
|
||||
source: options.source,
|
||||
sizeBytes,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -254,7 +274,7 @@ interface DockerResourceOptions {
|
||||
progress?: StorageSourceProgress;
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
value: number | undefined;
|
||||
detail: string;
|
||||
warning?: string;
|
||||
}
|
||||
@@ -282,32 +302,36 @@ function goCacheResources(
|
||||
progress: StorageSourceProgress | undefined,
|
||||
managedPath: string,
|
||||
): StorageResource[] {
|
||||
const resources: StorageResource[] = [
|
||||
goCache
|
||||
? {
|
||||
id: "go-cache",
|
||||
label: t("system:storageGoBuildCache"),
|
||||
value: formatGigabytes(goCache.size_bytes ?? 0),
|
||||
// A filesystem path from the API is never routed through the catalog.
|
||||
detail: goCache.path ?? managedPath,
|
||||
warning: goCache.warning,
|
||||
source: "go_cache",
|
||||
}
|
||||
: pendingStorageResource(
|
||||
t,
|
||||
"go-cache",
|
||||
t("system:storageGoBuildCache"),
|
||||
progress,
|
||||
"go_cache",
|
||||
),
|
||||
];
|
||||
const resources: StorageResource[] = [];
|
||||
if (goCache) {
|
||||
const sizeBytes = goCache.available === false ? undefined : measuredBytes(goCache.size_bytes);
|
||||
resources.push({
|
||||
id: "go-cache",
|
||||
label: t("system:storageGoBuildCache"),
|
||||
value:
|
||||
sizeBytes === undefined ? t(STORAGE_UNAVAILABLE_VALUE_KEY) : formatGigabytes(sizeBytes),
|
||||
// A filesystem path from the API is never routed through the catalog.
|
||||
detail: goCache.path ?? managedPath,
|
||||
warning: goCache.warning,
|
||||
source: "go_cache",
|
||||
sizeBytes,
|
||||
});
|
||||
} else {
|
||||
resources.push(
|
||||
pendingStorageResource(t, "go-cache", t("system:storageGoBuildCache"), progress, "go_cache"),
|
||||
);
|
||||
}
|
||||
if (goCache?.unmanaged_path) {
|
||||
const sizeBytes =
|
||||
goCache.available === false ? undefined : measuredBytes(goCache.unmanaged_size_bytes);
|
||||
resources.push({
|
||||
id: "unmanaged-go-cache",
|
||||
label: t("system:storageUserGoBuildCache"),
|
||||
value: formatGigabytes(goCache.unmanaged_size_bytes ?? 0),
|
||||
value:
|
||||
sizeBytes === undefined ? t(STORAGE_UNAVAILABLE_VALUE_KEY) : formatGigabytes(sizeBytes),
|
||||
detail: goCache.unmanaged_path,
|
||||
source: "go_cache",
|
||||
sizeBytes,
|
||||
});
|
||||
}
|
||||
return resources;
|
||||
@@ -376,6 +400,13 @@ function temporaryRootLine(
|
||||
);
|
||||
}
|
||||
|
||||
function temporarySummaryBytes(summary: StorageSystemTemporarySummary): number | undefined {
|
||||
if (summary.status === "unavailable" || summary.status === "not_applicable") {
|
||||
return undefined;
|
||||
}
|
||||
return measuredBytes(summary.size_bytes);
|
||||
}
|
||||
|
||||
function temporarySummaryValue(t: Translate, summary: StorageSystemTemporarySummary): string {
|
||||
if (summary.status === "unavailable") {
|
||||
return t(STORAGE_UNAVAILABLE_VALUE_KEY);
|
||||
@@ -383,10 +414,11 @@ function temporarySummaryValue(t: Translate, summary: StorageSystemTemporarySumm
|
||||
if (summary.status === "not_applicable") {
|
||||
return t("system:storageNotApplicableValue");
|
||||
}
|
||||
if (summary.size_bytes === undefined) {
|
||||
const sizeBytes = temporarySummaryBytes(summary);
|
||||
if (sizeBytes === undefined) {
|
||||
return t(STORAGE_UNAVAILABLE_VALUE_KEY);
|
||||
}
|
||||
return formatGigabytes(summary.size_bytes);
|
||||
return formatGigabytes(sizeBytes);
|
||||
}
|
||||
|
||||
function temporarySummaryDetail(
|
||||
@@ -405,21 +437,55 @@ function temporarySummaryDetail(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTemporaryWarnings(warnings: string[]): {
|
||||
values: string[];
|
||||
includesDeadline: boolean;
|
||||
} {
|
||||
const values: string[] = [];
|
||||
let includesDeadline = false;
|
||||
for (const warning of warnings) {
|
||||
for (const line of warning.split(/\r?\n/)) {
|
||||
const value = line.trim();
|
||||
if (!value) continue;
|
||||
if (value === LEGACY_DEADLINE_WARNING) {
|
||||
includesDeadline = true;
|
||||
continue;
|
||||
}
|
||||
if (values.includes(value) || values.length >= TEMPORARY_WARNING_LIMIT) continue;
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
return { values, includesDeadline };
|
||||
}
|
||||
|
||||
function systemTemporaryResource(
|
||||
t: Translate,
|
||||
summary: StorageSystemTemporarySummary,
|
||||
): StorageResource {
|
||||
const warnings = (summary.warnings ?? []).filter(Boolean).join(" · ");
|
||||
const normalizedWarnings = normalizeTemporaryWarnings([
|
||||
...(summary.warnings ?? []),
|
||||
...summary.roots.flatMap((root) => root.warnings ?? []),
|
||||
]);
|
||||
const includesDeadline =
|
||||
summary.reason === "deadline" ||
|
||||
summary.roots.some((root) => root.reason === "deadline") ||
|
||||
normalizedWarnings.includesDeadline;
|
||||
const value = temporarySummaryValue(t, summary);
|
||||
const statusDetail = temporarySummaryDetail(t, summary.status);
|
||||
const detailLines = summary.roots.map((root) => temporaryRootLine(t, root));
|
||||
if (includesDeadline) {
|
||||
detailLines.push(t("system:storageSystemTemporaryDeadlineDetail"));
|
||||
}
|
||||
return {
|
||||
id: SYSTEM_TEMPORARY_RESOURCE_ID,
|
||||
label: t("system:storageSystemTemporaryFolders"),
|
||||
value,
|
||||
detail: [t("system:storageSystemTemporaryInformational"), statusDetail].join(" "),
|
||||
detailLines: summary.roots.map((root) => temporaryRootLine(t, root)),
|
||||
warning: warnings || undefined,
|
||||
detailLines,
|
||||
warning: normalizedWarnings.values.join(" · ") || undefined,
|
||||
source: "system_temporary",
|
||||
sizeBytes: temporarySummaryBytes(summary),
|
||||
partial: summary.status === "partial",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,7 +519,7 @@ function dockerResources(
|
||||
progress,
|
||||
id: "managed-containers",
|
||||
label: t("system:storageKandevContainers"),
|
||||
value: formatGigabytes(docker?.managed_container_bytes ?? 0),
|
||||
value: docker?.managed_container_bytes,
|
||||
detail: t("system:storageManagedContainerCount", {
|
||||
count: docker?.managed_container_count ?? 0,
|
||||
}),
|
||||
@@ -463,7 +529,7 @@ function dockerResources(
|
||||
progress,
|
||||
id: "docker-image-layers",
|
||||
label: t("system:storageDockerImageLayers"),
|
||||
value: formatGigabytes(docker?.image_layer_bytes ?? 0),
|
||||
value: docker?.image_layer_bytes,
|
||||
detail: dockerHost,
|
||||
warning: dockerWarning,
|
||||
}),
|
||||
@@ -471,7 +537,7 @@ function dockerResources(
|
||||
progress,
|
||||
id: "docker-build-cache",
|
||||
label: t("system:storageDockerBuildCache"),
|
||||
value: formatGigabytes(docker?.build_cache_bytes ?? 0),
|
||||
value: docker?.build_cache_bytes,
|
||||
detail: dockerHost,
|
||||
warning: dockerWarning,
|
||||
}),
|
||||
@@ -479,20 +545,47 @@ function dockerResources(
|
||||
progress,
|
||||
id: "docker-unused-images",
|
||||
label: t("system:storageUnusedDockerImages"),
|
||||
value: formatGigabytes(docker?.unused_image_bytes ?? 0),
|
||||
value: docker?.unused_image_bytes,
|
||||
detail: t("system:storageUnusedImagesDetail"),
|
||||
warning: dockerWarning,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function sortAndScaleResources(resources: StorageResource[]): StorageResource[] {
|
||||
const ordered = resources
|
||||
.map((resource, index) => ({ resource, index }))
|
||||
.sort((left, right) => {
|
||||
if (left.resource.sizeBytes === undefined && right.resource.sizeBytes === undefined) {
|
||||
return left.index - right.index;
|
||||
}
|
||||
if (left.resource.sizeBytes === undefined) return 1;
|
||||
if (right.resource.sizeBytes === undefined) return -1;
|
||||
if (left.resource.sizeBytes !== right.resource.sizeBytes) {
|
||||
return right.resource.sizeBytes - left.resource.sizeBytes;
|
||||
}
|
||||
return left.index - right.index;
|
||||
});
|
||||
const maximum = ordered.reduce<number | undefined>((largest, entry) => {
|
||||
if (entry.resource.sizeBytes === undefined) return largest;
|
||||
return largest === undefined
|
||||
? entry.resource.sizeBytes
|
||||
: Math.max(largest, entry.resource.sizeBytes);
|
||||
}, undefined);
|
||||
return ordered.map(({ resource }) => {
|
||||
if (resource.sizeBytes === undefined || maximum === undefined) return resource;
|
||||
const barPercent = maximum === 0 ? 0 : Math.min(100, (resource.sizeBytes / maximum) * 100);
|
||||
return { ...resource, barPercent };
|
||||
});
|
||||
}
|
||||
|
||||
export function storageResources(
|
||||
t: Translate,
|
||||
overview: StorageOverviewResponse,
|
||||
): StorageResource[] {
|
||||
const summary = overview.summary ?? overview.analysis.partial_summary ?? {};
|
||||
const progress = overview.analysis.progress.sources;
|
||||
return [
|
||||
return sortAndScaleResources([
|
||||
workspaceResource(t, summary.workspaces, progress.workspaces),
|
||||
databaseResource(t, summary.database, progress.database, {
|
||||
id: DATABASE_RESOURCE_ID,
|
||||
@@ -526,5 +619,5 @@ export function storageResources(
|
||||
progress.temporary_artifacts,
|
||||
),
|
||||
...dockerResources(t, summary.docker, progress.docker, overview.capabilities.docker_host),
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function mockPartialSystemTemporaryOverview(page: Page, root: strin
|
||||
status: "partial",
|
||||
size_bytes: 24,
|
||||
included_in_total: false,
|
||||
reason: "informational_overlap",
|
||||
reason: "deadline",
|
||||
roots: [
|
||||
{
|
||||
requested_path: root,
|
||||
@@ -58,10 +58,18 @@ export async function mockPartialSystemTemporaryOverview(page: Page, root: strin
|
||||
status: "partial",
|
||||
size_bytes: 24,
|
||||
skipped_count: 1,
|
||||
warnings: ["fixture entry could not be measured"],
|
||||
warnings: [
|
||||
"context deadline exceeded",
|
||||
"context deadline exceeded\ncontext deadline exceeded",
|
||||
"fixture entry could not be measured",
|
||||
],
|
||||
},
|
||||
],
|
||||
warnings: ["One fixture entry was skipped."],
|
||||
warnings: [
|
||||
"context deadline exceeded",
|
||||
"One fixture entry was skipped.",
|
||||
"One fixture entry was skipped.",
|
||||
],
|
||||
};
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
@@ -71,6 +79,169 @@ export async function mockPartialSystemTemporaryOverview(page: Page, root: strin
|
||||
});
|
||||
}
|
||||
|
||||
export function storageBarsSnapshot(
|
||||
options: {
|
||||
workspaces?: number;
|
||||
database?: number;
|
||||
databaseBackups?: number;
|
||||
quarantine?: number;
|
||||
systemTemporary?: number;
|
||||
goCache?: number;
|
||||
goCacheWarning?: string;
|
||||
unmanagedGoCache?: number;
|
||||
temporaryArtifacts?: number;
|
||||
managedContainers?: number;
|
||||
imageLayers?: number;
|
||||
buildCache?: number;
|
||||
unusedImages?: number;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
workspaces: {
|
||||
total_bytes: options.workspaces ?? 8 * 1024 ** 3,
|
||||
active_bytes: 0,
|
||||
candidate_bytes: 0,
|
||||
},
|
||||
go_cache: {
|
||||
path: "/data/cache/go-build",
|
||||
size_bytes: options.goCache ?? 7 * 1024 ** 3,
|
||||
owned: true,
|
||||
unmanaged_path: "/data/home/.cache/go-build",
|
||||
unmanaged_size_bytes: options.unmanagedGoCache ?? 6 * 1024 ** 3,
|
||||
warning: options.goCacheWarning,
|
||||
},
|
||||
quarantine: { available: true, count: 1, size_bytes: options.quarantine ?? 5 * 1024 ** 3 },
|
||||
temporary_artifacts: {
|
||||
available: true,
|
||||
total_count: 1,
|
||||
total_bytes: options.temporaryArtifacts ?? 4 * 1024 ** 3,
|
||||
active_count: 0,
|
||||
active_bytes: 0,
|
||||
protected_count: 0,
|
||||
protected_bytes: 0,
|
||||
stale_count: 1,
|
||||
stale_bytes: options.temporaryArtifacts ?? 4 * 1024 ** 3,
|
||||
skipped_count: 0,
|
||||
},
|
||||
system_temporary: {
|
||||
status: "partial",
|
||||
size_bytes: options.systemTemporary ?? 3 * 1024 ** 3,
|
||||
included_in_total: false,
|
||||
reason: "deadline",
|
||||
roots: [],
|
||||
},
|
||||
docker: {
|
||||
available: true,
|
||||
managed_container_count: 1,
|
||||
managed_container_bytes: options.managedContainers ?? 2 * 1024 ** 3,
|
||||
image_layer_bytes: options.imageLayers ?? 1 * 1024 ** 3,
|
||||
build_cache_bytes: options.buildCache ?? 512 * 1024 ** 2,
|
||||
unused_image_bytes: options.unusedImages ?? 256 * 1024 ** 2,
|
||||
},
|
||||
database: {
|
||||
status: "measured",
|
||||
size_bytes: options.database ?? 10 * 1024 ** 3,
|
||||
path: "/data/kandev.db",
|
||||
included_in_total: true,
|
||||
},
|
||||
database_backups: {
|
||||
status: "measured",
|
||||
size_bytes: options.databaseBackups ?? 9 * 1024 ** 3,
|
||||
path: "/data/backups",
|
||||
included_in_total: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function mockStorageAnalysisBarsOverview(page: Page): Promise<{
|
||||
setSnapshot: (snapshot: Record<string, unknown>) => void;
|
||||
holdNextOverviewRefresh: () => {
|
||||
started: Promise<void>;
|
||||
release: () => void;
|
||||
};
|
||||
}> {
|
||||
let snapshot = storageBarsSnapshot();
|
||||
let heldRefresh: {
|
||||
resolveStarted: () => void;
|
||||
released: Promise<void>;
|
||||
} | null = null;
|
||||
await page.route("**/api/v1/system/storage", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
const requestHeaders = route.request().headers();
|
||||
const response = await fetch(route.request().url(), {
|
||||
headers: {
|
||||
...(requestHeaders.accept ? { accept: requestHeaders.accept } : {}),
|
||||
...(requestHeaders.cookie ? { cookie: requestHeaders.cookie } : {}),
|
||||
},
|
||||
});
|
||||
const body = JSON.parse(await response.text()) as {
|
||||
analyzed_at?: string | null;
|
||||
analysis?: Record<string, unknown> | null;
|
||||
summary?: Record<string, unknown> | null;
|
||||
};
|
||||
const analyzedAt = new Date().toISOString();
|
||||
body.summary = snapshot;
|
||||
body.analyzed_at = analyzedAt;
|
||||
body.analysis = {
|
||||
...(body.analysis ?? {}),
|
||||
state: "ready",
|
||||
completed_at: analyzedAt,
|
||||
duration_ms: 100,
|
||||
stale: false,
|
||||
error: null,
|
||||
partial_summary: null,
|
||||
};
|
||||
if (heldRefresh) {
|
||||
const refresh = heldRefresh;
|
||||
heldRefresh = null;
|
||||
refresh.resolveStarted();
|
||||
await refresh.released;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/v1/system/storage/analyze", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ job_id: "storage-bars-analysis" }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/v1/system/jobs/storage-bars-analysis", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: "storage-bars-analysis",
|
||||
kind: "storage-analysis",
|
||||
state: "succeeded",
|
||||
started_at: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
return {
|
||||
setSnapshot: (nextSnapshot) => (snapshot = nextSnapshot),
|
||||
holdNextOverviewRefresh: () => {
|
||||
let resolveStarted!: () => void;
|
||||
let resolveRelease!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve;
|
||||
});
|
||||
const released = new Promise<void>((resolve) => {
|
||||
resolveRelease = resolve;
|
||||
});
|
||||
heldRefresh = { resolveStarted, released };
|
||||
return { started, release: resolveRelease };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function mockTemporaryArtifactOverview(page: Page): Promise<void> {
|
||||
await page.route("**/api/v1/system/storage", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { mockStorageAnalysisBarsOverview } from "../../helpers/storage-maintenance";
|
||||
|
||||
async function assertNoHorizontalOverflow(page: Page) {
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth))
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test.describe("Mobile storage analysis bars", () => {
|
||||
test("keeps bars below the mobile header and rows touch-sized", async ({
|
||||
testPage,
|
||||
prCapture,
|
||||
}) => {
|
||||
await mockStorageAnalysisBarsOverview(testPage);
|
||||
await testPage.goto("/settings/system/storage");
|
||||
|
||||
const workspaceTrigger = testPage.getByTestId("storage-resource-workspaces-trigger");
|
||||
const systemTrigger = testPage.getByTestId("storage-resource-system-temporary-trigger");
|
||||
for (const trigger of [workspaceTrigger, systemTrigger]) {
|
||||
const box = await trigger.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
const workspaceLabel = testPage.getByText("Task workspaces", { exact: true });
|
||||
const workspaceBar = testPage.getByTestId("storage-resource-workspaces-bar");
|
||||
const labelBox = await workspaceLabel.boundingBox();
|
||||
const barBox = await workspaceBar.boundingBox();
|
||||
expect(labelBox).not.toBeNull();
|
||||
expect(barBox).not.toBeNull();
|
||||
expect(barBox!.y).toBeGreaterThanOrEqual(labelBox!.y + labelBox!.height - 1);
|
||||
expect(barBox!.width).toBeGreaterThan(0);
|
||||
|
||||
await systemTrigger.tap();
|
||||
const resource = testPage.getByTestId("storage-resource-system-temporary");
|
||||
await expect(resource).toContainText("Partial");
|
||||
await expect(resource).toContainText("Scan timed out. Showing partial usage.");
|
||||
await assertNoHorizontalOverflow(testPage);
|
||||
await prCapture.screenshot("mobile-storage-analysis-bars", {
|
||||
caption: "Phone storage analysis places the relative bar below the category header",
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
for (const width of [767, 768]) {
|
||||
test(`keeps the storage header contained at ${width}px`, async ({ testPage }) => {
|
||||
await testPage.setViewportSize({ width, height: 900 });
|
||||
await mockStorageAnalysisBarsOverview(testPage);
|
||||
await testPage.goto("/settings/system/storage");
|
||||
|
||||
const trigger = testPage.getByTestId("storage-resource-workspaces-trigger");
|
||||
const bar = testPage.getByTestId("storage-resource-workspaces-bar");
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
const barBox = await bar.boundingBox();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(barBox).not.toBeNull();
|
||||
expect(barBox!.x).toBeGreaterThanOrEqual(triggerBox!.x);
|
||||
expect(barBox!.x + barBox!.width).toBeLessThanOrEqual(triggerBox!.x + triggerBox!.width + 1);
|
||||
await assertNoHorizontalOverflow(testPage);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -19,6 +19,8 @@ test.describe("Mobile system temporary folders", () => {
|
||||
await trigger.tap();
|
||||
const resource = testPage.getByTestId("storage-resource-system-temporary");
|
||||
await expect(resource).toContainText("Partial");
|
||||
await expect(resource).toContainText("Scan timed out. Showing partial usage.");
|
||||
await expect(resource).not.toContainText("context deadline exceeded");
|
||||
await expect(resource).toContainText(fixture.root);
|
||||
await expect(resource).toContainText(
|
||||
"Read-only. This footprint can overlap counted categories.",
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import {
|
||||
mockStorageAnalysisBarsOverview,
|
||||
storageBarsSnapshot,
|
||||
} from "../../helpers/storage-maintenance";
|
||||
|
||||
const DEFAULT_ORDER = [
|
||||
"database",
|
||||
"database-backups",
|
||||
"workspaces",
|
||||
"go-cache",
|
||||
"unmanaged-go-cache",
|
||||
"quarantine",
|
||||
"temporary-artifacts",
|
||||
"system-temporary",
|
||||
"managed-containers",
|
||||
"docker-image-layers",
|
||||
"docker-build-cache",
|
||||
"docker-unused-images",
|
||||
];
|
||||
|
||||
function resourceTriggers(page: Page) {
|
||||
return page.locator('[data-testid^="storage-resource-"][data-testid$="-trigger"]');
|
||||
}
|
||||
|
||||
async function resourceOrder(page: Page) {
|
||||
return resourceTriggers(page).evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const testId = element.getAttribute("data-testid") ?? "";
|
||||
return testId.replace(/^storage-resource-/, "").replace(/-trigger$/, "");
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectResourceExpanded(page: Page, resourceId: string) {
|
||||
const resource = page.getByTestId(`storage-resource-${resourceId}`);
|
||||
await expect(resource.getByTestId(`storage-resource-${resourceId}-trigger`)).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
await expect(resource.locator('[data-slot="accordion-content"]')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Storage analysis bars", () => {
|
||||
test("sorts measured rows, scales decorative bars, and preserves totals", async ({
|
||||
testPage,
|
||||
prCapture,
|
||||
}) => {
|
||||
const { setSnapshot } = await mockStorageAnalysisBarsOverview(testPage);
|
||||
await testPage.goto("/settings/system/storage");
|
||||
|
||||
await expect(testPage.getByTestId("storage-analysis-bars-description")).toContainText(
|
||||
"Bars compare category sizes. Categories can overlap.",
|
||||
);
|
||||
await expect.poll(() => resourceOrder(testPage)).toEqual(DEFAULT_ORDER);
|
||||
|
||||
const totalText = await testPage.getByTestId("storage-analysis-total").textContent();
|
||||
expect(totalText).toContain("Total counted");
|
||||
await expect(testPage.getByTestId("storage-resource-database-bar-fill")).toHaveAttribute(
|
||||
"style",
|
||||
"width: 100%;",
|
||||
);
|
||||
await expect(testPage.getByTestId("storage-resource-workspaces-bar-fill")).toHaveAttribute(
|
||||
"style",
|
||||
"width: 80%;",
|
||||
);
|
||||
|
||||
const bars = testPage.locator('[data-testid^="storage-resource-"][data-testid$="-bar"]');
|
||||
for (const bar of await bars.all()) {
|
||||
await expect(bar).toHaveAttribute("aria-hidden", "true");
|
||||
expect(await bar.getAttribute("role")).toBeNull();
|
||||
}
|
||||
|
||||
const databaseTrigger = testPage.getByTestId("storage-resource-database-trigger");
|
||||
const databaseBar = testPage.getByTestId("storage-resource-database-bar");
|
||||
const triggerBox = await databaseTrigger.boundingBox();
|
||||
const barBox = await databaseBar.boundingBox();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(barBox).not.toBeNull();
|
||||
expect(barBox!.x).toBeGreaterThanOrEqual(triggerBox!.x);
|
||||
expect(barBox!.x + barBox!.width).toBeLessThanOrEqual(triggerBox!.x + triggerBox!.width + 1);
|
||||
expect(barBox!.width).toBeGreaterThan(0);
|
||||
|
||||
// @covers AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.6: all desktop tracks share one column.
|
||||
const rowGeometry = await Promise.all(
|
||||
["database", "system-temporary", "workspaces", "go-cache"].map(async (id) => ({
|
||||
bar: await testPage.getByTestId(`storage-resource-${id}-bar`).boundingBox(),
|
||||
value: await testPage
|
||||
.getByTestId(`storage-resource-${id}-trigger`)
|
||||
.locator('[data-testid^="storage-analysis-source-"]')
|
||||
.boundingBox(),
|
||||
})),
|
||||
);
|
||||
for (const { bar, value } of rowGeometry) {
|
||||
expect(bar).not.toBeNull();
|
||||
expect(value).not.toBeNull();
|
||||
expect(Math.abs(bar!.x - barBox!.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(bar!.width - barBox!.width)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
const systemTrigger = testPage.getByTestId("storage-resource-system-temporary-trigger");
|
||||
await systemTrigger.click();
|
||||
await expect(testPage.getByTestId("storage-resource-system-temporary")).toContainText(
|
||||
"Scan timed out. Showing partial usage.",
|
||||
);
|
||||
await expect(systemTrigger).toContainText("Partial");
|
||||
expect(totalText).toBe(await testPage.getByTestId("storage-analysis-total").textContent());
|
||||
expect(
|
||||
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true);
|
||||
|
||||
await prCapture.screenshot("storage-analysis-bars", {
|
||||
caption: "Desktop storage analysis compares measured category sizes with relative bars",
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
setSnapshot({
|
||||
...storageBarsSnapshot({ workspaces: 8 * 1024 ** 3, quarantine: 0 }),
|
||||
database: { status: "unavailable", included_in_total: false },
|
||||
temporary_artifacts: { available: true },
|
||||
go_cache: { available: false },
|
||||
docker: {
|
||||
available: false,
|
||||
managed_container_count: 0,
|
||||
managed_container_bytes: 0,
|
||||
image_layer_bytes: 0,
|
||||
build_cache_bytes: 0,
|
||||
unused_image_bytes: 0,
|
||||
},
|
||||
});
|
||||
await testPage.getByTestId("storage-analyze").click();
|
||||
await expect(testPage.getByTestId("storage-analyze")).toHaveAttribute(
|
||||
"data-job-state",
|
||||
"succeeded",
|
||||
);
|
||||
await expect(testPage.getByTestId("storage-resource-quarantine-bar-fill")).toHaveAttribute(
|
||||
"style",
|
||||
"width: 0%;",
|
||||
);
|
||||
await expect(testPage.getByTestId("storage-resource-database-bar")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("storage-resource-temporary-artifacts-bar")).toHaveCount(0);
|
||||
const unavailableValue = await testPage
|
||||
.getByTestId("storage-resource-managed-containers-trigger")
|
||||
.locator('[data-testid="storage-analysis-source-docker"]')
|
||||
.boundingBox();
|
||||
const measuredValue = await testPage
|
||||
.getByTestId("storage-resource-workspaces-trigger")
|
||||
.locator('[data-testid="storage-analysis-source-workspaces"]')
|
||||
.boundingBox();
|
||||
expect(unavailableValue).not.toBeNull();
|
||||
expect(measuredValue).not.toBeNull();
|
||||
expect(Math.abs(unavailableValue!.x - measuredValue!.x)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("keeps expanded rows and focus attached to IDs after refresh reordering", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
const { setSnapshot, holdNextOverviewRefresh } =
|
||||
await mockStorageAnalysisBarsOverview(testPage);
|
||||
setSnapshot(storageBarsSnapshot({ goCache: 16 * 1024 ** 3 }));
|
||||
const cleanupRequests: string[] = [];
|
||||
testPage.on("request", (request) => {
|
||||
if (
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === "/api/v1/system/storage/run"
|
||||
) {
|
||||
cleanupRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
await testPage.goto("/settings/system/storage");
|
||||
|
||||
const systemTrigger = testPage.getByTestId("storage-resource-system-temporary-trigger");
|
||||
const workspaceTrigger = testPage.getByTestId("storage-resource-workspaces-trigger");
|
||||
await systemTrigger.click();
|
||||
await workspaceTrigger.click();
|
||||
await expectResourceExpanded(testPage, "system-temporary");
|
||||
await expectResourceExpanded(testPage, "workspaces");
|
||||
|
||||
const goCacheTrigger = testPage.getByTestId("storage-resource-go-cache-trigger");
|
||||
await goCacheTrigger.click();
|
||||
const cleanButton = testPage.getByTestId("storage-go-cache-clean");
|
||||
await expect(cleanButton).toBeVisible();
|
||||
await cleanButton.focus();
|
||||
await expect(cleanButton).toBeFocused();
|
||||
|
||||
setSnapshot(
|
||||
storageBarsSnapshot({
|
||||
workspaces: 1 * 1024 ** 3,
|
||||
systemTemporary: 20 * 1024 ** 3,
|
||||
goCache: 16 * 1024 ** 3,
|
||||
goCacheWarning: "cache measurement warning",
|
||||
}),
|
||||
);
|
||||
const heldRefresh = holdNextOverviewRefresh();
|
||||
await expect(cleanButton).toBeFocused();
|
||||
await testPage.getByTestId("storage-analyze").click();
|
||||
await heldRefresh.started;
|
||||
await cleanButton.focus();
|
||||
await expect(cleanButton).toBeFocused();
|
||||
heldRefresh.release();
|
||||
await expect(testPage.getByTestId("storage-analyze")).toHaveAttribute(
|
||||
"data-job-state",
|
||||
"succeeded",
|
||||
);
|
||||
await expect
|
||||
.poll(() => resourceOrder(testPage))
|
||||
.toEqual([
|
||||
"system-temporary",
|
||||
"go-cache",
|
||||
"database",
|
||||
"database-backups",
|
||||
"unmanaged-go-cache",
|
||||
"quarantine",
|
||||
"temporary-artifacts",
|
||||
"managed-containers",
|
||||
"workspaces",
|
||||
"docker-image-layers",
|
||||
"docker-build-cache",
|
||||
"docker-unused-images",
|
||||
]);
|
||||
await expectResourceExpanded(testPage, "system-temporary");
|
||||
await expectResourceExpanded(testPage, "workspaces");
|
||||
await expectResourceExpanded(testPage, "go-cache");
|
||||
await expect(cleanButton).toBeFocused();
|
||||
expect(cleanupRequests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,8 @@ test.describe("System temporary folders", () => {
|
||||
await trigger.click();
|
||||
const resource = testPage.getByTestId("storage-resource-system-temporary");
|
||||
await expect(resource).toContainText("Partial");
|
||||
await expect(resource).toContainText("Scan timed out. Showing partial usage.");
|
||||
await expect(resource).not.toContainText("context deadline exceeded");
|
||||
await expect(resource).toContainText(fixture.root);
|
||||
await expect(resource).toContainText("One fixture entry was skipped.");
|
||||
await prCapture.screenshot("system-temporary-partial", {
|
||||
|
||||
@@ -382,6 +382,7 @@
|
||||
"storageAnalysisComplete": "Analysis complete",
|
||||
"storageAnalysisDescription": "A read-only breakdown of current usage and reclaimable space. Run Analyze occasionally to refresh these estimates; it never deletes or moves anything.",
|
||||
"storageAnalysisScope": "Classified categories cover selected Kandev and user storage resources and do not represent all host filesystem usage.",
|
||||
"storageAnalysisBarsDescription": "Bars compare category sizes. Categories can overlap.",
|
||||
"storageAnalysisFailed": "Analysis failed",
|
||||
"storageAnalysisCacheLifetime": "Cache lifetime: {{duration}}",
|
||||
"storageAnalysisDuration": "Scan duration: {{duration}}",
|
||||
@@ -589,6 +590,7 @@
|
||||
"storageSystemTemporaryPartialDetail": "Some entries could not be measured.",
|
||||
"storageSystemTemporaryUnavailableDetail": "The folder could not be measured.",
|
||||
"storageSystemTemporaryNotApplicableDetail": "The folder is not present.",
|
||||
"storageSystemTemporaryDeadlineDetail": "Scan timed out. Showing partial usage.",
|
||||
"storageSystemTemporaryResolvedRoot": "Requested path {{requested}} resolves to {{resolved}}.",
|
||||
"storageSystemTemporaryRoot": "{{path}}: {{size}} · {{status}}",
|
||||
"storageSystemTemporarySkippedCount_one": "{{count}} entry skipped",
|
||||
|
||||
@@ -382,6 +382,7 @@
|
||||
"storageAnalysisComplete": "分析完了",
|
||||
"storageAnalysisDescription": "現在の使用量と回収可能容量の読み取り専用内訳。推定値更新したいときは時々 Analyze 実行。何も削除も移動もなし。",
|
||||
"storageAnalysisScope": "分類カテゴリは選択した Kandev とユーザーストレージ資源を対象にし、ホストファイルシステム使用量全体は示さない。",
|
||||
"storageAnalysisBarsDescription": "バーはカテゴリのサイズを比較します。カテゴリは重複する場合があります。",
|
||||
"storageAnalysisFailed": "分析失敗",
|
||||
"storageAnalysisCacheLifetime": "キャッシュ有効期間: {{duration}}",
|
||||
"storageAnalysisDuration": "スキャン時間: {{duration}}",
|
||||
@@ -589,6 +590,7 @@
|
||||
"storageSystemTemporaryPartialDetail": "一部の項目を計測できませんでした。",
|
||||
"storageSystemTemporaryUnavailableDetail": "フォルダーを計測できませんでした。",
|
||||
"storageSystemTemporaryNotApplicableDetail": "フォルダーが存在しません。",
|
||||
"storageSystemTemporaryDeadlineDetail": "スキャンがタイムアウトしました。部分的な使用量を表示しています。",
|
||||
"storageSystemTemporaryResolvedRoot": "要求パス {{requested}} は {{resolved}} に解決されます。",
|
||||
"storageSystemTemporaryRoot": "{{path}}: {{size}} · {{status}}",
|
||||
"storageSystemTemporarySkippedCount_one": "{{count}} 項目をスキップ",
|
||||
|
||||
@@ -382,6 +382,7 @@
|
||||
"storageAnalysisComplete": "Àńàĺŷśĩś ćōḿƥĺēţē",
|
||||
"storageAnalysisDescription": "À ŕēàď-ōńĺŷ ƀŕēàķďōŵń ōƒ ćũŕŕēńţ ũśàĝē àńď ŕēćĺàĩḿàƀĺē śƥàćē. Ŕũń Àńàĺŷźē ōććàśĩōńàĺĺŷ ţō ŕēƒŕēśĥ ţĥēśē ēśţĩḿàţēś; ĩţ ńēvēŕ ďēĺēţēś ōŕ ḿōvēś àńŷţĥĩńĝ.",
|
||||
"storageAnalysisScope": "Ćĺàśśĩƒĩēď ćàţēĝōŕĩēś ćōvēŕ śēĺēćţēď Ķàńďēv àńď ũśēŕ śţōŕàĝē ŕēśōũŕćēś àńď ďō ńōţ ŕēƥŕēśēńţ àĺĺ ĥōśţ ƒĩĺēśŷśţēḿ ũśàĝē.",
|
||||
"storageAnalysisBarsDescription": "Ɓàŕś ćōḿƥàŕē ćàţēĝōŕŷ śĩźēś. Ćàţēĝōŕĩēś ćàń ōvēŕĺàƥ.",
|
||||
"storageAnalysisFailed": "Àńàĺŷśĩś ƒàĩĺēď",
|
||||
"storageAnalysisCacheLifetime": "Ćàćĥē ĺĩƒēţĩḿē: {{duration}}",
|
||||
"storageAnalysisDuration": "Śćàń ďũŕàţĩōń: {{duration}}",
|
||||
@@ -589,6 +590,7 @@
|
||||
"storageSystemTemporaryPartialDetail": "Śōḿē ēńţŕĩēś ćōũĺď ńōţ ƀē ḿēàśũŕēď.",
|
||||
"storageSystemTemporaryUnavailableDetail": "Ţĥē ƒōĺďēŕ ćōũĺď ńōţ ƀē ḿēàśũŕēď.",
|
||||
"storageSystemTemporaryNotApplicableDetail": "Ţĥē ƒōĺďēŕ ĩś ńōţ ƥŕēśēńţ.",
|
||||
"storageSystemTemporaryDeadlineDetail": "Śćàń ţĩḿēď ōũţ. Śĥōŵĩńĝ ƥàŕţĩàĺ ũśàĝē.",
|
||||
"storageSystemTemporaryResolvedRoot": "Ŕēqũēśţēď ƥàţĥ {{requested}} ŕēśōĺvēś ţō {{resolved}}.",
|
||||
"storageSystemTemporaryRoot": "{{path}}: {{size}} · {{status}}",
|
||||
"storageSystemTemporarySkippedCount_one": "{{count}} ēńţŕŷ śķĩƥƥēď",
|
||||
|
||||
@@ -377,6 +377,7 @@
|
||||
"storageAnalysisComplete": "Análise concluída",
|
||||
"storageAnalysisDescription": "Uma decomposição só de leitura da utilização atual e do espaço recuperável. Execute a Análise de vez em quando para atualizar estas estimativas; nunca elimina nem move nada.",
|
||||
"storageAnalysisScope": "As categorias classificadas abrangem recursos de armazenamento selecionados do Kandev e do utilizador e não representam toda a utilização do sistema de ficheiros do anfitrião.",
|
||||
"storageAnalysisBarsDescription": "As barras comparam os tamanhos das categorias. As categorias podem sobrepor-se.",
|
||||
"storageAnalysisFailed": "A análise falhou",
|
||||
"storageAnalysisCacheLifetime": "Duração da cache: {{duration}}",
|
||||
"storageAnalysisDuration": "Duração da análise: {{duration}}",
|
||||
@@ -584,6 +585,7 @@
|
||||
"storageSystemTemporaryPartialDetail": "Não foi possível medir algumas entradas.",
|
||||
"storageSystemTemporaryUnavailableDetail": "Não foi possível medir a pasta.",
|
||||
"storageSystemTemporaryNotApplicableDetail": "A pasta não está presente.",
|
||||
"storageSystemTemporaryDeadlineDetail": "A análise atingiu o tempo limite. A mostrar a utilização parcial.",
|
||||
"storageSystemTemporaryResolvedRoot": "O caminho pedido {{requested}} resolve para {{resolved}}.",
|
||||
"storageSystemTemporaryRoot": "{{path}}: {{size}} · {{status}}",
|
||||
"storageSystemTemporarySkippedCount_one": "{{count}} entrada ignorada",
|
||||
|
||||
@@ -379,6 +379,7 @@
|
||||
"storageAnalysisComplete": "分析完成",
|
||||
"storageAnalysisDescription": "当前用量与可回收空间的只读分解。偶尔运行“分析”以刷新估算;它从不删除或移动任何内容。",
|
||||
"storageAnalysisScope": "分类类别仅涵盖选定的 Kandev 和用户存储资源,不代表主机文件系统的全部使用情况。",
|
||||
"storageAnalysisBarsDescription": "条形图比较各类别的大小。类别之间可能重叠。",
|
||||
"storageAnalysisFailed": "分析失败",
|
||||
"storageAnalysisCacheLifetime": "缓存有效期:{{duration}}",
|
||||
"storageAnalysisDuration": "扫描时长:{{duration}}",
|
||||
@@ -586,6 +587,7 @@
|
||||
"storageSystemTemporaryPartialDetail": "部分条目无法测量。",
|
||||
"storageSystemTemporaryUnavailableDetail": "无法测量此文件夹。",
|
||||
"storageSystemTemporaryNotApplicableDetail": "此文件夹不存在。",
|
||||
"storageSystemTemporaryDeadlineDetail": "扫描超时。正在显示部分使用量。",
|
||||
"storageSystemTemporaryResolvedRoot": "请求路径 {{requested}} 解析为 {{resolved}}。",
|
||||
"storageSystemTemporaryRoot": "{{path}}:{{size}} · {{status}}",
|
||||
"storageSystemTemporarySkippedCount_one": "已跳过 {{count}} 个条目",
|
||||
|
||||
@@ -379,6 +379,7 @@
|
||||
"storageAnalysisComplete": "分析完成",
|
||||
"storageAnalysisDescription": "目前用量與可回收空間的只讀分解。偶爾執行「分析」以刷新估算;它從不刪除或移動任何內容。",
|
||||
"storageAnalysisScope": "分類類別僅涵蓋選定的 Kandev 和用戶存儲資源,不代表主機檔案系統的全部使用情況。",
|
||||
"storageAnalysisBarsDescription": "條形圖比較各類別的大小。類別之間可能重疊。",
|
||||
"storageAnalysisFailed": "分析失敗",
|
||||
"storageAnalysisCacheLifetime": "快取有效期:{{duration}}",
|
||||
"storageAnalysisDuration": "掃描時長:{{duration}}",
|
||||
@@ -586,6 +587,7 @@
|
||||
"storageSystemTemporaryPartialDetail": "部分條目無法測量。",
|
||||
"storageSystemTemporaryUnavailableDetail": "無法測量此資料夾。",
|
||||
"storageSystemTemporaryNotApplicableDetail": "此資料夾不存在。",
|
||||
"storageSystemTemporaryDeadlineDetail": "掃描超時。正在顯示部分使用量。",
|
||||
"storageSystemTemporaryResolvedRoot": "請求路徑 {{requested}} 解析為 {{resolved}}。",
|
||||
"storageSystemTemporaryRoot": "{{path}}:{{size}} · {{status}}",
|
||||
"storageSystemTemporarySkippedCount_one": "已跳過 {{count}} 個條目",
|
||||
|
||||
@@ -379,6 +379,7 @@
|
||||
"storageAnalysisComplete": "分析完成",
|
||||
"storageAnalysisDescription": "目前用量與可回收空間的只讀分解。偶爾執行「分析」以重新整理估算;它從不刪除或移動任何內容。",
|
||||
"storageAnalysisScope": "分類類別僅涵蓋選定的 Kandev 和使用者儲存資源,不代表主機檔案系統的全部使用情況。",
|
||||
"storageAnalysisBarsDescription": "條形圖比較各類別的大小。類別之間可能重疊。",
|
||||
"storageAnalysisFailed": "分析失敗",
|
||||
"storageAnalysisCacheLifetime": "快取有效期:{{duration}}",
|
||||
"storageAnalysisDuration": "掃描時長:{{duration}}",
|
||||
@@ -586,6 +587,7 @@
|
||||
"storageSystemTemporaryPartialDetail": "部分條目無法測量。",
|
||||
"storageSystemTemporaryUnavailableDetail": "無法測量此資料夾。",
|
||||
"storageSystemTemporaryNotApplicableDetail": "此資料夾不存在。",
|
||||
"storageSystemTemporaryDeadlineDetail": "掃描超時。正在顯示部分使用量。",
|
||||
"storageSystemTemporaryResolvedRoot": "請求路徑 {{requested}} 解析為 {{resolved}}。",
|
||||
"storageSystemTemporaryRoot": "{{path}}:{{size}} · {{status}}",
|
||||
"storageSystemTemporarySkippedCount_one": "已跳過 {{count}} 個條目",
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
created: 2026-09-22
|
||||
status: implemented
|
||||
requirements:
|
||||
- REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-003
|
||||
- REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-005
|
||||
system_design:
|
||||
- ../../specs/system-page/system-design/storage-analysis-presentation.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Storage analysis presentation
|
||||
|
||||
## Overview
|
||||
|
||||
Deliver concise temporary-folder timeout feedback and relative usage bars inside the existing Storage accordion.
|
||||
The work orders were sequential because both changed the same resource projection and component tests.
|
||||
The implementation and verification evidence are recorded below.
|
||||
|
||||
- [Requirements](../../specs/system-page/requirements/storage-maintenance.md): 003.10 and requirement 005.
|
||||
- [System design](../../specs/system-page/system-design/storage-analysis-presentation.md).
|
||||
- [Existing temporary-storage decision](../../decisions/2026-09-11-temporary-storage-visibility-policy.md).
|
||||
|
||||
Confirmed intent comes from the accepted preview: descending size order, bars, existing collapsible behavior, and concise timeout feedback.
|
||||
No material question remains open. Routine choices use stable ties, a shared maximum, and one fill color.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- All existing storage resource rows, sorted by numeric measurements.
|
||||
- Proportional bars, explicit partial state, accessible headers, and phone composition.
|
||||
- Deadline warning normalization, retained partial samples, and preserved unrelated diagnostic examples.
|
||||
- Localized copy, focused browser evidence, and operations guidance during implementation.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- New categories, sorting controls, saved preferences, drilldown, or a Grafana dependency.
|
||||
- Scan performance changes, longer deadlines, cleanup changes, or host-wide disk accounting.
|
||||
- New APIs, persisted state, release flags, metrics, or changes to access restrictions.
|
||||
- Commit, push, or PR creation.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Task 01 changes `tempstore.measurementFromResult` and `summarize` to normalize joined warnings.
|
||||
The frontend `systemTemporaryResource` turns the existing deadline reason into one localized explanation.
|
||||
It also handles repeated deadline lines in older snapshots without hiding unrelated diagnostics.
|
||||
The shared `filescan.Limiter` and scan cancellation lifecycle remain unchanged.
|
||||
|
||||
Task 02 adds numeric measurements to `StorageResource` and orders the canonical list from `storageResources`.
|
||||
The local pure helper owns stable ordering and scale derivation.
|
||||
`ResourceRow` renders the bar within `AccordionTrigger`.
|
||||
Keep stable IDs, `Accordion type="multiple"`, detail actions, and the current snapshot preference.
|
||||
The existing `storage-disk-capacity-card.tsx` provides the nearby track/token precedent.
|
||||
Use decorative spans for these relative bars instead of its capacity progress semantics.
|
||||
|
||||
Use TDD for changed logic and focused browser tests for each visible outcome.
|
||||
Install dependencies once from `apps/` before any pnpm command in a fresh worktree.
|
||||
Reuse existing test fixtures and their isolated temporary roots.
|
||||
|
||||
### Companion packages
|
||||
|
||||
The completed [temporary-folder package](../storage-temporary-folders/plan.md) remains historical evidence.
|
||||
Its manifest links this successor for the presentation changes.
|
||||
The completed [analysis-progress package](../storage-analysis-progress/plan.md),
|
||||
[database footprint package](../storage-database-footprint/plan.md), and
|
||||
[progressive-loading package](../storage-progressive-loading/plan.md) define unchanged compatibility behavior.
|
||||
Do not reopen their tasks or rewrite their result counts.
|
||||
Run the affected existing component and Storage browser suites as specified in the work orders.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-00: Current desktop Storage analysis, based on the source and supplied screenshot.
|
||||
Headers have labels and sizes, without bars. Categories use fixed order.
|
||||
The expanded temporary-folder warning can contain many repeated deadline errors.
|
||||
|
||||
```text
|
||||
Task workspaces 68.71 GB v
|
||||
Database 7.09 GB v
|
||||
Database backups 19.99 GB v
|
||||
Quarantined resources 47.26 GB v
|
||||
System temporary folders 51.08 GB ^
|
||||
/tmp: 51.08 GB | Partial | 1,015 entries skipped
|
||||
context deadline exceeded context deadline exceeded ...
|
||||
```
|
||||
|
||||
UI-01: Proposed desktop, Settings > System > Storage, measured rows with one expanded.
|
||||
Only the categories from the screenshot are shown here. Implementation includes every existing row.
|
||||
|
||||
```text
|
||||
Storage analysis
|
||||
Total counted: 242.71 GB
|
||||
Bars compare category sizes. Categories can overlap.
|
||||
|
||||
Task workspaces [####################] 68.71 GB v
|
||||
-----------------------------------------------------------
|
||||
System temporary folders [Partial] [###############.....] 51.08 GB ^
|
||||
Read-only. This footprint can overlap counted categories.
|
||||
/tmp: 51.08 GB | Partial | 1,015 entries skipped
|
||||
Scan timed out. Showing partial usage.
|
||||
<up to ten distinct non-timeout diagnostic examples>
|
||||
-----------------------------------------------------------
|
||||
Quarantined resources [##############......] 47.26 GB v
|
||||
-----------------------------------------------------------
|
||||
Database backups [######..............] 19.99 GB v
|
||||
-----------------------------------------------------------
|
||||
Database [##..................] 7.09 GB v
|
||||
```
|
||||
|
||||
The Partial label belongs to the header beside the size and can wrap beneath it.
|
||||
The total is retained from the screenshot. It is not the sum of these example rows.
|
||||
|
||||
UI-02: Proposed phone, same entry and expanded state.
|
||||
|
||||
```text
|
||||
Storage analysis
|
||||
Total counted: 242.71 GB
|
||||
Bars compare category sizes.
|
||||
Categories can overlap.
|
||||
|
||||
Task workspaces 68.71 GB v
|
||||
[##############################]
|
||||
-----------------------------------
|
||||
System temporary folders [Partial] ^
|
||||
51.08 GB
|
||||
[######################........]
|
||||
Read-only. This footprint can
|
||||
overlap counted categories.
|
||||
/tmp: 51.08 GB
|
||||
Partial | 1,015 entries skipped
|
||||
Scan timed out.
|
||||
Showing partial usage.
|
||||
-----------------------------------
|
||||
Quarantined resources v
|
||||
47.26 GB
|
||||
[#####################.........]
|
||||
```
|
||||
|
||||
UI-03: First-scan and unavailable states, shared semantic order.
|
||||
Phone bars occupy the line beneath each measured header.
|
||||
|
||||
```text
|
||||
Database backups [##########..........] 10.00 GB v
|
||||
Database [....................] 0.00 GB v
|
||||
Task workspaces Scanning v
|
||||
System temporary folders Unavailable v
|
||||
```
|
||||
|
||||
The first track is illustrative. A larger measured row can precede this excerpt.
|
||||
Unmeasured rows have no measured track. All-zero snapshots show only empty measured tracks.
|
||||
Existing failed-refresh feedback stays visible above the retained snapshot.
|
||||
|
||||
Required structure: whole-row activation, descending measured order, stable row identity,
|
||||
inline expanded details, common bar scale, and a two-line phone header.
|
||||
There is one page scroll owner and no fixed region or new overlay.
|
||||
Phone triggers have at least 44-pixel hit targets. Labels and paths wrap.
|
||||
Spacing, example sizes, and ASCII fill density are illustrative, not pixel specifications.
|
||||
|
||||
UI-01 and UI-02 map to AC-003.10 and AC-005.1–.7.
|
||||
UI-03 maps to AC-005.3 and .8.
|
||||
Here, short AC references use the prefix `AC-SYSTEM-PAGE-STORAGE-MAINTENANCE`.
|
||||
|
||||
## Tests
|
||||
|
||||
Proposed test names are implementation targets, not claims of existing coverage.
|
||||
|
||||
| Criteria | Test file and proposed case |
|
||||
| --- | --- |
|
||||
| 003.10 | `tempstore/provider_test.go`: `TestTemporaryDeadlineWarningsAreBounded`, mixed error leaves, distinct roots, retained sample/counts |
|
||||
| 003.6, .10 | Existing `TestAnalyzeTemporaryDeadlineReturnsPartialSample` and cancellation tests |
|
||||
| 003.10 | `storage-overview-card.test.tsx`: `shows one localized timeout and preserves unrelated diagnostics`, including older joined warnings |
|
||||
| 005.1, .2, .3 | New `storage-overview-resources.test.ts`: `maps every resource measurement`, `sorts numeric sizes with stable ties`, `handles zero and missing sizes` |
|
||||
| 005.2, .3 | Same new test file: `scales against the largest measured footprint`, including all-zero and partial data |
|
||||
| 005.4, .8 | `storage-overview-card.test.tsx`: `preserves expanded and focused rows after refresh reordering`, first-scan and stale-snapshot cases |
|
||||
| 005.5 | Existing `storage-totals.test.ts`: informational and subset exclusions remain unchanged |
|
||||
| 005.6, .7 | Component assertions for label, size, partial status, decorative bars, and localized descriptions |
|
||||
|
||||
Frontend test paths are relative to `apps/web/components/settings/system/storage/`.
|
||||
Backend test paths are relative to `apps/backend/internal/system/storage/`.
|
||||
|
||||
## E2E tests
|
||||
|
||||
| Work order | Files and projects | Required evidence |
|
||||
| --- | --- | --- |
|
||||
| 01 | Existing `tests/system/storage-temporary-folders.spec.ts`, chromium; `mobile-storage-temporary-folders.spec.ts`, mobile-chrome | Open partial row, one timeout explanation, retained size/counts and unrelated warning, long-path wrapping, no horizontal overflow |
|
||||
| 02 | New `tests/system/storage-analysis-bars.spec.ts`, chromium; `mobile-storage-analysis-bars.spec.ts`, mobile-chrome | Descending order, proportional fills, zero/unknown states, partial header, independent expansion, reorder/refresh preservation, no cleanup request |
|
||||
| 02 | Existing Storage maintenance and temporary-folder suites in both projects | Existing actions, snapshots, and disclosures remain usable |
|
||||
|
||||
New files live in `apps/web/e2e/tests/system/`.
|
||||
Fixtures use `test-base` and controlled overview responses through `helpers/storage-maintenance.ts`.
|
||||
Never measure the developer's host temporary directory for browser assertions.
|
||||
Use the configured Pixel 5 project. Add breakpoint geometry cases at 767 and 768 pixels.
|
||||
Assert actual trigger height, bar placement, header containment, and no document horizontal overflow.
|
||||
Capture and inspect desktop and phone screenshots through `prCapture`.
|
||||
Exact commands are in each work order. The managed runner builds current assets and cleans its own instance.
|
||||
Run desktop and mobile commands separately. Do not overlap suites or override worker limits.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Concise temporary-folder timeout feedback](task-01-timeout-feedback.md)
|
||||
- [x] [Task 02: Sorted storage bars](task-02-sorted-storage-bars.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
Implementation verification on 2026-09-22:
|
||||
|
||||
- `go test ./internal/system/storage/tempstore ./internal/system/storage/filescan`: passed.
|
||||
- Focused Storage component suite: passed, 4 files and 38 tests.
|
||||
- `pnpm exec tsc --noEmit --pretty false`: passed.
|
||||
- Storage and new browser-file ESLint: passed with six existing test-only warnings and no errors.
|
||||
- `pnpm --filter @kandev/web build:vite`: passed.
|
||||
- `make -C apps/backend build`: passed.
|
||||
- Managed desktop Storage browser command: passed, 13 tests.
|
||||
- Managed mobile Storage browser command: passed, 11 tests.
|
||||
- `node --test scripts/validate-public-docs.test.mjs` and `node scripts/validate-public-docs.mjs`: passed, 62 tests and 47 published pages.
|
||||
- `python3 scripts/list-docs.py validate` and `python3 scripts/lint-spec-files.py --all`: passed, 299 decisions and 1109 specifications.
|
||||
- `pnpm run i18n:ratchet`: passed; the scoped system namespace Traditional Chinese generation also passed.
|
||||
- `git diff --check`: passed.
|
||||
- Review follow-up: the serialized empty distinct Go cache now retains `unmanaged_size_bytes: 0`,
|
||||
and the storage card preserves focused descendants, including row cleanup actions, during reorder.
|
||||
- Focused desktop bars E2E after the review fix: passed, including held-refresh focus timing and
|
||||
expanded-content assertions.
|
||||
- Focused mobile bars E2E after the review fix: passed, 3 tests.
|
||||
|
||||
Design validation on 2026-09-22:
|
||||
|
||||
- `python3 scripts/list-docs.py validate`: passed, 299 decisions and 1109 specifications.
|
||||
- `python3 scripts/lint-spec-files.test.py`: passed, 36 tests.
|
||||
- `python3 scripts/lint-spec-files.py --all`: passed.
|
||||
- `git diff --check -- docs/specs docs/plans`: passed.
|
||||
- `git status --short -- docs/specs docs/plans`: confirmed the expected package and companion changes.
|
||||
- Local document audit: all package links, requirement IDs, and work-order design paths resolve.
|
||||
- Catalog output includes the new design under system-page.
|
||||
|
||||
The full `pnpm run i18n:check` command remains blocked by 32 pre-existing missing Japanese
|
||||
catalog keys in the unrelated `executors` and `task` namespaces; the new storage keys are
|
||||
complete and the new-code ratchet is clean. The global `pnpm run i18n:zh-hant` command has the
|
||||
same pre-existing Traditional Chinese catalog gaps outside the `system` namespace.
|
||||
|
||||
No commit or publication occurred. Public operations guidance now documents relative bars and
|
||||
temporary-folder timeout behavior. Delivery state belongs to this plan and its work orders.
|
||||
|
||||
## Risks
|
||||
|
||||
- Missing byte fields currently default to zero in some formatted rows. Sorting must not inherit that ambiguity.
|
||||
- Mixed joined errors can contain a deadline and a real I/O error. Deadline filtering must preserve the latter.
|
||||
- Bars compare overlapping and subset footprints. The total and capacity card must retain their separate meanings.
|
||||
- First-scan completion can move rows. Stable keys must preserve focus and open details.
|
||||
- Long localized labels can squeeze desktop bars or phone values. Rendered geometry checks cover both boundaries.
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
id: "01-timeout-feedback"
|
||||
title: "Concise temporary-folder timeout feedback"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-003
|
||||
acceptance_criteria:
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.6
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.8
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.10
|
||||
system_design:
|
||||
- ../../specs/system-page/system-design/storage-analysis-presentation.md
|
||||
---
|
||||
|
||||
# Task 01: Concise temporary-folder timeout feedback
|
||||
|
||||
## Summary
|
||||
|
||||
Replace repeated deadline messages with one localized explanation in the expanded temporary-folder row.
|
||||
Preserve sampled bytes, partial state, skipped counts, and bounded non-deadline diagnostics.
|
||||
|
||||
## In scope
|
||||
|
||||
- TDD tests for repeated and mixed joined errors, multiple roots, counts, and cancellation.
|
||||
- Normalize warning leaves at the tempstore projection boundary.
|
||||
- Localize the existing deadline reason and normalize older warning payloads in the resource projection.
|
||||
- Desktop and mobile browser assertions for bounded timeout feedback.
|
||||
- Update the temporary-folder explanation in public operations guidance during implementation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Bars, sorting, new scanner workers, scan deadline changes, scan-performance work, cleanup, and new API fields.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Deadline results retain sampled bytes and counts, with at most ten distinct non-deadline diagnostic examples.
|
||||
2. Both new and older snapshots show one localized timeout explanation. Mixed joined errors retain unrelated diagnostics.
|
||||
3. Desktop and phone users can inspect partial details without repeated deadline text or horizontal overflow.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01 and UI-02 excerpt: expanded temporary-folder details at Settings > System > Storage.
|
||||
See the [combined preview](plan.md#ascii-ui-preview). Bars belong to Task 02.
|
||||
|
||||
```text
|
||||
Desktop:
|
||||
System temporary folders 51.08 GB ^
|
||||
Read-only. This footprint can overlap counted categories.
|
||||
/tmp: 51.08 GB | Partial | 1,015 entries skipped
|
||||
Scan timed out. Showing partial usage.
|
||||
<bounded distinct diagnostic examples>
|
||||
|
||||
Phone:
|
||||
System temporary folders ^
|
||||
51.08 GB
|
||||
Read-only. This footprint can
|
||||
overlap counted categories.
|
||||
/tmp: 51.08 GB
|
||||
Partial | 1,015 entries skipped
|
||||
Scan timed out.
|
||||
Showing partial usage.
|
||||
```
|
||||
|
||||
Maps to AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.6, .8, .10.
|
||||
Existing inline expansion and one page scroll owner remain required.
|
||||
Copy and paths wrap within the row. Example numbers and spacing are illustrative.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root. Install workspace dependencies once before the first pnpm command.
|
||||
Use deterministic injected scanner results instead of waiting for the production deadline.
|
||||
Write the focused regression tests first and observe their failures before changing production logic.
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps/backend && go test ./internal/system/storage/tempstore ./internal/system/storage/filescan)
|
||||
(cd apps/web && pnpm test components/settings/system/storage/storage-overview-card.test.tsx components/settings/system/storage/storage-totals.test.ts)
|
||||
(cd apps/web && pnpm exec eslint components/settings/system/storage/storage-overview-resources.ts components/settings/system/storage/storage-overview-card.test.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run i18n:zh-hant)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm run i18n:ratchet)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/system/storage-temporary-folders.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/system/mobile-storage-temporary-folders.spec.ts)
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Before the final checks, add translations in en, pt-pt, zh-cn, and ja, then generate zh-hk and zh-tw.
|
||||
Extend existing temporary-folder browser cases with a controlled deadline payload and one unrelated warning.
|
||||
Assert one explanation, retained sampled size/counts, wrapped long paths, and no raw deadline repetitions.
|
||||
Capture and inspect the desktop and phone detail region through `prCapture`.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/system/storage/tempstore/provider.go`
|
||||
- `apps/backend/internal/system/storage/tempstore/provider_test.go`
|
||||
- `apps/web/components/settings/system/storage/storage-overview-resources.ts`
|
||||
- `apps/web/components/settings/system/storage/storage-overview-card.test.tsx`
|
||||
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw,ja}/system.json`
|
||||
- `apps/web/e2e/helpers/storage-maintenance.ts`
|
||||
- `apps/web/e2e/tests/system/storage-temporary-folders.spec.ts`
|
||||
- `apps/web/e2e/tests/system/mobile-storage-temporary-folders.spec.ts`
|
||||
- `docs/public/operations.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
- `errors.Is` on a joined error matches any deadline leaf. Do not discard the entire mixed error.
|
||||
- Old cached warnings can contain many newline-separated deadline messages in one string.
|
||||
- Existing cancellation and strict scanner consumers must retain their behavior.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`. This order shares frontend and browser helper files with Task 02.
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/system-page/requirements/storage-maintenance.md), section 003.
|
||||
- [Design](../../specs/system-page/system-design/storage-analysis-presentation.md), Deadline feedback.
|
||||
- Existing `TestAnalyzeTemporaryDeadlineReturnsPartialSample` and temporary-folder component/browser cases.
|
||||
- Scoped backend and web `AGENTS.md`, plus TDD, E2E, mobile-parity, and docs-maintainer skills.
|
||||
|
||||
## Results
|
||||
|
||||
- `go test ./internal/system/storage/tempstore ./internal/system/storage/filescan`: passed.
|
||||
- Focused Storage component tests: passed, including the localized timeout and retained diagnostics case.
|
||||
- Existing desktop and mobile temporary-folder browser cases: passed with one timeout explanation,
|
||||
retained partial values and counts, unrelated diagnostics, and no horizontal overflow.
|
||||
- Public operations guidance now documents partial timeout samples and their non-cleanup meaning.
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
id: "02-sorted-storage-bars"
|
||||
title: "Sorted storage bars"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-timeout-feedback"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-005
|
||||
acceptance_criteria:
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.1
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.2
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.3
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.4
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.5
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.6
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.7
|
||||
- AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.8
|
||||
system_design:
|
||||
- ../../specs/system-page/system-design/storage-analysis-presentation.md
|
||||
---
|
||||
|
||||
# Task 02: Sorted storage bars
|
||||
|
||||
## Summary
|
||||
|
||||
Add relative usage bars to all measured storage headers and sort by descending numeric size.
|
||||
Preserve the existing Accordion, details, actions, refresh behavior, and phone access.
|
||||
|
||||
## In scope
|
||||
|
||||
- Numeric size projection for every existing category, including optional and subset rows.
|
||||
- Stable sorting, common scale, zero and unknown states, and visible partial status.
|
||||
- Desktop and phone header composition, localization, and accessibility.
|
||||
- Unit, component, and browser coverage for order, scale, expansion, refresh, focus, and geometry.
|
||||
- Public operations guidance for relative bars and unchanged counted totals.
|
||||
|
||||
## Out of scope
|
||||
|
||||
New categories, saved preferences, sort controls, host-capacity thresholds, chart libraries,
|
||||
new scan behavior, nested resource sorting, cleanup actions, and backend schema changes.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Eligible rows sort by descending bytes with stable ties and proportional fills. Missing measurements remain distinct from zero.
|
||||
2. Existing expansion, focus, details, actions, and stale-snapshot behavior survive sorting and refresh.
|
||||
3. Desktop and phone headers match the preview and pass localization, accessibility, geometry, and existing total checks.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Desktop measured rows. UI-02: Phone measured rows.
|
||||
See the [combined preview and state matrix](plan.md#ascii-ui-preview).
|
||||
|
||||
```text
|
||||
Desktop:
|
||||
Bars compare category sizes. Categories can overlap.
|
||||
Task workspaces [####################] 68.71 GB v
|
||||
System temporary folders [###############.....] 51.08 GB ^
|
||||
Partial
|
||||
/tmp: 51.08 GB | Partial | 1,015 entries skipped
|
||||
Scan timed out. Showing partial usage.
|
||||
Quarantined resources [##############......] 47.26 GB v
|
||||
|
||||
Phone:
|
||||
Task workspaces 68.71 GB v
|
||||
[##############################]
|
||||
System temporary folders ^
|
||||
51.08 GB | Partial
|
||||
[######################........]
|
||||
/tmp: 51.08 GB
|
||||
Partial | 1,015 entries skipped
|
||||
Scan timed out.
|
||||
Showing partial usage.
|
||||
```
|
||||
|
||||
UI-03: Unknown and zero states use the same row structure.
|
||||
|
||||
```text
|
||||
Database [....................] 0.00 GB v
|
||||
Task workspaces Scanning v
|
||||
```
|
||||
|
||||
Maps to all AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005 criteria.
|
||||
Required structure: shared scale, full-row activation, inline details, wrapped phone labels, and one page scroll owner.
|
||||
Unknown rows have no measured bar. Partial remains visible in the header.
|
||||
Keep 44-pixel phone/coarse-pointer targets and existing desktop density.
|
||||
Example numbers, fill counts, and spacing are illustrative.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root after Task 01. Workspace dependencies are already installed.
|
||||
Write the focused unit/component and browser regressions before production changes.
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm test components/settings/system/storage/storage-overview-resources.test.ts components/settings/system/storage/storage-overview-card.test.tsx components/settings/system/storage/storage-totals.test.ts components/settings/system/storage/storage-disk-capacity-card.test.tsx)
|
||||
(cd apps/web && pnpm exec eslint components/settings/system/storage)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm run i18n:zh-hant)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm run i18n:ratchet)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/system/storage-analysis-bars.spec.ts tests/system/storage-maintenance.spec.ts tests/system/storage-temporary-folders.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/system/mobile-storage-analysis-bars.spec.ts tests/system/mobile-storage-maintenance.spec.ts tests/system/mobile-storage-temporary-folders.spec.ts)
|
||||
node --test scripts/validate-public-docs.test.mjs
|
||||
node scripts/validate-public-docs.mjs
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Create `storage-overview-resources.test.ts` and the two bars E2E files named above.
|
||||
Before the final checks, add all translations and generate the Traditional Chinese pair.
|
||||
Use controlled snapshots with shuffled byte magnitudes, stable ties, zero, missing fields, and partial values.
|
||||
Add a locale case where equal formatted sizes have different raw bytes.
|
||||
|
||||
Open two rows, focus one trigger, then replace the snapshot with changed magnitudes.
|
||||
Assert both expanded identities survive and the focused resource remains focused.
|
||||
Test first-scan source completion separately from stale-snapshot refresh.
|
||||
Verify that sorting, expansion, and resizing issue no cleanup requests.
|
||||
|
||||
Check proportional fill geometry, text visibility, no new keyboard stops, and no horizontal overflow.
|
||||
Verify the configured phone viewport and 767/768-pixel composition boundaries.
|
||||
Capture and inspect desktop and phone screenshots through `prCapture`.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/settings/system/storage/storage-overview-resources.ts`
|
||||
- `apps/web/components/settings/system/storage/storage-overview-resources.test.ts` (new)
|
||||
- `apps/web/components/settings/system/storage/storage-overview-card.tsx`
|
||||
- `apps/web/components/settings/system/storage/storage-overview-card.test.tsx`
|
||||
- Optional local helper/component under the same directory, with its logic covered by the listed tests.
|
||||
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw,ja}/system.json`
|
||||
- `apps/web/e2e/helpers/storage-maintenance.ts`
|
||||
- `apps/web/e2e/tests/system/storage-analysis-bars.spec.ts` (new)
|
||||
- `apps/web/e2e/tests/system/mobile-storage-analysis-bars.spec.ts` (new)
|
||||
- `docs/public/operations.md`
|
||||
- This plan, work orders, and the paired draft design for final status/results.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01, because both tasks change resource projection, localization, component tests, and browser fixtures.
|
||||
This dependency orders integration and preserves the completed timeout behavior.
|
||||
|
||||
## Risks
|
||||
|
||||
- Parsing formatted values gives incorrect order across locales and rounded sizes.
|
||||
- Array-index keys can transfer expansion to the wrong resource after sorting.
|
||||
- Some existing rows represent subsets or overlaps. Never recalculate Total counted from bar rows.
|
||||
- Desktop sizing assumptions can clip long translated labels on phones.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`.
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/system-page/requirements/storage-maintenance.md), section 005.
|
||||
- [Design](../../specs/system-page/system-design/storage-analysis-presentation.md), numeric mapping and presentation sections.
|
||||
- Existing `StorageOverviewResources`, `ResourceRow`, `storageResources`, and capacity-card styling.
|
||||
- Existing Storage maintenance browser fixtures and completed Task 01.
|
||||
- Scoped web `AGENTS.md`, plus TDD, E2E, mobile-parity, and docs-maintainer skills.
|
||||
|
||||
## Results
|
||||
|
||||
- `storage-overview-resources.test.ts`: passed numeric mapping, stable sorting, common scale, zero,
|
||||
missing, invalid, optional, and subset measurement cases.
|
||||
- `storage-overview-card.test.tsx`: passed decorative bar, partial status, expansion, and focus
|
||||
assertions; the full focused Storage component suite passed.
|
||||
- Desktop and mobile bars browser suites: passed descending order, proportional fills, unknown and
|
||||
zero states, refresh reordering, stable expansion and focus, touch target geometry, breakpoints,
|
||||
and horizontal containment.
|
||||
- Public operations guidance now explains relative bars and unchanged counted totals.
|
||||
@@ -13,6 +13,9 @@ legacy_specs: []
|
||||
|
||||
## Overview
|
||||
|
||||
The completed [storage analysis presentation package](../storage-analysis-presentation/plan.md)
|
||||
extends row ordering, usage bars, and timeout feedback. This completed package retains its original evidence.
|
||||
|
||||
Deliver temporary-folder visibility first, then optional cleanup of registered artifacts, then integrated browser evidence.
|
||||
Each slice preserves the distinction between measured storage and cleanup ownership.
|
||||
All work stays sequential in the primary session.
|
||||
|
||||
@@ -317,7 +317,17 @@ The Storage page also reports **System temporary folders** as a read-only footpr
|
||||
service's effective temporary folder and, on Unix, `/tmp` when it resolves to a distinct folder.
|
||||
Resolved roots, measured size, and partial or unavailable status are shown. This footprint is
|
||||
informational and can overlap counted categories, so it is excluded from **Total counted**. It has
|
||||
no cleanup action and does not claim ownership of any path.
|
||||
no cleanup action and does not claim ownership of any path. The analysis rows use the measured byte
|
||||
values to order categories from largest to smallest. A decorative bar compares each displayed
|
||||
measurement with the largest displayed measurement; zero measurements have an empty bar, and
|
||||
unknown or unavailable measurements have no bar. These bars compare footprints and do not change
|
||||
**Total counted**, capacity thresholds, or cleanup behavior. Overlapping categories can therefore
|
||||
appear in more than one row.
|
||||
|
||||
A temporary-folder scan that reaches its deadline keeps the sampled bytes, partial status, and
|
||||
skipped-entry counts. The expanded row shows one timeout explanation and keeps a bounded set of
|
||||
other diagnostic examples. A timeout does not authorize cleanup or indicate that the sampled size
|
||||
is a final folder total.
|
||||
|
||||
The Host tab separately reports **Temporary Kandev files** created by services that need a short-lived
|
||||
directory under the host temporary root. Each current file is registered in the Kandev database
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
status: active
|
||||
system: system-page
|
||||
created: 2026-07-14
|
||||
updated: 2026-09-12
|
||||
updated: 2026-09-22
|
||||
owners:
|
||||
- cfl
|
||||
---
|
||||
@@ -90,6 +90,7 @@ The existing application size-unit convention remains unchanged.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.7:** Analysis shall not read file contents, follow nested symlinks, cross nested mounts, change ownership, or remove files.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.8:** Desktop and phone users shall inspect sizes, long paths, and limitations without horizontal page scrolling. Copy shall use the selected language.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.9:** Existing storage access restrictions shall apply. Analysis requests shall not accept arbitrary paths from clients.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.10:** When the temporary-folder scan reaches its deadline, the expanded row shall show one localized timeout explanation and retain sampled bytes and partial status. Repeated deadline messages shall not appear. Other diagnostics shall remain bounded to ten distinct examples, with existing skipped-entry counts preserved.
|
||||
|
||||
### REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-004: Scheduled cleanup of owned temporary artifacts
|
||||
|
||||
@@ -114,8 +115,32 @@ Shared-file deletion, legacy-directory adoption, arbitrary cleanup paths, config
|
||||
new artifact producers, remote temporary folders, and changes to inherited agent temporary variables
|
||||
are outside this extension. Whole-host disk reconciliation remains outside storage analysis.
|
||||
|
||||
### REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-005: Relative storage usage bars
|
||||
|
||||
**Intent:** Operators can identify the largest measured categories without opening each storage row.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.1:** Analysis rows shall sort by measured bytes, largest first. Equal sizes shall retain their existing category order. Unmeasured rows shall appear last in that order.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.2:** Each measured row shall show its size and a proportional bar in the collapsible header. Every bar shall use zero as its origin and the largest displayed measurement as its maximum.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.3:** Measured zero shall show an empty track and its size. Unknown, pending, failed, and not-applicable rows shall retain their status without a measured bar. Partial measurements shall use sampled bytes and retain visible partial status.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.4:** Rows shall retain independent expansion, keyboard activation, and existing detail actions. Sorting and refreshed measurements shall preserve expansion and focus for each remaining row.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.5:** Bars shall compare category footprints without changing Total counted or the separate filesystem-capacity indicator. Visible copy shall explain relative sizing and possible category overlap. Bar color shall not imply a cleanup recommendation or capacity threshold.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.6:** Desktop headers shall show label, bar, size, and chevron. Phone headers shall place the bar beneath the label and size. Labels and details shall wrap without horizontal page scrolling. Phone triggers shall have a touch target of at least 44 pixels.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.7:** Text shall communicate size, measurement status, and expansion without reliance on color. All new copy shall use the selected language. Bars shall not add keyboard stops or announce scan completion.
|
||||
- **AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-005.8:** First-scan progress shall retain distinct unmeasured states as measured rows enter size order. A refresh shall keep the previous snapshot until its atomic replacement. Existing refresh and failure behavior shall remain available.
|
||||
|
||||
#### Exclusions
|
||||
|
||||
New categories, sorting controls, saved order preferences, per-file drilldown, cleanup policy changes,
|
||||
scan deadline changes, and host-wide disk attribution are outside this extension.
|
||||
|
||||
## System design
|
||||
|
||||
The implemented usage bars and timeout feedback are defined in
|
||||
[Storage analysis presentation](../system-design/storage-analysis-presentation.md).
|
||||
The [plan package](../../../plans/storage-analysis-presentation/plan.md) records implementation and verification evidence.
|
||||
|
||||
The implemented temporary-storage extension is defined in
|
||||
[Temporary storage visibility and cleanup](../system-design/storage-temporary-folders.md).
|
||||
Its [implementation package](../../../plans/storage-temporary-folders/plan.md) contains completed work orders.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
status: current
|
||||
system: system-page
|
||||
requirements:
|
||||
- REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-003
|
||||
- REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-005
|
||||
created: 2026-09-22
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Storage analysis presentation
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
System-page owns storage measurements and their operator-facing presentation.
|
||||
This design extends the existing Storage accordion with relative bars and concise deadline feedback.
|
||||
It preserves the [temporary-storage reader](storage-temporary-folders.md), cleanup ownership, and classified total.
|
||||
|
||||
The user accepted descending bars, unchanged collapsible behavior, a phone layout, and concise timeout feedback.
|
||||
The implementation is recorded in the [plan package](../../../plans/storage-analysis-presentation/plan.md).
|
||||
No new persistence, endpoint, release flag, scan limit, or cleanup action is required.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement or criterion | Design section |
|
||||
| --- | --- |
|
||||
| AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.10 | Deadline feedback |
|
||||
| REQ-SYSTEM-PAGE-STORAGE-MAINTENANCE-005 | Resource measurements, Ordering and scale, Accordion presentation |
|
||||
| AC-SYSTEM-PAGE-STORAGE-MAINTENANCE-003.3, .6, .8 | Compatibility, Deadline feedback, Phone composition |
|
||||
|
||||
## Current source evidence
|
||||
|
||||
Paths below are relative to the repository root.
|
||||
|
||||
- `apps/backend/internal/system/storage/filescan/measure.go` joins partition errors in `Limiter.MeasureWithOptions`.
|
||||
- `apps/backend/internal/system/storage/tempstore/provider.go` walks joined error leaves in
|
||||
`measurementFromResult`, removes deadline leaves, and retains distinct non-deadline diagnostics.
|
||||
- The warning count is bounded at the projection boundary, including warnings carried by joined errors.
|
||||
- `storage-overview-resources.ts` normalizes warning strings for the visible row and projects numeric
|
||||
measurements for sorting and scale derivation.
|
||||
- `StorageResource` carries optional numeric size, bar scale, and partial-state fields alongside
|
||||
localized display values.
|
||||
- `StorageOverviewResources` renders `Accordion type="multiple"`, with stable resource IDs as keys and item values.
|
||||
|
||||
Frontend filenames in this design are under `apps/web/components/settings/system/storage/`, unless another path is given.
|
||||
|
||||
## Resource measurements
|
||||
|
||||
Extend the local `StorageResource` view model with optional `sizeBytes` and a partial-status field.
|
||||
Read numeric bytes from the current summary. Never parse `value`, which contains localized text.
|
||||
Do not change the public `StorageOverviewResponse` shape.
|
||||
|
||||
| Row | Numeric source |
|
||||
| --- | --- |
|
||||
| Task workspaces | `workspaces.total_bytes` |
|
||||
| Database / Database backups | respective `size_bytes` for measured status |
|
||||
| Quarantine | `quarantine.size_bytes` unless unavailable |
|
||||
| System temporary folders | `system_temporary.size_bytes` for measured or partial status |
|
||||
| Managed Go cache | `go_cache.size_bytes` unless unavailable |
|
||||
| Distinct user Go cache | `go_cache.unmanaged_size_bytes`, only for the existing distinct-path row |
|
||||
| Registered temporary artifacts | `temporary_artifacts.total_bytes` unless unavailable |
|
||||
| Kandev containers | `docker.managed_container_bytes` when Docker is available |
|
||||
| Docker image layers | `docker.image_layer_bytes` when Docker is available |
|
||||
| Docker build cache | `docker.build_cache_bytes` when Docker is available |
|
||||
| Unused Docker images | `docker.unused_image_bytes` when Docker is available |
|
||||
|
||||
Only finite, nonnegative numbers are eligible. Missing or invalid fields have no numeric bar.
|
||||
Explicit zero is a measurement. Do not convert absent values to zero for sorting or display.
|
||||
Status gates take precedence over stale numeric fields in unavailable or not-applicable responses.
|
||||
Keep existing detail text, source progress, ownership warnings, and action handlers.
|
||||
Expose the existing system-temporary partial status in its header beside the row title.
|
||||
|
||||
## Ordering and scale
|
||||
|
||||
Build the existing canonical category list, then sort a copy by descending `sizeBytes`.
|
||||
Use canonical category position for equal measurements and unmeasured rows.
|
||||
All numeric measurements, including zero and partial samples, precede unmeasured rows.
|
||||
Locale changes must not change ties. Keep all existing categories, including subset measurements.
|
||||
|
||||
The maximum is the largest eligible measurement in the displayed snapshot.
|
||||
Each fill is `100 * sizeBytes / maximum`, clamped to 0–100.
|
||||
For an all-zero snapshot, every measured track is empty. Never divide by zero.
|
||||
Do not impose a minimum fill that overstates small measurements.
|
||||
|
||||
Keep the current `summary ?? analysis.partial_summary` snapshot preference.
|
||||
First-scan source completion updates order and scale from available measurements.
|
||||
During refresh, the displayed snapshot continues to own both until atomic replacement.
|
||||
Keep resource IDs stable so ordering does not reset expansion, focus, or existing action state.
|
||||
No saved sort state or additional scan requests are required.
|
||||
|
||||
The maximum includes informational and subset rows because their full footprints are being compared.
|
||||
It is not Total counted or disk capacity. Existing overlap rules in `storage-totals.ts` remain unchanged.
|
||||
|
||||
## Accordion presentation
|
||||
|
||||
Keep the existing Accordion and full-row trigger, including its chevron and keyboard behavior.
|
||||
Use a flexible desktop header with label, rounded track, size, then chevron.
|
||||
Use existing theme tokens for the muted track and one consistent fill.
|
||||
Use amber status text or a badge beside the row title for partial measurements, without encoding size thresholds.
|
||||
Keep existing action buttons inside expanded content.
|
||||
|
||||
Render bars as decorative spans with `aria-hidden="true"`.
|
||||
The trigger already exposes the label, formatted size, partial status, and expansion.
|
||||
A relative footprint is not scan progress. Do not give it progress-bar semantics.
|
||||
Bars introduce no tooltip, keyboard stop, animation requirement, or nested interactive control.
|
||||
|
||||
Add visible localized explanatory text near the rows:
|
||||
"Bars compare category sizes. Categories can overlap."
|
||||
Keep the existing classified-total scope explanation.
|
||||
Long labels wrap. Numeric values and chevrons remain readable.
|
||||
|
||||
## Phone composition
|
||||
|
||||
Entry: Settings > System > Storage. Primary row action: tap to expand or collapse.
|
||||
The nearest shipped exemplar is the Storage accordion in `mobile-storage-maintenance.spec.ts`.
|
||||
Its inline detail and whole-row activation fit short reference content.
|
||||
The curated mobile guide contributes one focal flow, visible actions, and a single scroll owner.
|
||||
|
||||
Below the existing 768-pixel phone boundary, place the bar beneath the label and value.
|
||||
A long label can occupy its own line. Keep the chevron at the trailing edge.
|
||||
Use one shared resource model and Accordion state across viewport changes.
|
||||
The existing page owns scrolling and safe-area behavior. Do not add a fixed footer or nested scroller.
|
||||
Keep phone/coarse-pointer trigger hit targets at least 44 pixels.
|
||||
Preserve desktop density and existing action sizing.
|
||||
|
||||
Verify the configured Pixel 5 viewport, 767 pixels, and 768 pixels.
|
||||
Check actual bar position, target bounds, wrapped paths, and page containment.
|
||||
The plan owns the exact [ASCII previews](../../../plans/storage-analysis-presentation/plan.md#ascii-ui-preview).
|
||||
|
||||
## Deadline feedback
|
||||
|
||||
Retain the current 60-second source deadline, sampled bytes, partial status, and `reason: deadline`.
|
||||
A deadline explains an incomplete measurement. It does not imply failed cleanup or corrupt storage.
|
||||
|
||||
Normalize warning examples at the `tempstore` projection boundary.
|
||||
Walk joined error leaves before converting them into strings.
|
||||
Discard leaves classified as `context.DeadlineExceeded`, while retaining distinct non-deadline diagnostics.
|
||||
Do not discard a mixed joined error merely because `errors.Is` finds a deadline somewhere inside it.
|
||||
Merge remaining examples with `result.Warnings`, deduplicate them, and apply the existing ten-example limit.
|
||||
Apply the same deduplication when `summarize` combines root warnings.
|
||||
Retain skipped-entry counts exactly. Cancelled partitions do not invent skipped-entry counts.
|
||||
Keep parent cancellation behavior and shared strict scanner callers unchanged.
|
||||
|
||||
In `systemTemporaryResource`, derive one localized explanation from the summary or root `reason: deadline`:
|
||||
"Scan timed out. Showing partial usage."
|
||||
Show it once in the expanded category, alongside existing root sizes, partial status, and skipped counts.
|
||||
Other bounded diagnostics remain visible.
|
||||
|
||||
Handle cached or older payloads at this same frontend boundary.
|
||||
Split legacy warning strings on line breaks, discard exact trimmed deadline-only lines, and deduplicate remaining examples.
|
||||
Use structured reasons first. Legacy exact deadline-only lines can also identify this known timeout.
|
||||
Do not match arbitrary path substrings or hide unrelated warnings.
|
||||
Cap the rendered examples at ten, independently of the timeout explanation.
|
||||
No cache flush, migration, automatic retry, or new diagnostic overlay is required.
|
||||
|
||||
## Compatibility and verification
|
||||
|
||||
The [temporary-storage ownership decision](../../../decisions/2026-09-11-temporary-storage-visibility-policy.md)
|
||||
remains authoritative. Analysis grants no cleanup authority.
|
||||
Storage access restrictions, safe traversal, events, and API authorization remain unchanged.
|
||||
Total counted excludes system temporary folders and avoids double-counting existing subsets.
|
||||
|
||||
All new copy uses `t()` in the `system` namespace.
|
||||
Update English, pt-pt, zh-cn, zh-hk, zh-tw, and ja.
|
||||
Generate the Traditional Chinese pair with `pnpm run i18n:zh-hant`.
|
||||
|
||||
Use deterministic joined errors and injected scanner results for timeout tests.
|
||||
Do not wait 60 seconds or scan host temporary folders.
|
||||
Use frontend unit tests for source mapping, sorting, invalid values, ratios, and old payloads.
|
||||
Use component and browser tests for expansion, refresh, focus, localization, and mobile geometry.
|
||||
The plan records exact commands and implementation results.
|
||||
Public operations documentation now explains relative bars and temporary-folder timeout behavior.
|
||||
@@ -143,6 +143,10 @@ Permanent deletion retains its existing reporting and retention deadline.
|
||||
|
||||
## Presentation
|
||||
|
||||
The current [storage analysis presentation design](storage-analysis-presentation.md) extends row ordering
|
||||
and timeout feedback. Its measured size order supersedes the adjacency described below.
|
||||
The existing reader, ownership, totals, and cleanup contracts remain authoritative.
|
||||
|
||||
Add a System temporary folders disclosure beside Kandev temporary artifacts in the analysis section.
|
||||
Show full folder size and the visible note: `Informational. Can overlap counted categories.`
|
||||
Expanded content shows resolved roots, partial status, and measurement limitations.
|
||||
@@ -161,7 +165,8 @@ Keep one page scroll owner. Paths wrap within the resource detail.
|
||||
Keep required help visible inline. No tooltip or overlay is needed to understand cleanup scope.
|
||||
Phone controls have at least 44-pixel hit targets. Fine-pointer buttons retain 28-pixel density.
|
||||
No fixed toolbar or new navigation route is required.
|
||||
All new copy uses `system` translations in all five supported language catalogs.
|
||||
All new copy uses `system` translations in the `en`, `pt-pt`, `zh-cn`, `zh-hk`,
|
||||
`zh-tw`, and `ja` language catalogs.
|
||||
|
||||
## Verification and delivery
|
||||
|
||||
|
||||
Reference in New Issue
Block a user