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} /> pFad - Phonifier reborn

      Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

      Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


      Alternative Proxies:

      Alternative Proxy

      pFad Proxy

      pFad v3 Proxy

      pFad v4 Proxy