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({
days
@@ -694,12 +696,31 @@ export function StorageCleanupSections() {
const { scope, connectedEnvironments, targets } = useSettingsScope();
const scopedSettings = useScopedSettings();
const updateSettings = useUpdateScopedSettings();
- if (scope.kind === "project" || scope.kind === "checkout") return null;
const settings = scopedSettings.storageCleanup;
- const ruleStatus = (key: "browserArtifactsAfterDays" | "logsAfterDays") =>
- targets.some((target) => target.settings.storageCleanup[key] !== settings[key])
+ const targetIdsKey = environmentIdsKey(targets.map((target) => target.environmentId).sort());
+ const artifactsMixed =
+ targets.length > 1 &&
+ targets.some(
+ (target) =>
+ target.settings.storageCleanup.browserArtifactsAfterDays !==
+ settings.browserArtifactsAfterDays,
+ );
+ const logsMixed =
+ targets.length > 1 &&
+ targets.some(
+ (target) => target.settings.storageCleanup.logsAfterDays !== settings.logsAfterDays,
+ );
+ const settledArtifactsStatus = useDebouncedValue(`${targetIdsKey}:${artifactsMixed}`, 300);
+ const settledLogsStatus = useDebouncedValue(`${targetIdsKey}:${logsMixed}`, 300);
+ const artifactsStatus =
+ targets.length > 1 && settledArtifactsStatus === `${targetIdsKey}:true`
+ ? "Mixed across selected machines"
+ : undefined;
+ const logsStatus =
+ targets.length > 1 && settledLogsStatus === `${targetIdsKey}:true`
? "Mixed across selected machines"
: undefined;
+ if (scope.kind === "project" || scope.kind === "checkout") return null;
const update = (patch: Partial) =>
updateSettings({ storageCleanup: patch });
const databaseStorageNotice = (
@@ -725,32 +746,36 @@ export function StorageCleanupSections() {
<>
{databaseStorageNotice}
- update({ browserArtifactsAfterDays })}
- />
- }
- />
- update({ logsAfterDays })}
- />
- }
- />
+
+ update({ browserArtifactsAfterDays })}
+ />
+ }
+ />
+
+
+ update({ logsAfterDays })}
+ />
+ }
+ />
+
>