feat: preview sorted storage usage bars (#3867)

This commit is contained in:
Carlos Florêncio
2026-09-23 13:45:37 +01:00
committed by GitHub
parent 9944354518
commit d3db4c7279
30 changed files with 2122 additions and 84 deletions
@@ -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),
];
]);
}
+174 -3
View File
@@ -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", {
+2
View File
@@ -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",
+2
View File
@@ -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}} 項目をスキップ",
+2
View File
@@ -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}} ēńţŕŷ śķĩƥƥēď",
+2
View File
@@ -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",
+2
View File
@@ -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}} 个条目",
+2
View File
@@ -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}} 個條目",
+2
View File
@@ -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}} 個條目",