Skip to content

Commit b8cfe76

Browse files
committed
feat: add support for one-way websockets to UI
1 parent e312932 commit b8cfe76

File tree

9 files changed

+177
-104
lines changed

9 files changed

+177
-104
lines changed

site/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,6 @@
164164
"@vitejs/plugin-react": "4.3.4",
165165
"autoprefixer": "10.4.20",
166166
"chromatic": "11.25.2",
167-
"eventsourcemock": "2.0.0",
168167
"express": "4.21.2",
169168
"jest": "29.7.0",
170169
"jest-canvas-mock": "2.5.2",
@@ -189,7 +188,11 @@
189188
"vite-plugin-checker": "0.8.0",
190189
"vite-plugin-turbosnap": "1.0.3"
191190
},
192-
"browserslist": ["chrome 110", "firefox 111", "safari 16.0"],
191+
"browserslist": [
192+
"chrome 110",
193+
"firefox 111",
194+
"safari 16.0"
195+
],
193196
"resolutions": {
194197
"optionator": "0.9.3",
195198
"semver": "7.6.2"

site/pnpm-lock.yaml

Lines changed: 1 addition & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/@types/eventsourcemock.d.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

site/src/api/api.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@
2222
import globalAxios, { type AxiosInstance, isAxiosError } from "axios";
2323
import type dayjs from "dayjs";
2424
import userAgentParser from "ua-parser-js";
25+
import { OneWayWebSocket } from "utils/OneWayWebSocket";
2526
import { delay } from "../utils/delay";
26-
import * as TypesGen from "./typesGenerated";
2727
import type { PostWorkspaceUsageRequest } from "./typesGenerated";
28+
import * as TypesGen from "./typesGenerated";
2829

2930
const getMissingParameters = (
3031
oldBuildParameters: TypesGen.WorkspaceBuildParameter[],
@@ -106,21 +107,21 @@ const getMissingParameters = (
106107
* @returns An EventSource that emits agent metadata event objects
107108
* (ServerSentEvent)
108109
*/
109-
export const watchAgentMetadata = (agentId: string): EventSource => {
110-
return new EventSource(
111-
`${location.protocol}//${location.host}/api/v2/workspaceagents/${agentId}/watch-metadata`,
112-
{ withCredentials: true },
110+
export const watchAgentMetadata = (agentId: string): OneWayWebSocket => {
111+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
112+
return new OneWayWebSocket(
113+
`${protocol}//${location.host}/api/v2/workspaceagents/${agentId}/watch-metadata-ws`,
113114
);
114115
};
115116

116117
/**
117118
* @returns {EventSource} An EventSource that emits workspace event objects
118119
* (ServerSentEvent)
119120
*/
120-
export const watchWorkspace = (workspaceId: string): EventSource => {
121-
return new EventSource(
122-
`${location.protocol}//${location.host}/api/v2/workspaces/${workspaceId}/watch`,
123-
{ withCredentials: true },
121+
export const watchWorkspace = (workspaceId: string): OneWayWebSocket => {
122+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
123+
return new OneWayWebSocket(
124+
`${protocol}//${location.host}/api/v2/workspaces/${workspaceId}/watch-ws`,
124125
);
125126
};
126127

@@ -1080,25 +1081,27 @@ class ApiMethods {
10801081
};
10811082

10821083
getWorkspaceByOwnerAndName = async (
1083-
username = "me",
1084+
username: string | undefined,
10841085
workspaceName: string,
10851086
params?: TypesGen.WorkspaceOptions,
10861087
): Promise<TypesGen.Workspace> => {
1088+
const user = username || "me";
10871089
const response = await this.axios.get<TypesGen.Workspace>(
1088-
`/api/v2/users/${username}/workspace/${workspaceName}`,
1090+
`/api/v2/users/${user}/workspace/${workspaceName}`,
10891091
{ params },
10901092
);
10911093

10921094
return response.data;
10931095
};
10941096

10951097
getWorkspaceBuildByNumber = async (
1096-
username = "me",
1098+
username: string | undefined,
10971099
workspaceName: string,
10981100
buildNumber: number,
10991101
): Promise<TypesGen.WorkspaceBuild> => {
1102+
const name = username || "me";
11001103
const response = await this.axios.get<TypesGen.WorkspaceBuild>(
1101-
`/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`,
1104+
`/api/v2/users/${name}/workspace/${workspaceName}/builds/${buildNumber}`,
11021105
);
11031106

11041107
return response.data;
@@ -1279,11 +1282,12 @@ class ApiMethods {
12791282
};
12801283

12811284
createWorkspace = async (
1282-
userId = "me",
1285+
userId: string | undefined,
12831286
workspace: TypesGen.CreateWorkspaceRequest,
12841287
): Promise<TypesGen.Workspace> => {
1288+
const id = userId || "me";
12851289
const response = await this.axios.post<TypesGen.Workspace>(
1286-
`/api/v2/users/${userId}/workspaces`,
1290+
`/api/v2/users/${id}/workspaces`,
12871291
workspace,
12881292
);
12891293

site/src/modules/resources/AgentMetadata.tsx

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import Skeleton from "@mui/material/Skeleton";
33
import Tooltip from "@mui/material/Tooltip";
44
import { watchAgentMetadata } from "api/api";
55
import type {
6+
ServerSentEvent,
67
WorkspaceAgent,
78
WorkspaceAgentMetadata,
89
} from "api/typesGenerated";
10+
import { displayError } from "components/GlobalSnackbar/utils";
911
import { Stack } from "components/Stack/Stack";
1012
import dayjs from "dayjs";
1113
import {
@@ -17,6 +19,7 @@ import {
1719
useState,
1820
} from "react";
1921
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
22+
import type { OneWayWebSocket } from "utils/OneWayWebSocket";
2023

2124
type ItemStatus = "stale" | "valid" | "loading";
2225

@@ -46,43 +49,50 @@ export const AgentMetadata: FC<AgentMetadataProps> = ({
4649
agent,
4750
storybookMetadata,
4851
}) => {
49-
const [metadata, setMetadata] = useState<
50-
WorkspaceAgentMetadata[] | undefined
51-
>(undefined);
52-
52+
const [metadata, setMetadata] = useState<WorkspaceAgentMetadata[]>();
5353
useEffect(() => {
54+
// Even though we're using storybookMetadata as the initial value of the
55+
// `metadata` state, we can't sync on `metadata` itself. If we did, the
56+
// moment we update the state with a new event, we would re-trigger the
57+
// effect and immediately destroy the connection
5458
if (storybookMetadata !== undefined) {
55-
setMetadata(storybookMetadata);
5659
return;
5760
}
5861

59-
let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
62+
let timeoutId: number | undefined = undefined;
63+
let latestSocket: OneWayWebSocket | undefined = undefined;
6064

61-
const connect = (): (() => void) => {
62-
const source = watchAgentMetadata(agent.id);
65+
const createNewConnection = () => {
66+
const socket = watchAgentMetadata(agent.id);
67+
latestSocket = socket;
6368

64-
source.onerror = (e) => {
65-
console.error("received error in watch stream", e);
69+
socket.addEventListener("error", () => {
70+
displayError("Socket closed unexpectedly. Creating new connection...");
6671
setMetadata(undefined);
67-
source.close();
68-
69-
timeout = setTimeout(() => {
70-
connect();
71-
}, 3000);
72-
};
73-
74-
source.addEventListener("data", (e) => {
75-
const data = JSON.parse(e.data);
76-
setMetadata(data);
72+
timeoutId = window.setTimeout(() => {
73+
createNewConnection();
74+
}, 3_000);
7775
});
78-
return () => {
79-
if (timeout !== undefined) {
80-
clearTimeout(timeout);
76+
77+
socket.addEventListener("message", (e) => {
78+
try {
79+
const payload = JSON.parse(e.data) as ServerSentEvent;
80+
if (payload.type === "data") {
81+
setMetadata(payload.data as WorkspaceAgentMetadata[]);
82+
}
83+
} catch {
84+
displayError(
85+
"Unable to process newest response from server. Please try refreshing the page.",
86+
);
8187
}
82-
source.close();
83-
};
88+
});
89+
};
90+
91+
createNewConnection();
92+
return () => {
93+
window.clearTimeout(timeoutId);
94+
latestSocket?.close();
8495
};
85-
return connect();
8696
}, [agent.id, storybookMetadata]);
8797

8898
if (metadata === undefined) {
Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,38 @@
11
import { watchBuildLogsByTemplateVersionId } from "api/api";
22
import type { ProvisionerJobLog, TemplateVersion } from "api/typesGenerated";
3+
import { useEffectEvent } from "hooks/hookPolyfills";
34
import { useEffect, useState } from "react";
45

56
export const useWatchVersionLogs = (
67
templateVersion: TemplateVersion | undefined,
78
options?: { onDone: () => Promise<unknown> },
89
) => {
9-
const [logs, setLogs] = useState<ProvisionerJobLog[] | undefined>();
10+
const [logs, setLogs] = useState<ProvisionerJobLog[]>();
1011
const templateVersionId = templateVersion?.id;
11-
const templateVersionStatus = templateVersion?.job.status;
12+
const [cachedVersionId, setCachedVersionId] = useState(templateVersionId);
13+
if (cachedVersionId !== templateVersionId) {
14+
setCachedVersionId(templateVersionId);
15+
setLogs([]);
16+
}
1217

13-
// biome-ignore lint/correctness/useExhaustiveDependencies: consider refactoring
18+
const stableOnDone = useEffectEvent(() => options?.onDone());
19+
const status = templateVersion?.job.status;
20+
const canWatch = status === "running" || status === "pending";
1421
useEffect(() => {
15-
setLogs(undefined);
16-
}, [templateVersionId]);
17-
18-
useEffect(() => {
19-
if (!templateVersionId || !templateVersionStatus) {
20-
return;
21-
}
22-
23-
if (
24-
templateVersionStatus !== "running" &&
25-
templateVersionStatus !== "pending"
26-
) {
22+
if (!templateVersionId || !canWatch) {
2723
return;
2824
}
2925

3026
const socket = watchBuildLogsByTemplateVersionId(templateVersionId, {
31-
onMessage: (log) => {
32-
setLogs((logs) => (logs ? [...logs, log] : [log]));
33-
},
34-
onDone: options?.onDone,
35-
onError: (error) => {
36-
console.error(error);
27+
onError: (error) => console.error(error),
28+
onDone: stableOnDone,
29+
onMessage: (newLog) => {
30+
setLogs((current) => [...(current ?? []), newLog]);
3731
},
3832
});
3933

40-
return () => {
41-
socket.close();
42-
};
43-
}, [options?.onDone, templateVersionId, templateVersionStatus]);
34+
return () => socket.close();
35+
}, [stableOnDone, canWatch, templateVersionId]);
4436

4537
return logs;
4638
};

site/src/pages/WorkspacePage/WorkspacePage.test.tsx

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { screen, waitFor, within } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import * as apiModule from "api/api";
44
import type { TemplateVersionParameter, Workspace } from "api/typesGenerated";
5-
import EventSourceMock from "eventsourcemock";
5+
import MockServerSocket from "jest-websocket-mock";
66
import {
77
DashboardContext,
88
type DashboardProvider,
@@ -84,23 +84,11 @@ const testButton = async (
8484

8585
const user = userEvent.setup();
8686
await user.click(button);
87-
expect(actionMock).toBeCalled();
87+
expect(actionMock).toHaveBeenCalled();
8888
};
8989

90-
let originalEventSource: typeof window.EventSource;
91-
92-
beforeAll(() => {
93-
originalEventSource = window.EventSource;
94-
// mocking out EventSource for SSE
95-
window.EventSource = EventSourceMock;
96-
});
97-
98-
beforeEach(() => {
99-
jest.resetAllMocks();
100-
});
101-
102-
afterAll(() => {
103-
window.EventSource = originalEventSource;
90+
afterEach(() => {
91+
MockServerSocket.clean();
10492
});
10593

10694
describe("WorkspacePage", () => {

site/src/pages/WorkspacePage/WorkspacePage.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { useQuery, useQueryClient } from "react-query";
1515
import { useParams } from "react-router-dom";
1616
import { WorkspaceReadyPage } from "./WorkspaceReadyPage";
1717
import { type WorkspacePermissions, workspaceChecks } from "./permissions";
18+
import { displayError } from "components/GlobalSnackbar/utils";
1819

1920
export const WorkspacePage: FC = () => {
2021
const queryClient = useQueryClient();
@@ -82,20 +83,26 @@ export const WorkspacePage: FC = () => {
8283
return;
8384
}
8485

85-
const eventSource = watchWorkspace(workspaceId);
86-
87-
eventSource.addEventListener("data", async (event) => {
88-
const newWorkspaceData = JSON.parse(event.data) as Workspace;
89-
await updateWorkspaceData(newWorkspaceData);
86+
const socket = watchWorkspace(workspaceId);
87+
socket.addEventListener("message", (event) => {
88+
try {
89+
const sse = JSON.parse(event.data);
90+
if (sse.type === "data") {
91+
updateWorkspaceData(sse.data as Workspace);
92+
}
93+
} catch {
94+
displayError(
95+
"Unable to process latest data from the server. Please try refreshing the page.",
96+
);
97+
}
9098
});
91-
92-
eventSource.addEventListener("error", (event) => {
93-
console.error("Error on getting workspace changes.", event);
99+
socket.addEventListener("error", () => {
100+
displayError(
101+
"Unable to get workspace changes. Connection has been closed.",
102+
);
94103
});
95104

96-
return () => {
97-
eventSource.close();
98-
};
105+
return () => socket.close();
99106
}, [updateWorkspaceData, workspaceId]);
100107

101108
// Page statuses

0 commit comments

Comments
 (0)