diff --git a/site/package.json b/site/package.json index 513887ce4152c..1c6e11886da27 100644 --- a/site/package.json +++ b/site/package.json @@ -43,6 +43,7 @@ "cronstrue": "2.11.0", "dayjs": "1.11.4", "emoji-mart": "^5.2.1", + "eventsourcemock": "^2.0.0", "formik": "^2.2.9", "front-matter": "4.0.2", "history": "5.3.0", diff --git a/site/src/@types/eventsourcemock.d.ts b/site/src/@types/eventsourcemock.d.ts new file mode 100644 index 0000000000000..12f9cc003ebf0 --- /dev/null +++ b/site/src/@types/eventsourcemock.d.ts @@ -0,0 +1 @@ +declare module "eventsourcemock" diff --git a/site/src/api/api.ts b/site/src/api/api.ts index d3dae5a17c765..5a4cc2a2e9825 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -219,6 +219,18 @@ export const getWorkspace = async ( return response.data } +/** + * + * @param workspaceId + * @returns An EventSource that emits workspace event objects (ServerSentEvent) + */ +export const watchWorkspace = (workspaceId: string): EventSource => { + return new EventSource( + `${location.protocol}//${location.host}/api/v2/workspaces/${workspaceId}/watch`, + { withCredentials: true }, + ) +} + export const getURLWithSearchParams = ( basePath: string, filter?: TypesGen.WorkspaceFilter | TypesGen.UsersRequest, diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index fd9e26849b0d4..61e46f19821c7 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import { fireEvent, screen, waitFor } from "@testing-library/react" import userEvent from "@testing-library/user-event" +import EventSource from "eventsourcemock" import i18next from "i18next" import { rest } from "msw" import * as api from "../../api/api" @@ -23,6 +24,7 @@ import { MockWorkspaceAgentConnecting, MockWorkspaceAgentDisconnected, MockWorkspaceBuild, + MockWorkspaceResource2, renderWithAuth, } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" @@ -71,6 +73,11 @@ const testStatus = async (ws: Workspace, label: string) => { beforeEach(() => { jest.resetAllMocks() + + // mocking out EventSource for SSE + Object.defineProperty(window, "EventSource", { + value: EventSource, + }) }) describe("WorkspacePage", () => { @@ -196,18 +203,43 @@ describe("WorkspacePage", () => { describe("Resources", () => { it("shows the status of each agent in each resource", async () => { const getTemplateMock = jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate) + + const workspaceWithResources = { + ...MockWorkspace, + latest_build: { + ...MockWorkspaceBuild, + resources: [ + { + ...MockWorkspaceResource2, + agents: [ + MockWorkspaceAgent, + MockWorkspaceAgentDisconnected, + MockWorkspaceAgentConnecting, + ], + }, + ], + }, + } + + server.use( + rest.get(`/api/v2/users/:username/workspace/:workspaceName`, (req, res, ctx) => { + return res(ctx.status(200), ctx.json(workspaceWithResources)) + }), + ) + renderWithAuth(, { route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}`, path: "/@:username/:workspace", }) + const agent1Names = await screen.findAllByText(MockWorkspaceAgent.name) - expect(agent1Names.length).toEqual(2) + expect(agent1Names.length).toEqual(1) const agent2Names = await screen.findAllByText(MockWorkspaceAgentDisconnected.name) expect(agent2Names.length).toEqual(2) const agent1Status = await screen.findAllByText( DisplayAgentStatusLanguage[MockWorkspaceAgent.status], ) - expect(agent1Status.length).toEqual(4) + expect(agent1Status.length).toEqual(1) const agentDisconnected = await screen.findAllByText( DisplayAgentStatusLanguage[MockWorkspaceAgentDisconnected.status], ) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 17e25a3e20431..39192b6224eb2 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -40,9 +40,8 @@ export const WorkspacePage: FC = () => { workspace, getWorkspaceError, template, - refreshTemplateError, - resources, - getResourcesError, + refreshTemplateWarning, + refreshWorkspaceWarning, builds, getBuildsError, permissions, @@ -70,7 +69,7 @@ export const WorkspacePage: FC = () => { return (
{Boolean(getWorkspaceError) && } - {Boolean(refreshTemplateError) && } + {Boolean(refreshTemplateWarning) && } {Boolean(checkPermissionsError) && }
) @@ -128,11 +127,11 @@ export const WorkspacePage: FC = () => { handleDelete={() => workspaceSend("ASK_DELETE")} handleUpdate={() => workspaceSend("UPDATE")} handleCancel={() => workspaceSend("CANCEL")} - resources={resources} + resources={workspace.latest_build.resources} builds={builds} canUpdateWorkspace={canUpdateWorkspace} workspaceErrors={{ - [WorkspaceErrors.GET_RESOURCES_ERROR]: getResourcesError, + [WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning, [WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError, [WorkspaceErrors.BUILD_ERROR]: buildError, [WorkspaceErrors.CANCELLATION_ERROR]: cancellationError, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 598d37753a71f..04183a3ddfbd7 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -380,7 +380,7 @@ export const MockWorkspaceAgentOutdated: TypesGen.WorkspaceAgent = { export const MockWorkspaceAgentConnecting: TypesGen.WorkspaceAgent = { ...MockWorkspaceAgent, - id: "test-workspace-agent-2", + id: "test-workspace-agent-connecting", name: "another-workspace-agent", status: "connecting", version: "", diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 94c1a02c220b0..144f128670779 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -13,28 +13,28 @@ const latestBuild = (builds: TypesGen.WorkspaceBuild[]) => { } const Language = { - refreshTemplateError: "Error updating workspace: latest template could not be fetched.", + refreshTemplateWarning: "Error updating workspace: latest template could not be fetched.", buildError: "Workspace action failed.", } type Permissions = Record, boolean> export interface WorkspaceContext { + // our server side events instance + eventSource?: EventSource workspace?: TypesGen.Workspace template?: TypesGen.Template build?: TypesGen.WorkspaceBuild - resources?: TypesGen.WorkspaceResource[] getWorkspaceError?: Error | unknown - // error creating a new WorkspaceBuild - buildError?: Error | unknown - // these are separate from getX errors because they don't make the page unusable - refreshWorkspaceError: Error | unknown - refreshTemplateError: Error | unknown - getResourcesError: Error | unknown + // these are labeled as warnings because they don't make the page unusable + refreshWorkspaceWarning?: Error | unknown + refreshTemplateWarning: Error | unknown // Builds builds?: TypesGen.WorkspaceBuild[] getBuildsError?: Error | unknown loadMoreBuildsError?: Error | unknown + // error creating a new WorkspaceBuild + buildError?: Error | unknown cancellationMessage?: Types.Message cancellationError?: Error | unknown // permissions @@ -45,6 +45,7 @@ export interface WorkspaceContext { export type WorkspaceEvent = | { type: "GET_WORKSPACE"; workspaceName: string; username: string } + | { type: "REFRESH_WORKSPACE"; data: TypesGen.ServerSentEvent["data"] } | { type: "START" } | { type: "STOP" } | { type: "ASK_DELETE" } @@ -53,7 +54,9 @@ export type WorkspaceEvent = | { type: "UPDATE" } | { type: "CANCEL" } | { type: "LOAD_MORE_BUILDS" } + | { type: "CHECK_REFRESH_TIMELINE"; data: TypesGen.ServerSentEvent["data"] } | { type: "REFRESH_TIMELINE" } + | { type: "EVENT_SOURCE_ERROR"; error: Error | unknown } export const checks = { readWorkspace: "readWorkspace", @@ -109,11 +112,8 @@ export const workspaceMachine = createMachine( cancelWorkspace: { data: Types.Message } - refreshWorkspace: { - data: TypesGen.Workspace | undefined - } - getResources: { - data: TypesGen.WorkspaceResource[] + listening: { + data: TypesGen.ServerSentEvent } getBuilds: { data: TypesGen.WorkspaceBuild[] @@ -158,7 +158,7 @@ export const workspaceMachine = createMachine( tags: "loading", }, refreshingTemplate: { - entry: "clearRefreshTemplateError", + entry: "clearRefreshTemplateWarning", invoke: { src: "getTemplate", id: "refreshTemplate", @@ -170,7 +170,7 @@ export const workspaceMachine = createMachine( ], onError: [ { - actions: ["assignRefreshTemplateError", "displayRefreshTemplateError"], + actions: ["assignRefreshTemplateWarning", "displayRefreshTemplateWarning"], target: "error", }, ], @@ -199,32 +199,33 @@ export const workspaceMachine = createMachine( ready: { type: "parallel", states: { - pollingWorkspace: { - initial: "refreshingWorkspace", + listening: { + initial: "gettingEvents", states: { - refreshingWorkspace: { - entry: "clearRefreshWorkspaceError", + gettingEvents: { + entry: ["clearRefreshWorkspaceWarning", "initializeEventSource"], + exit: "closeEventSource", invoke: { - src: "refreshWorkspace", - id: "refreshWorkspace", - onDone: [ - { - actions: ["refreshTimeline", "assignWorkspace"], - target: "waiting", - }, - ], - onError: [ - { - actions: "assignRefreshWorkspaceError", - target: "waiting", - }, - ], + src: "listening", + id: "listening", + }, + on: { + REFRESH_WORKSPACE: { + actions: ["refreshWorkspace"], + }, + EVENT_SOURCE_ERROR: { + target: "error", + }, + CHECK_REFRESH_TIMELINE: { + actions: ["refreshTimeline"], + }, }, }, - waiting: { + error: { + entry: "assignRefreshWorkspaceWarning", after: { "1000": { - target: "refreshingWorkspace", + target: "gettingEvents", }, }, }, @@ -348,7 +349,7 @@ export const workspaceMachine = createMachine( }, }, refreshingTemplate: { - entry: "clearRefreshTemplateError", + entry: "clearRefreshTemplateWarning", invoke: { src: "getTemplate", id: "refreshTemplate", @@ -360,7 +361,7 @@ export const workspaceMachine = createMachine( ], onError: [ { - actions: ["assignRefreshTemplateError", "displayRefreshTemplateError"], + actions: ["assignRefreshTemplateWarning", "displayRefreshTemplateWarning"], target: "idle", }, ], @@ -368,37 +369,6 @@ export const workspaceMachine = createMachine( }, }, }, - pollingResources: { - initial: "gettingResources", - states: { - gettingResources: { - entry: "clearGetResourcesError", - invoke: { - src: "getResources", - id: "getResources", - onDone: [ - { - actions: "assignResources", - target: "waiting", - }, - ], - onError: [ - { - actions: "assignGetResourcesError", - target: "waiting", - }, - ], - }, - }, - waiting: { - after: { - "5000": { - target: "gettingResources", - }, - }, - }, - }, - }, timeline: { initial: "gettingBuilds", states: { @@ -477,6 +447,7 @@ export const workspaceMachine = createMachine( template: undefined, build: undefined, permissions: undefined, + eventSource: undefined, }), assignWorkspace: assign({ workspace: (_, event) => event.data, @@ -525,30 +496,29 @@ export const workspaceMachine = createMachine( clearCancellationError: assign({ cancellationError: (_) => undefined, }), - assignRefreshWorkspaceError: assign({ - refreshWorkspaceError: (_, event) => event.data, - }), - clearRefreshWorkspaceError: assign({ - refreshWorkspaceError: (_) => undefined, + // SSE related actions + // open a new EventSource so we can stream SSE + initializeEventSource: assign({ + eventSource: (context) => context.workspace && API.watchWorkspace(context.workspace.id), }), - assignRefreshTemplateError: assign({ - refreshTemplateError: (_, event) => event.data, + closeEventSource: (context) => context.eventSource && context.eventSource.close(), + refreshWorkspace: assign({ + workspace: (_, event) => event.data, }), - displayRefreshTemplateError: () => { - displayError(Language.refreshTemplateError) - }, - clearRefreshTemplateError: assign({ - refreshTemplateError: (_) => undefined, + assignRefreshWorkspaceWarning: assign({ + refreshWorkspaceWarning: (_, event) => event, }), - // Resources - assignResources: assign({ - resources: (_, event) => event.data, + clearRefreshWorkspaceWarning: assign({ + refreshWorkspaceWarning: (_) => undefined, }), - assignGetResourcesError: assign({ - getResourcesError: (_, event) => event.data, + assignRefreshTemplateWarning: assign({ + refreshTemplateWarning: (_, event) => event.data, }), - clearGetResourcesError: assign({ - getResourcesError: (_) => undefined, + displayRefreshTemplateWarning: () => { + displayError(Language.refreshTemplateWarning) + }, + clearRefreshTemplateWarning: assign({ + refreshTemplateWarning: (_) => undefined, }), // Timeline assignBuilds: assign({ @@ -583,9 +553,10 @@ export const workspaceMachine = createMachine( if (!context.builds) { return } - // When it is a refresh workspace event, we want to check if the latest + + // When it is a CHECK_REFRESH_TIMELINE workspace event, we want to check if the latest // build was updated to not over fetch the builds - if (event.type === "done.invoke.refreshWorkspace") { + if (event.type === "CHECK_REFRESH_TIMELINE") { const latestBuildInTimeline = latestBuild(context.builds) const isUpdated = event.data?.latest_build.updated_at !== latestBuildInTimeline.updated_at if (isUpdated) { @@ -650,27 +621,28 @@ export const workspaceMachine = createMachine( throw Error("Cannot cancel workspace without build id") } }, - refreshWorkspace: async (context) => { - if (context.workspace) { - return await API.getWorkspaceByOwnerAndName( - context.workspace.owner_name, - context.workspace.name, - { - include_deleted: true, - }, - ) - } else { - throw Error("Cannot refresh workspace without id") + listening: (context) => (send) => { + if (!context.eventSource) { + send({ type: "EVENT_SOURCE_ERROR", error: "error initializing sse" }) + return } - }, - getResources: async (context) => { - // If the job hasn't completed, fetching resources will result - // in an unfriendly error for the user. - if (!context.workspace?.latest_build.job.completed_at) { - return [] + + context.eventSource.addEventListener("data", (event) => { + // refresh our workspace with each SSE + send({ type: "REFRESH_WORKSPACE", data: JSON.parse(event.data) }) + // refresh our timeline + send({ type: "CHECK_REFRESH_TIMELINE", data: JSON.parse(event.data) }) + }) + + // handle any error events returned by our sse + context.eventSource.addEventListener("error", (event) => { + send({ type: "EVENT_SOURCE_ERROR", error: event }) + }) + + // handle any sse implementation exceptions + context.eventSource.onerror = () => { + send({ type: "EVENT_SOURCE_ERROR", error: "sse error" }) } - const resources = await API.getWorkspaceResources(context.workspace.latest_build.id) - return resources }, getBuilds: async (context) => { if (context.workspace) { diff --git a/site/webpack.dev.ts b/site/webpack.dev.ts index 9e59efa6aa3a9..dad40526220b7 100644 --- a/site/webpack.dev.ts +++ b/site/webpack.dev.ts @@ -74,6 +74,8 @@ const config: Configuration = { secure: false, }, }, + // We must disable compression to get SSEs to work (in workspaceXService.ts) + compress: false, static: ["./static"], }, diff --git a/site/yarn.lock b/site/yarn.lock index 8680851774568..c14c11bbe9ce6 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -7144,6 +7144,11 @@ events@^3.0.0, events@^3.2.0, events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +eventsourcemock@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/eventsourcemock/-/eventsourcemock-2.0.0.tgz#83f66bc537e4909ef385bf84272e300737954ef0" + integrity sha512-tSmJnuE+h6A8/hLRg0usf1yL+Q8w01RQtmg0Uzgoxk/HIPZrIUeAr/A4es/8h1wNsoG8RdiESNQLTKiNwbSC3Q== + evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02"