mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: show workspace build logs during tasks creation (#19413)
This is part of https://github.com/coder/coder/issues/19363 **Screenshot:** <img width="1610" height="974" alt="Screenshot 2025-08-19 at 12 32 54" src="https://github.com/user-attachments/assets/c7435b67-49ac-4b88-ae2d-014787cea5f2" /> **Video demo:** https://github.com/user-attachments/assets/2249affd-3d51-4ff0-8a5f-a0358a90d659
This commit is contained in:
@@ -2,25 +2,28 @@ 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 isChromatic from "chromatic/isChromatic";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { Margins } from "components/Margins/Margins";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
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";
|
||||
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||
import { type FC, type ReactNode, useEffect, useRef } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useQuery } from "react-query";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Link as RouterLink, useParams } from "react-router";
|
||||
import { ellipsizeText } from "utils/ellipsizeText";
|
||||
import { pageTitle } from "utils/page";
|
||||
import {
|
||||
ActiveTransition,
|
||||
getActiveTransitionStats,
|
||||
WorkspaceBuildProgress,
|
||||
} from "../WorkspacePage/WorkspaceBuildProgress";
|
||||
import { TaskApps } from "./TaskApps";
|
||||
import { TaskSidebar } from "./TaskSidebar";
|
||||
import { TaskTopbar } from "./TaskTopbar";
|
||||
|
||||
const TaskPage = () => {
|
||||
const { workspace: workspaceName, username } = useParams() as {
|
||||
@@ -37,18 +40,7 @@ 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 (
|
||||
@@ -95,38 +87,9 @@ const TaskPage = () => {
|
||||
}
|
||||
|
||||
let content: ReactNode = null;
|
||||
const _terminatedStatuses: WorkspaceStatus[] = [
|
||||
"canceled",
|
||||
"canceling",
|
||||
"deleted",
|
||||
"deleting",
|
||||
"stopped",
|
||||
"stopping",
|
||||
];
|
||||
|
||||
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 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>
|
||||
<div className="text-content-secondary text-sm">{lastStage}</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<WorkspaceBuildProgress
|
||||
workspace={task.workspace}
|
||||
transitionStats={transition}
|
||||
variant="task"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
content = <TaskBuildingWorkspace task={task} />;
|
||||
} else if (task.workspace.latest_build.status === "failed") {
|
||||
content = (
|
||||
<div className="w-full min-h-80 flex items-center justify-center">
|
||||
@@ -170,14 +133,7 @@ const TaskPage = () => {
|
||||
</Margins>
|
||||
);
|
||||
} else {
|
||||
content = <TaskApps task={task} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle(ellipsizeText(task.prompt, 64) ?? "Task")}</title>
|
||||
</Helmet>
|
||||
content = (
|
||||
<PanelGroup autoSaveId="task" direction="horizontal">
|
||||
<Panel defaultSize={25} minSize={20}>
|
||||
<TaskSidebar task={task} />
|
||||
@@ -185,14 +141,95 @@ const TaskPage = () => {
|
||||
<PanelResizeHandle>
|
||||
<div className="w-1 bg-border h-full hover:bg-border-hover transition-all relative" />
|
||||
</PanelResizeHandle>
|
||||
<Panel className="[&>*]:h-full">{content}</Panel>
|
||||
<Panel className="[&>*]:h-full">
|
||||
<TaskApps task={task} />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle(ellipsizeText(task.prompt, 64))}</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="flex flex-col h-full">
|
||||
<TaskTopbar task={task} />
|
||||
{content}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskPage;
|
||||
|
||||
type TaskBuildingWorkspaceProps = { task: Task };
|
||||
|
||||
const TaskBuildingWorkspace: FC<TaskBuildingWorkspaceProps> = ({ task }) => {
|
||||
const { data: template } = useQuery(
|
||||
templateQueryOptions(task.workspace.template_id),
|
||||
);
|
||||
|
||||
const buildLogs = useWorkspaceBuildLogs(task?.workspace.latest_build.id);
|
||||
|
||||
// If no template yet, use an indeterminate progress bar.
|
||||
const transitionStats = (template &&
|
||||
getActiveTransitionStats(template, task.workspace)) || {
|
||||
P50: 0,
|
||||
P95: null,
|
||||
};
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: this effect should run when build logs change
|
||||
useEffect(() => {
|
||||
if (isChromatic()) {
|
||||
return;
|
||||
}
|
||||
const scrollAreaEl = scrollAreaRef.current;
|
||||
const scrollAreaViewportEl = scrollAreaEl?.querySelector<HTMLDivElement>(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
);
|
||||
if (scrollAreaViewportEl) {
|
||||
scrollAreaViewportEl.scrollTop = scrollAreaViewportEl.scrollHeight;
|
||||
}
|
||||
}, [buildLogs]);
|
||||
|
||||
return (
|
||||
<section className="w-full h-full flex justify-center items-center p-6 overflow-y-auto">
|
||||
<div className="flex flex-col gap-6 items-center w-full">
|
||||
<header className="flex flex-col items-center text-center">
|
||||
<h3 className="m-0 font-medium text-content-primary text-xl">
|
||||
Starting your workspace
|
||||
</h3>
|
||||
<div className="text-content-secondary">
|
||||
Your task will be running in a few moments
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="w-full max-w-screen-lg flex flex-col gap-4 overflow-hidden">
|
||||
<WorkspaceBuildProgress
|
||||
workspace={task.workspace}
|
||||
transitionStats={transitionStats}
|
||||
variant="task"
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
className="h-96 border border-solid border-border rounded-lg"
|
||||
>
|
||||
<WorkspaceBuildLogs
|
||||
sticky
|
||||
className="border-0 rounded-none"
|
||||
logs={buildLogs ?? []}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export class WorkspaceDoesNotHaveAITaskError extends Error {
|
||||
constructor(workspace: Workspace) {
|
||||
super(
|
||||
@@ -228,3 +265,7 @@ export const data = {
|
||||
} satisfies Task;
|
||||
},
|
||||
};
|
||||
|
||||
const ellipsizeText = (text: string, maxLength = 80): string => {
|
||||
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`;
|
||||
};
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
import type { WorkspaceApp } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { ArrowLeftIcon, EllipsisVerticalIcon } from "lucide-react";
|
||||
import type { Task } from "modules/tasks/tasks";
|
||||
import type { FC } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import { TaskAppIFrame } from "./TaskAppIframe";
|
||||
import { TaskStatusLink } from "./TaskStatusLink";
|
||||
|
||||
type TaskSidebarProps = {
|
||||
task: Task;
|
||||
@@ -84,60 +68,6 @@ export const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col h-full shrink-0 w-full">
|
||||
<header className="border-0 border-b border-solid border-border p-4 pt-0">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="subtle" asChild className="-ml-2">
|
||||
<RouterLink to="/tasks">
|
||||
<ArrowLeftIcon />
|
||||
<span className="sr-only">Back to tasks</span>
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Back to tasks</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<DropdownMenu>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="subtle" className="-mr-2">
|
||||
<EllipsisVerticalIcon />
|
||||
<span className="sr-only">Settings</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<RouterLink
|
||||
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
|
||||
>
|
||||
View workspace
|
||||
</RouterLink>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<h1 className="m-0 mt-1 text-base font-medium truncate">
|
||||
{task.prompt || task.workspace.name}
|
||||
</h1>
|
||||
|
||||
{task.workspace.latest_app_status?.uri && (
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
<TaskStatusLink uri={task.workspace.latest_app_status.uri} />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{sidebarAppStatus === "healthy" && sidebarApp ? (
|
||||
<TaskAppIFrame
|
||||
active
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import type { Task } from "modules/tasks/tasks";
|
||||
import type { FC } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import { TaskStatusLink } from "./TaskStatusLink";
|
||||
|
||||
type TaskTopbarProps = { task: Task };
|
||||
|
||||
export const TaskTopbar: FC<TaskTopbarProps> = ({ task }) => {
|
||||
return (
|
||||
<header className="flex items-center px-3 h-14 border-solid border-border border-0 border-b">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="icon" variant="subtle" asChild>
|
||||
<RouterLink to="/tasks">
|
||||
<ArrowLeftIcon />
|
||||
<span className="sr-only">Back to tasks</span>
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Back to tasks</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<h1 className="m-0 text-base font-medium truncate">{task.prompt}</h1>
|
||||
|
||||
{task.workspace.latest_app_status?.uri && (
|
||||
<div className="flex items-center gap-2 flex-wrap ml-4">
|
||||
<TaskStatusLink uri={task.workspace.latest_app_status.uri} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button asChild size="sm" variant="outline" className="ml-auto">
|
||||
<RouterLink
|
||||
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
|
||||
>
|
||||
View workspace
|
||||
</RouterLink>
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -17,7 +17,7 @@ import { ResourcesSidebar } from "./ResourcesSidebar";
|
||||
import { resourceOptionValue, useResourcesNav } from "./useResourcesNav";
|
||||
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection";
|
||||
import {
|
||||
ActiveTransition,
|
||||
getActiveTransitionStats,
|
||||
WorkspaceBuildProgress,
|
||||
} from "./WorkspaceBuildProgress";
|
||||
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
|
||||
@@ -68,7 +68,9 @@ export const Workspace: FC<WorkspaceProps> = ({
|
||||
const navigate = useNavigate();
|
||||
|
||||
const transitionStats =
|
||||
template !== undefined ? ActiveTransition(template, workspace) : undefined;
|
||||
template !== undefined
|
||||
? getActiveTransitionStats(template, workspace)
|
||||
: undefined;
|
||||
|
||||
const sidebarOption = useSearchParamsKey({ key: "sidebar" });
|
||||
const setSidebarOption = (newOption: string) => {
|
||||
|
||||
@@ -9,9 +9,9 @@ import { type FC, useEffect, useState } from "react";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
// ActiveTransition gets the build estimate for the workspace,
|
||||
// getActiveTransitionStats gets the build estimate for the workspace,
|
||||
// if it is in a transition state.
|
||||
export const ActiveTransition = (
|
||||
export const getActiveTransitionStats = (
|
||||
template: Template,
|
||||
workspace: Workspace,
|
||||
): TransitionStats | undefined => {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ellipsizeText } from "./ellipsizeText";
|
||||
import type { Nullable } from "./nullable";
|
||||
|
||||
describe("ellipsizeText", () => {
|
||||
it.each([
|
||||
[undefined, 10, undefined],
|
||||
[null, 10, undefined],
|
||||
["", 10, ""],
|
||||
["Hello World", "Hello World".length, "Hello World"],
|
||||
["Hello World", "Hello...".length, "Hello..."],
|
||||
])(
|
||||
"ellipsizeText(%p, %p) returns %p",
|
||||
(
|
||||
str: Nullable<string>,
|
||||
maxLength: number | undefined,
|
||||
output: Nullable<string>,
|
||||
) => {
|
||||
expect(ellipsizeText(str, maxLength)).toBe(output);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Nullable } from "./nullable";
|
||||
|
||||
/** Truncates and ellipsizes text if it's longer than maxLength */
|
||||
export const ellipsizeText = (
|
||||
text: Nullable<string>,
|
||||
maxLength = 80,
|
||||
): string | undefined => {
|
||||
if (typeof text !== "string") {
|
||||
return;
|
||||
}
|
||||
return text.length <= maxLength
|
||||
? text
|
||||
: `${text.substr(0, maxLength - 3)}...`;
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* A Nullable may be its concrete type, `null` or `undefined`
|
||||
* @remark Exact opposite of the native TS type NonNullable<T>
|
||||
*/
|
||||
export type Nullable<T> = null | undefined | T;
|
||||
Reference in New Issue
Block a user