From 5268b1e1a135fa82fac7e4fdd16c6934bbf58203 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 19 Sep 2024 18:19:09 +0000 Subject: [PATCH 01/51] Add base components for the chart --- .../src/components/GanttChart/Bar.stories.tsx | 28 ++ site/src/components/GanttChart/Bar.tsx | 63 ++++ .../components/GanttChart/Label.stories.tsx | 33 ++ site/src/components/GanttChart/Label.tsx | 39 +++ .../components/GanttChart/XGrid.stories.tsx | 23 ++ site/src/components/GanttChart/XGrid.tsx | 41 +++ .../components/GanttChart/XValues.stories.tsx | 26 ++ site/src/components/GanttChart/XValues.tsx | 52 +++ .../WorkspaceTimingChart/TimingBlocks.tsx | 70 ++++ .../WorkspaceTimingChart.stories.tsx | 33 ++ .../WorkspaceTimingChart.tsx | 204 ++++++++++++ .../WorkspaceTimingChart/storybookData.ts | 305 ++++++++++++++++++ .../WorkspaceTimingChart/timings.test.ts | 43 +++ .../WorkspaceTimingChart/timings.ts | 65 ++++ 14 files changed, 1025 insertions(+) create mode 100644 site/src/components/GanttChart/Bar.stories.tsx create mode 100644 site/src/components/GanttChart/Bar.tsx create mode 100644 site/src/components/GanttChart/Label.stories.tsx create mode 100644 site/src/components/GanttChart/Label.tsx create mode 100644 site/src/components/GanttChart/XGrid.stories.tsx create mode 100644 site/src/components/GanttChart/XGrid.tsx create mode 100644 site/src/components/GanttChart/XValues.stories.tsx create mode 100644 site/src/components/GanttChart/XValues.tsx create mode 100644 site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx create mode 100644 site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.stories.tsx create mode 100644 site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx create mode 100644 site/src/modules/workspaces/WorkspaceTimingChart/storybookData.ts create mode 100644 site/src/modules/workspaces/WorkspaceTimingChart/timings.test.ts create mode 100644 site/src/modules/workspaces/WorkspaceTimingChart/timings.ts diff --git a/site/src/components/GanttChart/Bar.stories.tsx b/site/src/components/GanttChart/Bar.stories.tsx new file mode 100644 index 0000000000000..15689c563e470 --- /dev/null +++ b/site/src/components/GanttChart/Bar.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Bar } from "./Bar"; +import { Label } from "./Label"; + +const meta: Meta = { + title: "components/GanttChart/Bar", + component: Bar, + args: { + width: 136, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const AfterLabel: Story = { + args: { + afterLabel: , + }, +}; + +export const GreenColor: Story = { + args: { + color: "green", + }, +}; diff --git a/site/src/components/GanttChart/Bar.tsx b/site/src/components/GanttChart/Bar.tsx new file mode 100644 index 0000000000000..45c5d31b7d1bb --- /dev/null +++ b/site/src/components/GanttChart/Bar.tsx @@ -0,0 +1,63 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import { forwardRef, type HTMLProps, type ReactNode } from "react"; + +type BarColor = "default" | "green"; + +type BarProps = Omit, "size"> & { + width: number; + children?: ReactNode; + color?: BarColor; + /** + * Label to be displayed adjacent to the bar component. + */ + afterLabel?: ReactNode; + /** + * The X position of the bar component. + */ + x?: number; +}; + +export const Bar = forwardRef( + ( + { color = "default", width, afterLabel, children, x, ...htmlProps }, + ref, + ) => { + return ( +
+
{children}
+ {afterLabel} +
+ ); + }, +); + +const styles = { + root: { + // Stack children horizontally for adjacent labels + display: "flex", + alignItems: "center", + width: "fit-content", + gap: 8, + }, + bar: { + border: "1px solid transparent", + borderRadius: 8, + height: 32, + }, +} satisfies Record>; + +const colorStyles = { + default: (theme) => ({ + backgroundColor: theme.palette.background.default, + borderColor: theme.palette.divider, + }), + green: (theme) => ({ + backgroundColor: theme.roles.success.background, + borderColor: theme.roles.success.outline, + color: theme.roles.success.text, + }), +} satisfies Record>; diff --git a/site/src/components/GanttChart/Label.stories.tsx b/site/src/components/GanttChart/Label.stories.tsx new file mode 100644 index 0000000000000..4e54d138deb84 --- /dev/null +++ b/site/src/components/GanttChart/Label.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Label } from "./Label"; +import ErrorOutline from "@mui/icons-material/ErrorOutline"; + +const meta: Meta = { + title: "components/GanttChart/Label", + component: Label, + args: { + children: "5s", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const SecondaryColor: Story = { + args: { + color: "secondary", + }, +}; + +export const StartIcon: Story = { + args: { + children: ( + <> + + docker_value + + ), + }, +}; diff --git a/site/src/components/GanttChart/Label.tsx b/site/src/components/GanttChart/Label.tsx new file mode 100644 index 0000000000000..f1bb635a888c0 --- /dev/null +++ b/site/src/components/GanttChart/Label.tsx @@ -0,0 +1,39 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import type { FC, HTMLAttributes } from "react"; + +type LabelColor = "inherit" | "primary" | "secondary"; + +type LabelProps = HTMLAttributes & { + color?: LabelColor; +}; + +export const Label: FC = ({ color = "inherit", ...htmlProps }) => { + return ; +}; + +const styles = { + label: { + lineHeight: 1, + fontSize: 12, + fontWeight: 500, + display: "inline-flex", + alignItems: "center", + gap: 4, + + "& svg": { + fontSize: 12, + }, + }, +} satisfies Record>; + +const colorStyles = { + inherit: { + color: "inherit", + }, + primary: (theme) => ({ + color: theme.palette.text.primary, + }), + secondary: (theme) => ({ + color: theme.palette.text.secondary, + }), +} satisfies Record>; diff --git a/site/src/components/GanttChart/XGrid.stories.tsx b/site/src/components/GanttChart/XGrid.stories.tsx new file mode 100644 index 0000000000000..db759c30c802b --- /dev/null +++ b/site/src/components/GanttChart/XGrid.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { XGrid } from "./XGrid"; + +const meta: Meta = { + title: "components/GanttChart/XGrid", + component: XGrid, + args: { + columnWidth: 130, + columns: 10, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/components/GanttChart/XGrid.tsx b/site/src/components/GanttChart/XGrid.tsx new file mode 100644 index 0000000000000..ea494d5327d5f --- /dev/null +++ b/site/src/components/GanttChart/XGrid.tsx @@ -0,0 +1,41 @@ +import type { FC, HTMLProps } from "react"; +import type { Interpolation, Theme } from "@emotion/react"; + +type XGridProps = HTMLProps & { + columns: number; + columnWidth: number; +}; + +export const XGrid: FC = ({ + columns, + columnWidth, + ...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 = { + grid: { + display: "flex", + width: "100%", + height: "100%", + }, + 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/components/GanttChart/XValues.stories.tsx b/site/src/components/GanttChart/XValues.stories.tsx new file mode 100644 index 0000000000000..a15ab06ba1177 --- /dev/null +++ b/site/src/components/GanttChart/XValues.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { XValues } from "./XValues"; + +const meta: Meta = { + title: "components/GanttChart/XValues", + component: XValues, + args: { + columnWidth: 130, + values: [ + "00:00:05", + "00:00:10", + "00:00:15", + "00:00:20", + "00:00:25", + "00:00:30", + "00:00:35", + "00:00:40", + "00:00:45", + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/site/src/components/GanttChart/XValues.tsx b/site/src/components/GanttChart/XValues.tsx new file mode 100644 index 0000000000000..ac5f97f9eb1c6 --- /dev/null +++ b/site/src/components/GanttChart/XValues.tsx @@ -0,0 +1,52 @@ +import type { FC, HTMLProps } from "react"; +import { Label } from "./Label"; +import type { Interpolation, Theme } from "@emotion/react"; + +type XValuesProps = HTMLProps & { + values: string[]; + columnWidth: number; +}; + +export const XValues: FC = ({ + values, + columnWidth, + ...htmlProps +}) => { + return ( +
+ {values.map((v) => ( +
+ +
+ ))} +
+ ); +}; + +const styles = { + row: { + display: "flex", + width: "fit-content", + }, + cell: { + display: "flex", + justifyContent: "center", + flexShrink: 0, + }, +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx b/site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx new file mode 100644 index 0000000000000..c3df71ac90a1a --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx @@ -0,0 +1,70 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import { MoreHorizOutlined } from "@mui/icons-material"; +import type { FC } from "react"; +import type { Timing } from "./timings"; + +const blocksPadding = 8; +const blocksSpacing = 4; +const moreIconSize = 18; + +type TimingBlocksProps = { + timings: Timing[]; + stageSize: number; + blockSize: number; +}; + +export const TimingBlocks: FC = ({ + timings, + stageSize, + blockSize, +}) => { + const realBlockSize = blockSize + blocksSpacing; + const freeSize = stageSize - blocksPadding * 2; + const necessarySize = realBlockSize * timings.length; + const hasSpacing = necessarySize <= freeSize; + const nOfPossibleBlocks = Math.floor( + (freeSize - moreIconSize) / realBlockSize, + ); + const nOfBlocks = hasSpacing ? timings.length : nOfPossibleBlocks; + + return ( +
+ {Array.from({ length: nOfBlocks }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: we are using the index as a key here because the blocks are not expected to be reordered +
+ ))} + {!hasSpacing && ( +
+ +
+ )} +
+ ); +}; + +const styles = { + blocks: { + display: "flex", + width: "100%", + height: "100%", + padding: blocksPadding, + gap: blocksSpacing, + alignItems: "center", + }, + block: { + borderRadius: 4, + height: 16, + backgroundColor: "#082F49", + border: "1px solid #38BDF8", + flexShrink: 0, + }, + extraBlock: { + color: "#38BDF8", + lineHeight: 0, + flexShrink: 0, + + "& svg": { + fontSize: moreIconSize, + }, + }, +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.stories.tsx b/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.stories.tsx new file mode 100644 index 0000000000000..5d9d1b4c8b305 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { WorkspaceTimingChart } from "./WorkspaceTimingChart"; +import { WorkspaceTimingsResponse } from "./storybookData"; + +const meta: Meta = { + title: "modules/workspaces/WorkspaceTimingChart", + component: WorkspaceTimingChart, + 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/WorkspaceTimingChart/WorkspaceTimingChart.tsx b/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx new file mode 100644 index 0000000000000..0209f8a2e14b1 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx @@ -0,0 +1,204 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import type { ProvisionerTiming } from "api/typesGenerated"; +import { Bar } from "components/GanttChart/Bar"; +import { Label } from "components/GanttChart/Label"; +import { XGrid } from "components/GanttChart/XGrid"; +import { XValues } from "components/GanttChart/XValues"; +import type { FC } from "react"; +import { + consolidateTimings, + intervals, + startOffset, + totalDuration, +} from "./timings"; +import { TimingBlocks } from "./TimingBlocks"; + +const columnWidth = 130; +// Spacing between bars +const barsSpacing = 20; +const timesHeight = 40; +// Adds left padding to ensure the first bar does not touch the sidebar border, +// enhancing visual separation. +const barsXPadding = 4; +// Predicting the caption height is necessary to add appropriate spacing to the +// grouped bars, ensuring alignment with the sidebar labels. +const captionHeight = 20; +// The time interval used to calculate the x-axis values. +const timeInterval = 5; +// We control the stages to be displayed in the chart so we can set the correct +// colors and labels. +const stages = [ + { name: "init" }, + { name: "plan" }, + { name: "graph" }, + { name: "apply" }, +]; + +type WorkspaceTimingChartProps = { + provisionerTimings: readonly ProvisionerTiming[]; +}; + +export const WorkspaceTimingChart: FC = ({ + provisionerTimings, +}) => { + const duration = totalDuration(provisionerTimings); + + const xValues = intervals(duration, timeInterval).map(formatSeconds); + const provisionerTiming = consolidateTimings(provisionerTimings); + + const applyBarHeightToLabel = (bar: HTMLDivElement | null) => { + if (!bar) { + return; + } + const labelId = bar.getAttribute("aria-labelledby"); + if (!labelId) { + return; + } + const label = document.querySelector(`#${labelId}`); + if (!label) { + return; + } + label.style.height = `${bar.clientHeight}px`; + }; + + return ( +
+
+
+ provisioning +
    + {stages.map((s) => ( +
  • + +
  • + ))} +
+
+
+ +
+ +
+ {stages.map((s) => { + const timings = provisionerTimings.filter( + (t) => t.stage === s.name, + ); + const stageTiming = consolidateTimings(timings); + const stageDuration = totalDuration(timings); + const offset = startOffset(provisionerTiming, stageTiming); + const stageSize = size(stageDuration); + + return ( + {stageDuration.toFixed(2)}s + } + aria-labelledby={`${s.name}-label`} + ref={applyBarHeightToLabel} + > + {timings.length > 1 && ( + + )} + + ); + })} + + +
+
+
+ ); +}; + +const formatSeconds = (seconds: number): string => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + return `${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`; +}; + +/** + * Returns the size in pixels based on the time interval and the column width + * for the interval. + */ +const size = (duration: number): number => { + return (duration / timeInterval) * columnWidth; +}; + +const styles = { + chart: { + display: "flex", + alignItems: "stretch", + height: "100%", + }, + sidebar: { + width: columnWidth, + flexShrink: 0, + padding: `${timesHeight}px 16px`, + }, + caption: (theme) => ({ + height: captionHeight, + 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: barsSpacing, + textAlign: "right", + }, + main: (theme) => ({ + display: "flex", + flexDirection: "column", + flex: 1, + borderLeft: `1px solid ${theme.palette.divider}`, + }), + xValues: (theme) => ({ + borderBottom: `1px solid ${theme.palette.divider}`, + height: timesHeight, + padding: `0px ${barsXPadding}px`, + minWidth: "100%", + flexShrink: 0, + position: "sticky", + top: 0, + zIndex: 1, + backgroundColor: theme.palette.background.default, + }), + bars: { + display: "flex", + flexDirection: "column", + position: "relative", + gap: barsSpacing, + padding: `${captionHeight}px ${barsXPadding}px`, + flex: 1, + }, +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/storybookData.ts b/site/src/modules/workspaces/WorkspaceTimingChart/storybookData.ts new file mode 100644 index 0000000000000..66410af65d339 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTimingChart/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]", + }, + ], +}; diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/timings.test.ts b/site/src/modules/workspaces/WorkspaceTimingChart/timings.test.ts new file mode 100644 index 0000000000000..2efdb95b66186 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTimingChart/timings.test.ts @@ -0,0 +1,43 @@ +import { + consolidateTimings, + intervals, + startOffset, + totalDuration, +} from "./timings"; + +test("totalDuration", () => { + const timings = [ + { started_at: "2021-01-01T00:00:10Z", ended_at: "2021-01-01T00:00:20Z" }, + { started_at: "2021-01-01T00:00:18Z", ended_at: "2021-01-01T00:00:34Z" }, + { started_at: "2021-01-01T00:00:20Z", ended_at: "2021-01-01T00:00:30Z" }, + ]; + expect(totalDuration(timings)).toBe(24); +}); + +test("intervals", () => { + expect(intervals(24, 5)).toEqual([5, 10, 15, 20, 25]); + expect(intervals(25, 5)).toEqual([5, 10, 15, 20, 25]); + expect(intervals(26, 5)).toEqual([5, 10, 15, 20, 25, 30]); +}); + +test("consolidateTimings", () => { + const timings = [ + { started_at: "2021-01-01T00:00:10Z", ended_at: "2021-01-01T00:00:22Z" }, + { started_at: "2021-01-01T00:00:18Z", ended_at: "2021-01-01T00:00:34Z" }, + { started_at: "2021-01-01T00:00:20Z", ended_at: "2021-01-01T00:00:30Z" }, + ]; + const timing = consolidateTimings(timings); + expect(timing.started_at).toBe("2021-01-01T00:00:10.000Z"); + expect(timing.ended_at).toBe("2021-01-01T00:00:34.000Z"); +}); + +test("startOffset", () => { + const timings = [ + { started_at: "2021-01-01T00:00:10Z", ended_at: "2021-01-01T00:00:22Z" }, + { started_at: "2021-01-01T00:00:18Z", ended_at: "2021-01-01T00:00:34Z" }, + { started_at: "2021-01-01T00:00:20Z", ended_at: "2021-01-01T00:00:30Z" }, + ]; + const consolidated = consolidateTimings(timings); + const timing = timings[1]; + expect(startOffset(consolidated, timing)).toBe(8); +}); diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/timings.ts b/site/src/modules/workspaces/WorkspaceTimingChart/timings.ts new file mode 100644 index 0000000000000..d83d71b374b28 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTimingChart/timings.ts @@ -0,0 +1,65 @@ +export type Timing = { + started_at: string; + ended_at: string; +}; + +/** + * Returns the total duration of the timings in seconds. + */ +export const totalDuration = (timings: readonly Timing[]): number => { + const sortedTimings = timings + .slice() + .sort( + (a, b) => + new Date(a.started_at).getTime() - new Date(b.started_at).getTime(), + ); + const start = new Date(sortedTimings[0].started_at); + + const sortedEndTimings = timings + .slice() + .sort( + (a, b) => new Date(a.ended_at).getTime() - new Date(b.ended_at).getTime(), + ); + const end = new Date(sortedEndTimings[sortedEndTimings.length - 1].ended_at); + + return (end.getTime() - start.getTime()) / 1000; +}; + +/** + * Returns an array of intervals in seconds based on the duration. + */ +export const intervals = (duration: number, interval: number): number[] => { + const intervals = Math.ceil(duration / interval); + return Array.from({ length: intervals }, (_, i) => i * interval + interval); +}; + +/** + * Consolidates the timings into a single timing. + */ +export const consolidateTimings = (timings: readonly Timing[]): Timing => { + const sortedTimings = timings + .slice() + .sort( + (a, b) => + new Date(a.started_at).getTime() - new Date(b.started_at).getTime(), + ); + const start = new Date(sortedTimings[0].started_at); + + const sortedEndTimings = timings + .slice() + .sort( + (a, b) => new Date(a.ended_at).getTime() - new Date(b.ended_at).getTime(), + ); + const end = new Date(sortedEndTimings[sortedEndTimings.length - 1].ended_at); + + return { started_at: start.toISOString(), ended_at: end.toISOString() }; +}; + +/** + * Returns the start offset in seconds + */ +export const startOffset = (base: Timing, timing: Timing): number => { + const parentStart = new Date(base.started_at).getTime(); + const start = new Date(timing.started_at).getTime(); + return (start - parentStart) / 1000; +}; From 4d509f901d4dd95775f09bcfdde756d9db4b91d1 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 19 Sep 2024 18:31:53 +0000 Subject: [PATCH 02/51] Improve spacing calc --- .../workspaces/WorkspaceTimingChart/TimingBlocks.tsx | 6 +++--- .../WorkspaceTimingChart/WorkspaceTimingChart.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx b/site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx index c3df71ac90a1a..5a67c267c5b67 100644 --- a/site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx +++ b/site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx @@ -18,12 +18,12 @@ export const TimingBlocks: FC = ({ stageSize, blockSize, }) => { - const realBlockSize = blockSize + blocksSpacing; + const spacingBetweenBlocks = (timings.length - 1) * blocksSpacing; const freeSize = stageSize - blocksPadding * 2; - const necessarySize = realBlockSize * timings.length; + const necessarySize = blockSize * timings.length + spacingBetweenBlocks; const hasSpacing = necessarySize <= freeSize; const nOfPossibleBlocks = Math.floor( - (freeSize - moreIconSize) / realBlockSize, + (freeSize - moreIconSize - spacingBetweenBlocks) / blockSize, ); const nOfBlocks = hasSpacing ? timings.length : nOfPossibleBlocks; diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx b/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx index 0209f8a2e14b1..ee4df594753af 100644 --- a/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx +++ b/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx @@ -107,7 +107,7 @@ export const WorkspaceTimingChart: FC = ({ )} From d48624b5e5757ee9a678b4c003637d80ac410f09 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 19 Sep 2024 18:41:23 +0000 Subject: [PATCH 03/51] Make bars clickable --- site/src/components/GanttChart/Bar.tsx | 21 ++++++++++++++++++- .../WorkspaceTimingChart.tsx | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/site/src/components/GanttChart/Bar.tsx b/site/src/components/GanttChart/Bar.tsx index 45c5d31b7d1bb..7d360cf5b8718 100644 --- a/site/src/components/GanttChart/Bar.tsx +++ b/site/src/components/GanttChart/Bar.tsx @@ -28,7 +28,14 @@ export const Bar = forwardRef( css={[styles.root, { transform: `translateX(${x}px)` }]} {...htmlProps} > -
{children}
+ {afterLabel}
); @@ -47,6 +54,18 @@ const styles = { border: "1px solid transparent", borderRadius: 8, height: 32, + display: "flex", + padding: 0, + + "&:not(:disabled)": { + cursor: "pointer", + + "&:focus, &:hover, &:active": { + outline: "none", + background: "#082F49", + borderColor: "#38BDF8", + }, + }, }, } satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx b/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx index ee4df594753af..12135b52b65d1 100644 --- a/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx +++ b/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx @@ -102,6 +102,7 @@ export const WorkspaceTimingChart: FC = ({ } aria-labelledby={`${s.name}-label`} ref={applyBarHeightToLabel} + disabled={timings.length <= 1} > {timings.length > 1 && ( Date: Fri, 20 Sep 2024 18:23:58 +0000 Subject: [PATCH 04/51] Refactor code to allow multiple views --- .../src/components/GanttChart/Bar.stories.tsx | 28 --- .../components/GanttChart/Label.stories.tsx | 33 --- site/src/components/GanttChart/Label.tsx | 39 --- .../components/GanttChart/XGrid.stories.tsx | 23 -- .../components/GanttChart/XValues.stories.tsx | 26 -- .../workspaces/WorkspaceTiming/Chart}/Bar.tsx | 0 .../WorkspaceTiming/Chart/Chart.tsx | 230 ++++++++++++++++++ .../Chart}/TimingBlocks.tsx | 31 +-- .../WorkspaceTiming/Chart/XAxis.tsx} | 41 ++-- .../WorkspaceTiming/Chart}/XGrid.tsx | 12 +- .../WorkspaceTiming/Chart/YAxis.tsx | 60 +++++ .../WorkspaceTiming/Chart/constants.ts | 25 ++ .../WorkspaceTimings.stories.tsx} | 10 +- .../WorkspaceTiming/WorkspaceTimings.tsx | 97 ++++++++ .../storybookData.ts | 0 .../WorkspaceTimingChart.tsx | 205 ---------------- .../WorkspaceTimingChart/timings.test.ts | 43 ---- .../WorkspaceTimingChart/timings.ts | 65 ----- 18 files changed, 460 insertions(+), 508 deletions(-) delete mode 100644 site/src/components/GanttChart/Bar.stories.tsx delete mode 100644 site/src/components/GanttChart/Label.stories.tsx delete mode 100644 site/src/components/GanttChart/Label.tsx delete mode 100644 site/src/components/GanttChart/XGrid.stories.tsx delete mode 100644 site/src/components/GanttChart/XValues.stories.tsx rename site/src/{components/GanttChart => modules/workspaces/WorkspaceTiming/Chart}/Bar.tsx (100%) create mode 100644 site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx rename site/src/modules/workspaces/{WorkspaceTimingChart => WorkspaceTiming/Chart}/TimingBlocks.tsx (64%) rename site/src/{components/GanttChart/XValues.tsx => modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx} (51%) rename site/src/{components/GanttChart => modules/workspaces/WorkspaceTiming/Chart}/XGrid.tsx (94%) create mode 100644 site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx create mode 100644 site/src/modules/workspaces/WorkspaceTiming/Chart/constants.ts rename site/src/modules/workspaces/{WorkspaceTimingChart/WorkspaceTimingChart.stories.tsx => WorkspaceTiming/WorkspaceTimings.stories.tsx} (67%) create mode 100644 site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx rename site/src/modules/workspaces/{WorkspaceTimingChart => WorkspaceTiming}/storybookData.ts (100%) delete mode 100644 site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx delete mode 100644 site/src/modules/workspaces/WorkspaceTimingChart/timings.test.ts delete mode 100644 site/src/modules/workspaces/WorkspaceTimingChart/timings.ts diff --git a/site/src/components/GanttChart/Bar.stories.tsx b/site/src/components/GanttChart/Bar.stories.tsx deleted file mode 100644 index 15689c563e470..0000000000000 --- a/site/src/components/GanttChart/Bar.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Bar } from "./Bar"; -import { Label } from "./Label"; - -const meta: Meta = { - title: "components/GanttChart/Bar", - component: Bar, - args: { - width: 136, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const AfterLabel: Story = { - args: { - afterLabel: , - }, -}; - -export const GreenColor: Story = { - args: { - color: "green", - }, -}; diff --git a/site/src/components/GanttChart/Label.stories.tsx b/site/src/components/GanttChart/Label.stories.tsx deleted file mode 100644 index 4e54d138deb84..0000000000000 --- a/site/src/components/GanttChart/Label.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Label } from "./Label"; -import ErrorOutline from "@mui/icons-material/ErrorOutline"; - -const meta: Meta = { - title: "components/GanttChart/Label", - component: Label, - args: { - children: "5s", - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const SecondaryColor: Story = { - args: { - color: "secondary", - }, -}; - -export const StartIcon: Story = { - args: { - children: ( - <> - - docker_value - - ), - }, -}; diff --git a/site/src/components/GanttChart/Label.tsx b/site/src/components/GanttChart/Label.tsx deleted file mode 100644 index f1bb635a888c0..0000000000000 --- a/site/src/components/GanttChart/Label.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { Interpolation, Theme } from "@emotion/react"; -import type { FC, HTMLAttributes } from "react"; - -type LabelColor = "inherit" | "primary" | "secondary"; - -type LabelProps = HTMLAttributes & { - color?: LabelColor; -}; - -export const Label: FC = ({ color = "inherit", ...htmlProps }) => { - return ; -}; - -const styles = { - label: { - lineHeight: 1, - fontSize: 12, - fontWeight: 500, - display: "inline-flex", - alignItems: "center", - gap: 4, - - "& svg": { - fontSize: 12, - }, - }, -} satisfies Record>; - -const colorStyles = { - inherit: { - color: "inherit", - }, - primary: (theme) => ({ - color: theme.palette.text.primary, - }), - secondary: (theme) => ({ - color: theme.palette.text.secondary, - }), -} satisfies Record>; diff --git a/site/src/components/GanttChart/XGrid.stories.tsx b/site/src/components/GanttChart/XGrid.stories.tsx deleted file mode 100644 index db759c30c802b..0000000000000 --- a/site/src/components/GanttChart/XGrid.stories.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { XGrid } from "./XGrid"; - -const meta: Meta = { - title: "components/GanttChart/XGrid", - component: XGrid, - args: { - columnWidth: 130, - columns: 10, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/site/src/components/GanttChart/XValues.stories.tsx b/site/src/components/GanttChart/XValues.stories.tsx deleted file mode 100644 index a15ab06ba1177..0000000000000 --- a/site/src/components/GanttChart/XValues.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { XValues } from "./XValues"; - -const meta: Meta = { - title: "components/GanttChart/XValues", - component: XValues, - args: { - columnWidth: 130, - values: [ - "00:00:05", - "00:00:10", - "00:00:15", - "00:00:20", - "00:00:25", - "00:00:30", - "00:00:35", - "00:00:40", - "00:00:45", - ], - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/site/src/components/GanttChart/Bar.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx similarity index 100% rename from site/src/components/GanttChart/Bar.tsx rename to site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx new file mode 100644 index 0000000000000..0a5b5560229bd --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx @@ -0,0 +1,230 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import { XGrid } from "./XGrid"; +import { XAxis } from "./XAxis"; +import type { FC } from "react"; +import { TimingBlocks } from "./TimingBlocks"; +import { + YAxis, + YAxisCaption, + YAxisCaptionHeight, + YAxisLabel, + YAxisLabels, + YAxisSection, +} from "./YAxis"; +import { + barsSpacing, + columnWidth, + contentSidePadding, + intervalDimension, + XAxisHeight, +} from "./constants"; +import { Bar } from "./Bar"; + +export type ChartProps = { + data: DataSection[]; + onBarClick: (label: string, section: string) => void; +}; + +// This chart can split data into sections. Eg. display the provisioning timings +// in one section and the scripting time in another +type DataSection = { + name: string; + timings: Timing[]; +}; + +// Useful to perform chart operations without requiring additional information +// such as labels or counts, which are only used for display purposes. +export type Duration = { + startedAt: Date; + endedAt: Date; +}; + +export type Timing = Duration & { + /** + * Label that will be displayed on the Y axis. + */ + label: string; + /** + * A timing can represent either a single time block or a group of time + * blocks. When it represents a group, we display blocks within the bars to + * clearly indicate to the user that the timing encompasses multiple time + * blocks. + */ + count: number; +}; + +export const Chart: FC = ({ data, onBarClick }) => { + const totalDuration = calcTotalDuration(data.flatMap((d) => d.timings)); + const intervals = createIntervals(totalDuration, intervalDimension); + + return ( +
+ + {data.map((section) => ( + + {section.name} + + {section.timings.map((t) => ( + + {t.label} + + ))} + + + ))} + + +
+ +
+ {data.map((section) => { + return ( +
+ {section.timings.map((t) => { + // The time this timing started relative to the initial timing + const offset = diffInSeconds( + t.startedAt, + totalDuration.startedAt, + ); + const size = secondsToPixel(durationToSeconds(t)); + return ( + { + onBarClick(t.label, section.name); + }} + > + {t.count > 1 && ( + + )} + + ); + })} +
+ ); + })} + + +
+
+
+ ); +}; + +// Ensures the sidebar label remains vertically aligned with its corresponding bar. +const applyBarHeightToLabel = (bar: HTMLDivElement | null) => { + if (!bar) { + return; + } + const labelId = bar.getAttribute("aria-labelledby"); + if (!labelId) { + 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 label = document.querySelector(`[id="${labelId}"]`); + if (!label) { + return; + } + label.style.height = `${bar.clientHeight}px`; +}; + +// Format a number in seconds to 00:00:00 format +const formatAsTimer = (seconds: number): string => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + return `${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`; +}; + +const durationToSeconds = (duration: Duration): number => { + return (duration.endedAt.getTime() - duration.startedAt.getTime()) / 1000; +}; + +// Create the intervals to be used in the XAxis +const createIntervals = (duration: Duration, range: number): number[] => { + const intervals = Math.ceil(durationToSeconds(duration) / range); + return Array.from({ length: intervals }, (_, i) => i * range + range); +}; + +const secondsToPixel = (seconds: number): number => { + return (columnWidth * seconds) / intervalDimension; +}; + +// Combine multiple durations into a single duration by using the initial start +// time and the final end time. +export const calcTotalDuration = (durations: readonly Duration[]): Duration => { + const sortedDurations = durations + .slice() + .sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime()); + const start = sortedDurations[0].startedAt; + + const sortedEndDurations = durations + .slice() + .sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime()); + const end = sortedEndDurations[sortedEndDurations.length - 1].endedAt; + return { startedAt: start, endedAt: end }; +}; + +const diffInSeconds = (b: Date, a: Date): number => { + return (b.getTime() - a.getTime()) / 1000; +}; + +const styles = { + chart: { + display: "flex", + alignItems: "stretch", + height: "100%", + fontSize: 12, + fontWeight: 500, + }, + sidebar: { + width: columnWidth, + flexShrink: 0, + padding: `${XAxisHeight}px 16px`, + }, + 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: barsSpacing, + textAlign: "right", + }, + main: (theme) => ({ + display: "flex", + flexDirection: "column", + flex: 1, + borderLeft: `1px solid ${theme.palette.divider}`, + }), + content: { + flex: 1, + position: "relative", + }, + bars: { + display: "flex", + flexDirection: "column", + gap: barsSpacing, + padding: `${YAxisCaptionHeight}px ${contentSidePadding}px`, + }, +} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/TimingBlocks.tsx similarity index 64% rename from site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx rename to site/src/modules/workspaces/WorkspaceTiming/Chart/TimingBlocks.tsx index 5a67c267c5b67..09032324a5bea 100644 --- a/site/src/modules/workspaces/WorkspaceTimingChart/TimingBlocks.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/TimingBlocks.tsx @@ -1,31 +1,26 @@ import type { Interpolation, Theme } from "@emotion/react"; import { MoreHorizOutlined } from "@mui/icons-material"; import type { FC } from "react"; -import type { Timing } from "./timings"; -const blocksPadding = 8; -const blocksSpacing = 4; +const sidePadding = 8; +const spaceBetweenBlocks = 4; const moreIconSize = 18; +const blockSize = 20; type TimingBlocksProps = { - timings: Timing[]; - stageSize: number; - blockSize: number; + count: number; + size: number; }; -export const TimingBlocks: FC = ({ - timings, - stageSize, - blockSize, -}) => { - const spacingBetweenBlocks = (timings.length - 1) * blocksSpacing; - const freeSize = stageSize - blocksPadding * 2; - const necessarySize = blockSize * timings.length + spacingBetweenBlocks; +export const TimingBlocks: FC = ({ count, size }) => { + const totalSpaceBetweenBlocks = (count - 1) * spaceBetweenBlocks; + const freeSize = size - sidePadding * 2; + const necessarySize = blockSize * count + totalSpaceBetweenBlocks; const hasSpacing = necessarySize <= freeSize; const nOfPossibleBlocks = Math.floor( - (freeSize - moreIconSize - spacingBetweenBlocks) / blockSize, + (freeSize - moreIconSize - totalSpaceBetweenBlocks) / blockSize, ); - const nOfBlocks = hasSpacing ? timings.length : nOfPossibleBlocks; + const nOfBlocks = hasSpacing ? count : nOfPossibleBlocks; return (
@@ -47,8 +42,8 @@ const styles = { display: "flex", width: "100%", height: "100%", - padding: blocksPadding, - gap: blocksSpacing, + padding: sidePadding, + gap: spaceBetweenBlocks, alignItems: "center", }, block: { diff --git a/site/src/components/GanttChart/XValues.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx similarity index 51% rename from site/src/components/GanttChart/XValues.tsx rename to site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx index ac5f97f9eb1c6..a36f977a600e2 100644 --- a/site/src/components/GanttChart/XValues.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx @@ -1,24 +1,20 @@ -import type { FC, HTMLProps } from "react"; -import { Label } from "./Label"; +import type { FC, HTMLProps, ReactNode } from "react"; import type { Interpolation, Theme } from "@emotion/react"; +import { columnWidth, contentSidePadding, XAxisHeight } from "./constants"; type XValuesProps = HTMLProps & { - values: string[]; - columnWidth: number; + labels: ReactNode[]; }; -export const XValues: FC = ({ - values, - columnWidth, - ...htmlProps -}) => { +export const XAxis: FC = ({ labels, ...htmlProps }) => { return (
- {values.map((v) => ( + {labels.map((l, i) => (
= ({ }, ]} > - + {l}
))}
@@ -40,13 +36,24 @@ export const XValues: FC = ({ }; const styles = { - row: { + row: (theme) => ({ display: "flex", width: "fit-content", - }, - cell: { + alignItems: "center", + borderBottom: `1px solid ${theme.palette.divider}`, + height: XAxisHeight, + padding: `0px ${contentSidePadding}px`, + 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, + }), } satisfies Record>; diff --git a/site/src/components/GanttChart/XGrid.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/XGrid.tsx similarity index 94% rename from site/src/components/GanttChart/XGrid.tsx rename to site/src/modules/workspaces/WorkspaceTiming/Chart/XGrid.tsx index ea494d5327d5f..083075f3023b7 100644 --- a/site/src/components/GanttChart/XGrid.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/XGrid.tsx @@ -1,16 +1,12 @@ import type { FC, HTMLProps } from "react"; import type { Interpolation, Theme } from "@emotion/react"; +import { columnWidth } from "./constants"; type XGridProps = HTMLProps & { columns: number; - columnWidth: number; }; -export const XGrid: FC = ({ - columns, - columnWidth, - ...htmlProps -}) => { +export const XGrid: FC = ({ columns, ...htmlProps }) => { return (
{[...Array(columns).keys()].map((key) => ( @@ -31,6 +27,10 @@ const styles = { display: "flex", width: "100%", height: "100%", + position: "absolute", + top: 0, + left: 0, + zIndex: -1, }, column: (theme) => ({ flexShrink: 0, 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..9efadeb3c3b79 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx @@ -0,0 +1,60 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import type { FC, HTMLProps } from "react"; +import { barsSpacing, XAxisHeight } 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 YAxis: FC> = (props) => { + return
; +}; + +export const YAxisSection: FC> = (props) => { + return
; +}; + +export const YAxisCaption: FC> = (props) => { + return ; +}; + +export const YAxisLabels: FC> = (props) => { + return
    ; +}; + +export const YAxisLabel: FC> = (props) => { + return
  • ; +}; + +const styles = { + root: { + width: 200, + flexShrink: 0, + padding: 16, + paddingTop: XAxisHeight, + }, + 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: barsSpacing, + textAlign: "right", + }, + label: { + display: "block", + maxWidth: "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..796b1def2cafe --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/constants.ts @@ -0,0 +1,25 @@ +/** + * Space between the bars in the chart. + */ +export const barsSpacing = 20; + +/** + * Height of the XAxis + */ +export const XAxisHeight = 40; + +/** + * Side padding to prevent the bars from touching the sidebar border, enhancing + * visual separation. + */ +export const contentSidePadding = 4; + +/** + * Column width for the XAxis + */ +export const columnWidth = 130; + +/** + * Time interval used to calculate the XAxis dimension. + */ +export const intervalDimension = 5; diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.stories.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx similarity index 67% rename from site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.stories.tsx rename to site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx index 5d9d1b4c8b305..ec52d6e91fbdf 100644 --- a/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.stories.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx @@ -1,10 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { WorkspaceTimingChart } from "./WorkspaceTimingChart"; +import { WorkspaceTimings } from "./WorkspaceTimings"; import { WorkspaceTimingsResponse } from "./storybookData"; -const meta: Meta = { - title: "modules/workspaces/WorkspaceTimingChart", - component: WorkspaceTimingChart, +const meta: Meta = { + title: "modules/workspaces/WorkspaceTimings", + component: WorkspaceTimings, args: { provisionerTimings: WorkspaceTimingsResponse.provisioner_timings, }, @@ -28,6 +28,6 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +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..5ed8d2274ea36 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -0,0 +1,97 @@ +import type { ProvisionerTiming } from "api/typesGenerated"; +import { + calcTotalDuration, + Chart, + type Duration, + type ChartProps, + type Timing, +} from "./Chart/Chart"; +import { useState, type FC } from "react"; + +// We control the stages to be displayed in the chart so we can set the correct +// colors and labels. +const provisioningStages = [ + { name: "init" }, + { name: "plan" }, + { name: "graph" }, + { name: "apply" }, +]; + +type WorkspaceTimingsProps = { + provisionerTimings: readonly ProvisionerTiming[]; +}; + +type TimingView = + | { type: "basic" } + // The advanced view enables users to filter results based on the XAxis label + | { type: "advanced"; selectedStage: string; parentSection: string }; + +export const WorkspaceTimings: FC = ({ + provisionerTimings, +}) => { + const [view, setView] = useState({ type: "basic" }); + let data: ChartProps["data"] = []; + + if (view.type === "basic") { + data = [ + { + name: "provisioning", + timings: provisioningStages.map((stage) => { + // Get all the timing durations for a stage + const durations = provisionerTimings + .filter((t) => t.stage === stage.name) + .map(extractDuration); + + // Calc the total duration + const stageDuration = calcTotalDuration(durations); + + // Mount the timing data that is required by the chart + const stageTiming: Timing = { + label: stage.name, + count: durations.length, + ...stageDuration, + }; + return stageTiming; + }), + }, + ]; + } + + if (view.type === "advanced") { + data = [ + { + name: `${view.selectedStage} stage`, + timings: provisionerTimings + .filter((t) => t.stage === view.selectedStage) + .map((t) => { + console.log("-> RESOURCE", t); + return { + label: t.resource, + count: 0, // Resource timings don't have inner timings + ...extractDuration(t), + } as Timing; + }), + }, + ]; + } + + return ( + { + setView({ + type: "advanced", + selectedStage: stage, + parentSection: section, + }); + }} + /> + ); +}; + +const extractDuration = (t: ProvisionerTiming): Duration => { + return { + startedAt: new Date(t.started_at), + endedAt: new Date(t.ended_at), + }; +}; diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/storybookData.ts b/site/src/modules/workspaces/WorkspaceTiming/storybookData.ts similarity index 100% rename from site/src/modules/workspaces/WorkspaceTimingChart/storybookData.ts rename to site/src/modules/workspaces/WorkspaceTiming/storybookData.ts diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx b/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx deleted file mode 100644 index 12135b52b65d1..0000000000000 --- a/site/src/modules/workspaces/WorkspaceTimingChart/WorkspaceTimingChart.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import type { Interpolation, Theme } from "@emotion/react"; -import type { ProvisionerTiming } from "api/typesGenerated"; -import { Bar } from "components/GanttChart/Bar"; -import { Label } from "components/GanttChart/Label"; -import { XGrid } from "components/GanttChart/XGrid"; -import { XValues } from "components/GanttChart/XValues"; -import type { FC } from "react"; -import { - consolidateTimings, - intervals, - startOffset, - totalDuration, -} from "./timings"; -import { TimingBlocks } from "./TimingBlocks"; - -const columnWidth = 130; -// Spacing between bars -const barsSpacing = 20; -const timesHeight = 40; -// Adds left padding to ensure the first bar does not touch the sidebar border, -// enhancing visual separation. -const barsXPadding = 4; -// Predicting the caption height is necessary to add appropriate spacing to the -// grouped bars, ensuring alignment with the sidebar labels. -const captionHeight = 20; -// The time interval used to calculate the x-axis values. -const timeInterval = 5; -// We control the stages to be displayed in the chart so we can set the correct -// colors and labels. -const stages = [ - { name: "init" }, - { name: "plan" }, - { name: "graph" }, - { name: "apply" }, -]; - -type WorkspaceTimingChartProps = { - provisionerTimings: readonly ProvisionerTiming[]; -}; - -export const WorkspaceTimingChart: FC = ({ - provisionerTimings, -}) => { - const duration = totalDuration(provisionerTimings); - - const xValues = intervals(duration, timeInterval).map(formatSeconds); - const provisionerTiming = consolidateTimings(provisionerTimings); - - const applyBarHeightToLabel = (bar: HTMLDivElement | null) => { - if (!bar) { - return; - } - const labelId = bar.getAttribute("aria-labelledby"); - if (!labelId) { - return; - } - const label = document.querySelector(`#${labelId}`); - if (!label) { - return; - } - label.style.height = `${bar.clientHeight}px`; - }; - - return ( -
    -
    -
    - provisioning -
      - {stages.map((s) => ( -
    • - -
    • - ))} -
    -
    -
    - -
    - -
    - {stages.map((s) => { - const timings = provisionerTimings.filter( - (t) => t.stage === s.name, - ); - const stageTiming = consolidateTimings(timings); - const stageDuration = totalDuration(timings); - const offset = startOffset(provisionerTiming, stageTiming); - const stageSize = size(stageDuration); - - return ( - {stageDuration.toFixed(2)}s - } - aria-labelledby={`${s.name}-label`} - ref={applyBarHeightToLabel} - disabled={timings.length <= 1} - > - {timings.length > 1 && ( - - )} - - ); - })} - - -
    -
    -
    - ); -}; - -const formatSeconds = (seconds: number): string => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const remainingSeconds = seconds % 60; - - return `${hours.toString().padStart(2, "0")}:${minutes - .toString() - .padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`; -}; - -/** - * Returns the size in pixels based on the time interval and the column width - * for the interval. - */ -const size = (duration: number): number => { - return (duration / timeInterval) * columnWidth; -}; - -const styles = { - chart: { - display: "flex", - alignItems: "stretch", - height: "100%", - }, - sidebar: { - width: columnWidth, - flexShrink: 0, - padding: `${timesHeight}px 16px`, - }, - caption: (theme) => ({ - height: captionHeight, - 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: barsSpacing, - textAlign: "right", - }, - main: (theme) => ({ - display: "flex", - flexDirection: "column", - flex: 1, - borderLeft: `1px solid ${theme.palette.divider}`, - }), - xValues: (theme) => ({ - borderBottom: `1px solid ${theme.palette.divider}`, - height: timesHeight, - padding: `0px ${barsXPadding}px`, - minWidth: "100%", - flexShrink: 0, - position: "sticky", - top: 0, - zIndex: 1, - backgroundColor: theme.palette.background.default, - }), - bars: { - display: "flex", - flexDirection: "column", - position: "relative", - gap: barsSpacing, - padding: `${captionHeight}px ${barsXPadding}px`, - flex: 1, - }, -} satisfies Record>; diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/timings.test.ts b/site/src/modules/workspaces/WorkspaceTimingChart/timings.test.ts deleted file mode 100644 index 2efdb95b66186..0000000000000 --- a/site/src/modules/workspaces/WorkspaceTimingChart/timings.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - consolidateTimings, - intervals, - startOffset, - totalDuration, -} from "./timings"; - -test("totalDuration", () => { - const timings = [ - { started_at: "2021-01-01T00:00:10Z", ended_at: "2021-01-01T00:00:20Z" }, - { started_at: "2021-01-01T00:00:18Z", ended_at: "2021-01-01T00:00:34Z" }, - { started_at: "2021-01-01T00:00:20Z", ended_at: "2021-01-01T00:00:30Z" }, - ]; - expect(totalDuration(timings)).toBe(24); -}); - -test("intervals", () => { - expect(intervals(24, 5)).toEqual([5, 10, 15, 20, 25]); - expect(intervals(25, 5)).toEqual([5, 10, 15, 20, 25]); - expect(intervals(26, 5)).toEqual([5, 10, 15, 20, 25, 30]); -}); - -test("consolidateTimings", () => { - const timings = [ - { started_at: "2021-01-01T00:00:10Z", ended_at: "2021-01-01T00:00:22Z" }, - { started_at: "2021-01-01T00:00:18Z", ended_at: "2021-01-01T00:00:34Z" }, - { started_at: "2021-01-01T00:00:20Z", ended_at: "2021-01-01T00:00:30Z" }, - ]; - const timing = consolidateTimings(timings); - expect(timing.started_at).toBe("2021-01-01T00:00:10.000Z"); - expect(timing.ended_at).toBe("2021-01-01T00:00:34.000Z"); -}); - -test("startOffset", () => { - const timings = [ - { started_at: "2021-01-01T00:00:10Z", ended_at: "2021-01-01T00:00:22Z" }, - { started_at: "2021-01-01T00:00:18Z", ended_at: "2021-01-01T00:00:34Z" }, - { started_at: "2021-01-01T00:00:20Z", ended_at: "2021-01-01T00:00:30Z" }, - ]; - const consolidated = consolidateTimings(timings); - const timing = timings[1]; - expect(startOffset(consolidated, timing)).toBe(8); -}); diff --git a/site/src/modules/workspaces/WorkspaceTimingChart/timings.ts b/site/src/modules/workspaces/WorkspaceTimingChart/timings.ts deleted file mode 100644 index d83d71b374b28..0000000000000 --- a/site/src/modules/workspaces/WorkspaceTimingChart/timings.ts +++ /dev/null @@ -1,65 +0,0 @@ -export type Timing = { - started_at: string; - ended_at: string; -}; - -/** - * Returns the total duration of the timings in seconds. - */ -export const totalDuration = (timings: readonly Timing[]): number => { - const sortedTimings = timings - .slice() - .sort( - (a, b) => - new Date(a.started_at).getTime() - new Date(b.started_at).getTime(), - ); - const start = new Date(sortedTimings[0].started_at); - - const sortedEndTimings = timings - .slice() - .sort( - (a, b) => new Date(a.ended_at).getTime() - new Date(b.ended_at).getTime(), - ); - const end = new Date(sortedEndTimings[sortedEndTimings.length - 1].ended_at); - - return (end.getTime() - start.getTime()) / 1000; -}; - -/** - * Returns an array of intervals in seconds based on the duration. - */ -export const intervals = (duration: number, interval: number): number[] => { - const intervals = Math.ceil(duration / interval); - return Array.from({ length: intervals }, (_, i) => i * interval + interval); -}; - -/** - * Consolidates the timings into a single timing. - */ -export const consolidateTimings = (timings: readonly Timing[]): Timing => { - const sortedTimings = timings - .slice() - .sort( - (a, b) => - new Date(a.started_at).getTime() - new Date(b.started_at).getTime(), - ); - const start = new Date(sortedTimings[0].started_at); - - const sortedEndTimings = timings - .slice() - .sort( - (a, b) => new Date(a.ended_at).getTime() - new Date(b.ended_at).getTime(), - ); - const end = new Date(sortedEndTimings[sortedEndTimings.length - 1].ended_at); - - return { started_at: start.toISOString(), ended_at: end.toISOString() }; -}; - -/** - * Returns the start offset in seconds - */ -export const startOffset = (base: Timing, timing: Timing): number => { - const parentStart = new Date(base.started_at).getTime(); - const start = new Date(timing.started_at).getTime(); - return (start - parentStart) / 1000; -}; From fd84ed94c7cf3d3ff83593d7671cce845893cc97 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 23 Sep 2024 17:27:39 +0000 Subject: [PATCH 05/51] Add basic view and breadcrumbs --- .../workspaces/WorkspaceTiming/Chart/Bar.tsx | 1 + .../WorkspaceTiming/Chart/Chart.tsx | 100 ++++++++------ .../WorkspaceTiming/Chart/YAxis.tsx | 27 ++-- .../WorkspaceTiming/Chart/constants.ts | 4 - .../WorkspaceTiming/WorkspaceTimings.tsx | 127 ++++++++++++++++-- 5 files changed, 191 insertions(+), 68 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx index 7d360cf5b8718..374219708284d 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx @@ -56,6 +56,7 @@ const styles = { height: 32, display: "flex", padding: 0, + minWidth: 8, "&:not(:disabled)": { cursor: "pointer", diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx index 0a5b5560229bd..e487d66945c5b 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx @@ -15,11 +15,20 @@ import { barsSpacing, columnWidth, contentSidePadding, - intervalDimension, XAxisHeight, } from "./constants"; import { Bar } from "./Bar"; +// 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 dimensions = { + small: 500, + default: 5_000, +}; + export type ChartProps = { data: DataSection[]; onBarClick: (label: string, section: string) => void; @@ -54,8 +63,33 @@ export type Timing = Duration & { }; export const Chart: FC = ({ data, onBarClick }) => { - const totalDuration = calcTotalDuration(data.flatMap((d) => d.timings)); - const intervals = createIntervals(totalDuration, intervalDimension); + const totalDuration = duration(data.flatMap((d) => d.timings)); + const totalTime = durationTime(totalDuration); + // Use smaller dimensions for the chart if the total time is less than 10 + // seconds; otherwise, use default intervals. + const dimension = totalTime < 10_000 ? dimensions.small : dimensions.default; + + // XAxis intervals + const intervalsCount = Math.ceil(totalTime / dimension); + const intervals = Array.from( + { length: intervalsCount }, + (_, i) => i * dimension + dimension, + ); + + // Helper function to convert time into pixel size, used for setting bar width + // and offset + const calcSize = (time: number): number => { + return (columnWidth * time) / dimension; + }; + + const formatTime = (time: number): string => { + if (dimension === dimensions.small) { + return `${time.toLocaleString()}ms`; + } + return `${(time / 1_000).toLocaleString(undefined, { + maximumFractionDigits: 2, + })}s`; + }; return (
    @@ -65,7 +99,10 @@ export const Chart: FC = ({ data, onBarClick }) => { {section.name} {section.timings.map((t) => ( - + {t.label} ))} @@ -75,28 +112,28 @@ export const Chart: FC = ({ data, onBarClick }) => {
    - +
    {data.map((section) => { return (
    {section.timings.map((t) => { - // The time this timing started relative to the initial timing - const offset = diffInSeconds( - t.startedAt, - totalDuration.startedAt, - ); - const size = secondsToPixel(durationToSeconds(t)); + const offset = + t.startedAt.getTime() - totalDuration.startedAt.getTime(); + const size = calcSize(durationTime(t)); return ( { + if (t.count <= 1) { + return; + } onBarClick(t.label, section.name); }} > @@ -130,41 +167,22 @@ const applyBarHeightToLabel = (bar: HTMLDivElement | null) => { // #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 label = document.querySelector(`[id="${labelId}"]`); + const label = document.querySelector( + `[id="${encodeURIComponent(labelId)}"]`, + ); if (!label) { return; } label.style.height = `${bar.clientHeight}px`; }; -// Format a number in seconds to 00:00:00 format -const formatAsTimer = (seconds: number): string => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const remainingSeconds = seconds % 60; - - return `${hours.toString().padStart(2, "0")}:${minutes - .toString() - .padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`; -}; - -const durationToSeconds = (duration: Duration): number => { - return (duration.endedAt.getTime() - duration.startedAt.getTime()) / 1000; -}; - -// Create the intervals to be used in the XAxis -const createIntervals = (duration: Duration, range: number): number[] => { - const intervals = Math.ceil(durationToSeconds(duration) / range); - return Array.from({ length: intervals }, (_, i) => i * range + range); -}; - -const secondsToPixel = (seconds: number): number => { - return (columnWidth * seconds) / intervalDimension; +const durationTime = (duration: Duration): number => { + return duration.endedAt.getTime() - duration.startedAt.getTime(); }; // Combine multiple durations into a single duration by using the initial start // time and the final end time. -export const calcTotalDuration = (durations: readonly Duration[]): Duration => { +export const duration = (durations: readonly Duration[]): Duration => { const sortedDurations = durations .slice() .sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime()); @@ -177,10 +195,6 @@ export const calcTotalDuration = (durations: readonly Duration[]): Duration => { return { startedAt: start, endedAt: end }; }; -const diffInSeconds = (b: Date, a: Date): number => { - return (b.getTime() - a.getTime()) / 1000; -}; - const styles = { chart: { display: "flex", @@ -216,6 +230,8 @@ const styles = { flexDirection: "column", flex: 1, borderLeft: `1px solid ${theme.palette.divider}`, + height: "fit-content", + minHeight: "100%", }), content: { flex: 1, diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx index 9efadeb3c3b79..2cfc230cc9a12 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx @@ -5,6 +5,8 @@ import { barsSpacing, XAxisHeight } 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
    ; @@ -23,14 +25,18 @@ export const YAxisLabels: FC> = (props) => { }; export const YAxisLabel: FC> = (props) => { - return
  • ; + return ( +
  • + {props.children} +
  • + ); }; const styles = { root: { - width: 200, + width: YAxisWidth, flexShrink: 0, - padding: 16, + padding: YAxisSidePadding, paddingTop: XAxisHeight, }, caption: (theme) => ({ @@ -51,10 +57,15 @@ const styles = { textAlign: "right", }, label: { - display: "block", - maxWidth: "100%", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", + 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 index 796b1def2cafe..32c056927e31c 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/constants.ts +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/constants.ts @@ -19,7 +19,3 @@ export const contentSidePadding = 4; */ export const columnWidth = 130; -/** - * Time interval used to calculate the XAxis dimension. - */ -export const intervalDimension = 5; diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx index 5ed8d2274ea36..14fc114b01b92 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -1,12 +1,16 @@ import type { ProvisionerTiming } from "api/typesGenerated"; import { - calcTotalDuration, Chart, type Duration, type ChartProps, type Timing, + duration, } from "./Chart/Chart"; import { useState, type FC } from "react"; +import type { Interpolation, Theme } from "@emotion/react"; +import ChevronRight from "@mui/icons-material/ChevronRight"; +import { YAxisSidePadding, YAxisWidth } from "./Chart/YAxis"; +import { SearchField } from "components/SearchField/SearchField"; // We control the stages to be displayed in the chart so we can set the correct // colors and labels. @@ -41,9 +45,7 @@ export const WorkspaceTimings: FC = ({ const durations = provisionerTimings .filter((t) => t.stage === stage.name) .map(extractDuration); - - // Calc the total duration - const stageDuration = calcTotalDuration(durations); + const stageDuration = duration(durations); // Mount the timing data that is required by the chart const stageTiming: Timing = { @@ -76,16 +78,46 @@ export const WorkspaceTimings: FC = ({ } return ( - { - setView({ - type: "advanced", - selectedStage: stage, - parentSection: section, - }); - }} - /> +
    + {view.type === "advanced" && ( +
    +
      +
    • + +
    • +
    • + +
    • +
    • {view.selectedStage}
    • +
    + + {}} + /> +
    + )} + + { + setView({ + type: "advanced", + selectedStage: stage, + parentSection: section, + }); + }} + /> +
    ); }; @@ -95,3 +127,70 @@ const extractDuration = (t: ProvisionerTiming): Duration => { endedAt: new Date(t.ended_at), }; }; + +const styles = { + panelBody: { + display: "flex", + flexDirection: "column", + height: "100%", + }, + toolbar: (theme) => ({ + borderBottom: `1px solid ${theme.palette.divider}`, + fontSize: 12, + display: "flex", + }), + breadcrumbs: (theme) => ({ + listStyle: "none", + margin: 0, + width: YAxisWidth, + padding: YAxisSidePadding, + display: "flex", + alignItems: "center", + gap: 4, + lineHeight: 1, + + "& 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", + border: "none", + fontSize: "inherit", + color: "inherit", + cursor: "pointer", + + "&:hover": { + color: theme.palette.text.primary, + }, + }), + searchField: (theme) => ({ + "& fieldset": { + border: 0, + borderRadius: 0, + borderLeft: `1px solid ${theme.palette.divider} !important`, + }, + + "& .MuiInputBase-root": { + height: "100%", + fontSize: 12, + }, + }), +} satisfies Record>; From f7f09ff3ef218ac611391aa8d5ee0acf5d22f061 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 23 Sep 2024 17:53:01 +0000 Subject: [PATCH 06/51] Add resource filtering --- .../WorkspaceTiming/WorkspaceTimings.tsx | 142 ++++++++++-------- 1 file changed, 82 insertions(+), 60 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx index 14fc114b01b92..872ce850cbfa2 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -1,17 +1,12 @@ import type { ProvisionerTiming } from "api/typesGenerated"; -import { - Chart, - type Duration, - type ChartProps, - type Timing, - duration, -} from "./Chart/Chart"; +import { Chart, type Duration, type Timing, duration } from "./Chart/Chart"; import { useState, type FC } from "react"; import type { Interpolation, Theme } from "@emotion/react"; import ChevronRight from "@mui/icons-material/ChevronRight"; import { YAxisSidePadding, YAxisWidth } from "./Chart/YAxis"; import { SearchField } from "components/SearchField/SearchField"; +// TODO: Export provisioning stages from the BE to the generated types. // We control the stages to be displayed in the chart so we can set the correct // colors and labels. const provisioningStages = [ @@ -21,65 +16,31 @@ const provisioningStages = [ { name: "apply" }, ]; +// The advanced view is an expanded view of the stage, allowing the user to see +// which resources within a stage are taking the most time. It supports resource +// filtering and displays bars with different colors representing various states +// such as created, deleted, etc. +type TimingView = + | { name: "basic" } + | { + name: "advanced"; + selectedStage: string; + parentSection: string; + filter: string; + }; + type WorkspaceTimingsProps = { provisionerTimings: readonly ProvisionerTiming[]; }; -type TimingView = - | { type: "basic" } - // The advanced view enables users to filter results based on the XAxis label - | { type: "advanced"; selectedStage: string; parentSection: string }; - export const WorkspaceTimings: FC = ({ provisionerTimings, }) => { - const [view, setView] = useState({ type: "basic" }); - let data: ChartProps["data"] = []; - - if (view.type === "basic") { - data = [ - { - name: "provisioning", - timings: provisioningStages.map((stage) => { - // Get all the timing durations for a stage - const durations = provisionerTimings - .filter((t) => t.stage === stage.name) - .map(extractDuration); - const stageDuration = duration(durations); - - // Mount the timing data that is required by the chart - const stageTiming: Timing = { - label: stage.name, - count: durations.length, - ...stageDuration, - }; - return stageTiming; - }), - }, - ]; - } - - if (view.type === "advanced") { - data = [ - { - name: `${view.selectedStage} stage`, - timings: provisionerTimings - .filter((t) => t.stage === view.selectedStage) - .map((t) => { - console.log("-> RESOURCE", t); - return { - label: t.resource, - count: 0, // Resource timings don't have inner timings - ...extractDuration(t), - } as Timing; - }), - }, - ]; - } + const [view, setView] = useState({ name: "basic" }); return (
    - {view.type === "advanced" && ( + {view.name === "advanced" && (
    • @@ -87,7 +48,7 @@ export const WorkspaceTimings: FC = ({ type="button" css={styles.breadcrumbButton} onClick={() => { - setView({ type: "basic" }); + setView({ name: "basic" }); }} > {view.parentSection} @@ -101,19 +62,26 @@ export const WorkspaceTimings: FC = ({ {}} + onChange={(q: string) => { + setView((v) => ({ + ...v, + filter: q, + })); + }} />
    )} { setView({ - type: "advanced", + name: "advanced", selectedStage: stage, parentSection: section, + filter: "", }); }} /> @@ -121,6 +89,57 @@ export const WorkspaceTimings: FC = ({ ); }; +export const selectChartData = ( + view: TimingView, + timings: readonly ProvisionerTiming[], +) => { + switch (view.name) { + case "basic": { + const groupedTimingsByStage = provisioningStages.map((stage) => { + const durations = timings + .filter((t) => t.stage === stage.name) + .map(extractDuration); + const stageDuration = duration(durations); + const stageTiming: Timing = { + label: stage.name, + count: durations.length, + ...stageDuration, + }; + return stageTiming; + }); + + return [ + { + name: "provisioning", + timings: groupedTimingsByStage, + }, + ]; + } + + case "advanced": { + const selectedStageTimings = timings + .filter( + (t) => + t.stage === view.selectedStage && t.resource.includes(view.filter), + ) + .map((t) => { + return { + label: t.resource, + count: 0, // Resource timings don't have inner timings + ...extractDuration(t), + } as Timing; + }); + + return [ + { + name: `${view.selectedStage} stage`, + timings: selectedStageTimings, + }, + ]; + } + } +}; + const extractDuration = (t: ProvisionerTiming): Duration => { return { startedAt: new Date(t.started_at), @@ -148,6 +167,7 @@ const styles = { alignItems: "center", gap: 4, lineHeight: 1, + flexShrink: 0, "& li": { display: "block", @@ -182,6 +202,8 @@ const styles = { }, }), searchField: (theme) => ({ + width: "100%", + "& fieldset": { border: 0, borderRadius: 0, From 2ffc75aaf94be51b3b1420eb56ccf15dcf6b0713 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 23 Sep 2024 18:30:16 +0000 Subject: [PATCH 07/51] Find the right tick spacings --- .../WorkspaceTiming/Chart/Chart.tsx | 95 ++++++++++--------- .../WorkspaceTiming/WorkspaceTimings.tsx | 4 +- 2 files changed, 53 insertions(+), 46 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx index e487d66945c5b..c5ff6826cbec7 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx @@ -19,35 +19,13 @@ import { } from "./constants"; import { Bar } from "./Bar"; -// 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 dimensions = { - small: 500, - default: 5_000, -}; - -export type ChartProps = { - data: DataSection[]; - onBarClick: (label: string, section: string) => void; -}; - -// This chart can split data into sections. Eg. display the provisioning timings -// in one section and the scripting time in another +// Data can be divided into sections. For example, display the provisioning +// timings in one section and the scripting timings in another. type DataSection = { name: string; timings: Timing[]; }; -// Useful to perform chart operations without requiring additional information -// such as labels or counts, which are only used for display purposes. -export type Duration = { - startedAt: Date; - endedAt: Date; -}; - export type Timing = Duration & { /** * Label that will be displayed on the Y axis. @@ -59,31 +37,43 @@ export type Timing = Duration & { * clearly indicate to the user that the timing encompasses multiple time * blocks. */ - count: number; + childrenCount: number; +}; + +// Extracts the 'startedAt' and 'endedAt' date fields from the main Timing type. +// This is useful for performing chart operations without needing additional +// information like labels or children count, which are only used for display +// purposes. +export type Duration = { + startedAt: Date; + endedAt: Date; +}; + +export type ChartProps = { + data: DataSection[]; + onBarClick: (label: string, section: string) => void; }; export const Chart: FC = ({ data, onBarClick }) => { const totalDuration = duration(data.flatMap((d) => d.timings)); const totalTime = durationTime(totalDuration); - // Use smaller dimensions for the chart if the total time is less than 10 - // seconds; otherwise, use default intervals. - const dimension = totalTime < 10_000 ? dimensions.small : dimensions.default; - - // XAxis intervals - const intervalsCount = Math.ceil(totalTime / dimension); - const intervals = Array.from( - { length: intervalsCount }, - (_, i) => i * dimension + dimension, + + // XAxis ticks + const tickSpacing = calcTickSpacing(totalTime); + const ticksCount = Math.ceil(totalTime / tickSpacing); + const ticks = Array.from( + { length: ticksCount }, + (_, i) => i * tickSpacing + tickSpacing, ); - // Helper function to convert time into pixel size, used for setting bar width - // and offset + // Helper function to convert the tick spacing into pixel size. This is used + // for setting the bar width and offset. const calcSize = (time: number): number => { - return (columnWidth * time) / dimension; + return (columnWidth * time) / tickSpacing; }; const formatTime = (time: number): string => { - if (dimension === dimensions.small) { + if (tickSpacing <= 1_000) { return `${time.toLocaleString()}ms`; } return `${(time / 1_000).toLocaleString(undefined, { @@ -112,7 +102,7 @@ export const Chart: FC = ({ data, onBarClick }) => {
    - +
    {data.map((section) => { return ( @@ -129,16 +119,16 @@ export const Chart: FC = ({ data, onBarClick }) => { afterLabel={formatTime(durationTime(t))} aria-labelledby={`${t.label}-label`} ref={applyBarHeightToLabel} - disabled={t.count <= 1} + disabled={t.childrenCount <= 1} onClick={() => { - if (t.count <= 1) { + if (t.childrenCount <= 1) { return; } onBarClick(t.label, section.name); }} > - {t.count > 1 && ( - + {t.childrenCount > 1 && ( + )} ); @@ -147,13 +137,30 @@ export const Chart: FC = ({ data, onBarClick }) => { ); })} - +
    ); }; +// 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 tickSpacings = [100, 500, 5_000]; + +const calcTickSpacing = (totalTime: number): number => { + const spacings = tickSpacings.slice().reverse(); + for (const s of spacings) { + if (totalTime > s) { + return s; + } + } + return spacings[0]; +}; + // Ensures the sidebar label remains vertically aligned with its corresponding bar. const applyBarHeightToLabel = (bar: HTMLDivElement | null) => { if (!bar) { diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx index 872ce850cbfa2..497609d908264 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -102,7 +102,7 @@ export const selectChartData = ( const stageDuration = duration(durations); const stageTiming: Timing = { label: stage.name, - count: durations.length, + childrenCount: durations.length, ...stageDuration, }; return stageTiming; @@ -125,7 +125,7 @@ export const selectChartData = ( .map((t) => { return { label: t.resource, - count: 0, // Resource timings don't have inner timings + childrenCount: 0, // Resource timings don't have inner timings ...extractDuration(t), } as Timing; }); From a8372e1bab99f1968244cd7776882fba21f883ca Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 23 Sep 2024 19:01:55 +0000 Subject: [PATCH 08/51] Add colors to the bars --- .../workspaces/WorkspaceTiming/Chart/Bar.tsx | 41 +++++++++---------- .../WorkspaceTiming/Chart/Chart.tsx | 36 ++++++++-------- .../WorkspaceTiming/WorkspaceTimings.tsx | 32 ++++++++++----- 3 files changed, 60 insertions(+), 49 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx index 374219708284d..2de0bee314ce5 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx @@ -1,11 +1,18 @@ import type { Interpolation, Theme } from "@emotion/react"; import { forwardRef, type HTMLProps, type ReactNode } from "react"; -type BarColor = "default" | "green"; +export type BarColor = { + border: string; + fill: string; +}; -type BarProps = Omit, "size"> & { +type BarProps = Omit, "size" | "color"> & { width: number; children?: ReactNode; + /** + * Color scheme for the bar. If not passed the default gray color will be + * used. + */ color?: BarColor; /** * Label to be displayed adjacent to the bar component. @@ -18,10 +25,7 @@ type BarProps = Omit, "size"> & { }; export const Bar = forwardRef( - ( - { color = "default", width, afterLabel, children, x, ...htmlProps }, - ref, - ) => { + ({ color, width, afterLabel, children, x, ...htmlProps }, ref) => { return (
    ( >
    )} - {data.flatMap((section) => section.timings).length > 0 ? ( - { - setView({ - name: "advanced", - selectedStage: stage, - parentSection: section, - filter: "", - }); - }} - /> - ) : ( -
    - {view.name === "basic" - ? "No data found" - : `No resource found for "${view.filter}"`} -
    - )} +
    + {data.flatMap((section) => section.timings).length > 0 ? ( + { + setView({ + name: "advanced", + selectedStage: stage, + parentSection: section, + filter: "", + }); + }} + /> + ) : ( +
    + {view.name === "basic" + ? "No data found" + : `No resource found for "${view.filter}"`} +
    + )} +
); }; @@ -212,6 +214,10 @@ const styles = { flexDirection: "column", height: "100%", }, + chartWrapper: { + flex: 1, + overflow: "auto", + }, toolbar: (theme) => ({ borderBottom: `1px solid ${theme.palette.divider}`, fontSize: 12, From 0b4747eb86796c946e8575e2da650abd1fa3bfcb Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 24 Sep 2024 20:34:56 +0000 Subject: [PATCH 14/51] Add tooltip --- .../workspaces/WorkspaceTiming/Chart/Bar.tsx | 44 +++++++++++++--- .../WorkspaceTiming/Chart/Chart.tsx | 4 +- .../WorkspaceTiming/WorkspaceTimings.tsx | 50 +++++++++++++++++++ 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx index 54935b9971b30..ef106365d7889 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx @@ -1,4 +1,6 @@ -import type { Interpolation, Theme } from "@emotion/react"; +import { css } from "@emotion/css"; +import { useTheme, type Interpolation, type Theme } from "@emotion/react"; +import Tooltip from "@mui/material/Tooltip"; import { forwardRef, type HTMLProps, type ReactNode } from "react"; export type BarColor = { @@ -22,21 +24,30 @@ type BarProps = Omit, "size" | "color"> & { * The X position of the bar component. */ x?: number; + /** + * The tooltip content for the bar. + */ + tooltip?: ReactNode; }; export const Bar = forwardRef( - ({ color, width, afterLabel, children, x, ...htmlProps }, ref) => { - return ( + ({ color, width, afterLabel, children, x, tooltip, ...htmlProps }, ref) => { + const theme = useTheme(); + const row = (
); + + if (tooltip) { + return ( + + {row} + + ); + } + + return row; }, ); const styles = { - root: { + row: { // Stack children horizontally for adjacent labels display: "flex", alignItems: "center", width: "fit-content", gap: 8, + cursor: "pointer", }, bar: (theme) => ({ border: "1px solid", diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx index 9912741e37630..6a7fe28bdbffe 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Chart.tsx @@ -1,7 +1,7 @@ import type { Interpolation, Theme } from "@emotion/react"; import { XGrid } from "./XGrid"; import { XAxis } from "./XAxis"; -import type { FC } from "react"; +import type { FC, ReactNode } from "react"; import { TimingBlocks } from "./TimingBlocks"; import { YAxis, @@ -40,6 +40,7 @@ export type Timing = Duration & { */ visible?: boolean; color?: BarColor; + tooltip?: ReactNode; }; // Extracts the 'startedAt' and 'endedAt' date fields from the main Timing type. @@ -128,6 +129,7 @@ export const Chart: FC = ({ data, onBarClick }) => { const size = calcSize(durationTime(t)); return ( , ...extractDuration(t), } as Timing; }); @@ -208,6 +211,19 @@ export const selectChartData = ( } }; +const ProvisionerTooltip: FC<{ timing: ProvisionerTiming }> = ({ timing }) => { + return ( +
+ {timing.source} + {timing.resource} + + + view template + +
+ ); +}; + const styles = { panelBody: { display: "flex", @@ -305,4 +321,38 @@ const styles = { border: `1px solid ${theme.palette.divider}`, backgroundColor: theme.palette.background.default, }), + tooltip: (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>; From 49d3a72af572962895c9618cd20370015a6dd6d4 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 25 Sep 2024 19:02:51 +0000 Subject: [PATCH 15/51] Refactor code and improve legends --- .../workspaces/WorkspaceTiming/Chart/Bar.tsx | 126 +++--- .../Chart/{TimingBlocks.tsx => BarBlocks.tsx} | 8 +- .../WorkspaceTiming/Chart/Chart.tsx | 400 +++++++----------- .../WorkspaceTiming/Chart/XAxis.tsx | 187 ++++++-- .../WorkspaceTiming/Chart/XGrid.tsx | 41 -- .../WorkspaceTiming/Chart/YAxis.tsx | 6 +- .../WorkspaceTiming/Chart/constants.ts | 24 +- .../workspaces/WorkspaceTiming/Chart/utils.ts | 89 ++++ .../WorkspaceTiming/ResourcesChart.tsx | 236 +++++++++++ .../WorkspaceTiming/StagesChart.tsx | 151 +++++++ .../WorkspaceTiming/WorkspaceTimings.tsx | 376 +++------------- 11 files changed, 906 insertions(+), 738 deletions(-) rename site/src/modules/workspaces/WorkspaceTiming/Chart/{TimingBlocks.tsx => BarBlocks.tsx} (90%) delete mode 100644 site/src/modules/workspaces/WorkspaceTiming/Chart/XGrid.tsx create mode 100644 site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts create mode 100644 site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx create mode 100644 site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx index ef106365d7889..60424abda1d3b 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx @@ -1,94 +1,65 @@ -import { css } from "@emotion/css"; -import { useTheme, type Interpolation, type Theme } from "@emotion/react"; -import Tooltip from "@mui/material/Tooltip"; -import { forwardRef, type HTMLProps, type ReactNode } from "react"; +import type { Interpolation, Theme } from "@emotion/react"; +import { type ButtonHTMLAttributes, forwardRef, type HTMLProps } from "react"; -export type BarColor = { - border: string; +export type BarColors = { + stroke: string; fill: string; }; -type BarProps = Omit, "size" | "color"> & { - width: number; - children?: ReactNode; +type BaseBarProps = Omit & { /** - * Color scheme for the bar. If not passed the default gray color will be - * used. - */ - color?: BarColor; - /** - * Label to be displayed adjacent to the bar component. + * The width of the bar component. */ - afterLabel?: ReactNode; + size: number; /** * The X position of the bar component. */ - x?: number; + offset: number; /** - * The tooltip content for the bar. + * Color scheme for the bar. If not passed the default gray color will be + * used. */ - tooltip?: ReactNode; + colors?: BarColors; }; +type BarProps = BaseBarProps>; + export const Bar = forwardRef( - ({ color, width, afterLabel, children, x, tooltip, ...htmlProps }, ref) => { - const theme = useTheme(); - const row = ( -
- - {afterLabel} -
+ ({ colors, size, children, offset, ...htmlProps }, ref) => { + return ( +
); + }, +); - if (tooltip) { - return ( - - {row} - - ); - } +type ClickableBarProps = BaseBarProps>; - return row; +export const ClickableBar = forwardRef( + ({ colors, size, offset, ...htmlProps }, ref) => { + return ( + + )} + + {!isLast && ( +
  • + +
  • + )} + + ); + })} + ); }; -// 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 tickSpacings = [100, 500, 5_000]; - -const calcTickSpacing = (totalTime: number): number => { - const spacings = tickSpacings.slice().reverse(); - for (const s of spacings) { - if (totalTime > s) { - return s; - } - } - return spacings[0]; +export const ChartSearch = (props: SearchFieldProps) => { + return ; }; -// Ensures the sidebar label remains vertically aligned with its corresponding bar. -const applyBarHeightToLabel = (bar: HTMLDivElement | null) => { - if (!bar) { - return; - } - const labelId = bar.getAttribute("aria-labelledby"); - if (!labelId) { - 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 label = document.querySelector( - `[id="${encodeURIComponent(labelId)}"]`, - ); - if (!label) { - return; - } - label.style.height = `${bar.clientHeight}px`; +export type ChartLegend = { + label: string; + colors?: BarColors; }; -const durationTime = (duration: Duration): number => { - return duration.endedAt.getTime() - duration.startedAt.getTime(); +type ChartLegendsProps = { + legends: ChartLegend[]; }; -// Combine multiple durations into a single duration by using the initial start -// time and the final end time. -export const combineDurations = (durations: readonly Duration[]): Duration => { - // If there are no durations, return a duration 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 (durations.length === 0) { - return { startedAt: new Date(), endedAt: new Date() }; - } - - const sortedDurations = durations - .slice() - .sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime()); - const start = sortedDurations[0].startedAt; - - const sortedEndDurations = durations - .slice() - .sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime()); - const end = sortedEndDurations[sortedEndDurations.length - 1].endedAt; - return { startedAt: start, endedAt: end }; +export const ChartLegends: FC = ({ legends }) => { + return ( +
      + {legends.map((l) => ( +
    • +
      + {l.label} +
    • + ))} +
    + ); }; const styles = { chart: { + height: "100%", + display: "flex", + flexDirection: "column", + }, + content: { display: "flex", alignItems: "stretch", - height: "100%", fontSize: 12, fontWeight: 500, + overflow: "auto", + flex: 1, }, - sidebar: { - width: columnWidth, - flexShrink: 0, - padding: `${XAxisHeight}px 16px`, - }, - caption: (theme) => ({ - height: YAxisCaptionHeight, + toolbar: (theme) => ({ + borderBottom: `1px solid ${theme.palette.divider}`, + fontSize: 12, display: "flex", - alignItems: "center", - fontSize: 10, - fontWeight: 500, - color: theme.palette.text.secondary, + flexAlign: "stretch", }), - labels: { + 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", - flexDirection: "column", - gap: barsSpacing, - textAlign: "right", - }, - main: (theme) => ({ - display: "flex", - flexDirection: "column", - flex: 1, - borderLeft: `1px solid ${theme.palette.divider}`, - height: "fit-content", - minHeight: "100%", - }), - content: { - flex: 1, - position: "relative", + alignItems: "center", + gap: 24, + paddingRight: YAxisSidePadding, }, - bars: { + legend: { + fontWeight: 500, display: "flex", - flexDirection: "column", - gap: barsSpacing, - padding: `${YAxisCaptionHeight}px ${contentSidePadding}px`, + 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 index a36f977a600e2..52606acc85ae2 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx @@ -1,48 +1,144 @@ -import type { FC, HTMLProps, ReactNode } from "react"; +import type { FC, HTMLProps } from "react"; import type { Interpolation, Theme } from "@emotion/react"; -import { columnWidth, contentSidePadding, XAxisHeight } from "./constants"; +import { YAxisCaptionHeight } from "./YAxis"; +import { formatTime } from "./utils"; +import { XAxisLabelsHeight, XAxisRowsGap } from "./constants"; -type XValuesProps = HTMLProps & { - labels: ReactNode[]; +export const XAxisWidth = 130; +export const XAxisSidePadding = 16; + +type XAxisProps = HTMLProps & { + ticks: number[]; + scale: number; +}; + +export const XAxis: FC = ({ ticks, scale, ...htmlProps }) => { + return ( +
    + + {ticks.map((tick) => ( + + {formatTime(tick, scale)} + + ))} + + {htmlProps.children} + +
    + ); +}; + +export const XAxisLabels: FC> = (props) => { + return
      ; +}; + +type XAxisLabelProps = HTMLProps & { + width: number; }; -export const XAxis: FC = ({ labels, ...htmlProps }) => { +export const XAxisLabel: FC = ({ width, ...htmlProps }) => { return ( -
      - {labels.map((l, i) => ( -
      - {l} -
      +
    • + ); +}; + +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 yAxisLabel = document.querySelector( + `[id="${encodeURIComponent(yAxisLabelId)}"]`, + ); + if (!yAxisLabel) { + console.warn(`Y-axis label with id ${yAxisLabelId} 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 = { - row: (theme) => ({ + 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: XAxisHeight, - padding: `0px ${contentSidePadding}px`, + height: XAxisLabelsHeight, + padding: `0px ${XAxisSidePadding}px`, minWidth: "100%", flexShrink: 0, position: "sticky", @@ -56,4 +152,35 @@ const styles = { 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/XGrid.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/XGrid.tsx deleted file mode 100644 index 083075f3023b7..0000000000000 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/XGrid.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { FC, HTMLProps } from "react"; -import type { Interpolation, Theme } from "@emotion/react"; -import { columnWidth } from "./constants"; - -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 = { - 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 index 2cfc230cc9a12..021adda9a43b3 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/YAxis.tsx @@ -1,6 +1,6 @@ import type { Interpolation, Theme } from "@emotion/react"; import type { FC, HTMLProps } from "react"; -import { barsSpacing, XAxisHeight } from "./constants"; +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. @@ -37,7 +37,7 @@ const styles = { width: YAxisWidth, flexShrink: 0, padding: YAxisSidePadding, - paddingTop: XAxisHeight, + paddingTop: XAxisLabelsHeight, }, caption: (theme) => ({ height: YAxisCaptionHeight, @@ -53,7 +53,7 @@ const styles = { listStyle: "none", display: "flex", flexDirection: "column", - gap: barsSpacing, + gap: XAxisRowsGap, textAlign: "right", }, label: { diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/constants.ts b/site/src/modules/workspaces/WorkspaceTiming/Chart/constants.ts index 32c056927e31c..110dfc5cca1ea 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/constants.ts +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/constants.ts @@ -1,21 +1,3 @@ -/** - * Space between the bars in the chart. - */ -export const barsSpacing = 20; - -/** - * Height of the XAxis - */ -export const XAxisHeight = 40; - -/** - * Side padding to prevent the bars from touching the sidebar border, enhancing - * visual separation. - */ -export const contentSidePadding = 4; - -/** - * Column width for the XAxis - */ -export const columnWidth = 130; - +// Constants that are used across the Chart components +export const XAxisLabelsHeight = 40; +export const XAxisRowsGap = 20; 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..791a6847a4b86 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts @@ -0,0 +1,89 @@ +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, scale: number): string => { + if (scale <= 1_000) { + return `${time.toLocaleString()}ms`; + } + return `${(time / 1_000).toLocaleString(undefined, { + maximumFractionDigits: 2, + })}s`; +}; + +// Helper function to convert the tick spacing into pixel size. This is used +// for setting the bar width and offset. +export const calcSize = ( + time: number, + scale: number, + columnWidth: number, +): number => { + return (columnWidth * time) / scale; +}; + +export const calcBarSizeAndOffset = ( + timing: BaseTiming, + generalTiming: BaseTiming, + scale: number, + columnWidth: number, +) => { + const offset = calcSize( + timing.startedAt.getTime() - generalTiming.startedAt.getTime(), + scale, + columnWidth, + ); + const size = calcSize(calcDuration(timing), scale, columnWidth); + return { + size, + offset, + }; +}; diff --git a/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx new file mode 100644 index 0000000000000..f8f256e17a27e --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx @@ -0,0 +1,236 @@ +import { + XAxis, + XAxisRow, + XAxisRows, + XAxisSections, + XAxisWidth, +} from "./Chart/XAxis"; +import { useState, type FC } from "react"; +import { + YAxis, + YAxisCaption, + YAxisLabel, + YAxisLabels, + YAxisSection, +} from "./Chart/YAxis"; +import { Bar } from "./Chart/Bar"; +import { + calcBarSizeAndOffset, + calcDuration, + combineTimings, + formatTime, + makeTicks, + type BaseTiming, +} from "./Chart/utils"; +import { + Chart, + ChartBreadcrumbs, + ChartContent, + type ChartLegend, + ChartLegends, + ChartSearch, + ChartToolbar, +} from "./Chart/Chart"; +import { type Interpolation, type Theme, useTheme } from "@emotion/react"; +import Tooltip, { type TooltipProps } from "@mui/material/Tooltip"; +import { css } from "@emotion/css"; +import { Link } from "react-router-dom"; +import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; + +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), scale)} + + ); + })} + + + + + + ); +}; + +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..522a42c15e943 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx @@ -0,0 +1,151 @@ +import { + XAxis, + XAxisRow, + XAxisRows, + XAxisSections, + XAxisWidth, +} from "./Chart/XAxis"; +import type { FC } from "react"; +import { + YAxis, + YAxisCaption, + YAxisLabel, + YAxisLabels, + YAxisSection, +} from "./Chart/YAxis"; +import { Bar, ClickableBar } from "./Chart/Bar"; +import { + calcBarSizeAndOffset, + calcDuration, + combineTimings, + formatTime, + makeTicks, + type BaseTiming, +} from "./Chart/utils"; +import { Chart, ChartContent } from "./Chart/Chart"; +import { BarBlocks } from "./Chart/BarBlocks"; + +// 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 }; + +// TODO: Export provisioning stages from the BE to the generated types. +export const stages: Stage[] = [ + { + name: "init", + category: "provisioning", + }, + { + name: "plan", + category: "provisioning", + }, + { + name: "graph", + category: "provisioning", + }, + { + name: "apply", + category: "provisioning", + }, +]; + +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 barSizeAndOffset = calcBarSizeAndOffset( + t, + generalTiming, + scale, + XAxisWidth, + ); + return ( + + {/** We only want to expand stages with more than one resource */} + {t.resources > 1 ? ( + { + onSelectStage(t, category); + }} + > + + + ) : ( + + )} + {formatTime(calcDuration(t), scale)} + + ); + })} + + ); + })} + + + + + ); +}; diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx index 5664a910e02f5..f32873132544e 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -1,47 +1,16 @@ import type { ProvisionerTiming } from "api/typesGenerated"; -import { - Chart, - type Duration, - type Timing, - combineDurations, -} from "./Chart/Chart"; import { useState, type FC } from "react"; import type { Interpolation, Theme } from "@emotion/react"; -import ChevronRight from "@mui/icons-material/ChevronRight"; -import { YAxisSidePadding, YAxisWidth } from "./Chart/YAxis"; -import { SearchField } from "components/SearchField/SearchField"; -import { Link } from "react-router-dom"; -import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; +import { stages, StagesChart } from "./StagesChart"; +import { type BaseTiming, combineTimings } from "./Chart/utils"; +import { ResourcesChart } from "./ResourcesChart"; -// TODO: Export provisioning stages from the BE to the generated types. -const provisioningStages = ["init", "plan", "graph", "apply"]; - -// TODO: Export actions from the BE to the generated types. -const colorsByActions: Record = { - create: { - fill: "#022C22", - border: "#BBF7D0", - }, - delete: { - fill: "#422006", - border: "#FDBA74", - }, - read: { - fill: "#082F49", - border: "#38BDF8", - }, -}; - -// The advanced view is an expanded view of the stage, allowing the user to see -// which resources within a stage are taking the most time. It supports resource -// filtering and displays bars with different colors representing various states -// such as created, deleted, etc. type TimingView = - | { name: "basic" } + | { name: "stages" } | { - name: "advanced"; - selectedStage: string; - parentSection: string; + name: "resources"; + stage: string; + category: string; filter: string; }; @@ -52,176 +21,62 @@ type WorkspaceTimingsProps = { export const WorkspaceTimings: FC = ({ provisionerTimings, }) => { - const [view, setView] = useState({ name: "basic" }); - const data = selectChartData(view, provisionerTimings); + const [view, setView] = useState({ name: "stages" }); return (
      - {view.name === "advanced" && ( -
      -
        -
      • - -
      • -
      • - -
      • -
      • {view.selectedStage}
      • -
      - - { - setView((v) => ({ - ...v, - filter: q, - })); - }} - /> - -
        - {Object.entries(colorsByActions).map(([action, colors]) => ( -
      • -
        - {action} -
      • - ))} -
      -
      + {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: "" }); + }} + /> )} -
      - {data.flatMap((section) => section.timings).length > 0 ? ( - { - setView({ - name: "advanced", - selectedStage: stage, - parentSection: section, - filter: "", - }); - }} - /> - ) : ( -
      - {view.name === "basic" - ? "No data found" - : `No resource found for "${view.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" }); + }} + /> + )}
      ); }; -export const selectChartData = ( - view: TimingView, - timings: readonly ProvisionerTiming[], -) => { - const extractDuration = (t: ProvisionerTiming): Duration => { - return { - startedAt: new Date(t.started_at), - endedAt: new Date(t.ended_at), - }; +const provisionerToBaseTiming = ( + provisioner: ProvisionerTiming, +): BaseTiming => { + return { + startedAt: new Date(provisioner.started_at), + endedAt: new Date(provisioner.ended_at), }; - - switch (view.name) { - case "basic": { - const groupedTimingsByStage = provisioningStages.map((stage) => { - const durations = timings - .filter((t) => t.stage === stage) - .map(extractDuration); - const stageDuration = combineDurations(durations); - const stageTiming: Timing = { - label: stage, - childrenCount: durations.length, - visible: true, - ...stageDuration, - }; - return stageTiming; - }); - - return [ - { - name: "provisioning", - timings: groupedTimingsByStage, - }, - ]; - } - - case "advanced": { - const selectedStageTimings = timings - .filter( - (t) => - t.stage === view.selectedStage && t.resource.includes(view.filter), - ) - .map((t) => { - const isCoderResource = - t.resource.startsWith("data.coder") || - t.resource.startsWith("coder_") || - t.resource.startsWith("module.coder"); - - return { - label: `${t.resource}.${t.action}`, - color: colorsByActions[t.action], - // We don't want to display coder resources. Those will always show - // up as super short values and don't have much value. - visible: !isCoderResource, - // Resource timings don't have inner timings - childrenCount: 0, - tooltip: , - ...extractDuration(t), - } as Timing; - }); - - return [ - { - name: `${view.selectedStage} stage`, - timings: selectedStageTimings, - }, - ]; - } - } -}; - -const ProvisionerTooltip: FC<{ timing: ProvisionerTiming }> = ({ timing }) => { - return ( -
      - {timing.source} - {timing.resource} - - - view template - -
      - ); }; const styles = { @@ -230,129 +85,4 @@ const styles = { flexDirection: "column", height: "100%", }, - chartWrapper: { - flex: 1, - overflow: "auto", - }, - 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, - }), - tooltip: (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>; From 647635d7c0aa257eba4951e63005cb60358b0990 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 25 Sep 2024 20:13:15 +0000 Subject: [PATCH 16/51] Adjust columns to fit the space --- .../workspaces/WorkspaceTiming/Chart/Bar.tsx | 29 ++++++---- .../WorkspaceTiming/Chart/BarBlocks.tsx | 26 ++++++--- .../WorkspaceTiming/Chart/Chart.tsx | 8 +-- .../WorkspaceTiming/Chart/XAxis.tsx | 54 +++++++++++++------ .../WorkspaceTiming/Chart/constants.ts | 3 ++ .../workspaces/WorkspaceTiming/Chart/utils.ts | 27 ++-------- .../WorkspaceTiming/ResourcesChart.tsx | 13 ++--- .../WorkspaceTiming/StagesChart.tsx | 24 ++++----- 8 files changed, 102 insertions(+), 82 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx index 60424abda1d3b..06b0992a3c6e5 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Bar.tsx @@ -1,5 +1,6 @@ import type { Interpolation, Theme } from "@emotion/react"; import { type ButtonHTMLAttributes, forwardRef, type HTMLProps } from "react"; +import { CSSVars } from "./constants"; export type BarColors = { stroke: string; @@ -8,9 +9,10 @@ export type BarColors = { type BaseBarProps = Omit & { /** - * The width of the bar component. + * Scale used to determine the width based on the given value. */ - size: number; + scale: number; + value: number; /** * The X position of the bar component. */ @@ -25,9 +27,13 @@ type BaseBarProps = Omit & { type BarProps = BaseBarProps>; export const Bar = forwardRef( - ({ colors, size, children, offset, ...htmlProps }, ref) => { + ({ colors, scale, value, offset, ...htmlProps }, ref) => { return ( -
      +
      ); }, ); @@ -35,11 +41,11 @@ export const Bar = forwardRef( type ClickableBarProps = BaseBarProps>; export const ClickableBar = forwardRef( - ({ colors, size, offset, ...htmlProps }, ref) => { + ({ colors, scale, value, offset, ...htmlProps }, ref) => { return ( + +
      + {view.name === "default" && ( + { + const stageTimings = timings.filter((t) => t.stage === s.name); + const stageRange = + stageTimings.length === 0 + ? undefined + : mergeTimeRanges(stageTimings.map(extractRange)); + return { + range: stageRange, + name: s.name, + categoryID: s.categoryID, + resources: stageTimings.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 === "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" }); - }} - /> - )} + {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" }); + }} + /> + )} +
      +
      ); }; @@ -105,10 +143,44 @@ const extractRange = ( }; }; +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 = { - panelBody: { + collapse: (theme) => ({ + borderRadius: 8, + border: `1px solid ${theme.palette.divider}`, + }), + collapseTrigger: { + background: "none", + border: 0, + padding: 16, + color: "inherit", + width: "100%", display: "flex", - flexDirection: "column", - height: "100%", + 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>; From 4eb24f70bb0d93942d4d61d2a8c0d0437cd81379 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 18 Oct 2024 18:47:56 +0000 Subject: [PATCH 41/51] Add timings to the workspace UI --- site/src/api/api.ts | 7 + site/src/api/queries/workspaces.ts | 7 + .../WorkspaceTiming/Chart/Tooltip.tsx | 9 + .../WorkspaceTiming/Chart/XAxis.tsx | 6 +- .../WorkspaceTiming/StagesChart.tsx | 30 +++- .../WorkspaceTimings.stories.tsx | 7 + .../WorkspaceTiming/WorkspaceTimings.tsx | 168 ++++++++++-------- site/src/pages/WorkspacePage/Workspace.tsx | 8 + .../WorkspacePage/WorkspaceReadyPage.tsx | 5 + 9 files changed, 161 insertions(+), 86 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7d87d9c8c2104..9ab5e84be49b9 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); }; + + workspaceTimings = async (workspaceId: string) => { + const res = await this.axios.get( + `/api/v2/workspaces/${workspaceId}/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/workspaces.ts b/site/src/api/queries/workspaces.ts index 87bdc158b8058..5a6389e0b93b4 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -391,3 +391,10 @@ export const workspaceUsage = (options: WorkspaceUsageOptions) => { refetchIntervalInBackground: true, }; }; + +export const timings = (workspaceId: string) => { + return { + queryKey: ["workspaces", workspaceId, "timings"], + queryFn: () => API.workspaceTimings(workspaceId), + }; +}; diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/Tooltip.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/Tooltip.tsx index 23bfdd29b89a4..fc1ab550a8854 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/Tooltip.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/Tooltip.tsx @@ -27,6 +27,12 @@ export const TooltipTitle: FC> = (props) => { return ; }; +export const TooltipShortDescription: FC> = ( + props, +) => { + return ; +}; + export const TooltipLink: FC = (props) => { return ( @@ -69,4 +75,7 @@ const styles = { 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 index 153c1fc226396..9c327c6be84d7 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx @@ -148,7 +148,7 @@ const styles = { flexShrink: 0, position: "sticky", top: 0, - zIndex: 1, + zIndex: 2, backgroundColor: theme.palette.background.default, }), label: (theme) => ({ @@ -163,6 +163,9 @@ const styles = { 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))", @@ -183,7 +186,6 @@ const styles = { position: "absolute", top: 0, left: 0, - zIndex: -1, }, column: (theme) => ({ flexShrink: 0, diff --git a/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx index cbd11112c5b47..1f6ed9d966853 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx @@ -5,7 +5,12 @@ 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, TooltipTitle } from "./Chart/Tooltip"; +import { + Tooltip, + type TooltipProps, + TooltipShortDescription, + TooltipTitle, +} from "./Chart/Tooltip"; import { XAxis, XAxisRow, XAxisSection } from "./Chart/XAxis"; import { YAxis, @@ -53,7 +58,9 @@ export const stages: Stage[] = [ title: ( <> Terraform initialization - Download providers & modules. + + Download providers & modules. + ), }, @@ -65,10 +72,10 @@ export const stages: Stage[] = [ title: ( <> Terraform plan - + Compare state of desired vs actual resources and compute changes to be made. - + ), }, @@ -80,9 +87,9 @@ export const stages: Stage[] = [ title: ( <> Terraform graph - + List all resources in plan, used to update coderd database. - + ), }, @@ -94,10 +101,10 @@ export const stages: Stage[] = [ title: ( <> Terraform apply - + Execute terraform plan to create/modify/delete resources into desired states. - + ), }, @@ -109,7 +116,9 @@ export const stages: Stage[] = [ title: ( <> Start - Scripts executed when the agent is starting. + + Scripts executed when the agent is starting. + ), }, @@ -260,6 +269,9 @@ const styles = { gap: 2, justifyContent: "flex-end", }, + stageDescription: { + maxWidth: 300, + }, info: (theme) => ({ width: 12, height: 12, diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx index 85ff2ead9ae5c..d1bbd56982fb0 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx @@ -24,6 +24,13 @@ export const Close: Story = { }, }; +export const Loading: Story = { + args: { + provisionerTimings: undefined, + agentScriptTimings: undefined, + }, +}; + export const ClickToOpen: Story = { args: { defaultIsOpen: false, diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx index b6d321c38601e..f5cdabeaa7013 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -9,6 +9,7 @@ import { KeyboardArrowDown } from "@mui/icons-material"; import KeyboardArrowUp from "@mui/icons-material/KeyboardArrowUp"; import Collapse from "@mui/material/Collapse"; import Button from "@mui/material/Button"; +import Skeleton from "@mui/material/Skeleton"; type TimingView = | { name: "default" } @@ -21,24 +22,30 @@ type TimingView = type WorkspaceTimingsProps = { defaultIsOpen?: boolean; - provisionerTimings: readonly ProvisionerTiming[]; - agentScriptTimings: readonly AgentScriptTiming[]; + provisionerTimings: readonly ProvisionerTiming[] | undefined; + agentScriptTimings: readonly AgentScriptTiming[] | undefined; }; export const WorkspaceTimings: FC = ({ - provisionerTimings, - agentScriptTimings, + provisionerTimings = [], + agentScriptTimings = [], defaultIsOpen = false, }) => { const [view, setView] = useState({ name: "default" }); const timings = [...provisionerTimings, ...agentScriptTimings]; const [isOpen, setIsOpen] = useState(defaultIsOpen); - const totalRange = mergeTimeRanges(timings.map(extractRange)); - const totalDuration = calcDuration(totalRange); + const isLoading = timings.length === 0; + + const displayProvisioningTime = () => { + const totalRange = mergeTimeRanges(timings.map(extractRange)); + const totalDuration = calcDuration(totalRange); + return humanizeDuration(totalDuration); + }; return (
      - -
      - {view.name === "default" && ( - { - const stageTimings = timings.filter((t) => t.stage === s.name); - const stageRange = - stageTimings.length === 0 - ? undefined - : mergeTimeRanges(stageTimings.map(extractRange)); - return { - range: stageRange, - name: s.name, - categoryID: s.categoryID, - resources: stageTimings.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) => { + {!isLoading && ( + +
      + {view.name === "default" && ( + { + const stageTimings = timings.filter( + (t) => t.stage === s.name, + ); + const stageRange = + stageTimings.length === 0 + ? undefined + : mergeTimeRanges(stageTimings.map(extractRange)); return { - range: extractRange(t), - name: t.resource, - source: t.source, - action: t.action, + range: stageRange, + name: s.name, + categoryID: s.categoryID, + resources: stageTimings.length, + error: stageTimings.some( + (t) => "status" in t && t.status === "exit_failure", + ), }; })} - category={view.category} - stage={view.stage} - onBack={() => { - setView({ name: "default" }); - }} - /> - )} + onSelectStage={(t, category) => { + setView({ + name: "detailed", + stage: t.name, + category, + filter: "", + }); + }} + /> + )} - {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" }); - }} - /> - )} -
      -
      + {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" }); + }} + /> + )} +
      +
      + )}
      ); }; @@ -163,6 +180,7 @@ const styles = { collapse: (theme) => ({ borderRadius: 8, border: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.default, }), collapseTrigger: { background: "none", diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index dbc4c6b65f41b..ebc3596e79a32 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -21,6 +21,7 @@ import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner"; import { WorkspaceTopbar } from "./WorkspaceTopbar"; import type { WorkspacePermissions } from "./permissions"; import { resourceOptionValue, useResourcesNav } from "./useResourcesNav"; +import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings"; export interface WorkspaceProps { handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; @@ -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..d1b519e72171d 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -10,6 +10,7 @@ import { deleteWorkspace, startWorkspace, stopWorkspace, + timings, toggleFavorite, updateWorkspace, } from "api/queries/workspaces"; @@ -156,6 +157,9 @@ export const WorkspaceReadyPage: FC = ({ // Cancel build const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient)); + // Build timings + const timingsQuery = useQuery(timings(workspace.id)); + const runLastBuild = ( buildParameters: TypesGen.WorkspaceBuildParameter[] | undefined, debug: boolean, @@ -260,6 +264,7 @@ export const WorkspaceReadyPage: FC = ({ ) } isOwner={isOwner} + timings={timingsQuery.data} /> Date: Fri, 18 Oct 2024 19:00:04 +0000 Subject: [PATCH 42/51] Fix fmt --- .../WorkspaceTiming/WorkspaceTimings.stories.tsx | 2 +- .../workspaces/WorkspaceTiming/WorkspaceTimings.tsx | 10 +++++----- site/src/pages/WorkspacePage/Workspace.tsx | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx index d1bbd56982fb0..a4191779624b6 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, waitFor, within } from "@storybook/test"; import { WorkspaceTimings } from "./WorkspaceTimings"; import { WorkspaceTimingsResponse } from "./storybookData"; -import { userEvent, within, expect, waitFor } from "@storybook/test"; const meta: Meta = { title: "modules/workspaces/WorkspaceTimings", diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx index f5cdabeaa7013..bf407be04d855 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -1,15 +1,15 @@ 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 } from "./ResourcesChart"; import { ScriptsChart } from "./ScriptsChart"; import { type StageCategory, StagesChart, stages } from "./StagesChart"; -import { KeyboardArrowDown } from "@mui/icons-material"; -import KeyboardArrowUp from "@mui/icons-material/KeyboardArrowUp"; -import Collapse from "@mui/material/Collapse"; -import Button from "@mui/material/Button"; -import Skeleton from "@mui/material/Skeleton"; type TimingView = | { name: "default" } diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index ebc3596e79a32..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"; @@ -21,7 +22,6 @@ import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner"; import { WorkspaceTopbar } from "./WorkspaceTopbar"; import type { WorkspacePermissions } from "./permissions"; import { resourceOptionValue, useResourcesNav } from "./useResourcesNav"; -import { WorkspaceTimings } from "modules/workspaces/WorkspaceTiming/WorkspaceTimings"; export interface WorkspaceProps { handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; From 75ea1e5bb75810cfd5ba818f806b912002b4abe1 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 21 Oct 2024 19:17:45 +0000 Subject: [PATCH 43/51] Fix code verbiage --- site/src/modules/workspaces/WorkspaceTiming/ScriptsChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/ScriptsChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/ScriptsChart.tsx index f8c490dcb74e6..5dfc57e51098f 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/ScriptsChart.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/ScriptsChart.tsx @@ -129,7 +129,7 @@ export const ScriptsChart: FC = ({ - Script exited with {t.exitCode} code + Script exited with code {t.exitCode} } > From edb885c7609a27f0bd34e7346a5f0b144dd26d2d Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 21 Oct 2024 19:22:43 +0000 Subject: [PATCH 44/51] Fetch timings from build and not workspace --- site/src/api/api.ts | 4 ++-- site/src/api/queries/workspaceBuilds.ts | 7 +++++++ site/src/api/queries/workspaces.ts | 7 ------- site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx | 6 ++++-- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 9ab5e84be49b9..b79fea12a0c31 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2180,9 +2180,9 @@ class ApiMethods { await this.axios.post("/api/v2/users/otp/change-password", req); }; - workspaceTimings = async (workspaceId: string) => { + workspaceBuildTimings = async (workspaceBuildId: string) => { const res = await this.axios.get( - `/api/v2/workspaces/${workspaceId}/timings`, + `/api/v2/workspacebuilds/${workspaceBuildId}/timings`, ); return res.data; }; diff --git a/site/src/api/queries/workspaceBuilds.ts b/site/src/api/queries/workspaceBuilds.ts index 4b097a1b2b960..ef93a7ca429f1 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: [...workspaceBuildsKey(workspaceBuildId), "timings"], + queryFn: () => API.workspaceBuildTimings(workspaceBuildId), + }; +}; diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 5a6389e0b93b4..87bdc158b8058 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -391,10 +391,3 @@ export const workspaceUsage = (options: WorkspaceUsageOptions) => { refetchIntervalInBackground: true, }; }; - -export const timings = (workspaceId: string) => { - return { - queryKey: ["workspaces", workspaceId, "timings"], - queryFn: () => API.workspaceTimings(workspaceId), - }; -}; diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index d1b519e72171d..54fe5668656a0 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -10,7 +10,6 @@ import { deleteWorkspace, startWorkspace, stopWorkspace, - timings, toggleFavorite, updateWorkspace, } from "api/queries/workspaces"; @@ -38,6 +37,7 @@ import { Workspace } from "./Workspace"; import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection"; import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; import type { WorkspacePermissions } from "./permissions"; +import { workspaceBuildTimings } from "api/queries/workspaceBuilds"; interface WorkspaceReadyPageProps { template: TypesGen.Template; @@ -158,7 +158,9 @@ export const WorkspaceReadyPage: FC = ({ const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient)); // Build timings - const timingsQuery = useQuery(timings(workspace.id)); + const timingsQuery = useQuery( + workspaceBuildTimings(workspace.latest_build.id), + ); const runLastBuild = ( buildParameters: TypesGen.WorkspaceBuildParameter[] | undefined, From f8bb22c743f2db4e1e49fed98a0b934ed8246d2e Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 21 Oct 2024 19:42:02 +0000 Subject: [PATCH 45/51] Don't could coder resources for blocks --- .../workspaces/WorkspaceTiming/ResourcesChart.tsx | 2 +- .../workspaces/WorkspaceTiming/StagesChart.tsx | 13 +++++++------ .../workspaces/WorkspaceTiming/WorkspaceTimings.tsx | 10 ++++++++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx index 0c9259af37244..b1c69b6d1baf7 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/ResourcesChart.tsx @@ -161,7 +161,7 @@ export const ResourcesChart: FC = ({ ); }; -const isCoderResource = (resource: string) => { +export const isCoderResource = (resource: string) => { return ( resource.startsWith("data.coder") || resource.startsWith("module.coder") || diff --git a/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx index 1f6ed9d966853..78fc6db734eb8 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx @@ -128,11 +128,12 @@ export const stages: Stage[] = [ type StageTiming = { 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. + /** + * 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. */ - resources: number; + 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 @@ -226,7 +227,7 @@ export const StagesChart: FC = ({ yAxisLabelId={encodeURIComponent(t.name)} > {/** We only want to expand stages with more than one resource */} - {t.resources > 1 ? ( + {t.visibleResources > 1 ? ( = ({ }} /> )} - + ) : ( diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx index bf407be04d855..76d187ad5170a 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -7,7 +7,7 @@ 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 } from "./ResourcesChart"; +import { isCoderResource, ResourcesChart } from "./ResourcesChart"; import { ScriptsChart } from "./ScriptsChart"; import { type StageCategory, StagesChart, stages } from "./StagesChart"; @@ -82,11 +82,17 @@ export const WorkspaceTimings: FC = ({ stageTimings.length === 0 ? undefined : mergeTimeRanges(stageTimings.map(extractRange)); + + // We don't want to allow users to inspect internal coder resources. + const visibleResources = stageTimings.filter( + (t) => "resource" in t && !isCoderResource(t.resource), + ); + return { range: stageRange, name: s.name, categoryID: s.categoryID, - resources: stageTimings.length, + visibleResources: visibleResources.length, error: stageTimings.some( (t) => "status" in t && t.status === "exit_failure", ), From 1ff245c631abd752c3ffe855d4593fce7f75a069 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 21 Oct 2024 20:00:27 +0000 Subject: [PATCH 46/51] Fix timings getting fetched before build getting done --- site/src/api/queries/workspaceBuilds.ts | 2 +- site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/site/src/api/queries/workspaceBuilds.ts b/site/src/api/queries/workspaceBuilds.ts index ef93a7ca429f1..0e8981ba71ea4 100644 --- a/site/src/api/queries/workspaceBuilds.ts +++ b/site/src/api/queries/workspaceBuilds.ts @@ -59,7 +59,7 @@ export const infiniteWorkspaceBuilds = ( export const workspaceBuildTimings = (workspaceBuildId: string) => { return { - queryKey: [...workspaceBuildsKey(workspaceBuildId), "timings"], + queryKey: ["workspaceBuilds", workspaceBuildId, "timings"], queryFn: () => API.workspaceBuildTimings(workspaceBuildId), }; }; diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 54fe5668656a0..300ed74b32f09 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -157,10 +157,11 @@ export const WorkspaceReadyPage: FC = ({ // Cancel build const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient)); - // Build timings - const timingsQuery = useQuery( - workspaceBuildTimings(workspace.latest_build.id), - ); + // 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, From 541ccba4946a6cbddd177f30bbd75e4bbc2b8f57 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 21 Oct 2024 20:01:23 +0000 Subject: [PATCH 47/51] Fix comment spacing --- site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx index 9c327c6be84d7..de88ec76e2b4d 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx @@ -12,9 +12,9 @@ type XAxisProps = HTMLProps & { 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. + // 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) { From b3f0351ad816eafce750e70b94484815f8282f0d Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 21 Oct 2024 20:04:07 +0000 Subject: [PATCH 48/51] Revert scales on def --- site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts b/site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts index 02073ff3984e8..9721e9f0d1317 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/utils.ts @@ -29,16 +29,15 @@ export const calcDuration = (range: TimeRange): number => { // 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 scales = [5_000, 500, 100]; const pickScale = (totalTime: number): number => { - const reversedScales = scales.slice().reverse(); - for (const s of reversedScales) { + for (const s of scales) { if (totalTime > s) { return s; } } - return reversedScales[0]; + return scales[0]; }; export const makeTicks = (time: number) => { From 65ca087d7fff7cafbff3c69696859822b3225e90 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Mon, 21 Oct 2024 20:10:13 +0000 Subject: [PATCH 49/51] Fix columnd calcl --- site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx index de88ec76e2b4d..4863b08ec19bd 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/Chart/XAxis.tsx @@ -23,8 +23,8 @@ export const XAxis: FC = ({ ticks, scale, ...htmlProps }) => { // 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("--x-axis-width", `${avgWidth}px`); + const width = avgWidth > XAxisMinWidth ? avgWidth : XAxisMinWidth; + rootEl.style.setProperty("--x-axis-width", `${width}px`); }, [ticks]); return ( From f2881697829c56950a749e6c4ba34898abf78096 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 22 Oct 2024 12:35:02 +0000 Subject: [PATCH 50/51] Fix fmt --- .../src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx | 2 +- site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx index 76d187ad5170a..da6c2b9aad94a 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -7,7 +7,7 @@ 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 { isCoderResource, ResourcesChart } from "./ResourcesChart"; +import { ResourcesChart, isCoderResource } from "./ResourcesChart"; import { ScriptsChart } from "./ScriptsChart"; import { type StageCategory, StagesChart, stages } from "./StagesChart"; diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 300ed74b32f09..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, @@ -37,7 +38,6 @@ import { Workspace } from "./Workspace"; import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection"; import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; import type { WorkspacePermissions } from "./permissions"; -import { workspaceBuildTimings } from "api/queries/workspaceBuilds"; interface WorkspaceReadyPageProps { template: TypesGen.Template; From 08874a3b02fae04a67cc5ed9394322a2acaf521b Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 23 Oct 2024 12:56:23 +0000 Subject: [PATCH 51/51] Add extra tests --- .../WorkspaceTiming/StagesChart.tsx | 1 + .../WorkspaceTimings.stories.tsx | 28 +++++++++++++++++++ .../WorkspaceTiming/WorkspaceTimings.tsx | 12 +++++--- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx index 78fc6db734eb8..8f37605ce5956 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/StagesChart.tsx @@ -229,6 +229,7 @@ export const StagesChart: FC = ({ {/** We only want to expand stages with more than one resource */} {t.visibleResources > 1 ? ( { + 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 index da6c2b9aad94a..4835cc2be8f69 100644 --- a/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx +++ b/site/src/modules/workspaces/WorkspaceTiming/WorkspaceTimings.tsx @@ -83,10 +83,14 @@ export const WorkspaceTimings: FC = ({ ? undefined : mergeTimeRanges(stageTimings.map(extractRange)); - // We don't want to allow users to inspect internal coder resources. - const visibleResources = stageTimings.filter( - (t) => "resource" in t && !isCoderResource(t.resource), - ); + // 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, 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