feat(site): add workspace timings (#15068)

Demo:

https://github.com/user-attachments/assets/046a7224-48e4-4e66-a2ff-a8e1252ad18b
This commit is contained in:
Bruno Quaresma
2024-10-23 10:09:37 -03:00
committed by GitHub
parent cd92220ab8
commit d89ecebb4e
17 changed files with 2204 additions and 0 deletions
+7
View File
@@ -2179,6 +2179,13 @@ class ApiMethods {
) => {
await this.axios.post<void>("/api/v2/users/otp/change-password", req);
};
workspaceBuildTimings = async (workspaceBuildId: string) => {
const res = await this.axios.get<TypesGen.WorkspaceBuildTimings>(
`/api/v2/workspacebuilds/${workspaceBuildId}/timings`,
);
return res.data;
};
}
// This is a hard coded CSRF token/cookie pair for local development. In prod,
+7
View File
@@ -56,3 +56,10 @@ export const infiniteWorkspaceBuilds = (
},
};
};
export const workspaceBuildTimings = (workspaceBuildId: string) => {
return {
queryKey: ["workspaceBuilds", workspaceBuildId, "timings"],
queryFn: () => API.workspaceBuildTimings(workspaceBuildId),
};
};
@@ -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<T> = Omit<T, "size" | "color"> & {
/**
* 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<HTMLProps<HTMLDivElement>>;
export const Bar = forwardRef<HTMLDivElement, BarProps>(
({ colors, scale, value, offset, ...htmlProps }, ref) => {
return (
<div
css={barCSS({ colors, scale, value, offset })}
{...htmlProps}
ref={ref}
/>
);
},
);
type ClickableBarProps = BaseBarProps<ButtonHTMLAttributes<HTMLButtonElement>>;
export const ClickableBar = forwardRef<HTMLButtonElement, ClickableBarProps>(
({ colors, scale, value, offset, ...htmlProps }, ref) => {
return (
<button
type="button"
css={[...barCSS({ colors, scale, value, offset }), styles.clickable]}
{...htmlProps}
ref={ref}
/>
);
},
);
export const barCSS = ({
scale,
value,
colors,
offset,
}: BaseBarProps<unknown>) => {
return [
styles.bar,
{
width: `calc((var(--x-axis-width) * ${value}) / ${scale})`,
backgroundColor: colors?.fill,
borderColor: colors?.stroke,
marginLeft: `calc((var(--x-axis-width) * ${offset}) / ${scale})`,
},
];
};
const styles = {
bar: (theme) => ({
border: "1px solid",
borderColor: theme.palette.divider,
backgroundColor: theme.palette.background.default,
borderRadius: 8,
// The bar should fill the row height.
height: "inherit",
display: "flex",
padding: 7,
minWidth: 24,
// Increase hover area
position: "relative",
"&::after": {
content: '""',
position: "absolute",
top: -2,
right: -8,
bottom: -2,
left: -8,
},
}),
clickable: {
cursor: "pointer",
// We need to make the bar width at least 34px to allow the "..." icons to be displayed.
// The calculation is border * 1 + side paddings * 2 + icon width (which is 18px)
minWidth: 34,
"&:focus, &:hover, &:active": {
outline: "none",
borderColor: "#38BDF8",
},
},
} satisfies Record<string, Interpolation<Theme>>;
@@ -0,0 +1,73 @@
import type { Interpolation, Theme } from "@emotion/react";
import MoreHorizOutlined from "@mui/icons-material/MoreHorizOutlined";
import { type FC, useEffect, useRef, useState } from "react";
const spaceBetweenBlocks = 4;
const moreIconSize = 18;
const blockSize = 20;
type BlocksProps = {
count: number;
};
export const Blocks: FC<BlocksProps> = ({ count }) => {
const [availableWidth, setAvailableWidth] = useState<number>(0);
const blocksRef = useRef<HTMLDivElement>(null);
// Fix: When using useLayoutEffect, Chromatic fails to calculate the right width.
useEffect(() => {
if (availableWidth || !blocksRef.current) {
return;
}
setAvailableWidth(blocksRef.current.clientWidth);
}, [availableWidth]);
const totalSpaceBetweenBlocks = (count - 1) * spaceBetweenBlocks;
const necessarySize = blockSize * count + totalSpaceBetweenBlocks;
const hasSpacing = necessarySize <= availableWidth;
const nOfPossibleBlocks = Math.floor(
(availableWidth - moreIconSize) / (blockSize + spaceBetweenBlocks),
);
const nOfBlocks = hasSpacing ? count : nOfPossibleBlocks;
return (
<div ref={blocksRef} css={styles.blocks}>
{Array.from({ length: nOfBlocks }, (_, i) => i + 1).map((n) => (
<div key={n} css={styles.block} style={{ minWidth: blockSize }} />
))}
{!hasSpacing && (
<div css={styles.more}>
<MoreHorizOutlined />
</div>
)}
</div>
);
};
const styles = {
blocks: {
display: "flex",
width: "100%",
height: "100%",
gap: spaceBetweenBlocks,
alignItems: "center",
},
block: {
borderRadius: 4,
height: 18,
backgroundColor: "#082F49",
border: "1px solid #38BDF8",
flexShrink: 0,
flex: 1,
},
more: {
color: "#38BDF8",
lineHeight: 0,
flexShrink: 0,
flex: 1,
"& svg": {
fontSize: moreIconSize,
},
},
} satisfies Record<string, Interpolation<Theme>>;
@@ -0,0 +1,250 @@
import type { Interpolation, Theme } from "@emotion/react";
import ChevronRight from "@mui/icons-material/ChevronRight";
import {
SearchField,
type SearchFieldProps,
} from "components/SearchField/SearchField";
import type { FC, HTMLProps } from "react";
import React, { useEffect, useRef } from "react";
import type { BarColors } from "./Bar";
export const Chart = (props: HTMLProps<HTMLDivElement>) => {
return <div css={styles.chart} {...props} />;
};
export const ChartContent: FC<HTMLProps<HTMLDivElement>> = (props) => {
const contentRef = useRef<HTMLDivElement>(null);
// Display a scroll mask when the content is scrollable and update its
// position on scroll. Remove the mask when the scroll reaches the bottom to
// ensure the last item is visible.
useEffect(() => {
const contentEl = contentRef.current;
if (!contentEl) return;
const hasScroll = contentEl.scrollHeight > contentEl.clientHeight;
contentEl.style.setProperty("--scroll-mask-opacity", hasScroll ? "1" : "0");
const handler = () => {
if (!hasScroll) {
return;
}
contentEl.style.setProperty("--scroll-top", `${contentEl.scrollTop}px`);
const isBottom =
contentEl.scrollTop + contentEl.clientHeight >= contentEl.scrollHeight;
contentEl.style.setProperty(
"--scroll-mask-opacity",
isBottom ? "0" : "1",
);
};
contentEl.addEventListener("scroll", handler);
return () => contentEl.removeEventListener("scroll", handler);
}, []);
return <div css={styles.content} {...props} ref={contentRef} />;
};
export const ChartToolbar = (props: HTMLProps<HTMLDivElement>) => {
return <div css={styles.toolbar} {...props} />;
};
type ChartBreadcrumb = {
label: string;
onClick?: () => void;
};
type ChartBreadcrumbsProps = {
breadcrumbs: ChartBreadcrumb[];
};
export const ChartBreadcrumbs: FC<ChartBreadcrumbsProps> = ({
breadcrumbs,
}) => {
return (
<ul css={styles.breadcrumbs}>
{breadcrumbs.map((b, i) => {
const isLast = i === breadcrumbs.length - 1;
return (
<React.Fragment key={b.label}>
<li>
{isLast ? (
b.label
) : (
<button
type="button"
css={styles.breadcrumbButton}
onClick={b.onClick}
>
{b.label}
</button>
)}
</li>
{!isLast && (
<li role="presentation">
<ChevronRight />
</li>
)}
</React.Fragment>
);
})}
</ul>
);
};
export const ChartSearch = (props: SearchFieldProps) => {
return <SearchField css={styles.searchField} {...props} />;
};
export type ChartLegend = {
label: string;
colors?: BarColors;
};
type ChartLegendsProps = {
legends: ChartLegend[];
};
export const ChartLegends: FC<ChartLegendsProps> = ({ legends }) => {
return (
<ul css={styles.legends}>
{legends.map((l) => (
<li key={l.label} css={styles.legend}>
<div
css={[
styles.legendSquare,
{
borderColor: l.colors?.stroke,
backgroundColor: l.colors?.fill,
},
]}
/>
{l.label}
</li>
))}
</ul>
);
};
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<string, Interpolation<Theme>>;
@@ -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<TooltipProps> = (props) => {
const theme = useTheme();
return (
<MUITooltip
classes={{
tooltip: css(styles.tooltip(theme)),
...props.classes,
}}
{...props}
/>
);
};
export const TooltipTitle: FC<HTMLProps<HTMLSpanElement>> = (props) => {
return <span css={styles.title} {...props} />;
};
export const TooltipShortDescription: FC<HTMLProps<HTMLSpanElement>> = (
props,
) => {
return <span css={styles.shortDesc} {...props} />;
};
export const TooltipLink: FC<LinkProps> = (props) => {
return (
<Link {...props} css={styles.link}>
<OpenInNewOutlined />
{props.children}
</Link>
);
};
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<string, Interpolation<Theme>>;
@@ -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<HTMLDivElement> & {
ticks: number[];
scale: number;
};
export const XAxis: FC<XAxisProps> = ({ ticks, scale, ...htmlProps }) => {
const rootRef = useRef<HTMLDivElement>(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 (
<div css={styles.root} {...htmlProps} ref={rootRef}>
<XAxisLabels>
{ticks.map((tick) => (
<XAxisLabel key={tick}>{formatTime(tick)}</XAxisLabel>
))}
</XAxisLabels>
{htmlProps.children}
<XGrid columns={ticks.length} />
</div>
);
};
export const XAxisLabels: FC<HTMLProps<HTMLUListElement>> = (props) => {
return <ul css={styles.labels} {...props} />;
};
export const XAxisLabel: FC<HTMLProps<HTMLLIElement>> = (props) => {
return (
<li
css={[
styles.label,
{
// To centralize the labels between columns, we need to:
// 1. Set the label width to twice the column width.
// 2. Shift the label to the left by half of the column width.
// Note: This adjustment is not applied to the first element,
// as the 0 label/value is not displayed in the chart.
width: "calc(var(--x-axis-width) * 2)",
"&:not(:first-child)": {
marginLeft: "calc(-1 * var(--x-axis-width))",
},
},
]}
{...props}
/>
);
};
export const XAxisSection: FC<HTMLProps<HTMLDivElement>> = (props) => {
return <section css={styles.section} {...props} />;
};
type XAxisRowProps = HTMLProps<HTMLDivElement> & {
yAxisLabelId: string;
};
export const XAxisRow: FC<XAxisRowProps> = ({ 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<HTMLSpanElement>(selector);
if (!yAxisLabel) {
console.warn(`Y-axis label with selector ${selector} not found.`);
return;
}
yAxisLabel.style.height = `${rowEl.clientHeight}px`;
};
return (
<div
css={styles.row}
{...htmlProps}
aria-labelledby={yAxisLabelId}
ref={syncYAxisLabelHeightToXAxisRow}
/>
);
};
type XGridProps = HTMLProps<HTMLDivElement> & {
columns: number;
};
export const XGrid: FC<XGridProps> = ({ columns, ...htmlProps }) => {
return (
<div css={styles.grid} role="presentation" {...htmlProps}>
{[...Array(columns).keys()].map((key) => (
<div
key={key}
css={[styles.column, { width: "var(--x-axis-width)" }]}
/>
))}
</div>
);
};
// 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) => `<svg width="2" height="446" viewBox="0 0 2 446" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.75 440.932L1.75 446L0.75 446L0.75 440.932L1.75 440.932ZM1.75 420.659L1.75 430.795L0.749999 430.795L0.749999 420.659L1.75 420.659ZM1.75 400.386L1.75 410.523L0.749998 410.523L0.749998 400.386L1.75 400.386ZM1.75 380.114L1.75 390.25L0.749998 390.25L0.749997 380.114L1.75 380.114ZM1.75 359.841L1.75 369.977L0.749997 369.977L0.749996 359.841L1.75 359.841ZM1.75 339.568L1.75 349.705L0.749996 349.705L0.749995 339.568L1.75 339.568ZM1.74999 319.295L1.74999 329.432L0.749995 329.432L0.749994 319.295L1.74999 319.295ZM1.74999 299.023L1.74999 309.159L0.749994 309.159L0.749994 299.023L1.74999 299.023ZM1.74999 278.75L1.74999 288.886L0.749993 288.886L0.749993 278.75L1.74999 278.75ZM1.74999 258.477L1.74999 268.614L0.749992 268.614L0.749992 258.477L1.74999 258.477ZM1.74999 238.204L1.74999 248.341L0.749991 248.341L0.749991 238.204L1.74999 238.204ZM1.74999 217.932L1.74999 228.068L0.74999 228.068L0.74999 217.932L1.74999 217.932ZM1.74999 197.659L1.74999 207.795L0.74999 207.795L0.749989 197.659L1.74999 197.659ZM1.74999 177.386L1.74999 187.523L0.749989 187.523L0.749988 177.386L1.74999 177.386ZM1.74999 157.114L1.74999 167.25L0.749988 167.25L0.749987 157.114L1.74999 157.114ZM1.74999 136.841L1.74999 146.977L0.749987 146.977L0.749986 136.841L1.74999 136.841ZM1.74999 116.568L1.74999 126.705L0.749986 126.705L0.749986 116.568L1.74999 116.568ZM1.74998 96.2955L1.74999 106.432L0.749985 106.432L0.749985 96.2955L1.74998 96.2955ZM1.74998 76.0228L1.74998 86.1591L0.749984 86.1591L0.749984 76.0228L1.74998 76.0228ZM1.74998 55.7501L1.74998 65.8864L0.749983 65.8864L0.749983 55.7501L1.74998 55.7501ZM1.74998 35.4774L1.74998 45.6137L0.749982 45.6137L0.749982 35.4774L1.74998 35.4774ZM1.74998 15.2047L1.74998 25.341L0.749982 25.341L0.749981 15.2047L1.74998 15.2047ZM1.74998 -4.37114e-08L1.74998 5.0683L0.749981 5.0683L0.749981 0L1.74998 -4.37114e-08Z" fill="${color}"/>
</svg>`;
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<string, Interpolation<Theme>>;
@@ -0,0 +1,77 @@
import type { Interpolation, Theme } from "@emotion/react";
import type { FC, HTMLProps } from "react";
export const YAxis: FC<HTMLProps<HTMLDivElement>> = (props) => {
return <div css={styles.root} {...props} />;
};
export const YAxisSection: FC<HTMLProps<HTMLDivElement>> = (props) => {
return <section {...props} css={styles.section} />;
};
export const YAxisHeader: FC<HTMLProps<HTMLSpanElement>> = (props) => {
return <header css={styles.header} {...props} />;
};
export const YAxisLabels: FC<HTMLProps<HTMLUListElement>> = (props) => {
return <ul css={styles.labels} {...props} />;
};
type YAxisLabelProps = Omit<HTMLProps<HTMLLIElement>, "id"> & {
id: string;
};
export const YAxisLabel: FC<YAxisLabelProps> = ({ id, ...props }) => {
return (
<li {...props} css={styles.label} id={encodeURIComponent(id)}>
<span>{props.children}</span>
</li>
);
};
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<string, Interpolation<Theme>>;
@@ -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();
};
@@ -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<string, ChartLegend> = {
"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<ResourcesChartProps> = ({
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 (
<Chart>
<ChartToolbar>
<ChartBreadcrumbs
breadcrumbs={[
{
label: category.name,
onClick: onBack,
},
{
label: stage,
},
]}
/>
<ChartSearch
placeholder="Filter results..."
value={filter}
onChange={setFilter}
/>
<ChartLegends legends={visibleLegends} />
</ChartToolbar>
<ChartContent>
<YAxis>
<YAxisSection>
<YAxisHeader>{stage} stage</YAxisHeader>
<YAxisLabels>
{visibleTimings.map((t) => (
<YAxisLabel key={t.name} id={encodeURIComponent(t.name)}>
{t.name}
</YAxisLabel>
))}
</YAxisLabels>
</YAxisSection>
</YAxis>
<XAxis ticks={ticks} scale={scale}>
<XAxisSection>
{visibleTimings.map((t) => {
const duration = calcDuration(t.range);
return (
<XAxisRow
key={t.name}
yAxisLabelId={encodeURIComponent(t.name)}
>
<Tooltip
title={
<>
<TooltipTitle>{t.name}</TooltipTitle>
<TooltipLink to="">view template</TooltipLink>
</>
}
>
<Bar
value={duration}
offset={calcOffset(t.range, generalTiming)}
scale={scale}
colors={legendsByAction[t.action].colors}
/>
</Tooltip>
{formatTime(duration)}
</XAxisRow>
);
})}
</XAxisSection>
</XAxis>
</ChartContent>
</Chart>
);
};
export const isCoderResource = (resource: string) => {
return (
resource.startsWith("data.coder") ||
resource.startsWith("module.coder") ||
resource.startsWith("coder_")
);
};
@@ -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<string, ChartLegend> = {
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<ScriptsChartProps> = ({
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 (
<Chart>
<ChartToolbar>
<ChartBreadcrumbs
breadcrumbs={[
{
label: category.name,
onClick: onBack,
},
{
label: stage,
},
]}
/>
<ChartSearch
placeholder="Filter results..."
value={filter}
onChange={setFilter}
/>
<ChartLegends legends={visibleLegends} />
</ChartToolbar>
<ChartContent>
<YAxis>
<YAxisSection>
<YAxisHeader>{stage} stage</YAxisHeader>
<YAxisLabels>
{visibleTimings.map((t) => (
<YAxisLabel key={t.name} id={encodeURIComponent(t.name)}>
{t.name}
</YAxisLabel>
))}
</YAxisLabels>
</YAxisSection>
</YAxis>
<XAxis ticks={ticks} scale={scale}>
<XAxisSection>
{visibleTimings.map((t) => {
const duration = calcDuration(t.range);
return (
<XAxisRow
key={t.name}
yAxisLabelId={encodeURIComponent(t.name)}
>
<Tooltip
title={
<TooltipTitle>
Script exited with <strong>code {t.exitCode}</strong>
</TooltipTitle>
}
>
<Bar
value={duration}
offset={calcOffset(t.range, generalTiming)}
scale={scale}
colors={legendsByStatus[t.status].colors}
/>
</Tooltip>
{formatTime(duration)}
</XAxisRow>
);
})}
</XAxisSection>
</XAxis>
</ChartContent>
</Chart>
);
};
@@ -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<TooltipProps, "children">;
};
export const stages: Stage[] = [
{
name: "init",
categoryID: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform initialization</TooltipTitle>
<TooltipShortDescription>
Download providers & modules.
</TooltipShortDescription>
</>
),
},
},
{
name: "plan",
categoryID: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform plan</TooltipTitle>
<TooltipShortDescription>
Compare state of desired vs actual resources and compute changes to
be made.
</TooltipShortDescription>
</>
),
},
},
{
name: "graph",
categoryID: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform graph</TooltipTitle>
<TooltipShortDescription>
List all resources in plan, used to update coderd database.
</TooltipShortDescription>
</>
),
},
},
{
name: "apply",
categoryID: "provisioning",
tooltip: {
title: (
<>
<TooltipTitle>Terraform apply</TooltipTitle>
<TooltipShortDescription>
Execute terraform plan to create/modify/delete resources into
desired states.
</TooltipShortDescription>
</>
),
},
},
{
name: "start",
categoryID: "workspaceBoot",
tooltip: {
title: (
<>
<TooltipTitle>Start</TooltipTitle>
<TooltipShortDescription>
Scripts executed when the agent is starting.
</TooltipShortDescription>
</>
),
},
},
];
type StageTiming = {
name: string;
/**
/**
* 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<StagesChartProps> = ({
timings,
onSelectStage,
}) => {
const totalRange = mergeTimeRanges(
timings.map((t) => t.range).filter((t) => t !== undefined),
);
const totalTime = calcDuration(totalRange);
const [ticks, scale] = makeTicks(totalTime);
return (
<Chart>
<ChartContent>
<YAxis>
{stageCategories.map((c) => {
const stagesInCategory = stages.filter(
(s) => s.categoryID === c.id,
);
return (
<YAxisSection key={c.id}>
<YAxisHeader>{c.name}</YAxisHeader>
<YAxisLabels>
{stagesInCategory.map((stage) => (
<YAxisLabel
key={stage.name}
id={encodeURIComponent(stage.name)}
>
<span css={styles.stageLabel}>
{stage.name}
<Tooltip {...stage.tooltip}>
<InfoOutlined css={styles.info} />
</Tooltip>
</span>
</YAxisLabel>
))}
</YAxisLabels>
</YAxisSection>
);
})}
</YAxis>
<XAxis ticks={ticks} scale={scale}>
{stageCategories.map((category) => {
const stageTimings = timings.filter(
(t) => t.categoryID === category.id,
);
return (
<XAxisSection key={category.id}>
{stageTimings.map((t) => {
// If the stage has no timing data, we just want to render an empty row
if (t.range === undefined) {
return (
<XAxisRow
key={t.name}
yAxisLabelId={encodeURIComponent(t.name)}
/>
);
}
const value = calcDuration(t.range);
const offset = calcOffset(t.range, totalRange);
return (
<XAxisRow
key={t.name}
yAxisLabelId={encodeURIComponent(t.name)}
>
{/** We only want to expand stages with more than one resource */}
{t.visibleResources > 1 ? (
<ClickableBar
aria-label={`View ${t.name} details`}
scale={scale}
value={value}
offset={offset}
onClick={() => {
onSelectStage(t, category);
}}
>
{t.error && (
<ErrorSharp
css={{
fontSize: 18,
color: "#F87171",
marginRight: 4,
}}
/>
)}
<Blocks count={t.visibleResources} />
</ClickableBar>
) : (
<Bar scale={scale} value={value} offset={offset} />
)}
{formatTime(calcDuration(t.range))}
</XAxisRow>
);
})}
</XAxisSection>
);
})}
</XAxis>
</ChartContent>
</Chart>
);
};
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<string, Interpolation<Theme>>;
@@ -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<typeof WorkspaceTimings> = {
title: "modules/workspaces/WorkspaceTimings",
component: WorkspaceTimings,
args: {
defaultIsOpen: true,
provisionerTimings: WorkspaceTimingsResponse.provisioner_timings,
agentScriptTimings: WorkspaceTimingsResponse.agent_script_timings,
},
};
export default meta;
type Story = StoryObj<typeof WorkspaceTimings>;
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");
},
};
@@ -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<WorkspaceTimingsProps> = ({
provisionerTimings = [],
agentScriptTimings = [],
defaultIsOpen = false,
}) => {
const [view, setView] = useState<TimingView>({ 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 (
<div css={styles.collapse}>
<Button
disabled={isLoading}
variant="text"
css={styles.collapseTrigger}
onClick={() => setIsOpen((o) => !o)}
>
{isOpen ? (
<KeyboardArrowUp css={{ fontSize: 16, marginRight: 16 }} />
) : (
<KeyboardArrowDown css={{ fontSize: 16, marginRight: 16 }} />
)}
<span>Provisioning time</span>
<span
css={(theme) => ({
marginLeft: "auto",
color: theme.palette.text.secondary,
})}
>
{isLoading ? (
<Skeleton variant="text" width={40} height={16} />
) : (
displayProvisioningTime()
)}
</span>
</Button>
{!isLoading && (
<Collapse in={isOpen}>
<div css={styles.collapseBody}>
{view.name === "default" && (
<StagesChart
timings={stages.map((s) => {
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" && (
<ResourcesChart
timings={provisionerTimings
.filter((t) => 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" && (
<ScriptsChart
timings={agentScriptTimings
.filter((t) => t.stage === view.stage)
.map((t) => {
return {
range: extractRange(t),
name: t.display_name,
status: t.status,
exitCode: t.exit_code,
};
})}
category={view.category}
stage={view.stage}
onBack={() => {
setView({ name: "default" });
}}
/>
)}
</div>
</Collapse>
)}
</div>
);
};
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<string, Interpolation<Theme>>;
@@ -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",
},
],
};
@@ -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<WorkspaceProps> = ({
latestVersion,
permissions,
isOwner,
timings,
}) => {
const navigate = useNavigate();
const theme = useTheme();
@@ -262,6 +265,11 @@ export const Workspace: FC<WorkspaceProps> = ({
)}
</section>
)}
<WorkspaceTimings
agentScriptTimings={timings?.agent_script_timings}
provisionerTimings={timings?.provisioner_timings}
/>
</div>
</div>
</div>
@@ -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<WorkspaceReadyPageProps> = ({
// 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<WorkspaceReadyPageProps> = ({
)
}
isOwner={isOwner}
timings={timingsQuery.data}
/>
<WorkspaceDeleteDialog