feat: use sse for workspace page (#4122)

* added error handling

* workspace machine cleanup

* renaming callback

* general cleanup

* fixed tests

* PR comments
This commit is contained in:
Kira Pilot
2022-09-21 14:32:00 -04:00
committed by GitHub
parent 3db9ea9dd2
commit 5698b9d706
9 changed files with 142 additions and 118 deletions
+1
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
declare module "eventsourcemock"
+12
View File
@@ -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,
@@ -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(<WorkspacePage />, {
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],
)
@@ -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 (
<div className={styles.error}>
{Boolean(getWorkspaceError) && <ErrorSummary error={getWorkspaceError} />}
{Boolean(refreshTemplateError) && <ErrorSummary error={refreshTemplateError} />}
{Boolean(refreshTemplateWarning) && <ErrorSummary error={refreshTemplateWarning} />}
{Boolean(checkPermissionsError) && <ErrorSummary error={checkPermissionsError} />}
</div>
)
@@ -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,
+1 -1
View File
@@ -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: "",
+81 -109
View File
@@ -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<keyof ReturnType<typeof permissionsToCheck>, 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,
// SSE related actions
// open a new EventSource so we can stream SSE
initializeEventSource: assign({
eventSource: (context) => context.workspace && API.watchWorkspace(context.workspace.id),
}),
clearRefreshWorkspaceError: assign({
refreshWorkspaceError: (_) => undefined,
closeEventSource: (context) => context.eventSource && context.eventSource.close(),
refreshWorkspace: assign({
workspace: (_, event) => event.data,
}),
assignRefreshTemplateError: assign({
refreshTemplateError: (_, event) => event.data,
assignRefreshWorkspaceWarning: assign({
refreshWorkspaceWarning: (_, event) => event,
}),
displayRefreshTemplateError: () => {
displayError(Language.refreshTemplateError)
clearRefreshWorkspaceWarning: assign({
refreshWorkspaceWarning: (_) => undefined,
}),
assignRefreshTemplateWarning: assign({
refreshTemplateWarning: (_, event) => event.data,
}),
displayRefreshTemplateWarning: () => {
displayError(Language.refreshTemplateWarning)
},
clearRefreshTemplateError: assign({
refreshTemplateError: (_) => undefined,
}),
// Resources
assignResources: assign({
resources: (_, event) => event.data,
}),
assignGetResourcesError: assign({
getResourcesError: (_, event) => event.data,
}),
clearGetResourcesError: assign({
getResourcesError: (_) => undefined,
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) {
+2
View File
@@ -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"],
},
+5
View File
@@ -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"