diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 05e1a156d1122..fe858603bff0f 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -135,9 +135,9 @@ func (c *Client) getWorkspace(ctx context.Context, id uuid.UUID, opts ...Request } type WorkspaceBuildsRequest struct { - WorkspaceID uuid.UUID + WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid" typescript:"-"` Pagination - Since time.Time + Since time.Time `json:"since,omitempty" format:"date-time"` } func (c *Client) WorkspaceBuilds(ctx context.Context, req WorkspaceBuildsRequest) ([]WorkspaceBuild, error) { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 09a9eb760bf3c..953ebf1dfa833 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -775,10 +775,10 @@ export const regenerateUserSSHKey = async ( export const getWorkspaceBuilds = async ( workspaceId: string, - since: Date, -): Promise => { + req?: TypesGen.WorkspaceBuildsRequest, +) => { const response = await axios.get( - `/api/v2/workspaces/${workspaceId}/builds?since=${since.toISOString()}`, + getURLWithSearchParams(`/api/v2/workspaces/${workspaceId}/builds`, req), ); return response.data; }; diff --git a/site/src/api/queries/workspaceBuilds.ts b/site/src/api/queries/workspaceBuilds.ts index 207e7374f39f5..c23f625b40835 100644 --- a/site/src/api/queries/workspaceBuilds.ts +++ b/site/src/api/queries/workspaceBuilds.ts @@ -1,4 +1,6 @@ +import { UseInfiniteQueryOptions } from "@tanstack/react-query"; import * as API from "api/api"; +import { WorkspaceBuild, WorkspaceBuildsRequest } from "api/typesGenerated"; export const workspaceBuildByNumber = ( username: string, @@ -6,8 +8,28 @@ export const workspaceBuildByNumber = ( buildNumber: number, ) => { return { - queryKey: [username, workspaceName, "workspaceBuild", buildNumber], + queryKey: ["workspaceBuild", username, workspaceName, buildNumber], queryFn: () => API.getWorkspaceBuildByNumber(username, workspaceName, buildNumber), }; }; + +export const infiniteWorkspaceBuilds = ( + workspaceId: string, + req?: WorkspaceBuildsRequest, +): UseInfiniteQueryOptions => { + const limit = req?.limit ?? 25; + + return { + queryKey: ["workspaceBuilds", workspaceId, req], + getNextPageParam: (lastPage, pages) => { + return pages.length + 1; + }, + queryFn: ({ pageParam = 0 }) => { + return API.getWorkspaceBuilds(workspaceId, { + limit, + offset: pageParam <= 0 ? 0 : (pageParam - 1) * limit, + }); + }, + }; +}; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3ce72f96dbfd4..240d9ba3b90e0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1479,8 +1479,7 @@ export interface WorkspaceBuildParameter { // From codersdk/workspaces.go export interface WorkspaceBuildsRequest extends Pagination { - readonly WorkspaceID: string; - readonly Since: string; + readonly since?: string; } // From codersdk/deployment.go diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx index d4056f2e76459..1d8707026799f 100644 --- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx @@ -23,13 +23,12 @@ export const WorkspaceBuildPage: FC = () => { keepPreviousData: true, }); const build = wsBuildQuery.data; - const { data: builds } = useQuery({ + const buildsQuery = useQuery({ queryKey: ["builds", username, build?.workspace_id], queryFn: () => { - return getWorkspaceBuilds( - build?.workspace_id ?? "", - dayjs().add(-30, "day").toDate(), - ); + return getWorkspaceBuilds(build?.workspace_id ?? "", { + since: dayjs().add(-30, "day").toISOString(), + }); }, enabled: Boolean(build), }); @@ -50,7 +49,7 @@ export const WorkspaceBuildPage: FC = () => { diff --git a/site/src/pages/WorkspacePage/BuildsTable.stories.tsx b/site/src/pages/WorkspacePage/BuildsTable.stories.tsx index d454b5e9a88c4..0c5f1adf64ac2 100644 --- a/site/src/pages/WorkspacePage/BuildsTable.stories.tsx +++ b/site/src/pages/WorkspacePage/BuildsTable.stories.tsx @@ -13,6 +13,7 @@ type Story = StoryObj; export const Example: Story = { args: { builds: MockBuilds, + hasMoreBuilds: true, }, }; @@ -21,3 +22,10 @@ export const Empty: Story = { builds: [], }, }; + +export const NoMoreBuilds: Story = { + args: { + builds: MockBuilds, + hasMoreBuilds: false, + }, +}; diff --git a/site/src/pages/WorkspacePage/BuildsTable.tsx b/site/src/pages/WorkspacePage/BuildsTable.tsx index d74a971c642a2..2cf4dd5524d34 100644 --- a/site/src/pages/WorkspacePage/BuildsTable.tsx +++ b/site/src/pages/WorkspacePage/BuildsTable.tsx @@ -10,43 +10,71 @@ import * as TypesGen from "api/typesGenerated"; import { EmptyState } from "components/EmptyState/EmptyState"; import { TableLoader } from "components/TableLoader/TableLoader"; import { BuildRow } from "./BuildRow"; +import { Stack } from "components/Stack/Stack"; +import LoadingButton from "@mui/lab/LoadingButton"; +import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined"; export const Language = { emptyMessage: "No builds found", }; export interface BuildsTableProps { - builds?: TypesGen.WorkspaceBuild[]; + builds: TypesGen.WorkspaceBuild[] | undefined; + onLoadMoreBuilds: () => void; + isLoadingMoreBuilds: boolean; + hasMoreBuilds: boolean; } export const BuildsTable: FC> = ({ builds, + onLoadMoreBuilds, + isLoadingMoreBuilds, + hasMoreBuilds, }) => { return ( - - - - {builds ? ( - new Date(build.created_at)} - row={(build) => } - /> - ) : ( - - )} + + +
+ + {builds ? ( + new Date(build.created_at)} + row={(build) => } + /> + ) : ( + + )} - {builds && builds.length === 0 && ( - - - - - - - - )} - -
-
+ {builds && builds.length === 0 && ( + + + + + + + + )} + + + + {hasMoreBuilds && ( + } + css={{ + display: "inline-flex", + margin: "auto", + borderRadius: "9999px", + }} + > + Load previous builds + + )} + ); }; diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index c2d178d7d1839..ebfab404172d4 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -55,7 +55,6 @@ export interface WorkspaceProps { isRestarting: boolean; workspace: TypesGen.Workspace; resources?: TypesGen.WorkspaceResource[]; - builds?: TypesGen.WorkspaceBuild[]; templateWarnings?: TypesGen.TemplateVersionWarning[]; canUpdateWorkspace: boolean; updateMessage?: string; @@ -70,6 +69,10 @@ export interface WorkspaceProps { quotaBudget?: number; handleBuildRetry: () => void; buildLogs?: React.ReactNode; + builds: TypesGen.WorkspaceBuild[] | undefined; + onLoadMoreBuilds: () => void; + isLoadingMoreBuilds: boolean; + hasMoreBuilds: boolean; } /** @@ -105,6 +108,9 @@ export const Workspace: FC> = ({ handleBuildRetry, templateWarnings, buildLogs, + onLoadMoreBuilds, + isLoadingMoreBuilds, + hasMoreBuilds, }) => { const styles = useStyles(); const navigate = useNavigate(); @@ -345,7 +351,12 @@ export const Workspace: FC> = ({ error={workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR]} /> ) : ( - + )} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 44ab8e0fbe1ae..53487210ca453 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -10,7 +10,8 @@ import { useOrganizationId } from "hooks"; import { isAxiosError } from "axios"; import { Margins } from "components/Margins/Margins"; import { workspaceQuota } from "api/queries/workspaceQuota"; -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { infiniteWorkspaceBuilds } from "api/queries/workspaceBuilds"; export const WorkspacePage: FC = () => { const params = useParams() as { @@ -26,10 +27,19 @@ export const WorkspacePage: FC = () => { workspaceName, username, }, + actions: { + refreshBuilds: async () => { + await buildsQuery.refetch(); + }, + }, }); const { workspace, error } = workspaceState.context; const quotaQuery = useQuery(workspaceQuota(username)); const pageError = error ?? quotaQuery.error; + const buildsQuery = useInfiniteQuery({ + ...infiniteWorkspaceBuilds(workspace?.id ?? ""), + enabled: Boolean(workspace), + }); if (pageError) { return ( @@ -53,6 +63,13 @@ export const WorkspacePage: FC = () => { workspaceState={workspaceState} quota={quotaQuery.data} workspaceSend={workspaceSend} + builds={buildsQuery.data?.pages.flat()} + buildsError={buildsQuery.error} + isLoadingMoreBuilds={buildsQuery.isFetchingNextPage} + onLoadMoreBuilds={async () => { + await buildsQuery.fetchNextPage(); + }} + hasMoreBuilds={Boolean(buildsQuery.hasNextPage)} /> ); diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 1316a743c4c52..53ef8a1ecc876 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -39,12 +39,22 @@ interface WorkspaceReadyPageProps { workspaceState: StateFrom; workspaceSend: (event: WorkspaceEvent) => void; quota?: TypesGen.WorkspaceQuota; + builds: TypesGen.WorkspaceBuild[] | undefined; + buildsError: unknown; + onLoadMoreBuilds: () => void; + isLoadingMoreBuilds: boolean; + hasMoreBuilds: boolean; } export const WorkspaceReadyPage = ({ workspaceState, workspaceSend, quota, + builds, + buildsError, + onLoadMoreBuilds, + isLoadingMoreBuilds, + hasMoreBuilds, }: WorkspaceReadyPageProps): JSX.Element => { const [_, bannerSend] = useActor( workspaceState.children["scheduleBannerMachine"], @@ -56,8 +66,6 @@ export const WorkspaceReadyPage = ({ template, templateVersion: currentVersion, deploymentValues, - builds, - getBuildsError, buildError, cancellationError, sshPrefix, @@ -168,6 +176,9 @@ export const WorkspaceReadyPage = ({ handleDormantActivate={() => workspaceSend({ type: "ACTIVATE" })} resources={workspace.latest_build.resources} builds={builds} + onLoadMoreBuilds={onLoadMoreBuilds} + isLoadingMoreBuilds={isLoadingMoreBuilds} + hasMoreBuilds={hasMoreBuilds} canUpdateWorkspace={canUpdateWorkspace} updateMessage={latestVersion?.message} canRetryDebugMode={canRetryDebugMode} @@ -175,7 +186,7 @@ export const WorkspaceReadyPage = ({ hideSSHButton={featureVisibility["browser_only"]} hideVSCodeDesktopButton={featureVisibility["browser_only"]} workspaceErrors={{ - [WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError, + [WorkspaceErrors.GET_BUILDS_ERROR]: buildsError, [WorkspaceErrors.BUILD_ERROR]: buildError || restartBuildError, [WorkspaceErrors.CANCELLATION_ERROR]: cancellationError, }} diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 26e96dbf06750..74161d95fed6d 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -1,43 +1,10 @@ import { getErrorMessage } from "api/errors"; -import dayjs from "dayjs"; import { workspaceScheduleBannerMachine } from "xServices/workspaceSchedule/workspaceScheduleBannerXService"; -import { assign, createMachine, send } from "xstate"; +import { assign, createMachine } from "xstate"; import * as API from "api/api"; import * as TypesGen from "api/typesGenerated"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; -const latestBuild = (builds: TypesGen.WorkspaceBuild[]) => { - // Cloning builds to not change the origin object with the sort() - return [...builds].sort((a, b) => { - return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); - })[0]; -}; - -const moreBuildsAvailable = ( - context: WorkspaceContext, - event: { - type: "REFRESH_TIMELINE"; - checkRefresh?: boolean; - data?: TypesGen.ServerSentEvent["data"]; - }, -) => { - // No need to refresh the timeline if it is not loaded - if (!context.builds) { - return false; - } - - if (!event.checkRefresh) { - return true; - } - - // After we refresh a workspace, we want to check if the latest - // build was updated before refreshing the timeline so as to not over fetch the builds - const latestBuildInTimeline = latestBuild(context.builds); - return ( - event.data.latest_build.updated_at !== latestBuildInTimeline.updated_at - ); -}; - type Permissions = Record, boolean>; export interface WorkspaceContext { @@ -87,8 +54,6 @@ export type WorkspaceEvent = | { type: "CANCEL" } | { type: "REFRESH_TIMELINE"; - checkRefresh?: boolean; - data?: TypesGen.ServerSentEvent["data"]; } | { type: "EVENT_SOURCE_ERROR"; error: unknown } | { type: "INCREASE_DEADLINE"; hours: number } @@ -201,6 +166,11 @@ export const workspaceMachine = createMachine( }, ready: { type: "parallel", + on: { + REFRESH_TIMELINE: { + actions: ["refreshBuilds"], + }, + }, states: { listening: { initial: "gettingEvents", @@ -422,36 +392,6 @@ export const workspaceMachine = createMachine( }, }, }, - timeline: { - initial: "gettingBuilds", - states: { - gettingBuilds: { - invoke: { - src: "getBuilds", - onDone: [ - { - actions: ["assignBuilds", "clearGetBuildsError"], - target: "loadedBuilds", - }, - ], - onError: [ - { - actions: "assignGetBuildsError", - target: "loadedBuilds", - }, - ], - }, - }, - loadedBuilds: { - on: { - REFRESH_TIMELINE: { - target: "#workspaceState.ready.timeline.gettingBuilds", - cond: "moreBuildsAvailable", - }, - }, - }, - }, - }, sshConfig: { initial: "gettingSshConfig", states: { @@ -553,16 +493,6 @@ export const workspaceMachine = createMachine( logWatchWorkspaceWarning: (_, event) => { console.error("Watch workspace error:", event); }, - // Timeline - assignBuilds: assign({ - builds: (_, event) => event.data, - }), - assignGetBuildsError: assign({ - getBuildsError: (_, event) => event.data, - }), - clearGetBuildsError: assign({ - getBuildsError: (_) => undefined, - }), // SSH assignSSHPrefix: assign({ sshPrefix: (_, { data }) => data.hostname_prefix, @@ -599,7 +529,6 @@ export const workspaceMachine = createMachine( }), }, guards: { - moreBuildsAvailable, isMissingBuildParameterError: (_, { data }) => { return data instanceof API.MissingBuildParameters; }, @@ -670,7 +599,7 @@ export const workspaceMachine = createMachine( throw Error("Cannot stop workspace without workspace id"); } }, - deleteWorkspace: async (context) => { + deleteWorkspace: (context) => async (send) => { if (context.workspace) { const deleteWorkspacePromise = await API.deleteWorkspace( context.workspace.id, @@ -712,15 +641,21 @@ export const workspaceMachine = createMachine( } context.eventSource.addEventListener("data", (event) => { + const newWorkspaceData = JSON.parse(event.data) as TypesGen.Workspace; // refresh our workspace with each SSE - send({ type: "REFRESH_WORKSPACE", data: JSON.parse(event.data) }); - // refresh our timeline - send({ - type: "REFRESH_TIMELINE", - checkRefresh: true, - data: JSON.parse(event.data), - }); - // refresh + send({ type: "REFRESH_WORKSPACE", data: newWorkspaceData }); + + const currentWorkspace = context.workspace!; + const hasNewBuild = + newWorkspaceData.latest_build.id !== + currentWorkspace.latest_build.id; + const lastBuildHasChanged = + newWorkspaceData.latest_build.status !== + currentWorkspace.latest_build.status; + + if (hasNewBuild || lastBuildHasChanged) { + send({ type: "REFRESH_TIMELINE" }); + } }); // handle any error events returned by our sse @@ -737,18 +672,6 @@ export const workspaceMachine = createMachine( context.eventSource?.close(); }; }, - getBuilds: async (context) => { - if (context.workspace) { - // For now, we only retrieve the last month of builds to minimize - // page bloat. We should add pagination in the future. - return await API.getWorkspaceBuilds( - context.workspace.id, - dayjs().add(-30, "day").toDate(), - ); - } else { - throw Error("Cannot get builds without id"); - } - }, scheduleBannerMachine: workspaceScheduleBannerMachine, getSSHPrefix: async () => { return API.getDeploymentSSHConfig();