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"