mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: add workspace build status to task page (#18520)
While a workspace is starting, display the build status and a progress bar.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, spyOn, within } from "@storybook/test";
|
||||
import { API } from "api/api";
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceApp,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
MockFailedWorkspace,
|
||||
MockStartingWorkspace,
|
||||
MockStoppedWorkspace,
|
||||
MockTemplate,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceApp,
|
||||
@@ -59,6 +61,16 @@ export const WaitingOnBuild: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WaitingOnBuildWithTemplate: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getTemplate").mockResolvedValue(MockTemplate);
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
prompt: "Create competitors page",
|
||||
workspace: MockStartingWorkspace,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const WaitingOnStatus: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(data, "fetchTask").mockResolvedValue({
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorDetail, getErrorMessage } from "api/errors";
|
||||
import { template as templateQueryOptions } from "api/queries/templates";
|
||||
import type { Workspace, WorkspaceStatus } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
|
||||
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -14,6 +16,10 @@ import { useParams } from "react-router-dom";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { ellipsizeText } from "utils/ellipsizeText";
|
||||
import { pageTitle } from "utils/page";
|
||||
import {
|
||||
ActiveTransition,
|
||||
WorkspaceBuildProgress,
|
||||
} from "../WorkspacePage/WorkspaceBuildProgress";
|
||||
import { TaskApps } from "./TaskApps";
|
||||
import { TaskSidebar } from "./TaskSidebar";
|
||||
|
||||
@@ -32,6 +38,19 @@ const TaskPage = () => {
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
|
||||
const { data: template } = useQuery({
|
||||
...templateQueryOptions(task?.workspace.template_id ?? ""),
|
||||
enabled: Boolean(task),
|
||||
});
|
||||
|
||||
const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"];
|
||||
const shouldStreamBuildLogs =
|
||||
task && waitingStatuses.includes(task.workspace.latest_build.status);
|
||||
const buildLogs = useWorkspaceBuildLogs(
|
||||
task?.workspace.latest_build.id ?? "",
|
||||
shouldStreamBuildLogs,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
@@ -77,7 +96,6 @@ const TaskPage = () => {
|
||||
}
|
||||
|
||||
let content: ReactNode = null;
|
||||
const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"];
|
||||
const terminatedStatuses: WorkspaceStatus[] = [
|
||||
"canceled",
|
||||
"canceling",
|
||||
@@ -88,16 +106,25 @@ const TaskPage = () => {
|
||||
];
|
||||
|
||||
if (waitingStatuses.includes(task.workspace.latest_build.status)) {
|
||||
// If no template yet, use an indeterminate progress bar.
|
||||
const transition = (template &&
|
||||
ActiveTransition(template, task.workspace)) || { P50: 0, P95: null };
|
||||
const lastStage =
|
||||
buildLogs?.[buildLogs.length - 1]?.stage || "Waiting for build status";
|
||||
content = (
|
||||
<div className="w-full min-h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<Spinner loading className="mb-4" />
|
||||
<div className="w-full min-h-80 flex flex-col">
|
||||
<div className="flex flex-col items-center grow justify-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-base">
|
||||
Starting your workspace
|
||||
</h3>
|
||||
<span className="text-content-secondary text-sm">
|
||||
This should take a few minutes
|
||||
</span>
|
||||
<div className="text-content-secondary text-sm">{lastStage}</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<WorkspaceBuildProgress
|
||||
workspace={task.workspace}
|
||||
transitionStats={transition}
|
||||
variant="task"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -62,11 +62,18 @@ const estimateFinish = (
|
||||
interface WorkspaceBuildProgressProps {
|
||||
workspace: Workspace;
|
||||
transitionStats: TransitionStats;
|
||||
// variant changes how the progress bar is displayed: with the workspace
|
||||
// variant the workspace transition and time remaining are displayed under the
|
||||
// bar aligned to the left and right respectively. With the task variant the
|
||||
// workspace transition is not displayed and the time remaining is displayed
|
||||
// centered above the bar, and the bar's border radius is removed.
|
||||
variant?: "workspace" | "task";
|
||||
}
|
||||
|
||||
export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
|
||||
workspace,
|
||||
transitionStats,
|
||||
variant,
|
||||
}) => {
|
||||
const job = workspace.latest_build.job;
|
||||
const [progressValue, setProgressValue] = useState<number | undefined>(0);
|
||||
@@ -114,6 +121,13 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
|
||||
}
|
||||
return (
|
||||
<div css={styles.stack}>
|
||||
{variant === "task" && (
|
||||
<div className="mb-1 text-center">
|
||||
<div css={styles.label} data-chromatic="ignore">
|
||||
{progressText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<LinearProgress
|
||||
data-chromatic="ignore"
|
||||
value={progressValue !== undefined ? progressValue : 0}
|
||||
@@ -126,19 +140,26 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
|
||||
? "determinate"
|
||||
: "indeterminate"
|
||||
}
|
||||
// If a transition is set, there is a moment on new load where the
|
||||
// bar accelerates to progressValue and then rapidly decelerates, which
|
||||
// is not indicative of true progress.
|
||||
classes={{ bar: classNames.bar }}
|
||||
classes={{
|
||||
// If a transition is set, there is a moment on new load where the bar
|
||||
// accelerates to progressValue and then rapidly decelerates, which is
|
||||
// not indicative of true progress.
|
||||
bar: classNames.bar,
|
||||
// With the "task" variant, the progress bar is fullscreen, so remove
|
||||
// the border radius.
|
||||
root: variant === "task" ? classNames.root : undefined,
|
||||
}}
|
||||
/>
|
||||
<div css={styles.barHelpers}>
|
||||
<div css={styles.label}>
|
||||
{capitalize(workspace.latest_build.status)} workspace...
|
||||
{variant !== "task" && (
|
||||
<div className="flex mt-1 justify-between">
|
||||
<div css={styles.label}>
|
||||
{capitalize(workspace.latest_build.status)} workspace...
|
||||
</div>
|
||||
<div css={styles.label} data-chromatic="ignore">
|
||||
{progressText}
|
||||
</div>
|
||||
</div>
|
||||
<div css={styles.label} data-chromatic="ignore">
|
||||
{progressText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -146,6 +167,9 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
|
||||
const classNames = {
|
||||
bar: css`
|
||||
transition: none;
|
||||
`,
|
||||
root: css`
|
||||
border-radius: 0;
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -154,11 +178,6 @@ const styles = {
|
||||
paddingLeft: 2,
|
||||
paddingRight: 2,
|
||||
},
|
||||
barHelpers: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 4,
|
||||
},
|
||||
label: (theme) => ({
|
||||
fontSize: 12,
|
||||
display: "block",
|
||||
|
||||
Reference in New Issue
Block a user