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..2ab553b1637fb --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx @@ -0,0 +1,92 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import { type ButtonHTMLAttributes, type HTMLProps, forwardRef } from "react"; +import { CSSVars } from "./constants"; + +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: { + 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: YAxisWidth, + padding: YAxisSidePadding, + 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: YAxisSidePadding, + }, + 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/XAxis.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx new file mode 100644 index 0000000000000..006abb8ae45bf --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx @@ -0,0 +1,198 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import { type FC, type HTMLProps, useLayoutEffect, useRef } from "react"; +import { YAxisCaptionHeight } from "./YAxis"; +import { CSSVars, XAxisLabelsHeight, XAxisRowsGap } from "./constants"; +import { formatTime } from "./utils"; + +export const XAxisMinWidth = 130; +export const XAxisSidePadding = 16; + +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); + avgWidth > XAxisMinWidth ? avgWidth : XAxisMinWidth; + rootEl.style.setProperty(CSSVars.xAxisWidth, `${avgWidth}px`); + }, [ticks]); + + return ( +
    + + {ticks.map((tick) => ( + {formatTime(tick)} + ))} + + {htmlProps.children} + +
    + ); +}; + +export const XAxisLabels: FC> = (props) => { + return
      ; +}; + +export const XAxisLabel: FC> = (props) => { + return ( +
    • + ); +}; + +export const XAxisSections: FC> = (props) => { + return
      ; +}; + +export const XAxisRows: 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: XAxisLabelsHeight, + padding: 0, + minWidth: "100%", + flexShrink: 0, + position: "sticky", + top: 0, + zIndex: 1, + backgroundColor: theme.palette.background.default, + }), + label: (theme) => ({ + display: "flex", + justifyContent: "center", + flexShrink: 0, + color: theme.palette.text.secondary, + }), + sections: { + flex: 1, + }, + rows: { + display: "flex", + flexDirection: "column", + gap: XAxisRowsGap, + padding: `${YAxisCaptionHeight}px ${XAxisSidePadding}px`, + }, + row: { + display: "flex", + alignItems: "center", + width: "fit-content", + gap: 8, + cursor: "pointer", + }, + grid: { + display: "flex", + width: "100%", + height: "100%", + position: "absolute", + top: 0, + left: 0, + zIndex: -1, + }, + 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..5b2af3c2c4f82 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx @@ -0,0 +1,75 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import type { FC, HTMLProps } from "react"; +import { XAxisLabelsHeight, XAxisRowsGap } from "./constants"; + +// Predicting the caption height is necessary to add appropriate spacing to the +// grouped bars, ensuring alignment with the sidebar labels. +export const YAxisCaptionHeight = 20; +export const YAxisWidth = 200; +export const YAxisSidePadding = 16; + +export const YAxis: FC> = (props) => { + return
      ; +}; + +export const YAxisSection: FC> = (props) => { + return
      ; +}; + +export const YAxisCaption: 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: YAxisWidth, + flexShrink: 0, + padding: YAxisSidePadding, + paddingTop: XAxisLabelsHeight, + }, + caption: (theme) => ({ + height: YAxisCaptionHeight, + display: "flex", + alignItems: "center", + fontSize: 10, + fontWeight: 500, + color: theme.palette.text.secondary, + }), + labels: { + margin: 0, + padding: 0, + listStyle: "none", + display: "flex", + flexDirection: "column", + gap: XAxisRowsGap, + textAlign: "right", + }, + 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/constants.ts b/site/src/modules/workspaces/WorkspaceTiming/Chart/constants.ts new file mode 100644 index 0000000000000..9db603c848d50 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/constants.ts @@ -0,0 +1,6 @@ +// Constants that are used across the Chart components +export const XAxisLabelsHeight = 40; +export const XAxisRowsGap = 20; +export const CSSVars = { + xAxisWidth: "--x-axis-width", +}; 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..00485390257fd --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts @@ -0,0 +1,63 @@ +export type BaseTiming = { + startedAt: Date; + endedAt: Date; +}; + +export const combineTimings = (timings: BaseTiming[]): BaseTiming => { + // If there are no timings, return a timing with the same start and end + // times. This prevents the chart from breaking when calculating the start and + // end times from an empty array. + if (timings.length === 0) { + return { startedAt: new Date(), endedAt: new Date() }; + } + + const sortedDurations = timings + .slice() + .sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime()); + const start = sortedDurations[0].startedAt; + + const sortedEndDurations = timings + .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 = (timing: BaseTiming): number => { + return timing.endedAt.getTime() - timing.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 = [100, 500, 5_000]; + +const pickScale = (totalTime: number): number => { + const reversedScales = scales.slice().reverse(); + for (const s of reversedScales) { + if (totalTime > s) { + return s; + } + } + return reversedScales[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 = ( + timing: BaseTiming, + generalTiming: BaseTiming, +): number => { + return timing.startedAt.getTime() - generalTiming.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..4884ac1dbe7c2 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx @@ -0,0 +1,231 @@ +import { css } from "@emotion/css"; +import { type Interpolation, type Theme, useTheme } from "@emotion/react"; +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +import Tooltip, { type TooltipProps } from "@mui/material/Tooltip"; +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 { XAxis, XAxisRow, XAxisRows, XAxisSections } from "./Chart/XAxis"; +import { + YAxis, + YAxisCaption, + YAxisLabel, + YAxisLabels, + YAxisSection, +} from "./Chart/YAxis"; +import { + type BaseTiming, + calcDuration, + calcOffset, + combineTimings, + formatTime, + makeTicks, +} from "./Chart/utils"; + +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 = BaseTiming & { + name: string; + source: string; + action: string; +}; + +export type ResourcesChartProps = { + category: string; + stage: string; + timings: ResourceTiming[]; + onBack: () => void; +}; + +export const ResourcesChart: FC = ({ + category, + stage, + timings, + onBack, +}) => { + const generalTiming = combineTimings(timings); + 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) => { + return ( + + + + + {formatTime(calcDuration(t))} + + ); + })} + + + + + + ); +}; + +const isCoderResource = (resource: string) => { + return ( + resource.startsWith("data.coder") || + resource.startsWith("module.coder") || + resource.startsWith("coder_") + ); +}; + +type ResourceTooltipProps = Omit & { + timing: ResourceTiming; +}; + +const ResourceTooltip: FC = ({ timing, ...props }) => { + const theme = useTheme(); + + return ( + + {timing.source} + {timing.name} + + + view template + +
      + } + /> + ); +}; + +const styles = { + tooltipTitle: (theme) => ({ + display: "flex", + flexDirection: "column", + fontWeight: 500, + fontSize: 12, + color: theme.palette.text.secondary, + }), + tooltipResource: (theme) => ({ + color: theme.palette.text.primary, + fontWeight: 600, + marginTop: 4, + display: "block", + maxWidth: "100%", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }), + tooltipLink: (theme) => ({ + color: "inherit", + textDecoration: "none", + display: "flex", + alignItems: "center", + gap: 4, + marginTop: 8, + + "&:hover": { + color: theme.palette.text.primary, + }, + + "& svg": { + width: 12, + height: 12, + }, + }), +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx new file mode 100644 index 0000000000000..c4ebcceb3c1f7 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx @@ -0,0 +1,245 @@ +import { css } from "@emotion/css"; +import { type Interpolation, type Theme, useTheme } from "@emotion/react"; +import InfoOutlined from "@mui/icons-material/InfoOutlined"; +import Tooltip, { type TooltipProps } from "@mui/material/Tooltip"; +import type { FC, PropsWithChildren } from "react"; +import { Bar, ClickableBar } from "./Chart/Bar"; +import { BarBlocks } from "./Chart/BarBlocks"; +import { Chart, ChartContent } from "./Chart/Chart"; +import { + XAxis, + XAxisMinWidth, + XAxisRow, + XAxisRows, + XAxisSections, +} from "./Chart/XAxis"; +import { + YAxis, + YAxisCaption, + YAxisLabel, + YAxisLabels, + YAxisSection, +} from "./Chart/YAxis"; +import { + type BaseTiming, + calcDuration, + calcOffset, + combineTimings, + formatTime, + makeTicks, +} from "./Chart/utils"; + +// TODO: Add "workspace boot" when scripting timings are done. +const stageCategories = ["provisioning"] as const; + +type StageCategory = (typeof stageCategories)[number]; + +type Stage = { + name: string; + category: StageCategory; + tooltip: { title: string; description: string }; +}; + +// TODO: Export provisioning stages from the BE to the generated types. +export const stages: Stage[] = [ + { + name: "init", + category: "provisioning", + tooltip: { + title: "Terraform initialization", + description: "Download providers & modules.", + }, + }, + { + name: "plan", + category: "provisioning", + tooltip: { + title: "Terraform plan", + description: + "Compare state of desired vs actual resources and compute changes to be made.", + }, + }, + { + name: "graph", + category: "provisioning", + tooltip: { + title: "Terraform graph", + description: + "List all resources in plan, used to update coderd database.", + }, + }, + { + name: "apply", + category: "provisioning", + tooltip: { + title: "Terraform apply", + description: + "Execute terraform plan to create/modify/delete resources into desired states.", + }, + }, +]; + +type StageTiming = BaseTiming & { + name: string; + /** + * Represents the number of resources included in this stage. This value is + * used to display individual blocks within the bar, indicating that the stage + * consists of multiple resource time blocks. + */ + resources: 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. + */ + category: StageCategory; +}; + +export type StagesChartProps = { + timings: StageTiming[]; + onSelectStage: (timing: StageTiming, category: StageCategory) => void; +}; + +export const StagesChart: FC = ({ + timings, + onSelectStage, +}) => { + const generalTiming = combineTimings(timings); + const totalTime = calcDuration(generalTiming); + const [ticks, scale] = makeTicks(totalTime); + + return ( + + + + {stageCategories.map((c) => { + const stagesInCategory = stages.filter((s) => s.category === c); + + return ( + + {c} + + {stagesInCategory.map((stage) => ( + + + {stage.name} + + + + + + ))} + + + ); + })} + + + + + {stageCategories.map((category) => { + const timingsInCategory = timings.filter( + (t) => t.category === category, + ); + return ( + + {timingsInCategory.map((t) => { + const value = calcDuration(t); + const offset = calcOffset(t, generalTiming); + + return ( + + {/** We only want to expand stages with more than one resource */} + {t.resources > 1 ? ( + { + onSelectStage(t, category); + }} + > + + + ) : ( + + )} + {formatTime(calcDuration(t))} + + ); + })} + + ); + })} + + + + + ); +}; + +type StageInfoTooltipProps = TooltipProps & { + title: string; + description: string; +}; + +const StageInfoTooltip: FC = ({ + title, + description, + children, +}) => { + const theme = useTheme(); + + return ( + + {title} + {description} +
      + } + > + {children} + + ); +}; + +const styles = { + stageLabel: { + display: "flex", + alignItems: "center", + gap: 2, + justifyContent: "flex-end", + }, + info: (theme) => ({ + width: 12, + height: 12, + color: theme.palette.text.secondary, + cursor: "pointer", + }), + tooltipTitle: (theme) => ({ + display: "flex", + flexDirection: "column", + fontWeight: 500, + fontSize: 12, + color: theme.palette.text.secondary, + gap: 4, + }), + infoStageName: (theme) => ({ + color: theme.palette.text.primary, + }), +} 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..ec52d6e91fbdf --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { WorkspaceTimings } from "./WorkspaceTimings"; +import { WorkspaceTimingsResponse } from "./storybookData"; + +const meta: Meta = { + title: "modules/workspaces/WorkspaceTimings", + component: WorkspaceTimings, + args: { + provisionerTimings: WorkspaceTimingsResponse.provisioner_timings, + }, + decorators: [ + (Story) => { + return ( +
      ({ + borderRadius: 8, + border: `1px solid ${theme.palette.divider}`, + width: 1200, + height: 420, + overflow: "auto", + })} + > + +
      + ); + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx new file mode 100644 index 0000000000000..8178db0aaacbb --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -0,0 +1,88 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import type { ProvisionerTiming } from "api/typesGenerated"; +import { type FC, useState } from "react"; +import { type BaseTiming, combineTimings } from "./Chart/utils"; +import { ResourcesChart } from "./ResourcesChart"; +import { StagesChart, stages } from "./StagesChart"; + +type TimingView = + | { name: "stages" } + | { + name: "resources"; + stage: string; + category: string; + filter: string; + }; + +type WorkspaceTimingsProps = { + provisionerTimings: readonly ProvisionerTiming[]; +}; + +export const WorkspaceTimings: FC = ({ + provisionerTimings, +}) => { + const [view, setView] = useState({ name: "stages" }); + + return ( +
      + {view.name === "stages" && ( + { + const stageTimings = provisionerTimings.filter( + (t) => t.stage === s.name, + ); + const combinedStageTiming = combineTimings( + stageTimings.map(provisionerToBaseTiming), + ); + return { + ...combinedStageTiming, + name: s.name, + category: s.category, + resources: stageTimings.length, + }; + })} + onSelectStage={(t, category) => { + setView({ name: "resources", stage: t.name, category, filter: "" }); + }} + /> + )} + + {view.name === "resources" && ( + t.stage === view.stage) + .map((t) => { + return { + ...provisionerToBaseTiming(t), + name: t.resource, + source: t.source, + action: t.action, + }; + })} + category={view.category} + stage={view.stage} + onBack={() => { + setView({ name: "stages" }); + }} + /> + )} +
      + ); +}; + +const provisionerToBaseTiming = ( + provisioner: ProvisionerTiming, +): BaseTiming => { + return { + startedAt: new Date(provisioner.started_at), + endedAt: new Date(provisioner.ended_at), + }; +}; + +const styles = { + panelBody: { + display: "flex", + flexDirection: "column", + height: "100%", + }, +} 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..66410af65d339 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/storybookData.ts @@ -0,0 +1,305 @@ +import type { WorkspaceTimings } from "api/typesGenerated"; + +export const WorkspaceTimingsResponse: WorkspaceTimings = { + provisioner_timings: [ + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:42.973852Z", + ended_at: "2024-09-17T11:30:54.242279Z", + stage: "init", + source: "terraform", + action: "initializing terraform", + resource: "state file", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.692398Z", + ended_at: "2024-09-17T11:30:54.978615Z", + stage: "plan", + source: "http", + action: "read", + resource: + 'module.jetbrains_gateway.data.http.jetbrains_ide_versions["GO"]', + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.701523Z", + ended_at: "2024-09-17T11:30:54.713539Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_workspace_owner.me", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.703545Z", + ended_at: "2024-09-17T11:30:54.712092Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_parameter.repo_base_dir", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.703799Z", + ended_at: "2024-09-17T11:30:54.714985Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.dotfiles.data.coder_parameter.dotfiles_uri[0]", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.703996Z", + ended_at: "2024-09-17T11:30:54.714505Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.coder-login.data.coder_workspace.me", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.70412Z", + ended_at: "2024-09-17T11:30:54.713716Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.coder-login.data.coder_workspace_owner.me", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.704179Z", + ended_at: "2024-09-17T11:30:54.715129Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_parameter.image_type", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.704374Z", + ended_at: "2024-09-17T11:30:54.710183Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_parameter.region", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.708087Z", + ended_at: "2024-09-17T11:30:54.981356Z", + stage: "plan", + source: "http", + action: "read", + resource: + 'module.jetbrains_gateway.data.http.jetbrains_ide_versions["WS"]', + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.714307Z", + ended_at: "2024-09-17T11:30:54.719983Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.jetbrains_gateway.data.coder_workspace.me", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.714563Z", + ended_at: "2024-09-17T11:30:54.718415Z", + stage: "plan", + source: "coder", + action: "read", + resource: "module.jetbrains_gateway.data.coder_parameter.jetbrains_ide", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.714664Z", + ended_at: "2024-09-17T11:30:54.718406Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_external_auth.github", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.714805Z", + ended_at: "2024-09-17T11:30:54.716919Z", + stage: "plan", + source: "coder", + action: "read", + resource: "data.coder_workspace.me", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.770341Z", + ended_at: "2024-09-17T11:30:54.773556Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "coder_agent.dev", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.790322Z", + ended_at: "2024-09-17T11:30:54.800107Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.personalize.coder_script.personalize", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.790805Z", + ended_at: "2024-09-17T11:30:54.798414Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.git-clone.coder_script.git_clone", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.790949Z", + ended_at: "2024-09-17T11:30:54.797751Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.slackme.coder_script.install_slackme", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.791221Z", + ended_at: "2024-09-17T11:30:54.793362Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.dotfiles.coder_script.dotfiles", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.792818Z", + ended_at: "2024-09-17T11:30:54.797757Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.code-server.coder_script.code-server", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.797364Z", + ended_at: "2024-09-17T11:30:54.799849Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.code-server.coder_app.code-server", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.79755Z", + ended_at: "2024-09-17T11:30:54.8023Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.coder-login.coder_script.coder-login", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.811595Z", + ended_at: "2024-09-17T11:30:54.815418Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.filebrowser.coder_script.filebrowser", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.812057Z", + ended_at: "2024-09-17T11:30:54.814969Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.filebrowser.coder_app.filebrowser", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:54.987669Z", + ended_at: "2024-09-17T11:30:54.988669Z", + stage: "plan", + source: "coder", + action: "state refresh", + resource: "module.jetbrains_gateway.coder_app.gateway", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:55.206842Z", + ended_at: "2024-09-17T11:30:55.593171Z", + stage: "plan", + source: "docker", + action: "read", + resource: "data.docker_registry_image.dogfood", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:55.207764Z", + ended_at: "2024-09-17T11:30:55.488281Z", + stage: "plan", + source: "docker", + action: "state refresh", + resource: "docker_volume.home_volume", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:55.59492Z", + ended_at: "2024-09-17T11:30:56.370447Z", + stage: "plan", + source: "docker", + action: "state refresh", + resource: "docker_image.dogfood", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:56.433324Z", + ended_at: "2024-09-17T11:30:56.976514Z", + stage: "graph", + source: "terraform", + action: "building terraform dependency graph", + resource: "state file", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:57.386136Z", + ended_at: "2024-09-17T11:30:57.387345Z", + stage: "apply", + source: "coder", + action: "delete", + resource: "module.coder-login.coder_script.coder-login", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:57.400376Z", + ended_at: "2024-09-17T11:30:57.402341Z", + stage: "apply", + source: "coder", + action: "create", + resource: "module.coder-login.coder_script.coder-login", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:30:57.699094Z", + ended_at: "2024-09-17T11:31:00.097627Z", + stage: "apply", + source: "docker", + action: "create", + resource: "docker_container.workspace[0]", + }, + { + job_id: "438507fa-8c93-42f4-9091-d772877dbc2b", + started_at: "2024-09-17T11:31:00.113522Z", + ended_at: "2024-09-17T11:31:00.117077Z", + stage: "apply", + source: "coder", + action: "create", + resource: "coder_metadata.container_info[0]", + }, + ], +};