From 5268b1e1a135fa82fac7e4fdd16c6934bbf58203 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 19 Sep 2024 18:19:09 +0000 Subject: [PATCH 01/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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 (