Skip to content

Commit 9a391eb

Browse files
fix(web): clear stale resource monitor errors after retry (#24229)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com> Co-authored-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent e340aae commit 9a391eb

3 files changed

Lines changed: 283 additions & 0 deletions

File tree

‎apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,11 @@ export function ResourceTelemetryDiagnostics({
822822
},
823823
environmentId,
824824
);
825+
const refreshHistory = history.refresh;
826+
const selectedHistoryRef = useRef({ environmentId, windowMs, refreshHistory });
827+
useEffect(() => {
828+
selectedHistoryRef.current = { environmentId, windowMs, refreshHistory };
829+
}, [environmentId, windowMs, refreshHistory]);
825830
const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, {
826831
reportFailure: false,
827832
});
@@ -929,6 +934,17 @@ export function ResourceTelemetryDiagnostics({
929934
return;
930935
setIsRetrying(true);
931936
void retryTelemetry()
937+
.then(() => {
938+
refreshHistory();
939+
const selectedHistory = selectedHistoryRef.current;
940+
if (
941+
environmentIdRef.current === environmentId &&
942+
selectedHistory.environmentId === environmentId &&
943+
selectedHistory.windowMs !== windowMs
944+
) {
945+
selectedHistory.refreshHistory();
946+
}
947+
})
932948
.catch((error: unknown) => {
933949
toastManager.add({
934950
type: "error",
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import { RegistryContext } from "@effect/atom-react";
2+
import {
3+
EnvironmentId,
4+
type ResourceTelemetryHistory,
5+
type ResourceTelemetrySnapshot,
6+
} from "@t3tools/contracts";
7+
import * as Cause from "effect/Cause";
8+
import * as DateTime from "effect/DateTime";
9+
import * as Data from "effect/Data";
10+
import * as Effect from "effect/Effect";
11+
import * as Option from "effect/Option";
12+
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
13+
import type { ReactNode } from "react";
14+
import { describe, expect, it, vi } from "vite-plus/test";
15+
16+
import { act, render } from "../../test/render";
17+
18+
const monitor = vi.hoisted(() => ({
19+
telemetry:
20+
vi.fn<
21+
(args: {
22+
environmentId: EnvironmentId;
23+
}) => Atom.Atom<AsyncResult.AsyncResult<ResourceTelemetrySnapshot, MonitorError>>
24+
>(),
25+
history:
26+
vi.fn<
27+
(args: {
28+
environmentId: EnvironmentId;
29+
input: { windowMs: number; bucketMs: number };
30+
}) => Atom.Atom<AsyncResult.AsyncResult<ResourceTelemetryHistory, MonitorError>>
31+
>(),
32+
retry: vi.fn(),
33+
}));
34+
35+
vi.mock("../components/settings/settingsLayout", () => ({
36+
SettingsSection: ({
37+
title,
38+
headerAction,
39+
children,
40+
}: {
41+
title: string;
42+
headerAction?: ReactNode;
43+
children: ReactNode;
44+
}) => (
45+
<section aria-label={title}>
46+
{headerAction}
47+
{children}
48+
</section>
49+
),
50+
}));
51+
52+
vi.mock("../state/environments", () => ({ usePrimaryEnvironment: () => null }));
53+
vi.mock("../state/session", () => ({
54+
readEnvironmentScope: () => true,
55+
useEnvironmentScope: () => true,
56+
}));
57+
vi.mock("../state/server", () => ({
58+
serverEnvironment: {
59+
resourceTelemetry: monitor.telemetry,
60+
resourceTelemetryHistory: monitor.history,
61+
},
62+
}));
63+
vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => monitor.retry }));
64+
65+
import { ResourceTelemetryDiagnostics } from "../components/settings/ResourceTelemetryDiagnostics";
66+
67+
class MonitorError extends Data.TaggedError("MonitorError")<{ readonly message: string }> {}
68+
69+
const environmentId = EnvironmentId.make("remote");
70+
const startError = "The resource monitor is not available.";
71+
const historyError = "Resource history could not start.";
72+
73+
function setup({ retryFails = false, holdRetry = false } = {}) {
74+
const readAt = DateTime.makeUnsafe(0);
75+
const aggregate = {
76+
processCount: 0,
77+
currentCpuPercent: 0,
78+
cpuTimeMs: 0,
79+
currentRssBytes: 0,
80+
peakRssBytes: 0,
81+
ioReadBytes: 0,
82+
ioWriteBytes: 0,
83+
ioReadBytesPerSecond: 0,
84+
ioWriteBytesPerSecond: 0,
85+
processStarts: 0,
86+
processExits: 0,
87+
};
88+
const source = {
89+
status: "healthy" as const,
90+
lastSampleAt: Option.some(readAt),
91+
lastError: Option.none<string>(),
92+
};
93+
const snapshot: ResourceTelemetrySnapshot = {
94+
readAt,
95+
sampleIntervalMs: 1000,
96+
processes: [],
97+
groups: { backend: aggregate, electron: aggregate, monitor: aggregate, allT3: aggregate },
98+
power: {
99+
source: "unknown",
100+
idle: "unknown",
101+
idleSeconds: null,
102+
locked: "unknown",
103+
suspended: false,
104+
onBattery: "unknown",
105+
lowPowerMode: "unknown",
106+
thermalState: "unknown",
107+
stale: false,
108+
updatedAt: readAt,
109+
},
110+
speedLimitPercent: Option.none(),
111+
attribution: { readAt, entries: [] },
112+
health: {
113+
native: source,
114+
desktop: source,
115+
sidecarVersion: Option.none(),
116+
sidecarPid: Option.none(),
117+
restartCount: 1,
118+
collectionDurationMicros: 0,
119+
scannedProcessCount: 0,
120+
retainedProcessCount: 0,
121+
inaccessibleProcessCount: 0,
122+
},
123+
};
124+
const history: ResourceTelemetryHistory = {
125+
readAt,
126+
windowMs: 900000,
127+
bucketMs: 30000,
128+
sampleIntervalMs: 1000,
129+
retainedSampleCount: 0,
130+
buckets: [],
131+
topProcesses: [],
132+
health: snapshot.health,
133+
};
134+
let running = false;
135+
const telemetryQueries = Atom.family((_environmentId: EnvironmentId) =>
136+
Atom.make(
137+
Effect.suspend(() =>
138+
running ? Effect.succeed(snapshot) : Effect.fail(new MonitorError({ message: startError })),
139+
),
140+
).pipe(Atom.keepAlive),
141+
);
142+
monitor.telemetry.mockReset();
143+
monitor.telemetry.mockImplementation(({ environmentId }) => telemetryQueries(environmentId));
144+
const historyReads = vi.fn<(key: string) => void>();
145+
const historyQueries = Atom.family((key: string) =>
146+
Atom.make(
147+
Effect.suspend(() => {
148+
historyReads(key);
149+
return running
150+
? Effect.succeed(history)
151+
: Effect.fail(new MonitorError({ message: historyError }));
152+
}),
153+
).pipe(Atom.keepAlive),
154+
);
155+
monitor.history.mockReset();
156+
monitor.history.mockImplementation(({ environmentId, input }) =>
157+
historyQueries(`${environmentId}:${input.windowMs}`),
158+
);
159+
let releaseRetry = () => {};
160+
const retryRelease = new Promise<void>((resolve) => {
161+
releaseRetry = resolve;
162+
});
163+
monitor.retry.mockReset();
164+
monitor.retry.mockImplementation(async () => {
165+
if (holdRetry) await retryRelease;
166+
if (retryFails)
167+
return AsyncResult.failure(Cause.fail(new MonitorError({ message: "Restart failed." })));
168+
running = true;
169+
return AsyncResult.success({ accepted: true, snapshot });
170+
});
171+
const registry = AtomRegistry.make();
172+
return {
173+
registry,
174+
historyQueries,
175+
historyReads,
176+
releaseRetry,
177+
[Symbol.dispose]: () => registry.dispose(),
178+
};
179+
}
180+
181+
describe("resource monitor retry", () => {
182+
it("clears the settings and history errors after a successful retry without remounting", async () => {
183+
using fixture = setup();
184+
await using screen = await render(
185+
<RegistryContext.Provider value={fixture.registry}>
186+
<ResourceTelemetryDiagnostics environmentId={environmentId} />
187+
</RegistryContext.Provider>,
188+
);
189+
await expect.element(screen.getByText(startError, { exact: true })).toBeVisible();
190+
await expect.element(screen.getByText(historyError, { exact: true })).toBeVisible();
191+
192+
await screen.getByRole("button", { name: "Retry monitor" }).click();
193+
194+
await expect.element(screen.getByText(startError, { exact: true })).not.toBeInTheDocument();
195+
await expect.element(screen.getByText(historyError, { exact: true })).not.toBeInTheDocument();
196+
await expect.element(screen.getByText("Native healthy", { exact: true })).toBeVisible();
197+
await expect
198+
.element(screen.getByRole("button", { name: "Retry monitor" }))
199+
.not.toBeInTheDocument();
200+
});
201+
202+
it.each([false, true])(
203+
"refreshes the changed history window only in the retry environment (switch environment: %s)",
204+
async (switchEnvironment) => {
205+
using fixture = setup({ holdRetry: true });
206+
const view = (selectedEnvironment: EnvironmentId) => (
207+
<RegistryContext.Provider value={fixture.registry}>
208+
<ResourceTelemetryDiagnostics environmentId={selectedEnvironment} />
209+
</RegistryContext.Provider>
210+
);
211+
await using screen = await render(view(environmentId));
212+
await expect.element(screen.getByText(historyError, { exact: true })).toBeVisible();
213+
await screen.getByRole("button", { name: "Retry monitor" }).click();
214+
await expect.element(screen.getByRole("button", { name: "Retry monitor" })).toBeDisabled();
215+
const selectedEnvironment = switchEnvironment ? EnvironmentId.make("other") : environmentId;
216+
if (switchEnvironment) await screen.rerender(view(selectedEnvironment));
217+
await screen.getByRole("button", { name: "5m", exact: true }).click();
218+
await expect.element(screen.getByText(historyError, { exact: true })).toBeVisible();
219+
const selectedKey = `${selectedEnvironment}:300000`;
220+
expect(fixture.registry.get(fixture.historyQueries(selectedKey))._tag).toBe("Failure");
221+
fixture.historyReads.mockClear();
222+
223+
await act(() => fixture.releaseRetry());
224+
225+
if (switchEnvironment) {
226+
await expect.element(screen.getByRole("button", { name: "Retry monitor" })).toBeEnabled();
227+
await expect.element(screen.getByText(historyError, { exact: true })).toBeVisible();
228+
expect(fixture.registry.get(fixture.historyQueries(selectedKey))._tag).toBe("Failure");
229+
expect(fixture.historyReads).not.toHaveBeenCalled();
230+
} else {
231+
await expect.element(screen.getByText(startError, { exact: true })).not.toBeInTheDocument();
232+
await expect
233+
.element(screen.getByText(historyError, { exact: true }))
234+
.not.toBeInTheDocument();
235+
expect(fixture.registry.get(fixture.historyQueries(selectedKey))._tag).toBe("Success");
236+
expect(fixture.historyReads.mock.calls.map(([key]) => key)).toEqual([selectedKey]);
237+
await screen.getByRole("button", { name: "15m", exact: true }).click();
238+
await expect
239+
.element(screen.getByText(historyError, { exact: true }))
240+
.not.toBeInTheDocument();
241+
}
242+
expect(fixture.registry.get(fixture.historyQueries(`${environmentId}:900000`))._tag).toBe(
243+
"Success",
244+
);
245+
expect(monitor.retry).toHaveBeenCalledExactlyOnceWith({ environmentId, input: {} });
246+
},
247+
);
248+
249+
it("keeps the failed monitor and history errors visible when retry fails", async () => {
250+
using fixture = setup({ retryFails: true });
251+
await using screen = await render(
252+
<RegistryContext.Provider value={fixture.registry}>
253+
<ResourceTelemetryDiagnostics environmentId={environmentId} />
254+
</RegistryContext.Provider>,
255+
);
256+
await expect.element(screen.getByText(startError, { exact: true })).toBeVisible();
257+
await expect.element(screen.getByText(historyError, { exact: true })).toBeVisible();
258+
259+
await screen.getByRole("button", { name: "Retry monitor" }).click();
260+
261+
await expect.element(screen.getByRole("button", { name: "Retry monitor" })).toBeEnabled();
262+
expect(monitor.retry).toHaveBeenCalledExactlyOnceWith({ environmentId, input: {} });
263+
await expect.element(screen.getByText(startError, { exact: true })).toBeVisible();
264+
await expect.element(screen.getByText(historyError, { exact: true })).toBeVisible();
265+
});
266+
});

‎apps/web/src/lib/resourceTelemetryState.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function useResourceTelemetry(
5252
if (result._tag === "Failure") {
5353
throw Cause.squash(result.cause);
5454
}
55+
query.refresh();
5556
return result.value.snapshot;
5657
};
5758

0 commit comments

Comments
 (0)