mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "eventsourcemock"
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user