Skip to content
135 changes: 124 additions & 11 deletions apps/web/src/components/settings/StorageSettings.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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";
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,
}));
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 }) => (
<div>
<h3>{title}</h3>
{description}
{status && (
<div
data-testid={`${title === "Delete old browser artifacts" ? "artifact" : "logs"}-retention-status`}
>
{status}
</div>
)}
{control}
{children}
</div>
Expand Down Expand Up @@ -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<number | null>,
logDays: ReadonlyArray<number | null> = 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 = () => (
<RegistryProvider>
<StorageCleanupSections />
Expand All @@ -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();
});
87 changes: 56 additions & 31 deletions apps/web/src/components/settings/StorageSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -142,8 +144,8 @@ function RetentionControl({
<NumberFieldDecrement aria-label={`Decrease ${label}`} />
<NumberFieldInput
aria-label={`${label} in days`}
size={new Intl.NumberFormat().format(draft ?? value).length}
className="field-sizing-content w-auto min-w-[1ch] grow-0 text-right"
size={5}
className="w-auto grow-0 text-right"
/>
<span aria-hidden="true" className="self-center pr-2 text-xs">
days
Expand Down Expand Up @@ -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<StorageCleanupSettings>) =>
updateSettings({ storageCleanup: patch });
const databaseStorageNotice = (
Expand All @@ -725,32 +746,36 @@ export function StorageCleanupSections() {
<>
{databaseStorageNotice}
<SettingsSection id="storage-artifacts" title="Artifacts and logs">
<SettingsRow
title="Delete old browser artifacts"
status={ruleStatus("browserArtifactsAfterDays")}
description="Delete saved browser captures after this many days. Older capture links will no longer open."
serverScoped
control={
<RetentionControl
label="Delete old browser artifacts"
value={settings.browserArtifactsAfterDays}
onChange={(browserArtifactsAfterDays) => update({ browserArtifactsAfterDays })}
/>
}
/>
<SettingsRow
title="Delete old rotated logs"
status={ruleStatus("logsAfterDays")}
description="Delete inactive rotated log files after this many days. Current logs are kept."
serverScoped
control={
<RetentionControl
label="Delete old rotated logs"
value={settings.logsAfterDays}
onChange={(logsAfterDays) => update({ logsAfterDays })}
/>
}
/>
<AnimatedHeight>
<SettingsRow
title="Delete old browser artifacts"
status={artifactsStatus}
description="Delete saved browser captures after this many days. Older capture links will no longer open."
serverScoped
control={
<RetentionControl
label="Delete old browser artifacts"
value={settings.browserArtifactsAfterDays}
onChange={(browserArtifactsAfterDays) => update({ browserArtifactsAfterDays })}
/>
}
/>
</AnimatedHeight>
<AnimatedHeight>
<SettingsRow
title="Delete old rotated logs"
status={logsStatus}
description="Delete inactive rotated log files after this many days. Current logs are kept."
serverScoped
control={
<RetentionControl
label="Delete old rotated logs"
value={settings.logsAfterDays}
onChange={(logsAfterDays) => update({ logsAfterDays })}
/>
}
/>
</AnimatedHeight>
</SettingsSection>
<CleanupSection />
</>
Expand Down
Loading