feat(site): load previous builds (#10076)

This commit is contained in:
Bruno Quaresma
2023-10-05 18:30:11 -03:00
committed by GitHub
parent 1eb21d247b
commit 04e7748a9b
11 changed files with 161 additions and 143 deletions
+2 -2
View File
@@ -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) {
+3 -3
View File
@@ -777,10 +777,10 @@ export const regenerateUserSSHKey = async (
export const getWorkspaceBuilds = async (
workspaceId: string,
since: Date,
): Promise<TypesGen.WorkspaceBuild[]> => {
req?: TypesGen.WorkspaceBuildsRequest,
) => {
const response = await axios.get<TypesGen.WorkspaceBuild[]>(
`/api/v2/workspaces/${workspaceId}/builds?since=${since.toISOString()}`,
getURLWithSearchParams(`/api/v2/workspaces/${workspaceId}/builds`, req),
);
return response.data;
};
+23 -1
View File
@@ -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<WorkspaceBuild[]> => {
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,
});
},
};
};
+1 -2
View File
@@ -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
@@ -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 = () => {
<WorkspaceBuildPageView
logs={logs}
build={build}
builds={builds}
builds={buildsQuery.data}
activeBuildNumber={buildNumber}
/>
</>
@@ -13,6 +13,7 @@ type Story = StoryObj<typeof BuildsTable>;
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,
},
};
+53 -25
View File
@@ -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<React.PropsWithChildren<BuildsTableProps>> = ({
builds,
onLoadMoreBuilds,
isLoadingMoreBuilds,
hasMoreBuilds,
}) => {
return (
<TableContainer>
<Table data-testid="builds-table" aria-describedby="builds table">
<TableBody>
{builds ? (
<Timeline
items={builds}
getDate={(build) => new Date(build.created_at)}
row={(build) => <BuildRow key={build.id} build={build} />}
/>
) : (
<TableLoader />
)}
<Stack>
<TableContainer>
<Table data-testid="builds-table" aria-describedby="builds table">
<TableBody>
{builds ? (
<Timeline
items={builds}
getDate={(build) => new Date(build.created_at)}
row={(build) => <BuildRow key={build.id} build={build} />}
/>
) : (
<TableLoader />
)}
{builds && builds.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<Box p={4}>
<EmptyState message={Language.emptyMessage} />
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{builds && builds.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<Box p={4}>
<EmptyState message={Language.emptyMessage} />
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{hasMoreBuilds && (
<LoadingButton
onClick={onLoadMoreBuilds}
loading={isLoadingMoreBuilds}
loadingPosition="start"
variant="outlined"
color="neutral"
startIcon={<ArrowDownwardOutlined />}
css={{
display: "inline-flex",
margin: "auto",
borderRadius: "9999px",
}}
>
Load previous builds
</LoadingButton>
)}
</Stack>
);
};
+13 -2
View File
@@ -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<React.PropsWithChildren<WorkspaceProps>> = ({
handleBuildRetry,
templateWarnings,
buildLogs,
onLoadMoreBuilds,
isLoadingMoreBuilds,
hasMoreBuilds,
}) => {
const styles = useStyles();
const navigate = useNavigate();
@@ -345,7 +351,12 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
error={workspaceErrors[WorkspaceErrors.GET_BUILDS_ERROR]}
/>
) : (
<BuildsTable builds={builds} />
<BuildsTable
builds={builds}
onLoadMoreBuilds={onLoadMoreBuilds}
isLoadingMoreBuilds={isLoadingMoreBuilds}
hasMoreBuilds={hasMoreBuilds}
/>
)}
</Stack>
</Margins>
+18 -1
View File
@@ -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)}
/>
</RequirePermission>
);
@@ -39,12 +39,22 @@ interface WorkspaceReadyPageProps {
workspaceState: StateFrom<typeof workspaceMachine>;
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,
}}
@@ -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<keyof ReturnType<typeof permissionsToCheck>, 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();