mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat(site): load previous builds (#10076)
This commit is contained in:
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Generated
+1
-2
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user