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}} 個條目",
|
||||
|
||||
Reference in New Issue
Block a user