diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7d87d9c8c2104..b79fea12a0c31 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2179,6 +2179,13 @@ class ApiMethods { ) => { await this.axios.post("/api/v2/users/otp/change-password", req); }; + + workspaceBuildTimings = async (workspaceBuildId: string) => { + const res = await this.axios.get( + `/api/v2/workspacebuilds/${workspaceBuildId}/timings`, + ); + return res.data; + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/workspaceBuilds.ts b/site/src/api/queries/workspaceBuilds.ts index 4b097a1b2b960..0e8981ba71ea4 100644 --- a/site/src/api/queries/workspaceBuilds.ts +++ b/site/src/api/queries/workspaceBuilds.ts @@ -56,3 +56,10 @@ export const infiniteWorkspaceBuilds = ( }, }; }; + +export const workspaceBuildTimings = (workspaceBuildId: string) => { + return { + queryKey: ["workspaceBuilds", workspaceBuildId, "timings"], + queryFn: () => API.workspaceBuildTimings(workspaceBuildId), + }; +}; diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx new file mode 100644 index 0000000000000..a98d91ae428b5 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx @@ -0,0 +1,105 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import { type ButtonHTMLAttributes, type HTMLProps, forwardRef } from "react"; + +export type BarColors = { + stroke: string; + fill: string; +}; + +type BaseBarProps = Omit & { + /** + * Scale used to determine the width based on the given value. + */ + scale: number; + value: number; + /** + * The X position of the bar component. + */ + offset: number; + /** + * Color scheme for the bar. If not passed the default gray color will be + * used. + */ + colors?: BarColors; +}; + +type BarProps = BaseBarProps>; + +export const Bar = forwardRef( + ({ colors, scale, value, offset, ...htmlProps }, ref) => { + return ( +
+ ); + }, +); + +type ClickableBarProps = BaseBarProps>; + +export const ClickableBar = forwardRef( + ({ colors, scale, value, offset, ...htmlProps }, ref) => { + return ( + + )} + + {!isLast && ( +
  • + +
  • + )} + + ); + })} + + ); +}; + +export const ChartSearch = (props: SearchFieldProps) => { + return ; +}; + +export type ChartLegend = { + label: string; + colors?: BarColors; +}; + +type ChartLegendsProps = { + legends: ChartLegend[]; +}; + +export const ChartLegends: FC = ({ legends }) => { + return ( +
      + {legends.map((l) => ( +
    • +
      + {l.label} +
    • + ))} +
    + ); +}; + +const styles = { + chart: { + "--header-height": "40px", + "--section-padding": "16px", + "--x-axis-rows-gap": "20px", + "--y-axis-width": "200px", + + height: "100%", + display: "flex", + flexDirection: "column", + }, + content: (theme) => ({ + display: "flex", + alignItems: "stretch", + fontSize: 12, + fontWeight: 500, + overflow: "auto", + flex: 1, + scrollbarColor: `${theme.palette.divider} ${theme.palette.background.default}`, + scrollbarWidth: "thin", + position: "relative", + + "&:before": { + content: "''", + position: "absolute", + bottom: "calc(-1 * var(--scroll-top, 0px))", + width: "100%", + height: 100, + background: `linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, ${theme.palette.background.default} 81.93%)`, + opacity: "var(--scroll-mask-opacity)", + zIndex: 1, + transition: "opacity 0.2s", + pointerEvents: "none", + }, + }), + toolbar: (theme) => ({ + borderBottom: `1px solid ${theme.palette.divider}`, + fontSize: 12, + display: "flex", + flexAlign: "stretch", + }), + breadcrumbs: (theme) => ({ + listStyle: "none", + margin: 0, + width: "var(--y-axis-width)", + padding: "var(--section-padding)", + display: "flex", + alignItems: "center", + gap: 4, + lineHeight: 1, + flexShrink: 0, + + "& li": { + display: "block", + + "&[role=presentation]": { + lineHeight: 0, + }, + }, + + "& li:first-child": { + color: theme.palette.text.secondary, + }, + + "& li[role=presentation]": { + color: theme.palette.text.secondary, + + "& svg": { + width: 14, + height: 14, + }, + }, + }), + breadcrumbButton: (theme) => ({ + background: "none", + padding: 0, + border: "none", + fontSize: "inherit", + color: "inherit", + cursor: "pointer", + + "&:hover": { + color: theme.palette.text.primary, + }, + }), + searchField: (theme) => ({ + flex: "1", + + "& fieldset": { + border: 0, + borderRadius: 0, + borderLeft: `1px solid ${theme.palette.divider} !important`, + }, + + "& .MuiInputBase-root": { + height: "100%", + fontSize: 12, + }, + }), + legends: { + listStyle: "none", + margin: 0, + padding: 0, + display: "flex", + alignItems: "center", + gap: 24, + paddingRight: "var(--section-padding)", + }, + legend: { + fontWeight: 500, + display: "flex", + alignItems: "center", + gap: 8, + lineHeight: 1, + }, + legendSquare: (theme) => ({ + width: 18, + height: 18, + borderRadius: 4, + border: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.default, + }), +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Tooltip.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Tooltip.tsx new file mode 100644 index 0000000000000..fc1ab550a8854 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Tooltip.tsx @@ -0,0 +1,81 @@ +import { css } from "@emotion/css"; +import { type Interpolation, type Theme, useTheme } from "@emotion/react"; +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +import MUITooltip, { + type TooltipProps as MUITooltipProps, +} from "@mui/material/Tooltip"; +import type { FC, HTMLProps } from "react"; +import { Link, type LinkProps } from "react-router-dom"; + +export type TooltipProps = MUITooltipProps; + +export const Tooltip: FC = (props) => { + const theme = useTheme(); + + return ( + + ); +}; + +export const TooltipTitle: FC> = (props) => { + return ; +}; + +export const TooltipShortDescription: FC> = ( + props, +) => { + return ; +}; + +export const TooltipLink: FC = (props) => { + return ( + + + {props.children} + + ); +}; + +const styles = { + tooltip: (theme) => ({ + backgroundColor: theme.palette.background.default, + border: `1px solid ${theme.palette.divider}`, + maxWidth: "max-content", + borderRadius: 8, + display: "flex", + flexDirection: "column", + fontWeight: 500, + fontSize: 12, + color: theme.palette.text.secondary, + gap: 4, + }), + title: (theme) => ({ + color: theme.palette.text.primary, + display: "block", + }), + link: (theme) => ({ + color: "inherit", + textDecoration: "none", + display: "flex", + alignItems: "center", + gap: 4, + + "&:hover": { + color: theme.palette.text.primary, + }, + + "& svg": { + width: 12, + height: 12, + }, + }), + shortDesc: { + maxWidth: 280, + }, +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx new file mode 100644 index 0000000000000..4863b08ec19bd --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx @@ -0,0 +1,196 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import { type FC, type HTMLProps, useLayoutEffect, useRef } from "react"; +import { formatTime } from "./utils"; + +const XAxisMinWidth = 130; + +type XAxisProps = HTMLProps & { + ticks: number[]; + scale: number; +}; + +export const XAxis: FC = ({ ticks, scale, ...htmlProps }) => { + const rootRef = useRef(null); + + // The X axis should occupy all available space. If there is extra space, + // increase the column width accordingly. Use a CSS variable to propagate the + // value to the child components. + useLayoutEffect(() => { + const rootEl = rootRef.current; + if (!rootEl) { + return; + } + // We always add one extra column to the grid to ensure that the last column + // is fully visible. + const avgWidth = rootEl.clientWidth / (ticks.length + 1); + const width = avgWidth > XAxisMinWidth ? avgWidth : XAxisMinWidth; + rootEl.style.setProperty("--x-axis-width", `${width}px`); + }, [ticks]); + + return ( +
    + + {ticks.map((tick) => ( + {formatTime(tick)} + ))} + + {htmlProps.children} + +
    + ); +}; + +export const XAxisLabels: FC> = (props) => { + return
      ; +}; + +export const XAxisLabel: FC> = (props) => { + return ( +
    • + ); +}; + +export const XAxisSection: FC> = (props) => { + return
      ; +}; + +type XAxisRowProps = HTMLProps & { + yAxisLabelId: string; +}; + +export const XAxisRow: FC = ({ yAxisLabelId, ...htmlProps }) => { + const syncYAxisLabelHeightToXAxisRow = (rowEl: HTMLDivElement | null) => { + if (!rowEl) { + return; + } + // Selecting a label with special characters (e.g., + // #coder_metadata.container_info[0]) will fail because it is not a valid + // selector. To handle this, we need to query by the id attribute and escape + // it with quotes. + const selector = `[id="${encodeURIComponent(yAxisLabelId)}"]`; + const yAxisLabel = document.querySelector(selector); + if (!yAxisLabel) { + console.warn(`Y-axis label with selector ${selector} not found.`); + return; + } + yAxisLabel.style.height = `${rowEl.clientHeight}px`; + }; + + return ( +
      + ); +}; + +type XGridProps = HTMLProps & { + columns: number; +}; + +export const XGrid: FC = ({ columns, ...htmlProps }) => { + return ( +
      + {[...Array(columns).keys()].map((key) => ( +
      + ))} +
      + ); +}; + +// A dashed line is used as a background image to create the grid. +// Using it as a background simplifies replication along the Y axis. +const dashedLine = (color: string) => ` + +`; + +const styles = { + root: (theme) => ({ + display: "flex", + flexDirection: "column", + flex: 1, + borderLeft: `1px solid ${theme.palette.divider}`, + height: "fit-content", + minHeight: "100%", + position: "relative", + }), + labels: (theme) => ({ + margin: 0, + listStyle: "none", + display: "flex", + width: "fit-content", + alignItems: "center", + borderBottom: `1px solid ${theme.palette.divider}`, + height: "var(--header-height)", + padding: 0, + minWidth: "100%", + flexShrink: 0, + position: "sticky", + top: 0, + zIndex: 2, + backgroundColor: theme.palette.background.default, + }), + label: (theme) => ({ + display: "flex", + justifyContent: "center", + flexShrink: 0, + color: theme.palette.text.secondary, + }), + + section: (theme) => ({ + display: "flex", + flexDirection: "column", + gap: "var(--x-axis-rows-gap)", + padding: "var(--section-padding)", + // Elevate this section to make it more prominent than the column dashes. + position: "relative", + zIndex: 1, + + "&:not(:first-of-type)": { + paddingTop: "calc(var(--section-padding) + var(--header-height))", + borderTop: `1px solid ${theme.palette.divider}`, + }, + }), + row: { + display: "flex", + alignItems: "center", + width: "fit-content", + gap: 8, + height: 32, + }, + grid: { + display: "flex", + width: "100%", + height: "100%", + position: "absolute", + top: 0, + left: 0, + }, + column: (theme) => ({ + flexShrink: 0, + backgroundRepeat: "repeat-y", + backgroundPosition: "right", + backgroundImage: `url("data:image/svg+xml,${encodeURIComponent(dashedLine(theme.palette.divider))}");`, + }), +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx new file mode 100644 index 0000000000000..4903f306c1ad4 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx @@ -0,0 +1,77 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import type { FC, HTMLProps } from "react"; + +export const YAxis: FC> = (props) => { + return
      ; +}; + +export const YAxisSection: FC> = (props) => { + return
      ; +}; + +export const YAxisHeader: FC> = (props) => { + return
      ; +}; + +export const YAxisLabels: FC> = (props) => { + return
        ; +}; + +type YAxisLabelProps = Omit, "id"> & { + id: string; +}; + +export const YAxisLabel: FC = ({ id, ...props }) => { + return ( +
      • + {props.children} +
      • + ); +}; + +const styles = { + root: { + width: "var(--y-axis-width)", + flexShrink: 0, + }, + section: (theme) => ({ + "&:not(:first-child)": { + borderTop: `1px solid ${theme.palette.divider}`, + }, + }), + header: (theme) => ({ + height: "var(--header-height)", + display: "flex", + alignItems: "center", + borderBottom: `1px solid ${theme.palette.divider}`, + fontSize: 10, + fontWeight: 500, + color: theme.palette.text.secondary, + paddingLeft: "var(--section-padding)", + paddingRight: "var(--section-padding)", + position: "sticky", + top: 0, + background: theme.palette.background.default, + }), + labels: { + margin: 0, + listStyle: "none", + display: "flex", + flexDirection: "column", + gap: "var(--x-axis-rows-gap)", + textAlign: "right", + padding: "var(--section-padding)", + }, + label: { + display: "flex", + alignItems: "center", + + "& > *": { + display: "block", + width: "100%", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + }, +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts b/site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts new file mode 100644 index 0000000000000..9721e9f0d1317 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts @@ -0,0 +1,56 @@ +export type TimeRange = { + startedAt: Date; + endedAt: Date; +}; + +/** + * Combines multiple timings into a single timing that spans the entire duration + * of the input timings. + */ +export const mergeTimeRanges = (ranges: TimeRange[]): TimeRange => { + const sortedDurations = ranges + .slice() + .sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime()); + const start = sortedDurations[0].startedAt; + + const sortedEndDurations = ranges + .slice() + .sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime()); + const end = sortedEndDurations[sortedEndDurations.length - 1].endedAt; + return { startedAt: start, endedAt: end }; +}; + +export const calcDuration = (range: TimeRange): number => { + return range.endedAt.getTime() - range.startedAt.getTime(); +}; + +// When displaying the chart we must consider the time intervals to display the +// data. For example, if the total time is 10 seconds, we should display the +// data in 200ms intervals. However, if the total time is 1 minute, we should +// display the data in 5 seconds intervals. To achieve this, we define the +// dimensions object that contains the time intervals for the chart. +const scales = [5_000, 500, 100]; + +const pickScale = (totalTime: number): number => { + for (const s of scales) { + if (totalTime > s) { + return s; + } + } + return scales[0]; +}; + +export const makeTicks = (time: number) => { + const scale = pickScale(time); + const count = Math.ceil(time / scale); + const ticks = Array.from({ length: count }, (_, i) => i * scale + scale); + return [ticks, scale] as const; +}; + +export const formatTime = (time: number): string => { + return `${time.toLocaleString()}ms`; +}; + +export const calcOffset = (range: TimeRange, baseRange: TimeRange): number => { + return range.startedAt.getTime() - baseRange.startedAt.getTime(); +}; diff --git a/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx new file mode 100644 index 0000000000000..b1c69b6d1baf7 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx @@ -0,0 +1,170 @@ +import { css } from "@emotion/css"; +import { type Interpolation, type Theme, useTheme } from "@emotion/react"; +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +import { type FC, useState } from "react"; +import { Link } from "react-router-dom"; +import { Bar } from "./Chart/Bar"; +import { + Chart, + ChartBreadcrumbs, + ChartContent, + type ChartLegend, + ChartLegends, + ChartSearch, + ChartToolbar, +} from "./Chart/Chart"; +import { Tooltip, TooltipLink, TooltipTitle } from "./Chart/Tooltip"; +import { XAxis, XAxisRow, XAxisSection } from "./Chart/XAxis"; +import { + YAxis, + YAxisHeader, + YAxisLabel, + YAxisLabels, + YAxisSection, +} from "./Chart/YAxis"; +import { + type TimeRange, + calcDuration, + calcOffset, + formatTime, + makeTicks, + mergeTimeRanges, +} from "./Chart/utils"; +import type { StageCategory } from "./StagesChart"; + +const legendsByAction: Record = { + "state refresh": { + label: "state refresh", + }, + create: { + label: "create", + colors: { + fill: "#022C22", + stroke: "#BBF7D0", + }, + }, + delete: { + label: "delete", + colors: { + fill: "#422006", + stroke: "#FDBA74", + }, + }, + read: { + label: "read", + colors: { + fill: "#082F49", + stroke: "#38BDF8", + }, + }, +}; + +type ResourceTiming = { + name: string; + source: string; + action: string; + range: TimeRange; +}; + +export type ResourcesChartProps = { + category: StageCategory; + stage: string; + timings: ResourceTiming[]; + onBack: () => void; +}; + +export const ResourcesChart: FC = ({ + category, + stage, + timings, + onBack, +}) => { + const generalTiming = mergeTimeRanges(timings.map((t) => t.range)); + const totalTime = calcDuration(generalTiming); + const [ticks, scale] = makeTicks(totalTime); + const [filter, setFilter] = useState(""); + const visibleTimings = timings.filter( + (t) => !isCoderResource(t.name) && t.name.includes(filter), + ); + const visibleLegends = [...new Set(visibleTimings.map((t) => t.action))].map( + (a) => legendsByAction[a], + ); + + return ( + + + + + + + + + + {stage} stage + + {visibleTimings.map((t) => ( + + {t.name} + + ))} + + + + + + + {visibleTimings.map((t) => { + const duration = calcDuration(t.range); + + return ( + + + {t.name} + view template + + } + > + + + {formatTime(duration)} + + ); + })} + + + + + ); +}; + +export const isCoderResource = (resource: string) => { + return ( + resource.startsWith("data.coder") || + resource.startsWith("module.coder") || + resource.startsWith("coder_") + ); +}; diff --git a/site/src/modules/workspaces/WorkspaceTiming/ScriptsChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/ScriptsChart.tsx new file mode 100644 index 0000000000000..5dfc57e51098f --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/ScriptsChart.tsx @@ -0,0 +1,153 @@ +import { type FC, useState } from "react"; +import { Bar } from "./Chart/Bar"; +import { + Chart, + ChartBreadcrumbs, + ChartContent, + type ChartLegend, + ChartLegends, + ChartSearch, + ChartToolbar, +} from "./Chart/Chart"; +import { Tooltip, TooltipTitle } from "./Chart/Tooltip"; +import { XAxis, XAxisRow, XAxisSection } from "./Chart/XAxis"; +import { + YAxis, + YAxisHeader, + YAxisLabel, + YAxisLabels, + YAxisSection, +} from "./Chart/YAxis"; +import { + type TimeRange, + calcDuration, + calcOffset, + formatTime, + makeTicks, + mergeTimeRanges, +} from "./Chart/utils"; +import type { StageCategory } from "./StagesChart"; + +const legendsByStatus: Record = { + ok: { + label: "success", + colors: { + fill: "#022C22", + stroke: "#BBF7D0", + }, + }, + exit_failure: { + label: "failure", + colors: { + fill: "#450A0A", + stroke: "#F87171", + }, + }, + timeout: { + label: "timed out", + colors: { + fill: "#422006", + stroke: "#FDBA74", + }, + }, +}; + +type ScriptTiming = { + name: string; + status: string; + exitCode: number; + range: TimeRange; +}; + +export type ScriptsChartProps = { + category: StageCategory; + stage: string; + timings: ScriptTiming[]; + onBack: () => void; +}; + +export const ScriptsChart: FC = ({ + category, + stage, + timings, + onBack, +}) => { + const generalTiming = mergeTimeRanges(timings.map((t) => t.range)); + const totalTime = calcDuration(generalTiming); + const [ticks, scale] = makeTicks(totalTime); + const [filter, setFilter] = useState(""); + const visibleTimings = timings.filter((t) => t.name.includes(filter)); + const visibleLegends = [...new Set(visibleTimings.map((t) => t.status))].map( + (s) => legendsByStatus[s], + ); + + return ( + + + + + + + + + + {stage} stage + + {visibleTimings.map((t) => ( + + {t.name} + + ))} + + + + + + + {visibleTimings.map((t) => { + const duration = calcDuration(t.range); + + return ( + + + Script exited with code {t.exitCode} + + } + > + + + + {formatTime(duration)} + + ); + })} + + + + + ); +}; diff --git a/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx new file mode 100644 index 0000000000000..8f37605ce5956 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx @@ -0,0 +1,283 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import ErrorSharp from "@mui/icons-material/ErrorSharp"; +import InfoOutlined from "@mui/icons-material/InfoOutlined"; +import type { FC } from "react"; +import { Bar, ClickableBar } from "./Chart/Bar"; +import { Blocks } from "./Chart/Blocks"; +import { Chart, ChartContent } from "./Chart/Chart"; +import { + Tooltip, + type TooltipProps, + TooltipShortDescription, + TooltipTitle, +} from "./Chart/Tooltip"; +import { XAxis, XAxisRow, XAxisSection } from "./Chart/XAxis"; +import { + YAxis, + YAxisHeader, + YAxisLabel, + YAxisLabels, + YAxisSection, +} from "./Chart/YAxis"; +import { + type TimeRange, + calcDuration, + calcOffset, + formatTime, + makeTicks, + 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"]; + tooltip: Omit; +}; + +export const stages: Stage[] = [ + { + name: "init", + categoryID: "provisioning", + tooltip: { + title: ( + <> + Terraform initialization + + Download providers & modules. + + + ), + }, + }, + { + name: "plan", + categoryID: "provisioning", + tooltip: { + title: ( + <> + Terraform plan + + Compare state of desired vs actual resources and compute changes to + be made. + + + ), + }, + }, + { + name: "graph", + categoryID: "provisioning", + tooltip: { + title: ( + <> + Terraform graph + + List all resources in plan, used to update coderd database. + + + ), + }, + }, + { + name: "apply", + categoryID: "provisioning", + tooltip: { + title: ( + <> + Terraform apply + + Execute terraform plan to create/modify/delete resources into + desired states. + + + ), + }, + }, + { + name: "start", + categoryID: "workspaceBoot", + tooltip: { + title: ( + <> + Start + + Scripts executed when the agent is starting. + + + ), + }, + }, +]; + +type StageTiming = { + name: string; + /** + /** + * 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 + * be undefined if a stage has no timing data. + */ + range: TimeRange | undefined; + /** + * Display an error icon within the bar to indicate when a stage has failed. + * This is used in the agent scripts stage. + */ + error?: boolean; +}; + +export type StagesChartProps = { + timings: StageTiming[]; + onSelectStage: (timing: StageTiming, category: StageCategory) => void; +}; + +export const StagesChart: FC = ({ + timings, + onSelectStage, +}) => { + const totalRange = mergeTimeRanges( + timings.map((t) => t.range).filter((t) => t !== undefined), + ); + const totalTime = calcDuration(totalRange); + const [ticks, scale] = makeTicks(totalTime); + + return ( + + + + {stageCategories.map((c) => { + const stagesInCategory = stages.filter( + (s) => s.categoryID === c.id, + ); + + return ( + + {c.name} + + {stagesInCategory.map((stage) => ( + + + {stage.name} + + + + + + ))} + + + ); + })} + + + + {stageCategories.map((category) => { + const stageTimings = timings.filter( + (t) => t.categoryID === category.id, + ); + return ( + + {stageTimings.map((t) => { + // If the stage has no timing data, we just want to render an empty row + if (t.range === undefined) { + return ( + + ); + } + + const value = calcDuration(t.range); + const offset = calcOffset(t.range, totalRange); + + return ( + + {/** We only want to expand stages with more than one resource */} + {t.visibleResources > 1 ? ( + { + onSelectStage(t, category); + }} + > + {t.error && ( + + )} + + + ) : ( + + )} + {formatTime(calcDuration(t.range))} + + ); + })} + + ); + })} + + + + ); +}; + +const styles = { + stageLabel: { + display: "flex", + alignItems: "center", + gap: 2, + justifyContent: "flex-end", + }, + stageDescription: { + maxWidth: 300, + }, + info: (theme) => ({ + width: 12, + height: 12, + color: theme.palette.text.secondary, + cursor: "pointer", + }), +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx new file mode 100644 index 0000000000000..b1bf487c52732 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, waitFor, within } from "@storybook/test"; +import { WorkspaceTimings } from "./WorkspaceTimings"; +import { WorkspaceTimingsResponse } from "./storybookData"; + +const meta: Meta = { + title: "modules/workspaces/WorkspaceTimings", + component: WorkspaceTimings, + args: { + defaultIsOpen: true, + provisionerTimings: WorkspaceTimingsResponse.provisioner_timings, + agentScriptTimings: WorkspaceTimingsResponse.agent_script_timings, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Open: Story = {}; + +export const Close: Story = { + args: { + defaultIsOpen: false, + }, +}; + +export const Loading: Story = { + args: { + provisionerTimings: undefined, + agentScriptTimings: undefined, + }, +}; + +export const ClickToOpen: Story = { + args: { + defaultIsOpen: false, + }, + parameters: { + chromatic: { disableSnapshot: true }, + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + await user.click(canvas.getByRole("button")); + await canvas.findByText("provisioning"); + }, +}; + +export const ClickToClose: Story = { + parameters: { + chromatic: { disableSnapshot: true }, + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + await canvas.findByText("provisioning"); + await user.click(canvas.getByText("Provisioning time", { exact: false })); + await waitFor(() => + expect(canvas.getByText("workspace boot")).not.toBeVisible(), + ); + }, +}; + +const [first, ...others] = WorkspaceTimingsResponse.agent_script_timings; +export const FailedScript: Story = { + args: { + agentScriptTimings: [ + { ...first, status: "exit_failure", exit_code: 1 }, + ...others, + ], + }, +}; + +// Navigate into a provisioning stage +export const NavigateToPlanStage: Story = { + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const detailsButton = canvas.getByRole("button", { + name: "View plan details", + }); + await user.click(detailsButton); + await canvas.findByText( + "module.dotfiles.data.coder_parameter.dotfiles_uri[0]", + ); + }, +}; + +// Navigating into a workspace boot stage +export const NavigateToStartStage: Story = { + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const detailsButton = canvas.getByRole("button", { + name: "View start details", + }); + await user.click(detailsButton); + await canvas.findByText("Startup Script"); + }, +}; diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx new file mode 100644 index 0000000000000..4835cc2be8f69 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -0,0 +1,214 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; +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 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"; + +type TimingView = + | { name: "default" } + | { + name: "detailed"; + stage: string; + category: StageCategory; + filter: string; + }; + +type WorkspaceTimingsProps = { + defaultIsOpen?: boolean; + provisionerTimings: readonly ProvisionerTiming[] | undefined; + agentScriptTimings: readonly AgentScriptTiming[] | undefined; +}; + +export const WorkspaceTimings: FC = ({ + provisionerTimings = [], + agentScriptTimings = [], + defaultIsOpen = false, +}) => { + const [view, setView] = useState({ name: "default" }); + const timings = [...provisionerTimings, ...agentScriptTimings]; + const [isOpen, setIsOpen] = useState(defaultIsOpen); + const isLoading = timings.length === 0; + + const displayProvisioningTime = () => { + const totalRange = mergeTimeRanges(timings.map(extractRange)); + const totalDuration = calcDuration(totalRange); + return humanizeDuration(totalDuration); + }; + + return ( +
        + + {!isLoading && ( + +
        + {view.name === "default" && ( + { + const stageTimings = timings.filter( + (t) => t.stage === s.name, + ); + const stageRange = + stageTimings.length === 0 + ? undefined + : mergeTimeRanges(stageTimings.map(extractRange)); + + // Prevent users from inspecting internal coder resources in + // provisioner timings. + const visibleResources = stageTimings.filter((t) => { + const isProvisionerTiming = "resource" in t; + return isProvisionerTiming + ? !isCoderResource(t.resource) + : true; + }); + + return { + 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) => { + setView({ + name: "detailed", + stage: t.name, + category, + filter: "", + }); + }} + /> + )} + + {view.name === "detailed" && + view.category.id === "provisioning" && ( + t.stage === view.stage) + .map((t) => { + return { + range: extractRange(t), + name: t.resource, + source: t.source, + action: t.action, + }; + })} + category={view.category} + stage={view.stage} + onBack={() => { + setView({ name: "default" }); + }} + /> + )} + + {view.name === "detailed" && + view.category.id === "workspaceBoot" && ( + 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" }); + }} + /> + )} +
        +
        + )} +
        + ); +}; + +const extractRange = ( + timing: ProvisionerTiming | AgentScriptTiming, +): TimeRange => { + return { + startedAt: new Date(timing.started_at), + endedAt: new Date(timing.ended_at), + }; +}; + +const humanizeDuration = (durationMs: number): string => { + const seconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours.toLocaleString()}h ${(minutes % 60).toLocaleString()}m`; + } + + if (minutes > 0) { + return `${minutes.toLocaleString()}m ${(seconds % 60).toLocaleString()}s`; + } + + return `${seconds.toLocaleString()}s`; +}; + +const styles = { + collapse: (theme) => ({ + borderRadius: 8, + border: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.default, + }), + collapseTrigger: { + background: "none", + border: 0, + padding: 16, + color: "inherit", + width: "100%", + display: "flex", + alignItems: "center", + height: 57, + fontSize: 14, + fontWeight: 500, + cursor: "pointer", + }, + collapseBody: (theme) => ({ + borderTop: `1px solid ${theme.palette.divider}`, + display: "flex", + flexDirection: "column", + height: 420, + }), +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTiming/storybookData.ts b/site/src/modules/workspaces/WorkspaceTiming/storybookData.ts new file mode 100644 index 0000000000000..828959f424107 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/storybookData.ts @@ -0,0 +1,416 @@ +import type { WorkspaceBuildTimings } from "api/typesGenerated"; + +export const WorkspaceTimingsResponse: WorkspaceBuildTimings = { + provisioner_timings: [ + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:38.582305Z", + ended_at: "2024-10-14T11:30:47.707708Z", + stage: "init", + source: "terraform", + action: "initializing terraform", + resource: "state file", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.255148Z", + ended_at: "2024-10-14T11:30:48.263557Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_workspace_owner.me", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.255183Z", + ended_at: "2024-10-14T11:30:48.267143Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_parameter.repo_base_dir", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.255196Z", + ended_at: "2024-10-14T11:30:48.264778Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.coder-login.data.coder_workspace_owner.me", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.255208Z", + ended_at: "2024-10-14T11:30:48.263557Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_parameter.image_type", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.255219Z", + ended_at: "2024-10-14T11:30:48.263596Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_external_auth.github", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.255265Z", + ended_at: "2024-10-14T11:30:48.274588Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.dotfiles.data.coder_parameter.dotfiles_uri[0]", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.263613Z", + ended_at: "2024-10-14T11:30:48.281025Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.jetbrains_gateway.data.coder_parameter.jetbrains_ide", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.264708Z", + ended_at: "2024-10-14T11:30:48.275815Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.jetbrains_gateway.data.coder_workspace.me", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.264873Z", + ended_at: "2024-10-14T11:30:48.270726Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_workspace.me", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.26545Z", + ended_at: "2024-10-14T11:30:48.281326Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_parameter.region", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.27066Z", + ended_at: "2024-10-14T11:30:48.292004Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.filebrowser.data.coder_workspace_owner.me", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.275249Z", + ended_at: "2024-10-14T11:30:48.292609Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.cursor.data.coder_workspace_owner.me", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.275368Z", + ended_at: "2024-10-14T11:30:48.306164Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.cursor.data.coder_workspace.me", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.279611Z", + ended_at: "2024-10-14T11:30:48.610826Z", + stage: "plan", + source: "http", + action: "read", + resource: + 'module.jetbrains_gateway.data.http.jetbrains_ide_versions["WS"]', + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.281101Z", + ended_at: "2024-10-14T11:30:48.289783Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.coder-login.data.coder_workspace.me", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.281158Z", + ended_at: "2024-10-14T11:30:48.292784Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.filebrowser.data.coder_workspace.me", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.306734Z", + ended_at: "2024-10-14T11:30:48.611667Z", + stage: "plan", + source: "http", + action: "read", + resource: + 'module.jetbrains_gateway.data.http.jetbrains_ide_versions["GO"]', + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.380177Z", + ended_at: "2024-10-14T11:30:48.385342Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "coder_agent.dev", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.414139Z", + ended_at: "2024-10-14T11:30:48.437781Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.slackme.coder_script.install_slackme", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.414522Z", + ended_at: "2024-10-14T11:30:48.436733Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.dotfiles.coder_script.dotfiles", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.415421Z", + ended_at: "2024-10-14T11:30:48.43439Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.git-clone.coder_script.git_clone", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.41568Z", + ended_at: "2024-10-14T11:30:48.427176Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.personalize.coder_script.personalize", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.416327Z", + ended_at: "2024-10-14T11:30:48.4375Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.code-server.coder_app.code-server", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.41705Z", + ended_at: "2024-10-14T11:30:48.435293Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.cursor.coder_app.cursor", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.422605Z", + ended_at: "2024-10-14T11:30:48.432662Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.coder-login.coder_script.coder-login", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.456454Z", + ended_at: "2024-10-14T11:30:48.46477Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.code-server.coder_script.code-server", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.456791Z", + ended_at: "2024-10-14T11:30:48.464265Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.filebrowser.coder_script.filebrowser", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.459278Z", + ended_at: "2024-10-14T11:30:48.463592Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.filebrowser.coder_app.filebrowser", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.624758Z", + ended_at: "2024-10-14T11:30:48.626424Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.jetbrains_gateway.coder_app.gateway", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.909834Z", + ended_at: "2024-10-14T11:30:49.198073Z", + stage: "plan", + source: "docker", + action: "state refresh", + resource: "docker_volume.home_volume", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:48.914974Z", + ended_at: "2024-10-14T11:30:49.279658Z", + stage: "plan", + source: "docker", + action: "read", + resource: "data.docker_registry_image.dogfood", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:49.281906Z", + ended_at: "2024-10-14T11:30:49.911366Z", + stage: "plan", + source: "docker", + action: "state refresh", + resource: "docker_image.dogfood", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:50.001069Z", + ended_at: "2024-10-14T11:30:50.53433Z", + stage: "graph", + source: "terraform", + action: "building terraform dependency graph", + resource: "state file", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:50.861398Z", + ended_at: "2024-10-14T11:30:50.91401Z", + stage: "apply", + source: "coder", + action: "delete", + resource: "module.coder-login.coder_script.coder-login", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:50.930172Z", + ended_at: "2024-10-14T11:30:50.932034Z", + stage: "apply", + source: "coder", + action: "create", + resource: "module.coder-login.coder_script.coder-login", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:51.228719Z", + ended_at: "2024-10-14T11:30:53.672338Z", + stage: "apply", + source: "docker", + action: "create", + resource: "docker_container.workspace[0]", + }, + { + job_id: "86fd4143-d95f-4602-b464-1149ede62269", + started_at: "2024-10-14T11:30:53.689718Z", + ended_at: "2024-10-14T11:30:53.693767Z", + stage: "apply", + source: "coder", + action: "create", + resource: "coder_metadata.container_info[0]", + }, + ], + agent_script_timings: [ + { + started_at: "2024-10-14T11:30:56.650536Z", + ended_at: "2024-10-14T11:31:10.852776Z", + exit_code: 0, + stage: "start", + status: "ok", + display_name: "Startup Script", + }, + { + started_at: "2024-10-14T11:30:56.650915Z", + ended_at: "2024-10-14T11:30:56.655558Z", + exit_code: 0, + stage: "start", + status: "ok", + display_name: "Dotfiles", + }, + { + started_at: "2024-10-14T11:30:56.650715Z", + ended_at: "2024-10-14T11:30:56.657682Z", + exit_code: 0, + stage: "start", + status: "ok", + display_name: "Personalize", + }, + { + started_at: "2024-10-14T11:30:56.650512Z", + ended_at: "2024-10-14T11:30:56.657981Z", + exit_code: 0, + stage: "start", + status: "ok", + display_name: "install_slackme", + }, + { + started_at: "2024-10-14T11:30:56.650659Z", + ended_at: "2024-10-14T11:30:57.318177Z", + exit_code: 0, + stage: "start", + status: "ok", + display_name: "Coder Login", + }, + { + started_at: "2024-10-14T11:30:56.650666Z", + ended_at: "2024-10-14T11:30:58.350832Z", + exit_code: 0, + stage: "start", + status: "ok", + display_name: "File Browser", + }, + { + started_at: "2024-10-14T11:30:56.652425Z", + ended_at: "2024-10-14T11:31:26.229407Z", + exit_code: 0, + stage: "start", + status: "ok", + display_name: "code-server", + }, + { + started_at: "2024-10-14T11:30:56.650423Z", + ended_at: "2024-10-14T11:30:56.657224Z", + exit_code: 0, + stage: "start", + status: "ok", + display_name: "Git Clone", + }, + ], +}; diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index dbc4c6b65f41b..c54ab25c1006c 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -8,6 +8,7 @@ import { Alert, AlertDetail } from "components/Alert/Alert"; import { SidebarIconButton } from "components/FullPageLayout/Sidebar"; import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { AgentRow } from "modules/resources/AgentRow"; +import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings"; import type { FC } from "react"; import { useNavigate } from "react-router-dom"; import { HistorySidebar } from "./HistorySidebar"; @@ -49,6 +50,7 @@ export interface WorkspaceProps { latestVersion?: TypesGen.TemplateVersion; permissions: WorkspacePermissions; isOwner: boolean; + timings?: TypesGen.WorkspaceBuildTimings; } /** @@ -81,6 +83,7 @@ export const Workspace: FC = ({ latestVersion, permissions, isOwner, + timings, }) => { const navigate = useNavigate(); const theme = useTheme(); @@ -262,6 +265,11 @@ export const Workspace: FC = ({ )}
      )} + +
      diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 29c1e9251594e..6859a5ada7882 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -3,6 +3,7 @@ import { getErrorMessage } from "api/errors"; import { buildInfo } from "api/queries/buildInfo"; import { deploymentConfig, deploymentSSHConfig } from "api/queries/deployment"; import { templateVersion, templateVersions } from "api/queries/templates"; +import { workspaceBuildTimings } from "api/queries/workspaceBuilds"; import { activate, cancelBuild, @@ -156,6 +157,12 @@ export const WorkspaceReadyPage: FC = ({ // Cancel build const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient)); + // Build Timings. Fetch build timings only when the build job is completed. + const timingsQuery = useQuery({ + ...workspaceBuildTimings(workspace.latest_build.id), + enabled: Boolean(workspace.latest_build.job.completed_at), + }); + const runLastBuild = ( buildParameters: TypesGen.WorkspaceBuildParameter[] | undefined, debug: boolean, @@ -260,6 +267,7 @@ export const WorkspaceReadyPage: FC = ({ ) } isOwner={isOwner} + timings={timingsQuery.data} />