diff --git a/apps/web/src/components/settings/StorageSettings.browser.test.tsx b/apps/web/src/components/settings/StorageSettings.browser.test.tsx index 877341c4b8a86..d46e8210f410e 100644 --- a/apps/web/src/components/settings/StorageSettings.browser.test.tsx +++ b/apps/web/src/components/settings/StorageSettings.browser.test.tsx @@ -1,4 +1,4 @@ -import { act, render } from "../../../test/render"; +import { act, fakeTimers, render } from "../../../test/render"; import { RegistryProvider } from "@effect/atom-react"; import { DEFAULT_SERVER_SETTINGS, type StorageCleanupReport } from "@t3tools/contracts"; import { type ReactNode } from "react"; @@ -6,6 +6,7 @@ import { expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ settings: {} as typeof DEFAULT_SERVER_SETTINGS, + targets: [] as Array<{ environmentId: string; settings: typeof DEFAULT_SERVER_SETTINGS }>, run: vi.fn(), report: null as StorageCleanupReport | null, })); @@ -34,11 +35,11 @@ vi.mock(import("../../state/use-atom-command"), async (original) => ({ vi.mock(import("./SettingsScopeContext"), async (original) => ({ ...(await original()), useSettingsScope: (() => { - const environment = { - environmentId: "env", - label: "Build workstation", + const environments = state.targets.map(({ environmentId, settings }) => ({ + environmentId, + label: environmentId, serverConfig: { - settings: state.settings, + settings, environment: { capabilities: { storageCleanup: true, @@ -47,12 +48,12 @@ vi.mock(import("./SettingsScopeContext"), async (original) => ({ }, }, }, - }; + })); return { - scope: { kind: "environment" }, - environments: [environment], - connectedEnvironments: [environment], - targets: [], + scope: { kind: "all" }, + environments, + connectedEnvironments: environments, + targets: state.targets, target: null, }; }) as never, @@ -67,10 +68,17 @@ vi.mock(import("./useScopedSettings"), async (original) => ({ vi.mock(import("./settingsLayout"), async (original) => ({ ...(await original()), SettingsPageContainer: (({ children }: { children: ReactNode }) => children) as never, - SettingsRow: ({ title, description, control, children }) => ( + SettingsRow: ({ title, description, status, control, children }) => (

{title}

{description} + {status && ( +
+ {status} +
+ )} {control} {children}
@@ -99,10 +107,33 @@ function setup() { }, projectSettingsOverrides: {}, }; + state.targets = ["env-a", "env-b"].map((environmentId) => ({ + environmentId, + settings: state.settings, + })); state.report = null; state.run.mockReset(); } +function setRetentionValues( + artifactDays: ReadonlyArray, + logDays: ReadonlyArray = artifactDays.map(() => null), + environmentIds: readonly string[] = ["env-a", "env-b"], +) { + state.targets = artifactDays.map((browserArtifactsAfterDays, index) => ({ + environmentId: environmentIds[index]!, + settings: { + ...state.settings, + storageCleanup: { + ...state.settings.storageCleanup, + browserArtifactsAfterDays, + logsAfterDays: logDays[index]!, + }, + }, + })); + state.settings = state.targets[0]?.settings ?? state.settings; +} + const panel = () => ( @@ -122,3 +153,85 @@ it("keeps cleanup disabled when settled retention and all other rules are off", await act(async () => button.click()); expect(state.run).not.toHaveBeenCalled(); }); + +it("settles mixed retention statuses independently across selected machines", async () => { + setup(); + using _timers = fakeTimers(); + await using screen = await render(panel()); + const advance = (ms: number) => act(() => vi.advanceTimersByTime(ms)); + + await advance(300); + setRetentionValues([8, null], [8, null]); + await screen.rerender(panel()); + await advance(125); + await expect.element(screen.getByTestId("artifact-retention-status")).not.toBeInTheDocument(); + await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument(); + setRetentionValues([null, null], [8, null]); + await screen.rerender(panel()); + await advance(8); + await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument(); + await advance(117); + await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument(); + setRetentionValues([null, null]); + await screen.rerender(panel()); + await advance(300); + await expect.element(screen.getByTestId("artifact-retention-status")).not.toBeInTheDocument(); + await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument(); + + setRetentionValues([8, null]); + await screen.rerender(panel()); + await advance(300); + await expect + .element(screen.getByTestId("artifact-retention-status")) + .toHaveTextContent("Mixed across selected machines"); + await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument(); + + setRetentionValues([null, null]); + await screen.rerender(panel()); + await advance(75); + await expect + .element(screen.getByTestId("artifact-retention-status")) + .toHaveTextContent("Mixed across selected machines"); + setRetentionValues([8, null]); + await screen.rerender(panel()); + await advance(133); + await expect + .element(screen.getByTestId("artifact-retention-status")) + .toHaveTextContent("Mixed across selected machines"); + setRetentionValues([null, null]); + await screen.rerender(panel()); + await advance(133); + await expect + .element(screen.getByTestId("artifact-retention-status")) + .toHaveTextContent("Mixed across selected machines"); + await advance(117); + await expect + .element(screen.getByTestId("artifact-retention-status")) + .toHaveTextContent("Mixed across selected machines"); + setRetentionValues([8, null]); + await screen.rerender(panel()); + await advance(300); + await expect + .element(screen.getByTestId("artifact-retention-status")) + .toHaveTextContent("Mixed across selected machines"); + + setRetentionValues([8], [null], ["env-c"]); + await screen.rerender(panel()); + await expect.element(screen.getByTestId("artifact-retention-status")).not.toBeInTheDocument(); + + setRetentionValues([null, null], [null, null], ["env-c", "env-d"]); + await screen.rerender(panel()); + await expect.element(screen.getByTestId("artifact-retention-status")).not.toBeInTheDocument(); + + setRetentionValues([null, null], [8, null]); + await screen.rerender(panel()); + await advance(300); + await expect.element(screen.getByTestId("artifact-retention-status")).not.toBeInTheDocument(); + await expect + .element(screen.getByTestId("logs-retention-status")) + .toHaveTextContent("Mixed across selected machines"); + setRetentionValues([null, null]); + await screen.rerender(panel()); + await advance(300); + await expect.element(screen.getByTestId("logs-retention-status")).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 2353ef5206b16..8be558dca6074 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -16,6 +16,8 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { useNowMinute } from "../../hooks/useNowMinute"; +import { useDebouncedValue } from "../../state/queries"; +import { AnimatedHeight } from "../AnimatedHeight"; import { Button } from "../ui/button"; import { toastManager } from "../ui/toast"; @@ -142,8 +144,8 @@ function RetentionControl({