feat: improve storage page loading (#2161)

This commit is contained in:
Carlos Florêncio
2026-08-02 20:11:23 +01:00
committed by GitHub
parent 11b8dae745
commit a100cb9169
46 changed files with 1935 additions and 189 deletions
+1
View File
@@ -831,6 +831,7 @@ func startGatewayAndServe(
})
storageComposition, err := provideStorageComposition(
cfg, dbPool, systemSvc.Jobs, lifecycleMgr, services.WorktreeMgr, services.Task,
func(message string, err error) { log.Error(message, zap.Error(err)) },
)
if err != nil {
log.Error("Failed to initialize storage maintenance", zap.Error(err))
@@ -36,6 +36,7 @@ func provideStorageComposition(
lifecycleMgr *lifecycle.Manager,
worktreeMgr *worktree.Manager,
taskSvc *taskservice.Service,
logError func(string, error),
) (*storageComposition, error) {
rawSettings, err := systemsettings.NewStore(pool)
if err != nil {
@@ -89,7 +90,7 @@ func provideStorageComposition(
})
handler := storagepkg.NewHandler(storagepkg.HandlerConfig{
Settings: settings, Runs: store, Quarantine: store, Overview: cachedOverview,
Mutations: operations, OnSettingsChanged: runtime.ApplySettings,
Mutations: operations, OnSettingsChanged: runtime.ApplySettings, LogError: logError,
})
return &storageComposition{
handler: handler, runtime: runtime, workspaceRestorer: quarantine,
@@ -167,11 +168,18 @@ func (o *storageOverview) Capabilities(
ctx context.Context,
settings storagepkg.StorageMaintenanceSettings,
) storagepkg.Capabilities {
dockerAvailable := o.dockerClient.Ping(ctx) == nil
return o.SettingsCapabilities(ctx, settings)
}
func (o *storageOverview) SettingsCapabilities(
ctx context.Context,
settings storagepkg.StorageMaintenanceSettings,
) storagepkg.Capabilities {
goPath := settings.GoCache.AdoptedPath
if goPath == "" {
goPath = filepath.Join(o.homeDir, "cache", "go-build")
}
dockerAvailable := o.dockerClient != nil && o.dockerClient.Ping(ctx) == nil
return storagepkg.Capabilities{
ManagedGoCachePath: goPath, GoCacheAdoptionAvailable: true,
DockerAvailable: dockerAvailable, DockerHost: o.dockerHost,
@@ -107,7 +107,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) {
//
// Update this number when adding or removing event subscriptions in
// RegisterTaskNotifications — it is intentionally exact.
const wantSubscriptions = 61
const wantSubscriptions = 62
if got := len(b.subscriptions); got != wantSubscriptions {
t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+
"did an event get subscribed twice?", got, wantSubscriptions)
@@ -221,6 +221,23 @@ func TestMCPStopTask_DirectParentStopsLongRunningChild(t *testing.T) {
require.Equal(t, v1.TaskStateInProgress, taskBefore.State)
require.True(t, manager.IsAgentRunningForSession(context.Background(), launch.SessionID))
promptsBefore := manager.promptCalls.Load()
// Agent stream delivery is asynchronous with respect to the RUNNING state
// notification. Wait for the simulated tool-call message before taking the
// baseline so a legitimate in-flight stream event is not mistaken for output
// created after the coordinator stop.
require.Eventually(t, func() bool {
messages, listErr := ts.TaskRepo.ListMessages(context.Background(), launch.SessionID)
if listErr != nil {
return false
}
for _, message := range messages {
if message.Type == models.MessageTypeToolCall &&
message.Metadata["tool_call_id"] == "long-running-work" {
return true
}
}
return false
}, 2*time.Second, 10*time.Millisecond)
messagesBefore, err := ts.TaskRepo.ListMessages(context.Background(), launch.SessionID)
require.NoError(t, err)
@@ -232,6 +232,7 @@ func (s *Service) handleAgentBootReady(ctx context.Context, data watcher.AgentEv
zap.String("session_id", data.SessionID))
return
}
if s.isCancelInFlight(data.SessionID) {
s.logger.Debug("ignoring agent.boot_ready while cancel is in progress",
zap.String("task_id", data.TaskID),
@@ -278,7 +279,6 @@ func (s *Service) handleAgentBootReady(ctx context.Context, data watcher.AgentEv
} else {
s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID, session)
}
// Drain any orphaned queued message. handleAgentReady drains on turn-end,
// but a session that crashed mid-turn (or never started its first turn)
// won't fire agent.ready — leaving e.g. workflow auto-start prompts stuck
@@ -457,6 +457,9 @@ func (s *Service) PauseForClarificationInput(ctx context.Context, sessionID stri
// (agent crashed mid-turn). In that case, skip the cancel signal but still reconcile the
// session's state so clarification recovery can proceed with a fresh prompt.
func (s *Service) cancelAgentSilent(ctx context.Context, taskID, sessionID string) error {
endCancel := s.beginCancelInFlight(sessionID)
defer endCancel()
if s.agentManager == nil {
s.logger.Debug("skipping silent clarification cancel because agent manager is not configured",
zap.String("task_id", taskID),
@@ -898,6 +898,8 @@ func TestHandleAgentBootReady_DoesNotDrainWhileCancelInFlight(t *testing.T) {
); err != nil {
t.Fatalf("queue prompt: %v", err)
}
endCancel := svc.beginCancelInFlight("s1")
defer endCancel()
lock, release := svc.acquireCancelInFlightGuard("s1")
defer release()
lock.Lock()
@@ -22,7 +22,17 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle
if payload == nil || payload.Data == nil {
return
}
if payload.SessionID != "" {
// Serialize stream side effects with cancellation/interrupt decisions.
// Checking the terminal-execution marker alone is insufficient: a stream
// handler can pass that check, then block while persisting a message while
// a coordinator stop marks the execution terminal. Holding the shared
// per-session guard makes the check and its side effects one decision.
lock, release := s.acquireCancelInFlightGuard(payload.SessionID)
defer release()
lock.Lock()
defer lock.Unlock()
}
taskID := payload.TaskID
sessionID := payload.SessionID
eventType := payload.Data.Type
@@ -1068,6 +1068,75 @@ func TestToolCallFromCompletedExecutionDoesNotCreateMessage(t *testing.T) {
"stale completed-execution tool calls must be dropped before message side effects")
}
type blockingToolCallMessageCreator struct {
mockMessageCreator
entered chan struct{}
release chan struct{}
}
func (m *blockingToolCallMessageCreator) CreateToolCallMessage(
context.Context,
string, string, string, string, string, string, string, *streams.NormalizedPayload,
) error {
close(m.entered)
<-m.release
m.toolCallWrites++
return nil
}
func TestAgentStreamEventWaitsForCancellationGuard(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "")
creator := &blockingToolCallMessageCreator{
entered: make(chan struct{}),
release: make(chan struct{}),
}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = creator
lock, releaseGuard := svc.acquireCancelInFlightGuard("s1")
lock.Lock()
eventDone := make(chan struct{})
go func() {
svc.handleAgentStreamEvent(ctx, &lifecycle.AgentStreamEventPayload{
TaskID: "t1",
SessionID: "s1",
ExecutionID: "exec-1",
Data: &lifecycle.AgentStreamEventData{
Type: agentEventToolCall,
ToolCallID: "tool-1",
ToolStatus: "running",
},
})
close(eventDone)
}()
select {
case <-creator.entered:
t.Fatal("stream side effect bypassed the cancellation guard")
case <-time.After(100 * time.Millisecond):
}
lock.Unlock()
releaseGuard()
select {
case <-creator.entered:
case <-time.After(time.Second):
t.Fatal("stream handler did not proceed after cancellation guard release")
}
close(creator.release)
select {
case <-eventDone:
case <-time.After(time.Second):
t.Fatal("stream handler did not finish")
}
require.Equal(t, 1, creator.toolCallWrites)
}
func TestToolCallStreamFromCompletedExecutionDoesNotSaveAgentText(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
+16 -4
View File
@@ -546,10 +546,12 @@ type Service struct {
// / handleAgentBootReady (TryLock, skip if a cancel/interrupt owns it),
// or QueueAndInterruptForPeerMessage (blocking Lock — must wait rather
// than work around a busy lock with an unguarded insert; see its doc
// comment). All of these must go through the same per-session guard —
// a second, independent lock for any of them would defeat the mutual
// exclusion the others rely on to avoid racing each other's
// take-and-dispatch decision for the same session.
// comment), or an agent stream handler persisting output (blocking Lock so
// cancellation cannot commit while a stream side effect is in flight).
// All of these must go through the same per-session guard — a second,
// independent lock for any of them would defeat the mutual exclusion the
// others rely on to avoid racing each other's take-and-dispatch decision
// for the same session.
//
// Entries are reference-counted (acquireCancelInFlightGuard /
// releaseCancelInFlightGuard) and pruned once nobody holds a reference,
@@ -558,6 +560,16 @@ type Service struct {
// long-lived backend's lifetime.
cancelInFlight map[string]*cancelInFlightGuard
// cancelOperations tracks cancellation intent separately from the shared
// cancelInFlight mutex. Stream and lifecycle handlers use that mutex to
// serialize their side effects, but they are not themselves cancellations;
// treating every mutex holder as a cancellation would make agent.boot_ready
// discard a legitimate boot signal while a stream frame is being persisted.
// Values are reference counts so duplicate cancellation requests waiting on
// the shared guard keep cancellation priority until the last request exits.
cancelOperationsMu sync.Mutex
cancelOperations map[string]int
// transientRetries tracks in-progress transient-provider-error (529
// Overloaded) retry loops. key: sessionID, value: *transientRetryEntry.
// A backoff timer per session re-drives the failed prompt; cancelled on
@@ -2443,6 +2443,9 @@ func coordinatorStopSessionID(session *models.TaskSession) string {
}
func (s *Service) stopTaskSessionForCoordinator(ctx context.Context, taskID, sessionID string) (bool, error) {
endCancel := s.beginCancelInFlight(sessionID)
defer endCancel()
lock, release := s.acquireCancelInFlightGuard(sessionID)
lock.Lock()
@@ -3916,20 +3919,50 @@ func (s *Service) acquireCancelInFlightGuard(sessionID string) (*sync.Mutex, fun
return &guard.mu, release
}
// isCancelInFlight reports whether sessionID's cancelInFlight guard is
// currently held by someone else, without itself claiming it — a
// TryLock-then-immediately-Unlock peek rather than a real acquisition. The
// guard entry this creates to perform the peek is released (and pruned if
// nothing else references it) before this returns, so a passive readiness
// probe never leaves permanent state behind.
// beginCancelInFlight records that a cancellation operation for sessionID is
// active (or waiting to claim the shared per-session guard). This is separate
// from cancelInFlight itself: stream handlers also hold that mutex while
// persisting output, but must not make boot-ready handling mistake their work
// for a cancellation. The returned release function is idempotent so callers
// can safely use it with both explicit and deferred cleanup.
func (s *Service) beginCancelInFlight(sessionID string) func() {
if sessionID == "" {
return func() {}
}
s.cancelOperationsMu.Lock()
if s.cancelOperations == nil {
s.cancelOperations = make(map[string]int)
}
s.cancelOperations[sessionID]++
s.cancelOperationsMu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
s.cancelOperationsMu.Lock()
count := s.cancelOperations[sessionID]
if count <= 1 {
delete(s.cancelOperations, sessionID)
} else {
s.cancelOperations[sessionID] = count - 1
}
s.cancelOperationsMu.Unlock()
})
}
}
// isCancelInFlight reports whether an actual cancellation operation for
// sessionID is active or waiting on the shared guard. It deliberately does
// not inspect the guard mutex itself because stream/lifecycle handlers also
// use that mutex for non-cancelling side effects.
func (s *Service) isCancelInFlight(sessionID string) bool {
lock, release := s.acquireCancelInFlightGuard(sessionID)
defer release()
if lock.TryLock() {
lock.Unlock()
if sessionID == "" {
return false
}
return true
s.cancelOperationsMu.Lock()
defer s.cancelOperationsMu.Unlock()
return s.cancelOperations[sessionID] > 0
}
// CancelAgent interrupts the current agent turn without terminating the process,
@@ -3946,6 +3979,8 @@ func (s *Service) CancelAgent(ctx context.Context, sessionID string) error {
}
s.logger.Debug("cancelling agent turn", zap.String("session_id", sessionID))
endCancel := s.beginCancelInFlight(sessionID)
defer endCancel()
// Deduplicate concurrent retries. The UI's cancel button has no in-flight
// disable, so impatient users click it multiple times while the agent is
@@ -2256,6 +2256,29 @@ func TestAcquireCancelInFlightGuard_PrunesEntryWhenNoLongerReferenced(t *testing
}
}
func TestCancelIntentDoesNotFollowSharedGuard(t *testing.T) {
svc := &Service{}
lock, release := svc.acquireCancelInFlightGuard("s1")
lock.Lock()
defer func() {
lock.Unlock()
release()
}()
if svc.isCancelInFlight("s1") {
t.Fatal("a non-cancelling shared guard holder must not look like a cancellation")
}
endCancel := svc.beginCancelInFlight("s1")
if !svc.isCancelInFlight("s1") {
t.Fatal("active cancellation intent must be visible to readiness probes")
}
endCancel()
if svc.isCancelInFlight("s1") {
t.Fatal("released cancellation intent must no longer be visible")
}
}
// --- StartCreatedSession ---
func TestStartCreatedSession_WrongTask(t *testing.T) {
@@ -54,11 +54,13 @@ type QuarantineSummary struct {
type OverviewProvider interface {
Summary(context.Context) (Summary, error)
Capabilities(context.Context, StorageMaintenanceSettings) Capabilities
SettingsCapabilities(context.Context, StorageMaintenanceSettings) Capabilities
}
type OverviewReader interface {
Get(context.Context) (OverviewSnapshot, error)
Capabilities(context.Context, StorageMaintenanceSettings) Capabilities
SettingsCapabilities(context.Context, StorageMaintenanceSettings) Capabilities
}
type HandlerConfig struct {
@@ -68,6 +70,7 @@ type HandlerConfig struct {
Overview OverviewReader
Mutations Mutations
OnSettingsChanged func(StorageMaintenanceSettings)
LogError func(string, error)
}
type Handler struct {
@@ -78,8 +81,15 @@ func NewHandler(config HandlerConfig) *Handler {
return &Handler{config: config}
}
func (h *Handler) logError(message string, err error) {
if h.config.LogError != nil {
h.config.LogError(message, err)
}
}
func RegisterRoutes(group *gin.RouterGroup, handler *Handler) {
group.GET("/storage", handler.getStorage)
group.GET("/storage/settings", handler.getStorageSettings)
group.PATCH("/storage/settings", handler.patchSettings)
group.POST("/storage/go-cache/adopt", handler.adoptGoCache)
group.POST("/storage/analyze", handler.analyze)
@@ -228,7 +238,8 @@ func writeMutationError(c *gin.Context, err error) {
func (h *Handler) getStorage(c *gin.Context) {
settings, err := h.config.Settings.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
h.logError("failed to load storage settings", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load storage settings"})
return
}
snapshot, err := h.config.Overview.Get(c.Request.Context())
@@ -251,6 +262,19 @@ func (h *Handler) getStorage(c *gin.Context) {
})
}
func (h *Handler) getStorageSettings(c *gin.Context) {
settings, err := h.config.Settings.GetSettings(c.Request.Context())
if err != nil {
h.logError("failed to load storage settings", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load storage settings"})
return
}
c.JSON(http.StatusOK, gin.H{
"settings": settings,
"capabilities": h.config.Overview.SettingsCapabilities(c.Request.Context(), settings),
})
}
type patchSettingsRequest struct {
Settings StorageMaintenanceSettings `json:"settings" binding:"required"`
Confirmations struct {
@@ -71,6 +71,81 @@ func TestGetStorageReturnsSnapshotAnalyzedAt(t *testing.T) {
}
}
func TestGetStorageSettingsReturnsPolicyWithoutOverviewScan(t *testing.T) {
gin.SetMode(gin.TestMode)
settings := DefaultSettings()
capabilities := Capabilities{
ManagedGoCachePath: "/data/cache/go-build",
GoCacheAdoptionAvailable: true,
DockerAvailable: true,
DockerHost: "unix:///var/run/docker.sock",
HostGlobalDockerCleanup: true,
}
overview := &recordingOverviewReader{capabilities: capabilities}
router := gin.New()
RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{
Settings: staticSettingsManager{settings: settings},
Overview: overview,
}))
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/system/storage/settings", nil))
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", response.Code)
}
var body struct {
Settings StorageMaintenanceSettings `json:"settings"`
Capabilities Capabilities `json:"capabilities"`
}
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Settings != settings {
t.Fatalf("settings = %#v, want %#v", body.Settings, settings)
}
if body.Capabilities != capabilities {
t.Fatalf("capabilities = %#v, want %#v", body.Capabilities, capabilities)
}
if overview.settingsCapabilitiesCalls != 1 {
t.Fatalf("settings capabilities calls = %d, want 1", overview.settingsCapabilitiesCalls)
}
if overview.capabilitiesCalls != 0 {
t.Fatalf("full capabilities calls = %d, want 0", overview.capabilitiesCalls)
}
if overview.getCalls != 0 {
t.Fatalf("overview scans = %d, want 0", overview.getCalls)
}
}
func TestGetStorageSettingsHidesInternalLoadFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
internalErr := errors.New("database credentials leaked")
var loggedMessage string
var loggedErr error
router := gin.New()
RegisterRoutes(router.Group("/api/v1/system"), NewHandler(HandlerConfig{
Settings: failingSettingsManager{getErr: internalErr},
LogError: func(message string, err error) {
loggedMessage, loggedErr = message, err
},
}))
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/system/storage/settings", nil))
if response.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", response.Code)
}
if strings.Contains(response.Body.String(), "credentials") ||
!strings.Contains(response.Body.String(), "failed to load storage settings") {
t.Fatalf("response did not use a client-safe message: %s", response.Body.String())
}
if loggedMessage != "failed to load storage settings" || !errors.Is(loggedErr, internalErr) {
t.Fatalf("logged error = (%q, %v), want original error", loggedMessage, loggedErr)
}
}
func TestDeleteQuarantineBulkValidatesConfirmation(t *testing.T) {
gin.SetMode(gin.TestMode)
mutations := &recordingMutations{}
@@ -119,16 +194,25 @@ func (m *recordingMutations) PurgeQuarantine(context.Context, QuarantinePurgeSco
return "job", nil
}
type failingSettingsManager struct{ err error }
type failingSettingsManager struct {
err error
getErr error
}
func (f failingSettingsManager) GetSettings(context.Context) (StorageMaintenanceSettings, error) {
if f.getErr != nil {
return DefaultSettings(), f.getErr
}
return DefaultSettings(), nil
}
type staticSettingsManager struct{}
type staticSettingsManager struct{ settings StorageMaintenanceSettings }
func (staticSettingsManager) GetSettings(context.Context) (StorageMaintenanceSettings, error) {
return DefaultSettings(), nil
func (s staticSettingsManager) GetSettings(context.Context) (StorageMaintenanceSettings, error) {
if s.settings == (StorageMaintenanceSettings{}) {
return DefaultSettings(), nil
}
return s.settings, nil
}
func (staticSettingsManager) SaveSettingsWithConfirmations(context.Context, StorageMaintenanceSettings, SaveConfirmations) (StorageMaintenanceSettings, error) {
@@ -147,6 +231,38 @@ func (o staticCachedOverview) Capabilities(context.Context, StorageMaintenanceSe
return Capabilities{}
}
func (o staticCachedOverview) SettingsCapabilities(
context.Context,
StorageMaintenanceSettings,
) Capabilities {
return Capabilities{}
}
type recordingOverviewReader struct {
capabilities Capabilities
getCalls int
capabilitiesCalls int
settingsCapabilitiesCalls int
}
func (o *recordingOverviewReader) Get(context.Context) (OverviewSnapshot, error) {
o.getCalls++
return OverviewSnapshot{}, nil
}
func (o *recordingOverviewReader) Capabilities(context.Context, StorageMaintenanceSettings) Capabilities {
o.capabilitiesCalls++
return o.capabilities
}
func (o *recordingOverviewReader) SettingsCapabilities(
context.Context,
StorageMaintenanceSettings,
) Capabilities {
o.settingsCapabilitiesCalls++
return o.capabilities
}
func (f failingSettingsManager) SaveSettingsWithConfirmations(
context.Context,
StorageMaintenanceSettings,
@@ -455,6 +455,13 @@ func (o *recordingRefreshOverview) Capabilities(context.Context, StorageMaintena
return Capabilities{}
}
func (o *recordingRefreshOverview) SettingsCapabilities(
context.Context,
StorageMaintenanceSettings,
) Capabilities {
return Capabilities{}
}
func (o *recordingRefreshOverview) Invalidate() {
o.invalidateCalls++
}
@@ -77,6 +77,13 @@ func (c *OverviewCache) Capabilities(ctx context.Context, settings StorageMainte
return c.provider.Capabilities(ctx, settings)
}
func (c *OverviewCache) SettingsCapabilities(
ctx context.Context,
settings StorageMaintenanceSettings,
) Capabilities {
return c.provider.SettingsCapabilities(ctx, settings)
}
func (c *OverviewCache) Invalidate() {
c.mu.Lock()
c.generation++
@@ -256,6 +256,13 @@ func (o *countingOverview) Capabilities(context.Context, StorageMaintenanceSetti
return Capabilities{}
}
func (o *countingOverview) SettingsCapabilities(
context.Context,
StorageMaintenanceSettings,
) Capabilities {
return Capabilities{}
}
type overviewResult struct {
snapshot OverviewSnapshot
err error
@@ -293,6 +300,13 @@ func (*firstCallBlockingOverview) Capabilities(
return Capabilities{}
}
func (*firstCallBlockingOverview) SettingsCapabilities(
context.Context,
StorageMaintenanceSettings,
) Capabilities {
return Capabilities{}
}
func (o *cancellableOverview) Summary(ctx context.Context) (Summary, error) {
o.calls++
close(o.started)
@@ -307,3 +321,10 @@ func (o *cancellableOverview) Summary(ctx context.Context) (Summary, error) {
func (o *cancellableOverview) Capabilities(context.Context, StorageMaintenanceSettings) Capabilities {
return Capabilities{}
}
func (o *cancellableOverview) SettingsCapabilities(
context.Context,
StorageMaintenanceSettings,
) Capabilities {
return Capabilities{}
}
@@ -31,6 +31,18 @@ func TestRegisterRoutesExposesStorageSummary(t *testing.T) {
}
}
func TestRegisterRoutesExposesStorageSettings(t *testing.T) {
router := newStorageRoutesTestRouter(t)
request := httptest.NewRequest(http.MethodGet, "/api/v1/system/storage/settings", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("GET /api/v1/system/storage/settings status = %d, want 200", response.Code)
}
}
func TestStorageSettingsRejectInvalidPatch(t *testing.T) {
router := newStorageRoutesTestRouter(t)
settings := storage.DefaultSettings()
@@ -230,3 +242,10 @@ func (emptyStorageOverview) Get(context.Context) (storage.OverviewSnapshot, erro
func (emptyStorageOverview) Capabilities(context.Context, storage.StorageMaintenanceSettings) storage.Capabilities {
return storage.Capabilities{}
}
func (emptyStorageOverview) SettingsCapabilities(
context.Context,
storage.StorageMaintenanceSettings,
) storage.Capabilities {
return storage.Capabilities{}
}
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
}));
const IDLE_PERIOD_TEST_ID = "storage-idle-period";
const SAVE_BUTTON_NAME = "Save changes";
const ANALYZE_TEST_ID = "storage-analyze";
vi.mock("@/hooks/domains/system/use-storage-maintenance", async (importOriginal) => ({
...(await importOriginal<typeof import("@/hooks/domains/system/use-storage-maintenance")>()),
@@ -117,7 +118,7 @@ describe("StorageMaintenanceSettings", () => {
render(<StorageMaintenanceSettings />, { wrapper: Providers });
const analyzeButton = screen.getByTestId("storage-analyze");
const analyzeButton = screen.getByTestId(ANALYZE_TEST_ID);
expect(analyzeButton.textContent?.trim()).toBe("Analysis complete");
expect(analyzeButton.getAttribute("data-job-state")).toBe("succeeded");
expect(screen.queryByTestId("storage-analysis-job")).toBeNull();
@@ -137,7 +138,7 @@ describe("StorageMaintenanceSettings", () => {
render(<StorageMaintenanceSettings />, { wrapper: Providers });
const analyzeButton = screen.getByTestId("storage-analyze") as HTMLButtonElement;
const analyzeButton = screen.getByTestId(ANALYZE_TEST_ID) as HTMLButtonElement;
expect(analyzeButton.textContent?.trim()).toBe("Analyzing...");
expect(analyzeButton.disabled).toBe(true);
});
@@ -283,7 +284,7 @@ describe("StorageMaintenanceSettings pending policy", () => {
mocks.useStorageMaintenance.mockReturnValue(currentController);
render(<StorageMaintenanceSettings />, { wrapper: Providers });
expect((screen.getByTestId("storage-analyze") as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByTestId(ANALYZE_TEST_ID) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByTestId("storage-run-now") as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByTestId(IDLE_PERIOD_TEST_ID), { target: { value: "31" } });
expect(
@@ -292,6 +293,19 @@ describe("StorageMaintenanceSettings pending policy", () => {
},
);
it("keeps section actions enabled while policy is loading", () => {
mocks.useStorageMaintenance.mockReturnValue({
...controller(overview),
loading: { policy: true, overview: false, runs: false, quarantine: false },
sectionErrors: { policy: null, overview: null, runs: null, quarantine: null },
});
render(<StorageMaintenanceSettings />, { wrapper: Providers });
expect((screen.getByTestId(ANALYZE_TEST_ID) as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByTestId("storage-run-now") as HTMLButtonElement).disabled).toBe(false);
});
it("blocks Go cache adoption while a save is pending", () => {
mocks.useStorageMaintenance.mockReturnValue({
...controller(overview),
@@ -2,8 +2,10 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import { Card, CardContent } from "@kandev/ui/card";
import { Spinner } from "@kandev/ui/spinner";
import { IconAlertTriangle, IconCheck, IconPlayerPlay, IconRefresh } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useStorageMaintenance,
type StorageBusyState,
@@ -187,19 +189,33 @@ function serializeSettings(settings: Settings | null): string {
}
function policyPendingAction(action: ReturnType<typeof useStorageMaintenance>["pendingAction"]) {
return action === "load" || action === "save" || action === "adopt";
return action === "save" || action === "adopt";
}
function policyBlockedReason(action: ReturnType<typeof useStorageMaintenance>["pendingAction"]) {
if (action === "adopt") return "Wait for Go cache adoption to finish.";
if (action === "load") return "Wait for storage settings to finish loading.";
function policyBlockedReason(
t: (key: string) => string,
action: ReturnType<typeof useStorageMaintenance>["pendingAction"],
loading: boolean,
) {
if (action === "adopt") return t("settings:storageAdoptionPending");
if (loading) return t("settings:storagePolicyLoadingBlock");
return undefined;
}
function storageActionDisabledReason(
t: (key: string) => string,
action: ReturnType<typeof useStorageMaintenance>["pendingAction"],
) {
if (action) return t("settings:storageActionPending");
return undefined;
}
function useStoragePolicyDraft(controller: ReturnType<typeof useStorageMaintenance>) {
const { t } = useTranslation();
const [draft, setDraft] = useState<Settings | null>(null);
const previousServerSettings = useRef<Settings | null>(null);
const savedSettings = controller.overview?.settings ?? null;
const savedSettings = controller.policy?.settings ?? controller.overview?.settings ?? null;
const policyLoading = controller.loading?.policy ?? !savedSettings;
useEffect(() => {
if (!savedSettings) return;
@@ -216,7 +232,7 @@ function useStoragePolicyDraft(controller: ReturnType<typeof useStorageMaintenan
previousServerSettings.current = savedSettings;
}, [savedSettings]);
const invalidReason = policyBlockedReason(controller.pendingAction);
const invalidReason = policyBlockedReason(t, controller.pendingAction, policyLoading);
useSettingsSaveContributor({
id: "system:storage-policy",
revision: serializeSettings(draft),
@@ -242,13 +258,125 @@ function useStoragePolicyDraft(controller: ReturnType<typeof useStorageMaintenan
return { draft, setDraft, savedSettings };
}
export function StorageMaintenanceSettings() {
const controller = useStorageMaintenance();
const { draft, setDraft, savedSettings } = useStoragePolicyDraft(controller);
function StoragePolicyState({ loading, error }: { loading: boolean; error?: string | null }) {
const { t } = useTranslation();
if (!loading && !error) return null;
return (
<Card data-testid="storage-policy-state">
<CardContent className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
{loading && <Spinner className="size-4" data-testid="storage-policy-spinner" />}
<span>{loading ? t("settings:loading") : t("settings:storageSectionUnavailable")}</span>
{error && <span className="break-words text-destructive">{error}</span>}
</CardContent>
</Card>
);
}
function StoragePrimarySections({
controller,
disabledReason,
draft,
setDraft,
savedSettings,
}: {
controller: ReturnType<typeof useStorageMaintenance>;
disabledReason?: string;
draft: Settings | null;
setDraft: (settings: Settings | null) => void;
savedSettings: Settings | null;
}) {
const controlsPending = policyPendingAction(controller.pendingAction);
const actionDisabledReason = controller.pendingAction
? "Wait for the current storage action to finish."
: undefined;
const policyLoading = controller.loading?.policy ?? !savedSettings;
const capabilities = controller.policy?.capabilities ?? controller.overview?.capabilities;
return (
<div className="min-w-0 space-y-4" data-testid="storage-primary-sections">
<StorageOverviewCard
overview={controller.overview}
settings={savedSettings ?? undefined}
loading={controller.loading?.overview}
error={controller.sectionErrors?.overview}
disabledReason={disabledReason}
onRunGoCache={() => void controller.runNow(["go_cache"])}
/>
<StoragePolicyState loading={policyLoading} error={controller.sectionErrors?.policy} />
{draft && savedSettings && capabilities && (
<StoragePolicyCard
settings={draft}
savedSettings={savedSettings}
capabilities={capabilities}
pending={controlsPending}
onChange={setDraft}
onAdopt={controller.adopt}
/>
)}
</div>
);
}
function StorageQuarantineSection({
controller,
disabledReason,
savedSettings,
}: {
controller: ReturnType<typeof useStorageMaintenance>;
disabledReason?: string;
savedSettings: Settings | null;
}) {
const deleteJobActive =
controller.deleteJob?.state === "queued" || controller.deleteJob?.state === "running";
return (
<StorageQuarantineCard
entries={controller.quarantine}
loading={controller.loading?.quarantine}
error={controller.sectionErrors?.quarantine}
deleteJobId={controller.deleteJob?.id}
deleteJobActive={deleteJobActive}
disabledReason={disabledReason}
schedulingEnabled={savedSettings?.enabled ?? false}
checkIntervalHours={savedSettings?.check_interval_hours ?? 24}
onRestore={controller.restore}
onDelete={controller.permanentlyDelete}
onClearEligible={controller.clearEligible}
onForceClearAll={controller.forceClearAll}
/>
);
}
function StoragePageSections({
controller,
disabledReason,
}: {
controller: ReturnType<typeof useStorageMaintenance>;
disabledReason?: string;
}) {
const { draft, setDraft, savedSettings } = useStoragePolicyDraft(controller);
return (
<>
<StoragePrimarySections
controller={controller}
disabledReason={disabledReason}
draft={draft}
setDraft={setDraft}
savedSettings={savedSettings}
/>
<StorageRunHistory
runs={controller.runs}
loading={controller.loading?.runs}
error={controller.sectionErrors?.runs}
/>
<StorageQuarantineSection
controller={controller}
disabledReason={disabledReason}
savedSettings={savedSettings}
/>
</>
);
}
export function StorageMaintenanceSettings() {
const { t } = useTranslation();
const controller = useStorageMaintenance();
const actionDisabledReason = storageActionDisabledReason(t, controller.pendingAction);
return (
<div className="min-w-0 space-y-6" data-testid="storage-settings-page">
@@ -256,38 +384,7 @@ export function StorageMaintenanceSettings() {
<StorageActionFeedback controller={controller} />
<div className="min-w-0 space-y-4" data-testid="storage-primary-sections">
<StorageOverviewCard
overview={controller.overview}
disabledReason={actionDisabledReason}
onRunGoCache={() => void controller.runNow(["go_cache"])}
/>
{draft && savedSettings && controller.overview && (
<StoragePolicyCard
settings={draft}
savedSettings={savedSettings}
capabilities={controller.overview.capabilities}
pending={controlsPending}
onChange={setDraft}
onAdopt={controller.adopt}
/>
)}
</div>
<StorageRunHistory runs={controller.runs} />
<StorageQuarantineCard
entries={controller.quarantine}
deleteJobId={controller.deleteJob?.id}
deleteJobActive={
controller.deleteJob?.state === "queued" || controller.deleteJob?.state === "running"
}
disabledReason={actionDisabledReason}
schedulingEnabled={controller.overview?.settings.enabled ?? false}
checkIntervalHours={controller.overview?.settings.check_interval_hours ?? 24}
onRestore={controller.restore}
onDelete={controller.permanentlyDelete}
onClearEligible={controller.clearEligible}
onForceClearAll={controller.forceClearAll}
/>
<StoragePageSections controller={controller} disabledReason={actionDisabledReason} />
</div>
);
}
@@ -1,4 +1,5 @@
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";
@@ -132,5 +133,48 @@ describe("StorageOverviewCard", () => {
expect(
screen.getByTestId("storage-resource-docker-image-layers-trigger").textContent,
).toContain("14 GB");
expect(screen.getByTestId("storage-analysis-total").textContent).toContain(
"Total counted: 47 GB",
);
expect(screen.getByTestId("storage-analysis-total-partial")).toBeTruthy();
});
});
describe("StorageOverviewCard refresh and policy state", () => {
it("uses the current saved policy for Go cache cleanup eligibility", () => {
const overview = {
...degradedOverview,
settings: {
...degradedOverview.settings,
go_cache: { ...degradedOverview.settings.go_cache, max_bytes: 20 * 1024 ** 3 },
},
summary: {
...degradedOverview.summary,
go_cache: {
...degradedOverview.summary.go_cache,
size_bytes: 15 * 1024 ** 3,
},
},
} satisfies StorageOverviewResponse;
const savedSettings = {
...overview.settings,
go_cache: { ...overview.settings.go_cache, max_bytes: 10 * 1024 ** 3 },
};
render(
<StorageOverviewCard overview={overview} settings={savedSettings} onRunGoCache={vi.fn()} />,
{ wrapper: TooltipProvider },
);
fireEvent.click(screen.getByTestId("storage-resource-go-cache-trigger"));
expect((screen.getByTestId("storage-go-cache-clean") as HTMLButtonElement).disabled).toBe(
false,
);
});
it("shows refresh progress while a cached snapshot is loading", () => {
render(<StorageOverviewCard overview={degradedOverview} loading onRunGoCache={vi.fn()} />);
expect(screen.getByTestId("storage-overview-spinner")).toBeTruthy();
});
});
@@ -3,13 +3,22 @@ import { Badge } from "@kandev/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
import { Spinner } from "@kandev/ui/spinner";
import { IconChartPie, IconTrash } from "@tabler/icons-react";
import type { StorageOverviewResponse, StorageQuarantineSummary } from "@/lib/types/system";
import { useTranslation } from "react-i18next";
import type {
StorageMaintenanceSettings,
StorageOverviewResponse,
StorageQuarantineSummary,
} from "@/lib/types/system";
import { formatRelativeTime } from "@/lib/utils";
import { StorageActionButton } from "./storage-action-button";
import { formatGigabytes } from "./storage-units";
import { storageAnalysisTotal } from "./storage-totals";
interface Props {
overview: StorageOverviewResponse | null;
settings?: StorageMaintenanceSettings;
loading?: boolean;
error?: string | null;
disabledReason?: string;
onRunGoCache: () => void;
}
@@ -22,12 +31,19 @@ interface StorageResource {
warning?: string;
}
function goCacheDisabledReason(overview: StorageOverviewResponse, pendingReason?: string) {
function goCacheDisabledReason(
overview: StorageOverviewResponse,
pendingReason?: string,
settings?: StorageMaintenanceSettings,
) {
if (pendingReason) return pendingReason;
if (overview.summary.go_cache.owned !== true) {
return "Only a Kandev-owned Go build cache can be cleaned.";
}
if ((overview.summary.go_cache.size_bytes ?? 0) <= overview.settings.go_cache.max_bytes) {
if (
(overview.summary.go_cache.size_bytes ?? 0) <=
(settings ?? overview.settings).go_cache.max_bytes
) {
return "The Go build cache is below its configured size limit.";
}
return undefined;
@@ -171,26 +187,40 @@ function ResourceRow({ resource, goCacheCleanupDisabledReason, onRunGoCache }: R
);
}
export function StorageOverviewCard({ overview, disabledReason, onRunGoCache }: Props) {
export function StorageOverviewCard({
overview,
settings,
loading,
error,
disabledReason,
onRunGoCache,
}: Props) {
const { t } = useTranslation();
const isLoading = loading ?? overview === null;
if (!overview) {
return (
<Card data-testid="storage-overview-card">
<CardContent className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Spinner className="size-4" data-testid="storage-overview-spinner" />
Loading storage data…
{isLoading && <Spinner className="size-4" data-testid="storage-overview-spinner" />}
<span>
{isLoading ? t("settings:storageLoadingData") : t("settings:storageSectionUnavailable")}
</span>
{error && <span className="break-words text-destructive">{error}</span>}
</CardContent>
</Card>
);
}
const { summary } = overview;
const analyzedAt = new Date(overview.analyzed_at).toLocaleString();
const cleanupDisabledReason = goCacheDisabledReason(overview, disabledReason);
const cleanupDisabledReason = goCacheDisabledReason(overview, disabledReason, settings);
const resources = storageResources(overview);
const total = storageAnalysisTotal(summary);
return (
<Card className="min-w-0" data-testid="storage-overview-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<IconChartPie className="size-4" /> Storage analysis
{isLoading && <Spinner className="size-4" data-testid="storage-overview-spinner" />}
{!summary.docker.available && <Badge variant="outline">Docker unavailable</Badge>}
</CardTitle>
<CardDescription>
@@ -205,6 +235,24 @@ export function StorageOverviewCard({ overview, disabledReason, onRunGoCache }:
>
Last analyzed {formatRelativeTime(overview.analyzed_at)}
</time>
<div
className="flex flex-wrap items-center gap-2 text-xs"
data-testid="storage-analysis-total"
>
<span className="font-medium">
{t("settings:storageTotalCounted", { size: formatGigabytes(total.bytes) })}
</span>
{total.partial && (
<Badge variant="outline" data-testid="storage-analysis-total-partial">
{t("settings:storageTotalPartial")}
</Badge>
)}
</div>
{error && (
<p className="break-words text-xs text-destructive" data-testid="storage-overview-error">
{t("settings:storageSectionUnavailable")}: {error}
</p>
)}
</CardHeader>
<CardContent className="min-w-0">
<Accordion type="multiple" className="min-w-0">
@@ -0,0 +1,91 @@
import type { ReactNode } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { StorageQuarantineEntry } from "@/lib/types/system";
import { StorageQuarantineCard } from "./storage-quarantine-card";
vi.mock("@/hooks/domains/system/use-system-jobs", () => ({
useSystemJob: () => undefined,
useSystemJobs: () => [],
}));
afterEach(cleanup);
function Providers({ children }: { children: ReactNode }) {
return <TooltipProvider>{children}</TooltipProvider>;
}
function renderCard(
props: { entries?: StorageQuarantineEntry[]; loading?: boolean; error?: string | null } = {},
) {
return render(
<StorageQuarantineCard
entries={props.entries ?? []}
schedulingEnabled={false}
checkIntervalHours={24}
onRestore={vi.fn()}
onDelete={vi.fn()}
onClearEligible={vi.fn()}
onForceClearAll={vi.fn()}
{...props}
/>,
{ wrapper: Providers },
);
}
describe("StorageQuarantineCard", () => {
it("renders an independent loading state", () => {
renderCard({ loading: true });
expect(screen.getByTestId("storage-quarantine-spinner")).toBeTruthy();
expect(screen.getByText("Loading quarantine…")).toBeTruthy();
expect(screen.queryByText("No restorable quarantined resources.")).toBeNull();
expect(screen.queryByTestId("storage-quarantine-total")).toBeNull();
});
it("renders an isolated error state", () => {
renderCard({ error: "quarantine unavailable" });
expect(screen.getByTestId("storage-quarantine-error").textContent).toContain(
"quarantine unavailable",
);
expect(screen.queryByTestId("storage-quarantine-spinner")).toBeNull();
expect(screen.queryByTestId("storage-quarantine-total")).toBeNull();
});
it("shows the total of all listed entries", () => {
const entries = [
{
id: "cache-1",
resource_type: "go_cache",
original_path: "/var/cache/go-build",
quarantine_path: "/var/trash/cache-1",
size_bytes: 2 * 1024 ** 3,
state: "quarantined",
quarantined_at: "2026-07-23T12:00:00Z",
delete_after: "2026-08-23T12:00:00Z",
last_error: "",
metadata: {},
},
{
id: "workspace-1",
resource_type: "task_workspace",
original_path: "/var/tasks/workspace-1",
quarantine_path: "/var/trash/workspace-1",
size_bytes: 1024 ** 3,
state: "quarantined",
quarantined_at: "2026-07-23T12:00:00Z",
delete_after: "2026-08-23T12:00:00Z",
last_error: "",
metadata: {},
},
] satisfies StorageQuarantineEntry[];
renderCard({ entries });
expect(screen.getByTestId("storage-quarantine-total").textContent).toBe(
"Total quarantined: 3 GB",
);
});
});
@@ -3,13 +3,16 @@
import { useEffect, useState } from "react";
import { Badge } from "@kandev/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
import { Spinner } from "@kandev/ui/spinner";
import { IconRestore, IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { StorageQuarantineEntry, StorageQuarantinePurgeScope } from "@/lib/types/system";
import { JobProgressIndicator } from "../job-progress-indicator";
import { PermanentDeleteDialog, QuarantinePurgeDialog } from "./storage-confirmation-dialogs";
import { StorageActionButton } from "./storage-action-button";
import { StorageSettingHelp } from "./storage-setting-help";
import { formatGigabytes } from "./storage-units";
import { quarantineTotalBytes } from "./storage-totals";
import {
formatQuarantineDeadline,
isQuarantineEligible,
@@ -21,6 +24,8 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647;
type Props = {
entries: StorageQuarantineEntry[];
loading?: boolean;
error?: string | null;
deleteJobId?: string;
deleteJobActive?: boolean;
disabledReason?: string;
@@ -107,7 +112,9 @@ function QuarantineHeader({
bulkDisabledReason,
schedulingEnabled,
checkIntervalHours,
showTotal,
onPurge,
totalBytes,
}: {
entries: StorageQuarantineEntry[];
counts: ReturnType<typeof quarantineCounts>;
@@ -115,8 +122,11 @@ function QuarantineHeader({
bulkDisabledReason?: string;
schedulingEnabled: boolean;
checkIntervalHours: number;
showTotal: boolean;
onPurge: (scope: StorageQuarantinePurgeScope) => void;
totalBytes: number;
}) {
const { t } = useTranslation();
return (
<CardHeader>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -165,6 +175,11 @@ function QuarantineHeader({
item if you still need it, or delete it permanently when you are certain. {counts.eligible}{" "}
eligible now · {counts.protected} protected
</CardDescription>
{showTotal && (
<p className="text-xs font-medium" data-testid="storage-quarantine-total">
{t("settings:storageQuarantineTotal", { size: formatGigabytes(totalBytes) })}
</p>
)}
<p className="text-xs text-muted-foreground" data-testid="storage-quarantine-schedule-copy">
{schedulingEnabled
? `Eligible entries are removed by the first successful maintenance run after their deadline (every ${checkIntervalHours} hours when the idle gate allows it).`
@@ -174,8 +189,58 @@ function QuarantineHeader({
);
}
function QuarantineContent({
entries,
loading,
error,
now,
disabledReason,
onRestore,
onDelete,
}: {
entries: StorageQuarantineEntry[];
loading: boolean;
error?: string | null;
now: Date;
disabledReason?: string;
onRestore: (id: string) => Promise<void>;
onDelete: (entry: StorageQuarantineEntry) => void;
}) {
const { t } = useTranslation();
if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" data-testid="storage-quarantine-spinner" />
{t("settings:storageQuarantineLoading")}
</div>
);
}
if (error) {
return (
<p className="break-words text-sm text-destructive" data-testid="storage-quarantine-error">
{t("settings:storageSectionUnavailable")}: {error}
</p>
);
}
if (entries.length === 0) {
return <p className="text-sm text-muted-foreground">{t("settings:storageQuarantineEmpty")}</p>;
}
return entries.map((entry) => (
<QuarantineEntryRow
key={entry.id}
entry={entry}
now={now}
disabledReason={disabledReason}
onRestore={onRestore}
onDelete={onDelete}
/>
));
}
export function StorageQuarantineCard({
entries,
loading = false,
error,
deleteJobId,
deleteJobActive,
disabledReason,
@@ -201,6 +266,7 @@ export function StorageQuarantineCard({
return () => clearTimeout(timer);
}, [entries, now]);
const counts = quarantineCounts(entries, now);
const totalBytes = quarantineTotalBytes(entries);
const bulkDisabledReason =
disabledReason ?? (deleteJobActive ? "A quarantine cleanup is still running." : undefined);
return (
@@ -212,22 +278,20 @@ export function StorageQuarantineCard({
bulkDisabledReason={bulkDisabledReason}
schedulingEnabled={schedulingEnabled}
checkIntervalHours={checkIntervalHours}
showTotal={!loading && !error}
onPurge={setPurgeScope}
totalBytes={totalBytes}
/>
<CardContent className="space-y-3">
{entries.length === 0 && (
<p className="text-sm text-muted-foreground">No restorable quarantined resources.</p>
)}
{entries.map((entry) => (
<QuarantineEntryRow
key={entry.id}
entry={entry}
now={now}
disabledReason={bulkDisabledReason}
onRestore={onRestore}
onDelete={setDeleteEntry}
/>
))}
<QuarantineContent
entries={entries}
loading={loading}
error={error}
now={now}
disabledReason={bulkDisabledReason}
onRestore={onRestore}
onDelete={setDeleteEntry}
/>
</CardContent>
<PermanentDeleteDialog
entry={deleteEntry}
@@ -0,0 +1,24 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { StorageRunHistory } from "./storage-run-history";
afterEach(cleanup);
describe("StorageRunHistory", () => {
it("renders an independent loading state", () => {
render(<StorageRunHistory runs={[]} loading />);
expect(screen.getByTestId("storage-run-history-spinner")).toBeTruthy();
expect(screen.getByText("Loading maintenance history…")).toBeTruthy();
expect(screen.queryByText("No storage maintenance runs yet.")).toBeNull();
});
it("renders an isolated error state", () => {
render(<StorageRunHistory runs={[]} error="history unavailable" />);
expect(screen.getByTestId("storage-run-history-error").textContent).toContain(
"history unavailable",
);
expect(screen.queryByTestId("storage-run-history-spinner")).toBeNull();
});
});
@@ -1,6 +1,8 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@kandev/ui/accordion";
import { Badge } from "@kandev/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Spinner } from "@kandev/ui/spinner";
import { useTranslation } from "react-i18next";
import type { StorageMaintenanceRun } from "@/lib/types/system";
function dateLabel(value: string): string {
@@ -8,39 +10,75 @@ function dateLabel(value: string): string {
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
export function StorageRunHistory({ runs }: { runs: StorageMaintenanceRun[] }) {
export function StorageRunHistory({
runs,
loading = false,
error,
}: {
runs: StorageMaintenanceRun[];
loading?: boolean;
error?: string | null;
}) {
const content = <StorageRunHistoryContent runs={runs} loading={loading} error={error} />;
return (
<Card className="min-w-0" data-testid="storage-run-history">
<CardHeader>
<CardTitle className="text-base">Maintenance history</CardTitle>
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<p className="text-sm text-muted-foreground">No storage maintenance runs yet.</p>
) : (
<Accordion type="multiple">
{runs.map((run) => (
<AccordionItem key={run.id} value={run.id} data-testid={`storage-run-${run.id}`}>
<AccordionTrigger className="min-h-11 items-center px-3 no-underline">
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Badge variant={run.state === "failed" ? "destructive" : "outline"}>
{run.state}
</Badge>
<span className="capitalize">{run.trigger}</span>
<span className="text-muted-foreground">{dateLabel(run.started_at)}</span>
</span>
</AccordionTrigger>
<AccordionContent className="px-3">
{run.message && <p className="mb-2 break-words text-amber-600">{run.message}</p>}
<pre className="max-w-full overflow-hidden whitespace-pre-wrap break-all rounded bg-muted p-3 text-[11px]">
{JSON.stringify(run.result, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</CardContent>
<CardContent>{content}</CardContent>
</Card>
);
}
function StorageRunHistoryContent({
runs,
loading,
error,
}: {
runs: StorageMaintenanceRun[];
loading: boolean;
error?: string | null;
}) {
const { t } = useTranslation();
if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" data-testid="storage-run-history-spinner" />
{t("settings:storageHistoryLoading")}
</div>
);
}
if (error) {
return (
<p className="break-words text-sm text-destructive" data-testid="storage-run-history-error">
{t("settings:storageSectionUnavailable")}: {error}
</p>
);
}
if (runs.length === 0) {
return <p className="text-sm text-muted-foreground">{t("settings:storageHistoryEmpty")}</p>;
}
return (
<Accordion type="multiple">
{runs.map((run) => (
<AccordionItem key={run.id} value={run.id} data-testid={`storage-run-${run.id}`}>
<AccordionTrigger className="min-h-11 items-center px-3 no-underline">
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Badge variant={run.state === "failed" ? "destructive" : "outline"}>
{run.state}
</Badge>
<span className="capitalize">{run.trigger}</span>
<span className="text-muted-foreground">{dateLabel(run.started_at)}</span>
</span>
</AccordionTrigger>
<AccordionContent className="px-3">
{run.message && <p className="mb-2 break-words text-amber-600">{run.message}</p>}
<pre className="max-w-full overflow-hidden whitespace-pre-wrap break-all rounded bg-muted p-3 text-[11px]">
{JSON.stringify(run.result, null, 2)}
</pre>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { StorageQuarantineEntry, StorageSummary } from "@/lib/types/system";
import { quarantineTotalBytes, storageAnalysisTotal } from "./storage-totals";
const completeSummary: StorageSummary = {
workspaces: {
total_bytes: 10,
active_bytes: 8,
candidate_bytes: 9,
},
go_cache: {
path: "/var/cache/kandev/go-build",
size_bytes: 3,
owned: true,
unmanaged_path: "/root/.cache/go-build",
unmanaged_size_bytes: 4,
},
quarantine: { count: 1, size_bytes: 2 },
docker: {
available: true,
managed_container_count: 1,
managed_container_bytes: 5,
image_layer_bytes: 6,
build_cache_bytes: 7,
unused_image_bytes: 11,
},
};
describe("storageAnalysisTotal", () => {
it("sums non-overlapping top-level measurements", () => {
expect(storageAnalysisTotal(completeSummary)).toEqual({ bytes: 37, partial: false });
});
it("excludes workspace subsets and unused Docker images", () => {
const withoutSubsets = storageAnalysisTotal(completeSummary);
const summary = {
...completeSummary,
workspaces: { ...completeSummary.workspaces, active_bytes: 0, candidate_bytes: 0 },
docker: { ...completeSummary.docker, unused_image_bytes: 0 },
};
expect(storageAnalysisTotal(summary)).toEqual(withoutSubsets);
});
it("marks unavailable top-level measurements as partial", () => {
const summary: StorageSummary = {
...completeSummary,
workspaces: { available: false, warning: "workspace inventory unavailable" },
quarantine: { available: false, warning: "quarantine unavailable" },
go_cache: { ...completeSummary.go_cache, size_bytes: undefined },
docker: { ...completeSummary.docker, available: false },
};
expect(storageAnalysisTotal(summary)).toEqual({ bytes: 4, partial: true });
});
it("does not require a user cache when no distinct path is reported", () => {
const summary: StorageSummary = {
...completeSummary,
go_cache: {
...completeSummary.go_cache,
unmanaged_path: undefined,
unmanaged_size_bytes: undefined,
},
};
expect(storageAnalysisTotal(summary)).toEqual({ bytes: 33, partial: false });
});
});
describe("quarantineTotalBytes", () => {
it("sums every listed entry, including zero-byte entries", () => {
const entries = [
{ size_bytes: 5 },
{ size_bytes: 0 },
{ size_bytes: 8 },
] as StorageQuarantineEntry[];
expect(quarantineTotalBytes(entries)).toBe(13);
expect(quarantineTotalBytes([])).toBe(0);
});
});
@@ -0,0 +1,49 @@
import type { StorageQuarantineEntry, StorageSummary } from "@/lib/types/system";
export interface StorageAnalysisTotal {
bytes: number;
partial: boolean;
}
function isMeasuredBytes(value: number | undefined): value is number {
return value !== undefined && Number.isFinite(value) && value >= 0;
}
export function storageAnalysisTotal(summary: StorageSummary): StorageAnalysisTotal {
const total: StorageAnalysisTotal = { bytes: 0, partial: false };
const addMeasurement = (value: number | undefined, available = true) => {
if (!available || !isMeasuredBytes(value)) {
total.partial = true;
return;
}
total.bytes += value;
};
addMeasurement(summary.workspaces.total_bytes, summary.workspaces.available !== false);
addMeasurement(
summary.quarantine.available === false ? undefined : summary.quarantine.size_bytes,
);
addMeasurement(summary.go_cache.size_bytes, summary.go_cache.available !== false);
if (summary.go_cache.unmanaged_path) {
addMeasurement(summary.go_cache.unmanaged_size_bytes);
}
if (summary.docker.available) {
addMeasurement(summary.docker.managed_container_bytes);
addMeasurement(summary.docker.image_layer_bytes);
addMeasurement(summary.docker.build_cache_bytes);
} else {
addMeasurement(undefined, false);
addMeasurement(undefined, false);
addMeasurement(undefined, false);
}
return total;
}
export function quarantineTotalBytes(entries: StorageQuarantineEntry[]): number {
return entries.reduce(
(total, entry) => total + (isMeasuredBytes(entry.size_bytes) ? entry.size_bytes : 0),
0,
);
}
@@ -126,7 +126,7 @@ test.describe("Mobile storage maintenance", () => {
.toBe(true);
});
test("shows progress while storage data is loading", async ({ testPage }) => {
test("shows progress while storage data is loading", async ({ testPage, prCapture }) => {
let overviewRequestStarted = false;
let releaseOverview: () => void = () => {};
let markOverviewObserved: () => void = () => {};
@@ -161,6 +161,15 @@ test.describe("Mobile storage maintenance", () => {
await expect(spinner).toBeVisible();
await expect(testPage.getByText("Loading storage data…")).toBeVisible();
await expect(testPage.getByTestId("storage-overview-card")).toBeInViewport();
await expect(testPage.getByTestId("storage-policy-card")).toBeVisible();
await expect(testPage.getByTestId("storage-run-history")).toBeVisible();
await expect(testPage.getByTestId("storage-quarantine-card")).toBeVisible();
await expect(testPage.getByTestId("storage-analysis-total")).toHaveCount(0);
await expect(testPage.getByTestId("toast-message")).toHaveCount(0);
await prCapture.screenshot("progressive-loading", {
caption: "Mobile storage keeps policy, history, and quarantine visible during analysis",
fullPage: true,
});
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth > window.innerWidth),
).toBe(false);
@@ -168,6 +177,8 @@ test.describe("Mobile storage maintenance", () => {
releaseOverview();
await expect(spinner).toBeHidden();
await expect(testPage.getByText("Storage analysis")).toBeVisible();
await expect(testPage.getByTestId("storage-analysis-total")).toBeVisible();
await expect(testPage.getByTestId("storage-quarantine-total")).toBeVisible();
} finally {
releaseOverview();
if (overviewRequestStarted) await overviewSettled;
@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import type { Route } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { seedManagedGoCache } from "../../helpers/storage-maintenance";
@@ -97,6 +98,68 @@ test.describe("System storage maintenance", () => {
}
});
test("shows fast storage sections while the overview scan is pending", async ({
testPage,
prCapture,
}) => {
let overviewRequestStarted = false;
let releaseOverview: () => void = () => {};
let markOverviewObserved: () => void = () => {};
let markOverviewSettled: () => void = () => {};
const overviewGate = new Promise<void>((resolve) => {
releaseOverview = resolve;
});
const overviewObserved = new Promise<void>((resolve) => {
markOverviewObserved = resolve;
});
const overviewSettled = new Promise<void>((resolve) => {
markOverviewSettled = resolve;
});
const overviewPattern = "**/api/v1/system/storage";
const holdOverview = async (route: Route) => {
const request = route.request();
const pathname = new URL(request.url()).pathname;
if (request.method() !== "GET" || pathname !== "/api/v1/system/storage") {
await route.continue();
return;
}
overviewRequestStarted = true;
markOverviewObserved();
await overviewGate;
try {
await route.continue();
} finally {
markOverviewSettled();
}
};
await testPage.route(overviewPattern, holdOverview);
try {
await testPage.goto("/settings/system/storage");
await overviewObserved;
await expect(testPage.getByTestId("storage-policy-card")).toBeVisible();
await expect(testPage.getByTestId("storage-run-history")).toBeVisible();
await expect(testPage.getByTestId("storage-quarantine-card")).toBeVisible();
await expect(testPage.getByTestId("storage-overview-spinner")).toBeVisible();
await expect(testPage.getByTestId("storage-analysis-total")).toHaveCount(0);
await expect(testPage.getByTestId("toast-message")).toHaveCount(0);
await prCapture.screenshot("progressive-loading", {
caption: "Desktop storage shows policy, history, and quarantine while analysis scans",
fullPage: true,
});
releaseOverview();
await expect(testPage.getByTestId("storage-analysis-total")).toBeVisible();
await expect(testPage.getByTestId("storage-quarantine-total")).toBeVisible();
await expect(testPage.getByTestId("storage-overview-spinner")).toHaveCount(0);
} finally {
releaseOverview();
if (overviewRequestStarted) await overviewSettled;
await testPage.unroute(overviewPattern, holdOverview);
}
});
test("persists policy and analyzes, quarantines, and restores an orphan workspace", async ({
testPage,
backend,
@@ -3,7 +3,11 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { ApiError } from "@/lib/api/client";
import type { StorageMaintenanceSettings, StorageOverviewResponse } from "@/lib/types/system";
import type {
StorageMaintenanceSettings,
StorageOverviewResponse,
StoragePolicyResponse,
} from "@/lib/types/system";
const mocks = vi.hoisted(() => ({
adopt: vi.fn(),
@@ -12,6 +16,7 @@ const mocks = vi.hoisted(() => ({
purge: vi.fn(),
fetchJob: vi.fn(),
fetchOverview: vi.fn(),
fetchPolicy: vi.fn(),
fetchQuarantine: vi.fn(),
fetchRuns: vi.fn(),
restore: vi.fn(),
@@ -31,6 +36,7 @@ vi.mock("@/lib/api/domains/system-api", () => ({
purgeStorageQuarantine: mocks.purge,
fetchSystemJob: mocks.fetchJob,
fetchStorageOverview: mocks.fetchOverview,
fetchStoragePolicy: mocks.fetchPolicy,
fetchStorageQuarantine: mocks.fetchQuarantine,
fetchStorageRuns: mocks.fetchRuns,
restoreStorageQuarantine: mocks.restore,
@@ -99,10 +105,12 @@ const TEST_COMMAND_BUSY_LABEL = "A test command is running";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve };
return { promise, resolve, reject };
}
function wrapper({ children }: { children: ReactNode }) {
@@ -112,6 +120,10 @@ function wrapper({ children }: { children: ReactNode }) {
beforeEach(() => {
vi.clearAllMocks();
mocks.fetchOverview.mockResolvedValue(overview);
mocks.fetchPolicy.mockResolvedValue({
settings: overview.settings,
capabilities: overview.capabilities,
});
mocks.fetchRuns.mockResolvedValue([]);
mocks.fetchQuarantine.mockResolvedValue([]);
mocks.fetchJob.mockResolvedValue(cleanupJob);
@@ -122,6 +134,29 @@ beforeEach(() => {
});
describe("useStorageMaintenance", () => {
it("publishes fast sections before a cold overview scan finishes", async () => {
const overviewRequest = deferred<StorageOverviewResponse>();
mocks.fetchOverview.mockReturnValueOnce(overviewRequest.promise);
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
await waitFor(() => expect(result.current.policy?.settings).toEqual(settings));
expect(result.current.loading).toMatchObject({
policy: false,
runs: false,
quarantine: false,
overview: true,
});
expect(result.current.overview).toBeNull();
await act(async () => {
overviewRequest.resolve(overview);
await overviewRequest.promise;
});
await waitFor(() => expect(result.current.overview).toEqual(overview));
expect(result.current.loading.overview).toBe(false);
});
it("loads overview, run history, and quarantine through the domain controller", async () => {
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
await waitFor(() => expect(result.current.overview).toEqual(overview));
@@ -143,6 +178,20 @@ describe("useStorageMaintenance", () => {
});
});
it("refreshes policy after save without starting another overview scan", async () => {
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
await waitFor(() => expect(result.current.overview).toEqual(overview));
mocks.fetchOverview.mockClear();
mocks.fetchPolicy.mockClear();
await act(async () => {
await result.current.save(settings);
});
expect(mocks.fetchPolicy).toHaveBeenCalledTimes(1);
expect(mocks.fetchOverview).not.toHaveBeenCalled();
});
it("starts eligible and forced quarantine bulk jobs", async () => {
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
await waitFor(() => expect(result.current.overview).toEqual(overview));
@@ -463,4 +512,59 @@ describe("useStorageMaintenance reload ordering", () => {
expect(result.current.overview).toEqual(newerOverview);
});
it("does not surface a stale reload failure after a newer result commits", async () => {
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
await waitFor(() => expect(result.current.overview).toEqual(overview));
const olderResponse = deferred<StorageOverviewResponse>();
const newerOverview = {
...overview,
settings: { ...overview.settings, idle_for_minutes: 24 },
};
mocks.fetchOverview
.mockReturnValueOnce(olderResponse.promise)
.mockResolvedValueOnce(newerOverview);
let olderReload!: Promise<void>;
await act(async () => {
olderReload = result.current.reload(["overview"]);
await result.current.reload(["overview"]);
});
await waitFor(() => expect(result.current.overview).toEqual(newerOverview));
await act(async () => {
olderResponse.reject(new Error("stale overview unavailable"));
await olderReload;
});
expect(result.current.overview).toEqual(newerOverview);
expect(result.current.sectionErrors.overview).toBeNull();
});
it("does not let a stale policy response overwrite go-cache adoption", async () => {
const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
await waitFor(() => expect(result.current.overview).toEqual(overview));
const policyResponse = deferred<StoragePolicyResponse>();
const adoptedSettings = {
...settings,
go_cache: { ...settings.go_cache, adopted_path: "/custom/go-build" },
};
const adoptedResponse = { settings: adoptedSettings, capabilities: overview.capabilities };
mocks.fetchPolicy.mockReturnValueOnce(policyResponse.promise);
mocks.adopt.mockResolvedValueOnce(adoptedResponse);
let staleReload!: Promise<void>;
await act(async () => {
staleReload = result.current.reload(["policy"]);
await result.current.adopt("/custom/go-build");
});
expect(result.current.policy?.settings).toEqual(adoptedSettings);
await act(async () => {
policyResponse.resolve({ settings, capabilities: overview.capabilities });
await staleReload;
});
expect(result.current.policy?.settings).toEqual(adoptedSettings);
});
});
@@ -17,6 +17,7 @@ import {
analyzeStorage,
deleteStorageQuarantine,
fetchStorageOverview,
fetchStoragePolicy,
fetchStorageQuarantine,
fetchStorageRuns,
purgeStorageQuarantine,
@@ -28,13 +29,13 @@ import type {
StorageBusyResource,
StorageBusyResponse,
StorageMaintenanceSettings,
StoragePolicyResponse,
StorageQuarantinePurgeScope,
SystemJob,
} from "@/lib/types/system";
import { useSystemJob } from "./use-system-jobs";
export type StoragePendingAction =
| "load"
| "save"
| "analyze"
| "run"
@@ -89,7 +90,10 @@ export function settingsWithDockerAcknowledgement(
};
}
type Reload = () => Promise<void>;
export type StorageSection = "policy" | "overview" | "runs" | "quarantine";
export type StorageSectionLoading = Record<StorageSection, boolean>;
export type StorageSectionErrors = Record<StorageSection, string | null>;
type Reload = (sections?: StorageSection[]) => Promise<void>;
type SetStorageError = Dispatch<SetStateAction<string | null>>;
const TERMINAL_REFRESH_RETRY_MS = 1000;
const TERMINAL_REFRESH_MAX_RETRY_MS = 8000;
@@ -97,23 +101,20 @@ const MAX_TERMINAL_REFRESH_ATTEMPTS = 6;
function useStorageActionRunner() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [pendingActions, setPendingActions] = useState<
Array<{ id: number; action: Exclude<StoragePendingAction, "load" | null> }>
Array<{ id: number; action: Exclude<StoragePendingAction, null> }>
>([]);
const nextPendingActionId = useRef(0);
const [error, setError] = useState<string | null>(null);
const finishLoading = useCallback(() => setLoading(false), []);
const pendingAction = useMemo<StoragePendingAction>(() => {
if (loading) return "load";
const policyAction = pendingActions.findLast(
({ action }) => action === "save" || action === "adopt",
);
return policyAction?.action ?? pendingActions[0]?.action ?? null;
}, [loading, pendingActions]);
}, [pendingActions]);
const perform = useCallback(
async (
action: Exclude<StoragePendingAction, "load" | null>,
action: Exclude<StoragePendingAction, null>,
work: () => Promise<void>,
rethrow = false,
) => {
@@ -133,7 +134,7 @@ function useStorageActionRunner() {
},
[toast],
);
return { pendingAction, error, setError, finishLoading, perform };
return { pendingAction, error, setError, perform };
}
function useStoragePolicyActions(
@@ -141,6 +142,7 @@ function useStoragePolicyActions(
reload: Reload,
toast: ReturnType<typeof useToast>["toast"],
clearBusy: () => void,
setPolicy: (policy: StoragePolicyResponse) => void,
) {
const save = useCallback(
async (settings: StorageMaintenanceSettings, confirmation?: "DEDICATED") => {
@@ -149,7 +151,7 @@ function useStoragePolicyActions(
"save",
async () => {
await saveStorageSettings(settings, confirmation);
await reload();
await reload(["policy"]);
toast({ title: "Storage policy saved", variant: "success" });
},
true,
@@ -162,12 +164,12 @@ function useStoragePolicyActions(
async (path: string) => {
clearBusy();
return perform("adopt", async () => {
await adoptStorageGoCache(path);
await reload();
const response = await adoptStorageGoCache(path);
setPolicy(response);
toast({ title: "Go build cache adopted", variant: "success" });
});
},
[clearBusy, perform, reload, toast],
[clearBusy, perform, setPolicy, toast],
);
return { save, adopt };
@@ -217,9 +219,9 @@ function useStorageBulkDeleteAction(
);
}
function useStorageActions(reload: Reload) {
function useStorageActions(reload: Reload, setPolicy: (policy: StoragePolicyResponse) => void) {
const { toast } = useToast();
const { pendingAction, error, setError, finishLoading, perform } = useStorageActionRunner();
const { pendingAction, error, setError, perform } = useStorageActionRunner();
const [analysisJobId, setAnalysisJobId] = useState<string | null>(null);
const [cleanupJobId, setCleanupJobId] = useState<string | null>(null);
const [deleteJobId, setDeleteJobId] = useState<string | null>(null);
@@ -228,7 +230,7 @@ function useStorageActions(reload: Reload) {
const cleanupJob = useSystemJob(cleanupJobId);
const deleteJob = useSystemJob(deleteJobId);
const clearBusy = useCallback(() => setBusy(null), []);
const { save, adopt } = useStoragePolicyActions(perform, reload, toast, clearBusy);
const { save, adopt } = useStoragePolicyActions(perform, reload, toast, clearBusy, setPolicy);
const analyze = useCallback(async () => {
clearBusy();
@@ -286,7 +288,7 @@ function useStorageActions(reload: Reload) {
clearBusy();
return perform("restore", async () => {
await restoreStorageQuarantine(id);
await reload();
await reload(["quarantine"]);
toast({ title: "Quarantined resource restored", variant: "success" });
});
},
@@ -298,7 +300,6 @@ function useStorageActions(reload: Reload) {
return {
pendingAction,
error,
finishLoading,
setError,
save,
analyze,
@@ -365,35 +366,94 @@ function useReloadCompletedJobs(
export function useStorageMaintenance() {
const storage = useAppStore((state) => state.system.storage);
const setPolicy = useAppStore((state) => state.setSystemStoragePolicy);
const setOverview = useAppStore((state) => state.setSystemStorageOverview);
const setRuns = useAppStore((state) => state.setSystemStorageRuns);
const setQuarantine = useAppStore((state) => state.setSystemStorageQuarantine);
const reloadGeneration = useRef(0);
const reload = useCallback(async () => {
const generation = ++reloadGeneration.current;
const [overview, runs, quarantine] = await Promise.all([
fetchStorageOverview(),
fetchStorageRuns(20),
fetchStorageQuarantine(),
]);
if (generation !== reloadGeneration.current) return;
setOverview(overview);
setRuns(runs);
setQuarantine(quarantine);
}, [setOverview, setQuarantine, setRuns]);
const { finishLoading, setError, ...actions } = useStorageActions(reload);
const [loading, setLoading] = useState<StorageSectionLoading>({
policy: true,
overview: true,
runs: true,
quarantine: true,
});
const [sectionErrors, setSectionErrors] = useState<StorageSectionErrors>({
policy: null,
overview: null,
runs: null,
quarantine: null,
});
const sectionGenerations = useRef<Record<StorageSection, number>>({
policy: 0,
overview: 0,
runs: 0,
quarantine: 0,
});
const loadSection = useCallback(
async <T>(section: StorageSection, request: () => Promise<T>, commit: (value: T) => void) => {
const generation = ++sectionGenerations.current[section];
setLoading((current) => ({ ...current, [section]: true }));
setSectionErrors((current) => ({ ...current, [section]: null }));
try {
const value = await request();
if (generation === sectionGenerations.current[section]) commit(value);
} catch (requestError) {
if (generation === sectionGenerations.current[section]) {
setSectionErrors((current) => ({
...current,
[section]: messageFromError(requestError),
}));
throw requestError;
}
} finally {
if (generation === sectionGenerations.current[section]) {
setLoading((current) => ({ ...current, [section]: false }));
}
}
},
[],
);
const reload = useCallback(
async (sections: StorageSection[] = ["policy", "overview", "runs", "quarantine"]) => {
const jobs = sections.map((section) => {
switch (section) {
case "policy":
return loadSection("policy", fetchStoragePolicy, setPolicy);
case "overview":
return loadSection("overview", fetchStorageOverview, setOverview);
case "runs":
return loadSection("runs", () => fetchStorageRuns(20), setRuns);
case "quarantine":
return loadSection("quarantine", fetchStorageQuarantine, setQuarantine);
}
});
const results = await Promise.allSettled(jobs);
const failure = results.find(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
if (failure) throw failure.reason;
},
[loadSection, setOverview, setPolicy, setQuarantine, setRuns],
);
const commitAdoptedPolicy = useCallback(
(policy: StoragePolicyResponse) => {
sectionGenerations.current.policy += 1;
setPolicy(policy);
setSectionErrors((current) => ({ ...current, policy: null }));
setLoading((current) => ({ ...current, policy: false }));
},
[setPolicy],
);
const actions = useStorageActions(reload, commitAdoptedPolicy);
useEffect(() => {
void reload()
.catch((requestError) => setError(messageFromError(requestError)))
.finally(finishLoading);
}, [finishLoading, reload, setError]);
void reload().catch(() => undefined);
}, [reload]);
useReloadCompletedJobs(
reload,
setError,
actions.setError,
actions.analysisJob,
actions.cleanupJob,
actions.deleteJob,
);
return { ...storage, ...actions, reload };
return { ...storage, ...actions, loading, sectionErrors, reload };
}
+40 -24
View File
@@ -31,6 +31,7 @@ import {
analyzeStorage,
deleteStorageQuarantine,
fetchStorageOverview,
fetchStoragePolicy,
fetchStorageQuarantine,
fetchStorageRuns,
purgeStorageQuarantine,
@@ -312,31 +313,31 @@ describe("restart", () => {
});
});
describe("storage maintenance", () => {
const settings = {
enabled: false,
check_interval_hours: 24,
idle_for_minutes: 10,
orphan_grace_hours: 168,
quarantine_retention_hours: 168,
workspaces: { enabled: true },
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,
},
};
const storageSettings = {
enabled: false,
check_interval_hours: 24,
idle_for_minutes: 10,
orphan_grace_hours: 168,
quarantine_retention_hours: 168,
workspaces: { enabled: true },
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,
},
};
describe("storage maintenance", () => {
it("loads overview and list resources without caching", async () => {
fetchSpy
.mockResolvedValueOnce(
jsonResponse({
settings,
settings: storageSettings,
summary: {},
capabilities: {},
analyzed_at: "2026-07-23T12:00:00Z",
@@ -355,19 +356,19 @@ describe("storage maintenance", () => {
});
it("saves dedicated Docker acknowledgement with its confirmation", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ settings }));
await saveStorageSettings(settings, "DEDICATED");
fetchSpy.mockResolvedValueOnce(jsonResponse({ settings: storageSettings }));
await saveStorageSettings(storageSettings, "DEDICATED");
expect(lastCall().url).toBe(`${BASE}/storage/settings`);
expect(method()).toBe("PATCH");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
settings,
settings: storageSettings,
confirmations: { dedicated_docker: "DEDICATED" },
});
});
it("uses fixed confirmations for Go-cache adoption and permanent deletion", async () => {
fetchSpy
.mockResolvedValueOnce(jsonResponse({ settings, capabilities: {} }))
.mockResolvedValueOnce(jsonResponse({ settings: storageSettings, capabilities: {} }))
.mockResolvedValueOnce(jsonResponse({ job_id: "delete-job" }));
await adoptStorageGoCache("/root/.cache/go-build");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({
@@ -416,3 +417,18 @@ describe("storage maintenance", () => {
expect((await restoreStorageQuarantine("entry-1")).id).toBe("restored");
});
});
describe("storage policy", () => {
it("loads storage policy without requesting the scan-backed overview", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({ settings: storageSettings, capabilities: { docker_available: true } }),
);
const response = await fetchStoragePolicy();
expect(lastCall().url).toBe(`${BASE}/storage/settings`);
expect(lastCall().init?.cache).toBe("no-store");
expect(response.settings).toEqual(storageSettings);
expect(response.capabilities.docker_available).toBe(true);
});
});
+8
View File
@@ -15,6 +15,7 @@ import type {
StorageMaintenanceRun,
StorageMaintenanceSettings,
StorageOverviewResponse,
StoragePolicyResponse,
StorageQuarantineEntry,
StorageQuarantinePurgeScope,
StorageSettingsResponse,
@@ -205,6 +206,13 @@ export function fetchStorageOverview(
});
}
export function fetchStoragePolicy(options?: ApiRequestOptions): Promise<StoragePolicyResponse> {
return fetchJson<StoragePolicyResponse>(`${SYSTEM_BASE}/storage/settings`, {
...options,
cache: "no-store",
});
}
export function saveStorageSettings(
settings: StorageMaintenanceSettings,
dedicatedDockerConfirmation?: "DEDICATED",
@@ -134,10 +134,17 @@ describe("system storage slice", () => {
analyzed_at: "2026-07-23T12:00:00Z",
last_run: null,
} satisfies StorageOverviewResponse;
const policy = { settings: overview.settings, capabilities: overview.capabilities };
store.getState().setSystemStoragePolicy(policy);
store.getState().setSystemStorageOverview(overview);
store.getState().setSystemStorageRuns([]);
store.getState().setSystemStorageQuarantine([]);
expect(store.getState().system.storage).toEqual({ overview, runs: [], quarantine: [] });
expect(store.getState().system.storage).toEqual({
policy,
overview,
runs: [],
quarantine: [],
});
});
});
@@ -11,7 +11,7 @@ export const defaultSystemState: SystemSliceState = {
updates: null,
jobs: {},
metrics: null,
storage: { overview: null, runs: [], quarantine: [] },
storage: { policy: null, overview: null, runs: [], quarantine: [] },
},
};
@@ -67,6 +67,10 @@ export const createSystemSlice: StateCreator<
set((draft) => {
draft.system.metrics = snapshot;
}),
setSystemStoragePolicy: (policy) =>
set((draft) => {
draft.system.storage.policy = policy;
}),
setSystemStorageOverview: (overview) =>
set((draft) => {
draft.system.storage.overview = overview;
@@ -9,6 +9,7 @@ import type {
SystemMetricsSnapshot,
StorageMaintenanceRun,
StorageOverviewResponse,
StoragePolicyResponse,
StorageQuarantineEntry,
} from "@/lib/types/system";
@@ -36,6 +37,7 @@ export type SystemSliceState = {
jobs: SystemJobsMap;
metrics: SystemMetricsSnapshot | null;
storage: {
policy: StoragePolicyResponse | null;
overview: StorageOverviewResponse | null;
runs: StorageMaintenanceRun[];
quarantine: StorageQuarantineEntry[];
@@ -54,6 +56,7 @@ export type SystemSliceActions = {
upsertSystemJob: (job: SystemJob) => void;
clearSystemJob: (jobId: string) => void;
setSystemMetricsSnapshot: (snapshot: SystemMetricsSnapshot) => void;
setSystemStoragePolicy: (policy: StoragePolicyResponse) => void;
setSystemStorageOverview: (overview: StorageOverviewResponse) => void;
setSystemStorageRuns: (runs: StorageMaintenanceRun[]) => void;
setSystemStorageQuarantine: (entries: StorageQuarantineEntry[]) => void;
+5
View File
@@ -325,6 +325,11 @@ export interface StorageOverviewResponse {
last_run: StorageMaintenanceRun | null;
}
export interface StoragePolicyResponse {
settings: StorageMaintenanceSettings;
capabilities: StorageCapabilities;
}
export interface StorageSettingsResponse {
settings: StorageMaintenanceSettings;
}
+12
View File
@@ -331,6 +331,18 @@
"supportedEditors": "Supported Editors",
"supportsPlaceholders": "Supports placeholders: {{placeholders}}",
"systemDefault": "System default",
"storageLoadingData": "Loading storage data…",
"storageAdoptionPending": "Wait for Go cache adoption to finish.",
"storagePolicyLoadingBlock": "Wait for storage settings to finish loading.",
"storageActionPending": "Wait for the current storage action to finish.",
"storageHistoryLoading": "Loading maintenance history…",
"storageHistoryEmpty": "No storage maintenance runs yet.",
"storageQuarantineLoading": "Loading quarantine…",
"storageQuarantineEmpty": "No restorable quarantined resources.",
"storageQuarantineTotal": "Total quarantined: {{size}}",
"storageSectionUnavailable": "This storage section could not be loaded",
"storageTotalCounted": "Total counted: {{size}}",
"storageTotalPartial": "Partial measurement",
"systemLoad1Min": "System load (1 min)",
"taskActions": "Task Actions",
"taskOverview": "Task overview",
+12
View File
@@ -331,6 +331,18 @@
"supportedEditors": "Śũƥƥōŕţēď Ēďĩţōŕś",
"supportsPlaceholders": "Śũƥƥōŕţś ƥĺàćēĥōĺďēŕś: {{placeholders}}",
"systemDefault": "Śŷśţēḿ ďēƒàũĺţ",
"storageLoadingData": "Ĺōàďĩńĝ śţōŕàĝē ďàţà…",
"storageAdoptionPending": "Ŵàĩţ ƒōŕ Ĝō ćàćĥē àďōƥţĩōń ţō ƒĩńĩśĥ.",
"storagePolicyLoadingBlock": "Ŵàĩţ ƒōŕ śţōŕàĝē śēţţĩńĝś ţō ƒĩńĩśĥ ĺōàďĩńĝ.",
"storageActionPending": "Ŵàĩţ ƒōŕ ţĥē ćũŕŕēńţ śţōŕàĝē àćţĩōń ţō ƒĩńĩśĥ.",
"storageHistoryLoading": "Ĺōàďĩńĝ ḿàĩńţēńàńćē ĥĩśţōŕŷ…",
"storageHistoryEmpty": "Ńō śţōŕàĝē ḿàĩńţēńàńćē ŕũńś ŷēţ.",
"storageQuarantineLoading": "Ĺōàďĩńĝ qũàŕàńţĩńē…",
"storageQuarantineEmpty": "Ńō ŕēśţōŕàƀĺē qũàŕàńţĩńēď ŕēśōũŕćēś.",
"storageQuarantineTotal": "Ţōţàĺ qũàŕàńţĩńēď: {{size}}",
"storageSectionUnavailable": "Ţĥĩś śţōŕàĝē śēćţĩōń ćōũĺď ńōţ ƀē ĺōàďēď",
"storageTotalCounted": "Ţōţàĺ ćōũńţēď: {{size}}",
"storageTotalPartial": "Ƥàŕţĩàĺ ḿēàśũŕēḿēńţ",
"systemLoad1Min": "Śŷśţēḿ ĺōàď (1 ḿĩń)",
"taskActions": "Ţàśķ Àćţĩōńś",
"taskOverview": "Ţàśķ ōvēŕvĩēŵ",
@@ -0,0 +1,147 @@
---
spec: docs/specs/system-page/storage-maintenance.md
created: 2026-08-02
status: complete
---
# Implementation Plan: Progressive Storage Loading and Totals
## Overview
Decouple the lightweight Storage policy read from the scan-backed overview, then let the frontend
publish policy, history, quarantine, and analysis results independently. Add derived totals for the
analysis snapshot and current quarantine list, with explicit partial semantics when a top-level
measurement is unavailable. Preserve the existing `GET /storage` response for compatibility.
## Backend
### Lightweight policy read
- In `apps/backend/internal/system/storage/handler.go`, register
`GET /api/v1/system/storage/settings` beside the existing `PATCH` route.
- Implement `getSettings` by calling `SettingsManager.GetSettings` and
`OverviewReader.SettingsCapabilities`; return `{ settings, capabilities }` without calling
`OverviewReader.Get`, `RunLister.ListRuns`, or any analysis provider.
- Keep `GET /api/v1/system/storage` unchanged so existing clients still receive
`{ settings, capabilities, summary, analyzed_at, last_run }`.
- Cover the route directly in `apps/backend/internal/system/storage/handler_test.go` with a
recording overview reader that fails the test if `Get` is invoked, and through the composed
System router in `apps/backend/internal/system/storage_routes_test.go`.
## Frontend
### API and state contracts
- Add `StoragePolicyResponse` to `apps/web/lib/types/system.ts` and
`fetchStoragePolicy()` to `apps/web/lib/api/domains/system-api.ts` for the new lightweight GET.
- Add `policy: StoragePolicyResponse | null` and `setSystemStoragePolicy` to the System Zustand
slice in `apps/web/lib/state/slices/system/types.ts` and `system-slice.ts`.
- Extend the existing API and slice tests for the route, response shape, and policy update.
### Independent loading and refresh
- Refactor `apps/web/hooks/domains/system/use-storage-maintenance.ts` so policy, overview, runs,
and quarantine requests start together but commit independently. Track per-section loading and
error state plus per-section request generations so a slow older response cannot overwrite newer
data from the same section.
- Remove first-load completion from the global `pendingAction`; policy-save coordination depends
on `policyLoading`, while analysis, history, and quarantine can remain independently pending.
- Make save refresh only the lightweight policy contract, and apply the existing adoption response
directly to policy state. Restore refreshes quarantine without waiting for an overview scan.
Terminal analysis/cleanup/delete jobs refresh the affected sections concurrently so history and
quarantine can settle even when the invalidated overview needs a new scan.
- Update `storage-maintenance-settings.tsx` to derive drafts and scheduling copy from `policy`, and
pass section loading/error state to the analysis, history, and quarantine cards.
- Give `storage-overview-card.tsx`, `storage-run-history.tsx`, and
`storage-quarantine-card.tsx` distinct progress, empty, and error states; a failure in one card
must not suppress sibling cards.
### Totals
- Add `apps/web/components/settings/system/storage/storage-totals.ts` with pure helpers that:
- sum workspace total, quarantine, managed and distinct user Go caches, managed-container
writable layers, Docker image layers, and Docker build cache;
- exclude active/candidate workspace and unused-image subset bytes to avoid double-counting;
- return a `partial` flag when a top-level measurement is unavailable or missing; and
- sum `size_bytes` across every listed quarantine entry.
- Render a localized **Total counted** value and partial indicator in
`storage-overview-card.tsx`, and a localized total in `storage-quarantine-card.tsx`.
- Add the new user-facing strings to `apps/web/src/locales/en/settings.json`; do not localize
identifiers, test IDs, confirmation tokens, or values used in logic.
### Mobile design contract
- Desktop outcome: the existing full-width card stack renders lightweight cards before analysis,
and totals appear in the relevant card headers without changing actions or navigation.
- Mobile entry point and exemplar: keep the Settings sheet → System → Storage path and existing
one-column card composition proven by
`apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts`.
- Mobile hierarchy: policy, history, and quarantine remain ordinary inline cards in the page's
single document scroll; Storage analysis owns only its card-level progress state. No drawer,
overlay, fixed control, or second scroll owner is introduced.
- Touch behavior and state are shared with desktop. Existing action targets stay at least 44px;
the totals are read-only and add no touch affordance. The mobile E2E scenario delays analysis,
proves the other cards and totals render, and asserts no horizontal document overflow.
## Tests
- **Lightweight policy route:** `apps/backend/internal/system/storage/handler_test.go` verifies the
response and proves no overview scan or run-history read occurs.
- **Composed route:** `apps/backend/internal/system/storage_routes_test.go` verifies
`GET /api/v1/system/storage/settings` is registered and returns `200`.
- **API and store:** `apps/web/lib/api/domains/system-api.test.ts` and
`apps/web/lib/state/slices/system/system-slice.test.ts` verify the new policy contract.
- **Progressive controller:** `apps/web/hooks/domains/system/use-storage-maintenance.test.tsx`
uses deferred promises to prove policy, runs, and quarantine publish before overview; covers
isolated failures, same-section stale-response protection, and mutation-specific refreshes.
- **Totals:** `apps/web/components/settings/system/storage/storage-totals.test.ts` verifies exact
sums, subset exclusion, partial analysis, zero/empty data, and quarantine aggregation.
- **Rendered states:** `storage-maintenance-settings.test.tsx`,
`storage-overview-card.test.tsx`, `storage-run-history.test.tsx`, and
`storage-quarantine-card.test.tsx` verify policy composition, loading/error/empty distinctions,
and localized totals.
## E2E Tests
- **Desktop progressive load:** extend
`apps/web/e2e/tests/system/storage-maintenance.spec.ts` to delay `GET /storage`, then verify the
policy controls, maintenance history, and quarantine list are visible before analysis finishes;
after release, verify the analysis total and quarantine total.
- **Mobile parity:** extend
`apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts` with the same delayed-analysis
outcome on `mobile-chrome`, including totals and absence of horizontal overflow.
## Verification Results
Completed. Each task records its exact commands and outcomes in its `## Results` section.
## Implementation Waves And Parallel Candidates
Wave 1:
- [x] [Task 01: Lightweight policy endpoint](task-01-policy-endpoint.md) — done
Wave 2:
- [x] [Task 02: Progressive section loading](task-02-progressive-loading.md) — done
Wave 3:
- [x] [Task 03: Storage totals](task-03-storage-totals.md) — done
Wave 4:
- [x] [Task 04: Desktop and mobile E2E](task-04-storage-e2e.md) — done
The tasks run sequentially in the primary conversation. The waves do not authorize subagents.
## Risks
- Docker unused-image bytes are a reclaimable subset of image-layer usage; adding both would
overstate the total. The pure total helper and tests make that exclusion explicit.
- Cleanup and restore invalidate the overview cache, so an automatic overview refresh may scan
again. Section-specific refreshes must let history and quarantine update without awaiting it.
- Empty arrays currently mean both “not loaded” and “loaded with no items”; explicit section state
is required to prevent misleading empty-state copy during requests.
- Capability reads ping Docker. They are expected to be much cheaper than `DiskUsage` and
filesystem traversal, but remain isolated from the overview scan and failure-scoped to policy.
@@ -0,0 +1,67 @@
---
id: "01-policy-endpoint"
title: "Lightweight policy endpoint"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/system-page/storage-maintenance.md"
---
# Task 01: Lightweight policy endpoint
## Acceptance
- `GET /api/v1/system/storage/settings` returns persisted settings and current capabilities.
- The lightweight GET does not request an overview snapshot, scan providers, or read run history.
- The existing scan-backed `GET /api/v1/system/storage` contract remains unchanged.
## Verification
From `apps/backend`:
```bash
rtk go test ./internal/system/storage ./internal/system
```
## Files likely touched
- `apps/backend/internal/system/storage/handler.go`
- `apps/backend/internal/system/storage/handler_test.go`
- `apps/backend/internal/system/storage_routes_test.go`
## Dependencies
None.
## Parallelism
Sequential. This contract is required by Task 02.
## Inputs
- Spec: What, API surface, failure modes, and first-load scenarios
- Plan: Backend → Lightweight policy read
- Existing `SettingsManager`, `OverviewReader.Capabilities`, and Storage route tests
## Risks
- Do not call `OverviewReader.Get` from the new handler.
- Keep internal settings read errors from exposing sensitive details beyond the existing contract.
## Output contract
Report the route and response shape, files changed, exact test result, blockers/risks, and update
this task plus `plan.md` status in the same conversation.
## Results
- RED: `rtk go test -run TestGetStorageSettingsReturnsPolicyWithoutOverviewScan ./internal/system/storage`
failed as expected with HTTP 404 before the route existed.
- GREEN/final: `rtk go test ./internal/system/storage ./internal/system` — 107 tests passed.
- Added `GET /api/v1/system/storage/settings`; it returns settings and settings-derived capabilities
without calling the scan-backed overview reader or probing Docker. Existing `GET /storage` behavior
remains unchanged.
- Settings-load failures now use a client-safe response while preserving the original error in the
server-side logging callback.
- No external side effects or cleanup required.
@@ -0,0 +1,96 @@
---
id: "02-progressive-loading"
title: "Progressive section loading"
status: done
wave: 2
depends_on: ["01-policy-endpoint"]
plan: "plan.md"
spec: "../../specs/system-page/storage-maintenance.md"
---
# Task 02: Progressive section loading
## Acceptance
- Policy, history, and quarantine publish and render without waiting for the overview request.
- Each section distinguishes loading, empty, ready, and failed states without hiding successful
sibling sections or accepting stale same-section responses.
- Save, adoption, restore, and terminal-job refreshes request only the data they need and never make
lightweight section updates wait for an invalidated overview scan.
## Verification
From `apps/`, install dependencies once if absent:
```bash
rtk pnpm install --frozen-lockfile
rtk pnpm --filter @kandev/web test -- --run lib/api/domains/system-api.test.ts lib/state/slices/system/system-slice.test.ts hooks/domains/system/use-storage-maintenance.test.tsx components/settings/system/storage/storage-maintenance-settings.test.tsx components/settings/system/storage/storage-overview-card.test.tsx components/settings/system/storage/storage-run-history.test.tsx components/settings/system/storage/storage-quarantine-card.test.tsx
```
From `apps/`:
```bash
rtk pnpm --filter @kandev/web run typecheck
```
The focused suite and typecheck run from the `apps/` workspace so pnpm resolves the monorepo
workspace consistently.
## Files likely touched
- `apps/web/lib/types/system.ts`
- `apps/web/lib/api/domains/system-api.ts`
- `apps/web/lib/api/domains/system-api.test.ts`
- `apps/web/lib/state/slices/system/types.ts`
- `apps/web/lib/state/slices/system/system-slice.ts`
- `apps/web/lib/state/slices/system/system-slice.test.ts`
- `apps/web/hooks/domains/system/use-storage-maintenance.ts`
- `apps/web/hooks/domains/system/use-storage-maintenance.test.tsx`
- `apps/web/components/settings/system/storage/storage-maintenance-settings.tsx`
- `apps/web/components/settings/system/storage/storage-overview-card.tsx`
- `apps/web/components/settings/system/storage/storage-overview-card.test.tsx`
- `apps/web/components/settings/system/storage/storage-run-history.tsx`
- `apps/web/components/settings/system/storage/storage-run-history.test.tsx`
- `apps/web/components/settings/system/storage/storage-quarantine-card.tsx`
- `apps/web/components/settings/system/storage/storage-quarantine-card.test.tsx`
## Dependencies
Task 01.
## Parallelism
Sequential. It establishes the section state consumed by Tasks 03 and 04.
## Inputs
- Spec: independent loading, isolated failures, and persistence scenarios
- Plan: Frontend → API and state contracts; Independent loading and refresh
- Existing controller generation guard and terminal refresh retry tests
## Risks
- Preserve settings drafts when policy responses arrive; never overwrite an unsaved user edit.
- Avoid turning a section failure into an unhandled promise rejection or a permanent loading state.
- Reconcile these likely files with the actual diff before marking the task done.
## Output contract
Report section state behavior, mutation refresh behavior, files changed, exact test results,
blockers/risks, and update this task plus `plan.md` status in the same conversation.
## Results
- Added the lightweight `GET /api/v1/system/storage/settings` client contract and persisted policy
state separately from the scan-backed overview.
- Split storage loading into policy, overview, history, and quarantine sections with per-section
loading/error state and generation guards so stale responses cannot overwrite newer data.
- Policy save/adoption and quarantine restore now refresh only their affected sections; terminal job
refreshes still reload all sections for a consistent post-run snapshot.
- Stale section failures no longer escape newer successful reloads, and Go-cache adoption invalidates
in-flight policy reads before committing its response.
- Rendered independent loading/error cards for policy, history, and quarantine while overview scanning
remains in progress.
- RED: API/store-focused tests initially failed because the new policy client and setter were absent.
- GREEN: `rtk pnpm --filter @kandev/web test -- --run lib/api/domains/system-api.test.ts lib/state/slices/system/system-slice.test.ts hooks/domains/system/use-storage-maintenance.test.tsx components/settings/system/storage/storage-maintenance-settings.test.tsx components/settings/system/storage/storage-overview-card.test.tsx components/settings/system/storage/storage-run-history.test.tsx components/settings/system/storage/storage-quarantine-card.test.tsx` — 7 files, 85 tests passed.
- GREEN: `rtk pnpm --filter @kandev/web run typecheck` from `apps` passed.
@@ -0,0 +1,82 @@
---
id: "03-storage-totals"
title: "Storage totals"
status: done
wave: 3
depends_on: ["02-progressive-loading"]
plan: "plan.md"
spec: "../../specs/system-page/storage-maintenance.md"
---
# Task 03: Storage totals
## Acceptance
- Storage analysis shows the sum of available non-overlapping top-level measurements and visibly
identifies a partial total when any required category is unavailable or missing.
- Analysis excludes active/candidate workspace bytes and unused-image bytes because those values
are subsets of already counted categories.
- Quarantine shows the sum of every currently listed entry's `size_bytes`, including zero/empty
state, without depending on the overview snapshot.
## Verification
From `apps/`, install dependencies once if absent:
```bash
rtk pnpm install --frozen-lockfile
rtk pnpm --filter @kandev/web test -- components/settings/system/storage/storage-totals.test.ts components/settings/system/storage/storage-overview-card.test.tsx components/settings/system/storage/storage-quarantine-card.test.tsx
rtk pnpm --filter @kandev/web i18n:check
rtk pnpm --filter @kandev/web i18n:ratchet
```
## Files likely touched
- `apps/web/components/settings/system/storage/storage-totals.ts`
- `apps/web/components/settings/system/storage/storage-totals.test.ts`
- `apps/web/components/settings/system/storage/storage-overview-card.tsx`
- `apps/web/components/settings/system/storage/storage-overview-card.test.tsx`
- `apps/web/components/settings/system/storage/storage-quarantine-card.tsx`
- `apps/web/components/settings/system/storage/storage-quarantine-card.test.tsx`
- `apps/web/src/locales/en/settings.json`
## Dependencies
Task 02.
## Parallelism
Sequential. It uses Task 02's loaded-section contract and supplies selectors for Task 04.
## Inputs
- Spec: total-counted and quarantine-total requirements and scenarios
- Plan: Frontend → Totals
- Existing `formatGigabytes` presentation helper
## Risks
- Keep the total a derived UI value; do not persist it or change byte-based API fields.
- Do not translate confirmation tokens, resource IDs, or values used for comparisons.
## Output contract
Report aggregation semantics, localized presentation, files changed, exact test results,
blockers/risks, and update this task plus `plan.md` status in the same conversation.
## Results
- Added pure `storageAnalysisTotal` and `quarantineTotalBytes` helpers. The analysis total adds
workspace total bytes, quarantine bytes, managed and distinct user Go caches, managed-container
writable layers, Docker image layers, and Docker build cache; active/candidate workspace bytes
and unused-image bytes remain excluded subsets.
- Missing, invalid, or unavailable top-level measurements set `partial: true` while preserving all
valid measurements in the displayed total. Quarantine totals sum every listed entry independently
of the overview snapshot.
- Added localized total labels and a partial-measurement badge to the analysis card, plus a
localized quarantine total.
- RED: `storage-totals.test.ts` initially failed because the new helper module did not exist.
- GREEN: `rtk pnpm --filter @kandev/web test -- --run components/settings/system/storage/storage-totals.test.ts components/settings/system/storage/storage-overview-card.test.tsx components/settings/system/storage/storage-quarantine-card.test.tsx` — 3 files, 13 tests passed.
- GREEN: `rtk pnpm --filter @kandev/web i18n:check` passed.
- GREEN: `rtk pnpm --filter @kandev/web i18n:ratchet` passed.
- GREEN: `rtk pnpm run typecheck` from `apps/web` passed.
@@ -0,0 +1,83 @@
---
id: "04-storage-e2e"
title: "Storage desktop and mobile E2E"
status: done
wave: 4
depends_on: ["03-storage-totals"]
plan: "plan.md"
spec: "../../specs/system-page/storage-maintenance.md"
---
# Task 04: Storage Desktop and Mobile E2E
## Acceptance
- Desktop E2E delays the scan-backed overview and proves policy, history, and quarantine render
first, then verifies analysis and quarantine totals after the delayed response settles.
- Mobile E2E proves the same user value through Settings sheet → System → Storage on the existing
one-column composition.
- The mobile state retains touch-reachable controls and has no horizontal document overflow.
## Verification
From `apps/`, install dependencies once if absent:
```bash
rtk pnpm install --frozen-lockfile
```
From `apps/web`:
```bash
rtk pnpm e2e:run tests/system/storage-maintenance.spec.ts
rtk pnpm e2e:run --project mobile-chrome tests/system/mobile-storage-maintenance.spec.ts
```
Confirm each command discovers the intended test count. The managed runner rebuilds the Go backend
and production Vite assets before executing and tears down its isolated runtime afterward.
## Files likely touched
- `apps/web/e2e/tests/system/storage-maintenance.spec.ts`
- `apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts`
- Existing Storage component files only if stable `data-testid` selectors are missing
## Dependencies
Task 03.
## Parallelism
Sequential; this is integrated proof of Tasks 01–03.
## Inputs
- Spec: first-load, totals, failure isolation, and mobile scenarios
- Plan: E2E Tests and Mobile design contract
- Existing delayed-overview mobile test and Storage settings navigation flow
## Risks
- Route patterns must distinguish `GET /storage` from `GET /storage/settings`.
- E2E assertions must observe fast sections before releasing the overview gate, not merely after all
requests have settled.
## Output contract
Report desktop/mobile flows, discovered test counts, exact command outcomes, artifact or teardown
evidence, blockers/risks, and update this task plus `plan.md` status in the same conversation.
## Results
- Added a desktop delayed-overview scenario that distinguishes `GET /api/v1/system/storage` from
the lightweight settings route and verifies policy, history, and quarantine cards before the
scan-backed overview is released. It then verifies both derived totals.
- Extended the mobile delayed-overview scenario with the same fast-section and totals assertions,
while retaining the no-horizontal-overflow check.
- GREEN: `rtk pnpm e2e:run tests/system/storage-maintenance.spec.ts` — 6 tests discovered, 6 passed
in 15.9s.
- GREEN: `rtk pnpm e2e:run --project mobile-chrome tests/system/mobile-storage-maintenance.spec.ts`
— 4 tests discovered, 4 passed in 10.3s.
- Retained PR capture artifacts: `storage-maintenance--progressive-loading.png` and
`mobile-storage-maintenance--progressive-loading.png`; the managed runners completed teardown
after each capture.
+42 -1
View File
@@ -1,7 +1,7 @@
---
status: building
created: 2026-07-14
updated: 2026-07-29
updated: 2026-08-02
owner: cfl
---
@@ -23,6 +23,13 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
- The page presents storage analysis and maintenance policy as separate full-width sections.
Analysis and cleanup state replaces the label and icon inside the action button that started it
instead of appearing as detached page status.
- On first load, maintenance policy, maintenance history, quarantine, and storage analysis load as
independent sections. A cold filesystem or Docker scan keeps only Storage analysis in its loading
state; policy controls, persisted run history, and the database-backed quarantine list render as
soon as their own requests finish.
- Each independently loaded section surfaces its own loading and failure state. A failed or slow
analysis never replaces already available policy, history, or quarantine content with a page-wide
loading or error state.
- User-facing storage totals and editable size limits are shown in GB. The frontend converts those
values to and from the byte-based API without changing the persisted data model.
- Maintenance settings use separate cards grouped by scope: schedule, workspaces and containers,
@@ -36,6 +43,12 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
bytes, the managed Go cache, the service user's default Go cache when it is a distinct path,
Kandev-managed container count and writable-layer bytes, Docker image-layer bytes, Docker build
cache, and unused Docker images.
- Storage analysis shows a total counted size derived from the available non-overlapping top-level
measurements: total task workspaces, quarantine, managed and distinct user Go caches,
Kandev-managed container writable layers, Docker image layers, and Docker build cache. Active and
candidate workspace bytes and unused-image bytes remain visible subset measurements and are not
added again. If any top-level measurement is unavailable, the total is visibly identified as
partial rather than presented as complete host disk usage.
- A successful storage analysis is reused for 15 minutes. Opening or refreshing the Storage page,
saving policy settings, and adopting an external Go cache consume that cached snapshot instead of
starting another filesystem or Docker scan. Manual **Analyze** always bypasses the cache and
@@ -84,6 +97,9 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
the actual automatic deletion occurs on the first successful scheduled maintenance run after
that time. When scheduling is disabled, it says that automatic deletion is off and names full
manual **Run now** or an explicit quarantine action as the available cleanup paths.
- The Quarantine section shows the sum of `size_bytes` for every currently listed restorable entry.
Its total is derived from the independently loaded quarantine list and does not wait for or depend
on the Storage analysis snapshot.
- Scheduled and full manual maintenance runs permanently delete eligible quarantine entries.
Resource-specific manual runs do not delete unrelated quarantine entries.
- **Clear eligible** permanently deletes every active quarantine entry whose retention deadline has
@@ -317,6 +333,9 @@ All routes are under the existing authenticated System route group.
GET /api/v1/system/storage
-> { settings, capabilities, summary, analyzed_at, last_run }
GET /api/v1/system/storage/settings
-> { settings, capabilities }
PATCH /api/v1/system/storage/settings
body: {
settings: complete StorageMaintenanceSettings object,
@@ -366,6 +385,11 @@ DELETE /api/v1/system/storage/quarantine
availability, configured Docker host, and whether host-global Docker cleanup is allowed. API
responses never expose secret environment values.
`GET /storage/settings` is the lightweight policy-read contract. It reads persisted settings and
capabilities without requesting an overview snapshot or invoking filesystem, Go-cache, quarantine,
or Docker analysis providers. `GET /storage` remains backward compatible and continues to return
the complete scan-backed overview contract.
`analyzed_at` is the RFC 3339 timestamp of the successful analysis that produced `summary`.
`GET /storage` reuses that snapshot for 15 minutes. `POST /storage/analyze` bypasses the freshness
window and replaces the cached snapshot only when the forced analysis succeeds.
@@ -492,6 +516,9 @@ the configured restore window.
ambiguous-state, or Git worktree-pruning failure keeps the affected entry and reports the error.
- Docker list/usage failure marks Docker analysis unavailable. Docker prune failure records the
daemon error and does not affect other providers.
- A policy, history, quarantine, or analysis read failure is isolated to that section. Other
successful section responses remain visible and usable, and retrying or completing one section
does not discard newer data already returned by another.
- Loss of the dedicated-daemon acknowledgment between analysis and cleanup cancels host-global
Docker operations.
- Failure to persist a run or cleanup intent prevents its destructive operation from starting.
@@ -521,6 +548,11 @@ the configured restore window.
daemon, **THEN** no destructive storage cleanup runs and the Storage page shows scheduling off.
- **GIVEN** scheduling is disabled, **WHEN** the user selects **Analyze**, **THEN** the page shows
reclaimable bytes without changing any filesystem or Docker resource.
- **GIVEN** the first Storage analysis after backend startup is still scanning, **WHEN** the
lightweight policy, history, and quarantine requests complete, **THEN** those three sections are
visible and usable while only Storage analysis continues to show progress.
- **GIVEN** one Storage section request fails, **WHEN** another section request succeeds, **THEN**
the successful section renders its current data and the failed section shows its own error state.
- **GIVEN** a successful storage snapshot is less than 15 minutes old, **WHEN** the user refreshes
the page or saves policy settings, **THEN** the same summary and `analyzed_at` are returned without
invoking the storage providers again.
@@ -546,6 +578,11 @@ the configured restore window.
- **GIVEN** task roots include active, recent orphan, and grace-eligible orphan directories,
**WHEN** storage analysis runs, **THEN** total workspace bytes include every classified task root
while active and reclaimable bytes remain separate subsets.
- **GIVEN** all analysis providers return measurements, **WHEN** Storage analysis renders its total,
**THEN** it sums the non-overlapping top-level measurements once and does not add active,
candidate, or unused-image subset bytes again.
- **GIVEN** one top-level analysis measurement is unavailable, **WHEN** Storage analysis renders its
total, **THEN** it sums the available measurements and identifies the result as partial.
- **GIVEN** archived or deleted tasks retain ready environment or active worktree rows for recovery,
**WHEN** storage analysis or cleanup classifies their old directories, **THEN** those historical
rows do not protect the directories from normal orphan grace and quarantine rules unless a live
@@ -562,6 +599,9 @@ the configured restore window.
- **GIVEN** protected and eligible quarantine entries, **WHEN** the Storage page renders, **THEN**
each row shows its exact `delete_after` timestamp and protected-or-eligible status, and the page
states whether automatic scheduled cleanup is enabled.
- **GIVEN** multiple restorable quarantine entries, **WHEN** the independently loaded Quarantine
section renders, **THEN** its total equals the sum of every listed entry's `size_bytes` without
waiting for Storage analysis.
- **GIVEN** protected and eligible quarantine entries, **WHEN** the user confirms **Clear eligible**
with `DELETE ELIGIBLE`, **THEN** every eligible entry is permanently deleted, protected entries
remain, and the completed job reports both groups.
@@ -645,3 +685,4 @@ the configured restore window.
- [Original Storage maintenance implementation](../../plans/storage-maintenance/plan.md)
- [Storage overview cache and settings follow-up](../../plans/storage-overview-cache/plan.md)
- [Quarantine lifecycle follow-up](../../plans/quarantine-lifecycle/plan.md)
- [Progressive Storage loading and totals](../../plans/storage-progressive-loading/plan.md)