feat(site): add agent connection timings (#15276)

Local preview:

<img width="1260" alt="Screenshot 2024-10-29 at 16 16 01"
src="https://github.com/user-attachments/assets/10fdb20d-1f2a-4b0a-a8a1-171050ee620d">


Close https://github.com/coder/internal/issues/116

---------

Co-authored-by: Danny Kopping <danny@coder.com>
This commit is contained in:
Bruno Quaresma
2024-11-01 13:29:00 -03:00
committed by GitHub
co-authored by Danny Kopping
parent 18ef954a03
commit e232aee011
22 changed files with 747 additions and 304 deletions
+11 -2
View File
@@ -57,9 +57,18 @@ export const infiniteWorkspaceBuilds = (
};
};
export const workspaceBuildTimings = (workspaceBuildId: string) => {
// We use readyAgentsCount to invalidate the query when an agent connects
export const workspaceBuildTimings = (
workspaceBuildId: string,
readyAgentsCount: number,
) => {
return {
queryKey: ["workspaceBuilds", workspaceBuildId, "timings"],
queryKey: [
"workspaceBuilds",
workspaceBuildId,
"timings",
{ readyAgentsCount },
],
queryFn: () => API.workspaceBuildTimings(workspaceBuildId),
};
};
+18 -2
View File
@@ -32,14 +32,25 @@ export interface AddLicenseRequest {
readonly license: string;
}
// From codersdk/workspacebuilds.go
export interface AgentConnectionTiming {
readonly started_at: string;
readonly ended_at: string;
readonly stage: TimingStage;
readonly workspace_agent_id: string;
readonly workspace_agent_name: string;
}
// From codersdk/workspacebuilds.go
export interface AgentScriptTiming {
readonly started_at: string;
readonly ended_at: string;
readonly exit_code: number;
readonly stage: string;
readonly stage: TimingStage;
readonly status: string;
readonly display_name: string;
readonly workspace_agent_id: string;
readonly workspace_agent_name: string;
}
// From codersdk/templates.go
@@ -1104,7 +1115,7 @@ export interface ProvisionerTiming {
readonly job_id: string;
readonly started_at: string;
readonly ended_at: string;
readonly stage: string;
readonly stage: TimingStage;
readonly source: string;
readonly action: string;
readonly resource: string;
@@ -1986,6 +1997,7 @@ export interface WorkspaceBuildParameter {
export interface WorkspaceBuildTimings {
readonly provisioner_timings: Readonly<Array<ProvisionerTiming>>;
readonly agent_script_timings: Readonly<Array<AgentScriptTiming>>;
readonly agent_connection_timings: Readonly<Array<AgentConnectionTiming>>;
}
// From codersdk/workspaces.go
@@ -2226,6 +2238,10 @@ export const TemplateRoles: TemplateRole[] = ["", "admin", "use"]
export type TemplateVersionWarning = "UNSUPPORTED_WORKSPACES"
export const TemplateVersionWarnings: TemplateVersionWarning[] = ["UNSUPPORTED_WORKSPACES"]
// From codersdk/workspacebuilds.go
export type TimingStage = "apply" | "connect" | "cron" | "graph" | "init" | "plan" | "start" | "stop"
export const TimingStages: TimingStage[] = ["apply", "connect", "cron", "graph", "init", "plan", "start", "stop"]
// From codersdk/workspaces.go
export type UsageAppName = "jetbrains" | "reconnecting-pty" | "ssh" | "vscode"
export const UsageAppNames: UsageAppName[] = ["jetbrains", "reconnecting-pty", "ssh", "vscode"]
@@ -1,8 +1,5 @@
import { css } from "@emotion/css";
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
import { type Theme, useTheme } from "@emotion/react";
import { type FC, useState } from "react";
import { Link } from "react-router-dom";
import { Bar } from "./Chart/Bar";
import {
Chart,
@@ -30,7 +27,7 @@ import {
makeTicks,
mergeTimeRanges,
} from "./Chart/utils";
import type { StageCategory } from "./StagesChart";
import type { Stage } from "./StagesChart";
type ResourceTiming = {
name: string;
@@ -40,14 +37,12 @@ type ResourceTiming = {
};
export type ResourcesChartProps = {
category: StageCategory;
stage: string;
stage: Stage;
timings: ResourceTiming[];
onBack: () => void;
};
export const ResourcesChart: FC<ResourcesChartProps> = ({
category,
stage,
timings,
onBack,
@@ -71,11 +66,11 @@ export const ResourcesChart: FC<ResourcesChartProps> = ({
<ChartBreadcrumbs
breadcrumbs={[
{
label: category.name,
label: stage.section,
onClick: onBack,
},
{
label: stage,
label: stage.name,
},
]}
/>
@@ -89,7 +84,7 @@ export const ResourcesChart: FC<ResourcesChartProps> = ({
<ChartContent>
<YAxis>
<YAxisSection>
<YAxisHeader>{stage} stage</YAxisHeader>
<YAxisHeader>{stage.name} stage</YAxisHeader>
<YAxisLabels>
{visibleTimings.map((t) => (
<YAxisLabel key={t.name} id={encodeURIComponent(t.name)}>
@@ -27,7 +27,7 @@ import {
makeTicks,
mergeTimeRanges,
} from "./Chart/utils";
import type { StageCategory } from "./StagesChart";
import type { Stage } from "./StagesChart";
type ScriptTiming = {
name: string;
@@ -37,14 +37,12 @@ type ScriptTiming = {
};
export type ScriptsChartProps = {
category: StageCategory;
stage: string;
stage: Stage;
timings: ScriptTiming[];
onBack: () => void;
};
export const ScriptsChart: FC<ScriptsChartProps> = ({
category,
stage,
timings,
onBack,
@@ -66,11 +64,11 @@ export const ScriptsChart: FC<ScriptsChartProps> = ({
<ChartBreadcrumbs
breadcrumbs={[
{
label: category.name,
label: stage.section,
onClick: onBack,
},
{
label: stage,
label: stage.name,
},
]}
/>
@@ -84,7 +82,7 @@ export const ScriptsChart: FC<ScriptsChartProps> = ({
<ChartContent>
<YAxis>
<YAxisSection>
<YAxisHeader>{stage} stage</YAxisHeader>
<YAxisHeader>{stage.name} stage</YAxisHeader>
<YAxisLabels>
{visibleTimings.map((t) => (
<YAxisLabel key={t.name} id={encodeURIComponent(t.name)}>
@@ -1,6 +1,7 @@
import type { Interpolation, Theme } from "@emotion/react";
import ErrorSharp from "@mui/icons-material/ErrorSharp";
import InfoOutlined from "@mui/icons-material/InfoOutlined";
import type { TimingStage } from "api/typesGenerated";
import type { FC } from "react";
import { Bar, ClickableBar } from "./Chart/Bar";
import { Blocks } from "./Chart/Blocks";
@@ -28,118 +29,34 @@ import {
mergeTimeRanges,
} from "./Chart/utils";
export type StageCategory = {
name: string;
id: "provisioning" | "workspaceBoot";
};
const stageCategories: StageCategory[] = [
{
name: "provisioning",
id: "provisioning",
},
{
name: "workspace boot",
id: "workspaceBoot",
},
] as const;
export type Stage = {
name: string;
categoryID: StageCategory["id"];
/**
* The name is used to identify the stage.
*/
name: TimingStage;
/**
* The value to display in the stage label. This can differ from the stage
* name to provide more context or clarity.
*/
label: string;
/**
* The section is used to group stages together.
*/
section: string;
/**
* The tooltip is used to provide additional information about the stage.
*/
tooltip: Omit<TooltipProps, "children">;
};
export const stages: Stage[] = [
{
name: "init",
categoryID: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform initialization</TooltipTitle>
<TooltipShortDescription>
Download providers & modules.
</TooltipShortDescription>
</>
),
},
},
{
name: "plan",
categoryID: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform plan</TooltipTitle>
<TooltipShortDescription>
Compare state of desired vs actual resources and compute changes to
be made.
</TooltipShortDescription>
</>
),
},
},
{
name: "graph",
categoryID: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform graph</TooltipTitle>
<TooltipShortDescription>
List all resources in plan, used to update coderd database.
</TooltipShortDescription>
</>
),
},
},
{
name: "apply",
categoryID: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform apply</TooltipTitle>
<TooltipShortDescription>
Execute Terraform plan to create/modify/delete resources into
desired states.
</TooltipShortDescription>
</>
),
},
},
{
name: "start",
categoryID: "workspaceBoot",
tooltip: {
title: (
<>
<TooltipTitle>Start</TooltipTitle>
<TooltipShortDescription>
Scripts executed when the agent is starting.
</TooltipShortDescription>
</>
),
},
},
];
type StageTiming = {
name: string;
/**
stage: Stage;
/**
* Represents the number of resources included in this stage that can be
* inspected. This value is used to display individual blocks within the bar,
* indicating that the stage consists of multiple resource time blocks.
*/
visibleResources: number;
/**
* Represents the category of the stage. This value is used to group stages
* together in the chart. For example, all provisioning stages are grouped
* together.
*/
categoryID: StageCategory["id"];
/**
* Represents the time range of the stage. This value is used to calculate the
* duration of the stage and to position the stage within the chart. This can
@@ -155,7 +72,7 @@ type StageTiming = {
export type StagesChartProps = {
timings: StageTiming[];
onSelectStage: (timing: StageTiming, category: StageCategory) => void;
onSelectStage: (stage: Stage) => void;
};
export const StagesChart: FC<StagesChartProps> = ({
@@ -167,27 +84,28 @@ export const StagesChart: FC<StagesChartProps> = ({
);
const totalTime = calcDuration(totalRange);
const [ticks, scale] = makeTicks(totalTime);
const sections = Array.from(new Set(timings.map((t) => t.stage.section)));
return (
<Chart>
<ChartContent>
<YAxis>
{stageCategories.map((c) => {
const stagesInCategory = stages.filter(
(s) => s.categoryID === c.id,
);
{sections.map((section) => {
const stages = timings
.filter((t) => t.stage.section === section)
.map((t) => t.stage);
return (
<YAxisSection key={c.id}>
<YAxisHeader>{c.name}</YAxisHeader>
<YAxisSection key={section}>
<YAxisHeader>{section}</YAxisHeader>
<YAxisLabels>
{stagesInCategory.map((stage) => (
{stages.map((stage) => (
<YAxisLabel
key={stage.name}
id={encodeURIComponent(stage.name)}
>
<span css={styles.stageLabel}>
{stage.name}
{stage.label}
<Tooltip {...stage.tooltip}>
<InfoOutlined css={styles.info} />
</Tooltip>
@@ -201,19 +119,19 @@ export const StagesChart: FC<StagesChartProps> = ({
</YAxis>
<XAxis ticks={ticks} scale={scale}>
{stageCategories.map((category) => {
{sections.map((section) => {
const stageTimings = timings.filter(
(t) => t.categoryID === category.id,
(t) => t.stage.section === section,
);
return (
<XAxisSection key={category.id}>
<XAxisSection key={section}>
{stageTimings.map((t) => {
// If the stage has no timing data, we just want to render an empty row
if (t.range === undefined) {
return (
<XAxisRow
key={t.name}
yAxisLabelId={encodeURIComponent(t.name)}
key={t.stage.name}
yAxisLabelId={encodeURIComponent(t.stage.name)}
/>
);
}
@@ -223,18 +141,18 @@ export const StagesChart: FC<StagesChartProps> = ({
return (
<XAxisRow
key={t.name}
yAxisLabelId={encodeURIComponent(t.name)}
key={t.stage.name}
yAxisLabelId={encodeURIComponent(t.stage.name)}
>
{/** We only want to expand stages with more than one resource */}
{t.visibleResources > 1 ? (
<ClickableBar
aria-label={`View ${t.name} details`}
aria-label={`View ${t.stage.label} details`}
scale={scale}
value={value}
offset={offset}
onClick={() => {
onSelectStage(t, category);
onSelectStage(t.stage);
}}
>
{t.error && (
@@ -281,3 +199,103 @@ const styles = {
cursor: "pointer",
}),
} satisfies Record<string, Interpolation<Theme>>;
export const provisioningStages: Stage[] = [
{
name: "init",
label: "init",
section: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform initialization</TooltipTitle>
<TooltipShortDescription>
Download providers & modules.
</TooltipShortDescription>
</>
),
},
},
{
name: "plan",
label: "plan",
section: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform plan</TooltipTitle>
<TooltipShortDescription>
Compare state of desired vs actual resources and compute changes to
be made.
</TooltipShortDescription>
</>
),
},
},
{
name: "graph",
label: "graph",
section: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform graph</TooltipTitle>
<TooltipShortDescription>
List all resources in plan, used to update coderd database.
</TooltipShortDescription>
</>
),
},
},
{
name: "apply",
label: "apply",
section: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform apply</TooltipTitle>
<TooltipShortDescription>
Execute Terraform plan to create/modify/delete resources into
desired states.
</TooltipShortDescription>
</>
),
},
},
];
export const agentStages = (section: string): Stage[] => {
return [
{
name: "connect",
label: "connect",
section,
tooltip: {
title: (
<>
<TooltipTitle>Connect</TooltipTitle>
<TooltipShortDescription>
Establish an RPC connection with the control plane.
</TooltipShortDescription>
</>
),
},
},
{
name: "start",
label: "run startup scripts",
section,
tooltip: {
title: (
<>
<TooltipTitle>Run startup scripts</TooltipTitle>
<TooltipShortDescription>
Execute each agent startup script.
</TooltipShortDescription>
</>
),
},
},
];
};
@@ -11,6 +11,7 @@ const meta: Meta<typeof WorkspaceTimings> = {
defaultIsOpen: true,
provisionerTimings: WorkspaceTimingsResponse.provisioner_timings,
agentScriptTimings: WorkspaceTimingsResponse.agent_script_timings,
agentConnectionTimings: WorkspaceTimingsResponse.agent_connection_timings,
},
parameters: {
chromatic,
@@ -32,6 +33,7 @@ export const Loading: Story = {
args: {
provisionerTimings: undefined,
agentScriptTimings: undefined,
agentConnectionTimings: undefined,
},
};
@@ -45,7 +47,7 @@ export const ClickToOpen: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
await user.click(canvas.getByRole("button"));
await user.click(canvas.getByText("Build timeline", { exact: false }));
await canvas.findByText("provisioning");
},
};
@@ -58,9 +60,9 @@ export const ClickToClose: Story = {
const user = userEvent.setup();
const canvas = within(canvasElement);
await canvas.findByText("provisioning");
await user.click(canvas.getByText("Provisioning time", { exact: false }));
await user.click(canvas.getByText("Build timeline", { exact: false }));
await waitFor(() =>
expect(canvas.getByText("workspace boot")).not.toBeVisible(),
expect(canvas.queryByText("workspace boot")).not.toBeInTheDocument(),
);
},
};
@@ -96,7 +98,7 @@ export const NavigateToStartStage: Story = {
const user = userEvent.setup();
const canvas = within(canvasElement);
const detailsButton = canvas.getByRole("button", {
name: "View start details",
name: "View run startup scripts details",
});
await user.click(detailsButton);
await canvas.findByText("Startup Script");
@@ -4,19 +4,27 @@ import KeyboardArrowUp from "@mui/icons-material/KeyboardArrowUp";
import Button from "@mui/material/Button";
import Collapse from "@mui/material/Collapse";
import Skeleton from "@mui/material/Skeleton";
import type { AgentScriptTiming, ProvisionerTiming } from "api/typesGenerated";
import type {
AgentConnectionTiming,
AgentScriptTiming,
ProvisionerTiming,
} from "api/typesGenerated";
import { type FC, useState } from "react";
import { type TimeRange, calcDuration, mergeTimeRanges } from "./Chart/utils";
import { ResourcesChart, isCoderResource } from "./ResourcesChart";
import { ScriptsChart } from "./ScriptsChart";
import { type StageCategory, StagesChart, stages } from "./StagesChart";
import {
type Stage,
StagesChart,
agentStages,
provisioningStages,
} from "./StagesChart";
type TimingView =
| { name: "default" }
| {
name: "detailed";
stage: string;
category: StageCategory;
stage: Stage;
filter: string;
};
@@ -24,20 +32,37 @@ type WorkspaceTimingsProps = {
defaultIsOpen?: boolean;
provisionerTimings: readonly ProvisionerTiming[] | undefined;
agentScriptTimings: readonly AgentScriptTiming[] | undefined;
agentConnectionTimings: readonly AgentConnectionTiming[] | undefined;
};
export const WorkspaceTimings: FC<WorkspaceTimingsProps> = ({
provisionerTimings = [],
agentScriptTimings = [],
agentConnectionTimings = [],
defaultIsOpen = false,
}) => {
const [view, setView] = useState<TimingView>({ name: "default" });
const timings = [...provisionerTimings, ...agentScriptTimings];
const timings = [
...provisionerTimings,
...agentScriptTimings,
...agentConnectionTimings,
];
const [isOpen, setIsOpen] = useState(defaultIsOpen);
const isLoading = timings.length === 0;
// All stages
const agentStageLabels = Array.from(
new Set(
agentConnectionTimings.map((t) => `agent (${t.workspace_agent_name})`),
),
);
const stages = [
...provisioningStages,
...agentStageLabels.flatMap((a) => agentStages(a)),
];
const displayProvisioningTime = () => {
const totalRange = mergeTimeRanges(timings.map(extractRange));
const totalRange = mergeTimeRanges(timings.map(toTimeRange));
const totalDuration = calcDuration(totalRange);
return humanizeDuration(totalDuration);
};
@@ -81,7 +106,7 @@ export const WorkspaceTimings: FC<WorkspaceTimingsProps> = ({
const stageRange =
stageTimings.length === 0
? undefined
: mergeTimeRanges(stageTimings.map(extractRange));
: mergeTimeRanges(stageTimings.map(toTimeRange));
// Prevent users from inspecting internal coder resources in
// provisioner timings.
@@ -93,67 +118,63 @@ export const WorkspaceTimings: FC<WorkspaceTimingsProps> = ({
});
return {
stage: s,
range: stageRange,
name: s.name,
categoryID: s.categoryID,
visibleResources: visibleResources.length,
error: stageTimings.some(
(t) => "status" in t && t.status === "exit_failure",
),
};
})}
onSelectStage={(t, category) => {
onSelectStage={(stage) => {
setView({
stage,
name: "detailed",
stage: t.name,
category,
filter: "",
});
}}
/>
)}
{view.name === "detailed" &&
view.category.id === "provisioning" && (
<ResourcesChart
timings={provisionerTimings
.filter((t) => t.stage === view.stage)
.map((t) => {
return {
range: extractRange(t),
{view.name === "detailed" && (
<>
{view.stage.section === "provisioning" && (
<ResourcesChart
timings={provisionerTimings
.filter((t) => t.stage === view.stage.name)
.map((t) => ({
range: toTimeRange(t),
name: t.resource,
source: t.source,
action: t.action,
};
})}
category={view.category}
stage={view.stage}
onBack={() => {
setView({ name: "default" });
}}
/>
)}
}))}
stage={view.stage}
onBack={() => {
setView({ name: "default" });
}}
/>
)}
{view.name === "detailed" &&
view.category.id === "workspaceBoot" && (
<ScriptsChart
timings={agentScriptTimings
.filter((t) => t.stage === view.stage)
.map((t) => {
return {
range: extractRange(t),
name: t.display_name,
status: t.status,
exitCode: t.exit_code,
};
})}
category={view.category}
stage={view.stage}
onBack={() => {
setView({ name: "default" });
}}
/>
)}
{view.stage.name === "start" && (
<ScriptsChart
timings={agentScriptTimings
.filter((t) => t.stage === view.stage.name)
.map((t) => {
return {
range: toTimeRange(t),
name: t.display_name,
status: t.status,
exitCode: t.exit_code,
};
})}
stage={view.stage}
onBack={() => {
setView({ name: "default" });
}}
/>
)}
</>
)}
</div>
</Collapse>
)}
@@ -161,9 +182,10 @@ export const WorkspaceTimings: FC<WorkspaceTimingsProps> = ({
);
};
const extractRange = (
timing: ProvisionerTiming | AgentScriptTiming,
): TimeRange => {
const toTimeRange = (timing: {
started_at: string;
ended_at: string;
}): TimeRange => {
return {
startedAt: new Date(timing.started_at),
endedAt: new Date(timing.ended_at),
@@ -355,6 +355,8 @@ export const WorkspaceTimingsResponse: WorkspaceBuildTimings = {
stage: "start",
status: "ok",
display_name: "Startup Script",
workspace_agent_id: "86fd4143-d95f-4602-b464-1149ede62269",
workspace_agent_name: "dev",
},
{
started_at: "2024-10-14T11:30:56.650915Z",
@@ -363,6 +365,8 @@ export const WorkspaceTimingsResponse: WorkspaceBuildTimings = {
stage: "start",
status: "ok",
display_name: "Dotfiles",
workspace_agent_id: "86fd4143-d95f-4602-b464-1149ede62269",
workspace_agent_name: "dev",
},
{
started_at: "2024-10-14T11:30:56.650715Z",
@@ -371,6 +375,8 @@ export const WorkspaceTimingsResponse: WorkspaceBuildTimings = {
stage: "start",
status: "ok",
display_name: "Personalize",
workspace_agent_id: "86fd4143-d95f-4602-b464-1149ede62269",
workspace_agent_name: "dev",
},
{
started_at: "2024-10-14T11:30:56.650512Z",
@@ -379,6 +385,8 @@ export const WorkspaceTimingsResponse: WorkspaceBuildTimings = {
stage: "start",
status: "ok",
display_name: "install_slackme",
workspace_agent_id: "86fd4143-d95f-4602-b464-1149ede62269",
workspace_agent_name: "dev",
},
{
started_at: "2024-10-14T11:30:56.650659Z",
@@ -387,6 +395,8 @@ export const WorkspaceTimingsResponse: WorkspaceBuildTimings = {
stage: "start",
status: "ok",
display_name: "Coder Login",
workspace_agent_id: "86fd4143-d95f-4602-b464-1149ede62269",
workspace_agent_name: "dev",
},
{
started_at: "2024-10-14T11:30:56.650666Z",
@@ -395,6 +405,8 @@ export const WorkspaceTimingsResponse: WorkspaceBuildTimings = {
stage: "start",
status: "ok",
display_name: "File Browser",
workspace_agent_id: "86fd4143-d95f-4602-b464-1149ede62269",
workspace_agent_name: "dev",
},
{
started_at: "2024-10-14T11:30:56.652425Z",
@@ -403,6 +415,8 @@ export const WorkspaceTimingsResponse: WorkspaceBuildTimings = {
stage: "start",
status: "ok",
display_name: "code-server",
workspace_agent_id: "86fd4143-d95f-4602-b464-1149ede62269",
workspace_agent_name: "dev",
},
{
started_at: "2024-10-14T11:30:56.650423Z",
@@ -411,6 +425,17 @@ export const WorkspaceTimingsResponse: WorkspaceBuildTimings = {
stage: "start",
status: "ok",
display_name: "Git Clone",
workspace_agent_id: "86fd4143-d95f-4602-b464-1149ede62269",
workspace_agent_name: "dev",
},
],
agent_connection_timings: [
{
started_at: "2024-10-14T11:30:55.650423Z",
ended_at: "2024-10-14T11:30:56.650423Z",
stage: "connect",
workspace_agent_id: "86fd4143-d95f-4602-b464-1149ede62269",
workspace_agent_name: "dev",
},
],
};
+2 -1
View File
@@ -267,8 +267,9 @@ export const Workspace: FC<WorkspaceProps> = ({
)}
<WorkspaceTimings
agentScriptTimings={timings?.agent_script_timings}
provisionerTimings={timings?.provisioner_timings}
agentScriptTimings={timings?.agent_script_timings}
agentConnectionTimings={timings?.agent_connection_timings}
/>
</div>
</div>
@@ -158,8 +158,11 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient));
// Build Timings. Fetch build timings only when the build job is completed.
const readyAgents = workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => a && a.lifecycle_state !== "starting");
const timingsQuery = useQuery({
...workspaceBuildTimings(workspace.latest_build.id),
...workspaceBuildTimings(workspace.latest_build.id, readyAgents.length),
enabled: Boolean(workspace.latest_build.job.completed_at),
});