From cdc0ea21e33bb2b62ac40610472a1f2cbd5c8459 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 7 Feb 2024 00:14:52 +0000 Subject: [PATCH 01/15] yay!!! --- .../WorkspaceScheduleControls.test.tsx | 45 +++++-------------- .../WorkspaceScheduleControls.tsx | 41 +++++++++-------- .../pages/WorkspacePage/WorkspaceTopbar.tsx | 4 ++ .../WorkspaceSchedulePage.tsx | 6 +-- site/src/utils/{schedule.ts => schedule.tsx} | 35 ++++++++++++--- 5 files changed, 67 insertions(+), 64 deletions(-) rename site/src/utils/{schedule.ts => schedule.tsx} (88%) diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx index 87599a49e89a0..94d57caa6c850 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx @@ -1,49 +1,28 @@ import { render, screen } from "@testing-library/react"; -import { ThemeProvider } from "contexts/ThemeProvider"; -import { QueryClient, QueryClientProvider, useQuery } from "react-query"; -import { MockWorkspace } from "testHelpers/entities"; -import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls"; -import { workspaceByOwnerAndName } from "api/queries/workspaces"; +import { type FC } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; import { RouterProvider, createMemoryRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event"; -import { server } from "testHelpers/server"; -import { rest } from "msw"; import dayjs from "dayjs"; import * as API from "api/api"; import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; +import { ThemeProvider } from "contexts/ThemeProvider"; +import { MockTemplate, MockWorkspace } from "testHelpers/entities"; +import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls"; -const Wrapper = () => { - const { data: workspace } = useQuery( - workspaceByOwnerAndName(MockWorkspace.owner_name, MockWorkspace.name), +const Wrapper: FC = () => { + return ( + ); - - if (!workspace) { - return null; - } - - return ; }; const BASE_DEADLINE = dayjs().add(3, "hour"); const renderScheduleControls = async () => { - server.use( - rest.get( - "/api/v2/users/:username/workspace/:workspaceName", - (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - ...MockWorkspace, - latest_build: { - ...MockWorkspace.latest_build, - deadline: BASE_DEADLINE.toISOString(), - }, - }), - ); - }, - ), - ); render( diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx index 4613f6d489c84..bb345688abc0a 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx @@ -1,9 +1,16 @@ import { type Interpolation, type Theme } from "@emotion/react"; -import Link, { LinkProps } from "@mui/material/Link"; +import Link, { type LinkProps } from "@mui/material/Link"; +import IconButton from "@mui/material/IconButton"; +import RemoveIcon from "@mui/icons-material/RemoveOutlined"; +import AddIcon from "@mui/icons-material/AddOutlined"; +import Tooltip from "@mui/material/Tooltip"; +import { visuallyHidden } from "@mui/utils"; +import { type Dayjs } from "dayjs"; import { forwardRef, type FC, useRef } from "react"; +import { useMutation, useQueryClient } from "react-query"; import { Link as RouterLink } from "react-router-dom"; import { isWorkspaceOn } from "utils/workspace"; -import type { Workspace } from "api/typesGenerated"; +import type { Template, Workspace } from "api/typesGenerated"; import { autostartDisplay, autostopDisplay, @@ -12,28 +19,22 @@ import { getMaxDeadlineChange, getMinDeadline, } from "utils/schedule"; -import IconButton from "@mui/material/IconButton"; -import RemoveIcon from "@mui/icons-material/RemoveOutlined"; -import AddIcon from "@mui/icons-material/AddOutlined"; -import Tooltip from "@mui/material/Tooltip"; -import _ from "lodash"; import { getErrorMessage } from "api/errors"; import { updateDeadline, workspaceByOwnerAndNameKey, } from "api/queries/workspaces"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; -import { useMutation, useQueryClient } from "react-query"; -import { Dayjs } from "dayjs"; -import { visuallyHidden } from "@mui/utils"; export interface WorkspaceScheduleControlsProps { workspace: Workspace; + template: Template; canUpdateSchedule: boolean; } export const WorkspaceScheduleControls: FC = ({ workspace, + template, canUpdateSchedule, }) => { const queryClient = useQueryClient(); @@ -90,7 +91,7 @@ export const WorkspaceScheduleControls: FC = ({ return (
{isWorkspaceOn(workspace) ? ( - + ) : ( Starts at {autostartDisplay(workspace.autostart_schedule)} @@ -133,22 +134,24 @@ export const WorkspaceScheduleControls: FC = ({ interface AutoStopDisplayProps { workspace: Workspace; + template: Template; } -const AutoStopDisplay: FC = ({ workspace }) => { - const display = autostopDisplay(workspace); +const AutoStopDisplay: FC = ({ workspace, template }) => { + const display = autostopDisplay(workspace, template); if (display.tooltip) { return ( ({ - color: isShutdownSoon(workspace) - ? `${theme.palette.warning.light} !important` - : undefined, - })} + css={ + isShutdownSoon(workspace) && + ((theme) => ({ + color: `${theme.palette.warning.light} !important`, + })) + } > - Stop {display.message} + {display.message} ); diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index 805fda059d453..245be29e2289a 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -33,6 +33,7 @@ import { shouldDisplayScheduleControls, } from "./WorkspaceScheduleControls"; import { WorkspacePermissions } from "./permissions"; +import { LastUsed } from "pages/WorkspacesPage/LastUsed"; export type WorkspaceError = | "getBuildsError" @@ -228,11 +229,14 @@ export const WorkspaceTopbar: FC = ({ )} + + {quota && quota.budget > 0 && ( diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 2c1eefc545aa0..49153a37e38b2 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -80,11 +80,7 @@ export const WorkspaceSchedulePage: FC = () => { {pageTitle([workspaceName, "Schedule"])} - + Workspace Schedule diff --git a/site/src/utils/schedule.ts b/site/src/utils/schedule.tsx similarity index 88% rename from site/src/utils/schedule.ts rename to site/src/utils/schedule.tsx index c2a9a77f9e4ab..f310e587cabfb 100644 --- a/site/src/utils/schedule.ts +++ b/site/src/utils/schedule.tsx @@ -1,12 +1,14 @@ import cronstrue from "cronstrue"; -import dayjs, { Dayjs } from "dayjs"; +import cronParser from "cron-parser"; +import dayjs, { type Dayjs } from "dayjs"; import duration from "dayjs/plugin/duration"; import relativeTime from "dayjs/plugin/relativeTime"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; -import { Template, Workspace } from "api/typesGenerated"; +import { type ReactNode } from "react"; +import { Link } from "react-router-dom"; +import type { Template, Workspace } from "api/typesGenerated"; import { isWorkspaceOn } from "./workspace"; -import cronParser from "cron-parser"; // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're // sorted alphabetically. @@ -90,9 +92,10 @@ export const isShuttingDown = ( export const autostopDisplay = ( workspace: Workspace, + template: Template, ): { - message: string; - tooltip?: string; + message: ReactNode; + tooltip?: ReactNode; } => { const ttl = workspace.ttl_ms; @@ -110,9 +113,27 @@ export const autostopDisplay = ( }; } else { const deadlineTz = deadline.tz(dayjs.tz.guess()); + let reason: ReactNode = ` because the ${template.display_name} template has an autostop requirment`; + if (template.autostop_requirement && template.allow_user_autostop) { + reason = ( + <> + {" "} + because this workspace has enabled autostop. You can disable it from + the{" "} + Workspace Schedule settings page + . + + ); + } return { - message: deadlineTz.fromNow(), - tooltip: deadlineTz.format("MMMM D, YYYY h:mm A"), + message: `Stop ${deadlineTz.fromNow()}`, + tooltip: ( + <> + This workspace will be stopped on{" "} + {deadlineTz.format("MMMM D, YYYY [at] h:mm A")} + {reason} + + ), }; } } else if (!ttl || ttl < 1) { From aa7adf8adc714ded2079e64e615b54e3748ed141 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 7 Feb 2024 18:14:04 +0000 Subject: [PATCH 02/15] :^) --- .../pages/WorkspacePage/ConnectionStatus.tsx | 68 +++++++++++++++++++ .../pages/WorkspacePage/WorkspaceTopbar.tsx | 36 +++++----- 2 files changed, 86 insertions(+), 18 deletions(-) create mode 100644 site/src/pages/WorkspacePage/ConnectionStatus.tsx diff --git a/site/src/pages/WorkspacePage/ConnectionStatus.tsx b/site/src/pages/WorkspacePage/ConnectionStatus.tsx new file mode 100644 index 0000000000000..b09eff767cad6 --- /dev/null +++ b/site/src/pages/WorkspacePage/ConnectionStatus.tsx @@ -0,0 +1,68 @@ +import { type FC } from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { useTheme } from "@emotion/react"; +import { Stack } from "components/Stack/Stack"; + +dayjs.extend(relativeTime); + +interface ConnectionStatusProps { + lastUsedAt: string; +} + +export const ConnectionStatus: FC = ({ lastUsedAt }) => { + const theme = useTheme(); + const t = dayjs(lastUsedAt); + const now = dayjs(); + let message = t.fromNow(); + let circle = ( + + ); + + if (t.isAfter(now.subtract(1, "hour"))) { + circle = ; + // Since the agent reports on a 10m interval, + // the last_used_at can be inaccurate when recent. + message = "Now"; + } else if (t.isAfter(now.subtract(3, "day"))) { + circle = ; + } else if (t.isAfter(now.subtract(1, "month"))) { + circle = ; + } else if (t.isAfter(now.subtract(100, "year"))) { + circle = ; + } else { + message = "Never"; + } + + return ( + + {circle} + {message} + + ); +}; + +type CircleProps = { + color: string; + variant?: "solid" | "outlined"; +}; + +const Circle: FC = ({ color, variant = "solid" }) => { + return ( +
+ ); +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index 245be29e2289a..5a965bdcd1e23 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -33,7 +33,7 @@ import { shouldDisplayScheduleControls, } from "./WorkspaceScheduleControls"; import { WorkspacePermissions } from "./permissions"; -import { LastUsed } from "pages/WorkspacesPage/LastUsed"; +import { ConnectionStatus } from "./ConnectionStatus"; export type WorkspaceError = | "getBuildsError" @@ -201,6 +201,23 @@ export const WorkspaceTopbar: FC = ({ + + + {shouldDisplayScheduleControls(workspace) && ( + + + + + + + + + )} + {shouldDisplayDormantData && ( @@ -220,23 +237,6 @@ export const WorkspaceTopbar: FC = ({ )} - {shouldDisplayScheduleControls(workspace) && ( - - - - - - - - - )} - - - {quota && quota.budget > 0 && ( From 276dffc6c833cfa0b15bf7352748a4603feb3b1c Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Wed, 7 Feb 2024 22:46:03 +0000 Subject: [PATCH 03/15] =?UTF-8?q?=F0=9F=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccountPage/AccountPage.test.tsx | 4 +- .../pages/WorkspacePage/ActivityStatus.tsx | 45 ++++++++++++ .../pages/WorkspacePage/ConnectionStatus.tsx | 68 ------------------- .../WorkspacePage/WorkspaceTopbar.stories.tsx | 22 ++++++ .../pages/WorkspacePage/WorkspaceTopbar.tsx | 4 +- site/src/utils/schedule.tsx | 7 +- 6 files changed, 76 insertions(+), 74 deletions(-) create mode 100644 site/src/pages/WorkspacePage/ActivityStatus.tsx delete mode 100644 site/src/pages/WorkspacePage/ConnectionStatus.tsx diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx index 910b131e3a494..3dc3683ae3e83 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx @@ -29,12 +29,12 @@ describe("AccountPage", () => { Promise.resolve({ id: userId, email: "user@coder.com", - created_at: new Date().toString(), + created_at: new Date().toISOString(), status: "active", organization_ids: ["123"], roles: [], avatar_url: "", - last_seen_at: new Date().toString(), + last_seen_at: new Date().toISOString(), login_type: "password", theme_preference: "", ...data, diff --git a/site/src/pages/WorkspacePage/ActivityStatus.tsx b/site/src/pages/WorkspacePage/ActivityStatus.tsx new file mode 100644 index 0000000000000..fb9061f0ac743 --- /dev/null +++ b/site/src/pages/WorkspacePage/ActivityStatus.tsx @@ -0,0 +1,45 @@ +import { type FC } from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import type { Workspace } from "api/typesGenerated"; +import { Pill } from "components/Pill/Pill"; + +dayjs.extend(relativeTime); + +interface ActivityStatusProps { + workspace: Workspace; +} + +export const ActivityStatus: FC = ({ workspace }) => { + const builtAt = dayjs(workspace.latest_build.created_at); + const usedAt = dayjs(workspace.last_used_at); + const now = dayjs(); + + // This needs to compare to `usedAt` instead of `now`, because the "grace period" for + // marking a workspace as "Connected" is a lot longer. If you compared `builtAt` to `now`, + // you could end up switching from "Ready" to "Connected" without ever actually connecting. + const isBuiltRecently = builtAt.isAfter(usedAt.subtract(2, "minute")); + const isUsedRecently = usedAt.isAfter(now.subtract(15, "minute")); + + switch (workspace.latest_build.status) { + // If the build is still "fresh", it'll be a while before the `last_used_at` gets bumped in + // a significant way by the agent, so just label it as ready instead of connected. + // Wait until `last_used_at` is at least 2 minutes after the build was created, _and_ still + // make sure to check that it's recent. + case isBuiltRecently && + isUsedRecently && + workspace.health.healthy && + "running": + return Ready; + // Since the agent reports on a 10m interval, we present any connection within that period + // plus a little wiggle room as an active connection. + case usedAt.isAfter(now.subtract(15, "minute")) && "running": + return Connected; + case "running": + case "stopping": + case "stopped": + return Not connected; + } + + return null; +}; diff --git a/site/src/pages/WorkspacePage/ConnectionStatus.tsx b/site/src/pages/WorkspacePage/ConnectionStatus.tsx deleted file mode 100644 index b09eff767cad6..0000000000000 --- a/site/src/pages/WorkspacePage/ConnectionStatus.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { type FC } from "react"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; -import { useTheme } from "@emotion/react"; -import { Stack } from "components/Stack/Stack"; - -dayjs.extend(relativeTime); - -interface ConnectionStatusProps { - lastUsedAt: string; -} - -export const ConnectionStatus: FC = ({ lastUsedAt }) => { - const theme = useTheme(); - const t = dayjs(lastUsedAt); - const now = dayjs(); - let message = t.fromNow(); - let circle = ( - - ); - - if (t.isAfter(now.subtract(1, "hour"))) { - circle = ; - // Since the agent reports on a 10m interval, - // the last_used_at can be inaccurate when recent. - message = "Now"; - } else if (t.isAfter(now.subtract(3, "day"))) { - circle = ; - } else if (t.isAfter(now.subtract(1, "month"))) { - circle = ; - } else if (t.isAfter(now.subtract(100, "year"))) { - circle = ; - } else { - message = "Never"; - } - - return ( - - {circle} - {message} - - ); -}; - -type CircleProps = { - color: string; - variant?: "solid" | "outlined"; -}; - -const Circle: FC = ({ color, variant = "solid" }) => { - return ( -
- ); -}; diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx index d1ed77cef97e8..9a3b51237989e 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx @@ -48,6 +48,28 @@ export const Outdated: Story = { }, }; +export const Ready: Story = { + args: { + workspace: { + ...baseWorkspace, + last_used_at: new Date().toISOString(), + latest_build: { + ...baseWorkspace.latest_build, + created_at: new Date().toISOString(), + }, + }, + }, +}; + +export const Connected: Story = { + args: { + workspace: { + ...baseWorkspace, + last_used_at: new Date().toISOString(), + }, + }, +}; + export const Dormant: Story = { args: { workspace: { diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index 5a965bdcd1e23..425e030601cdf 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -33,7 +33,7 @@ import { shouldDisplayScheduleControls, } from "./WorkspaceScheduleControls"; import { WorkspacePermissions } from "./permissions"; -import { ConnectionStatus } from "./ConnectionStatus"; +import { ActivityStatus } from "./ActivityStatus"; export type WorkspaceError = | "getBuildsError" @@ -201,7 +201,7 @@ export const WorkspaceTopbar: FC = ({ - + {shouldDisplayScheduleControls(workspace) && ( diff --git a/site/src/utils/schedule.tsx b/site/src/utils/schedule.tsx index f310e587cabfb..8227ca7f3b483 100644 --- a/site/src/utils/schedule.tsx +++ b/site/src/utils/schedule.tsx @@ -1,3 +1,4 @@ +import Link from "@mui/material/Link"; import cronstrue from "cronstrue"; import cronParser from "cron-parser"; import dayjs, { type Dayjs } from "dayjs"; @@ -6,7 +7,7 @@ import relativeTime from "dayjs/plugin/relativeTime"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { type ReactNode } from "react"; -import { Link } from "react-router-dom"; +import { Link as RouterLink } from "react-router-dom"; import type { Template, Workspace } from "api/typesGenerated"; import { isWorkspaceOn } from "./workspace"; @@ -120,7 +121,9 @@ export const autostopDisplay = ( {" "} because this workspace has enabled autostop. You can disable it from the{" "} - Workspace Schedule settings page + + Workspace Schedule settings page + . ); From c4d85e57211a491129e903a3e43db52c5e327201 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 8 Feb 2024 00:02:14 +0000 Subject: [PATCH 04/15] =?UTF-8?q?=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkspaceScheduleControls.tsx | 35 ++++---- .../WorkspacePage/WorkspaceTopbar.stories.tsx | 84 ++++++++++++++++++- site/src/utils/schedule.tsx | 25 ++++-- 3 files changed, 118 insertions(+), 26 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx index bb345688abc0a..63d683d3bae45 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx @@ -138,26 +138,27 @@ interface AutoStopDisplayProps { } const AutoStopDisplay: FC = ({ workspace, template }) => { - const display = autostopDisplay(workspace, template); + const { message, tooltip } = autostopDisplay(workspace, template); + + const display = ( + ({ + color: `${theme.palette.warning.light} !important`, + })) + } + > + {message} + + ); - if (display.tooltip) { - return ( - - ({ - color: `${theme.palette.warning.light} !important`, - })) - } - > - {display.message} - - - ); + if (tooltip) { + return {display}; } - return {display.message}; + return display; }; const ScheduleSettingsLink = forwardRef( diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx index 9a3b51237989e..ed5a11f2b713f 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx @@ -1,4 +1,5 @@ import { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, waitFor, within, screen } from "@storybook/test"; import { MockTemplate, MockTemplateVersion, @@ -42,7 +43,7 @@ export const Example: Story = {}; export const Outdated: Story = { args: { workspace: { - ...MockWorkspace, + ...baseWorkspace, outdated: true, }, }, @@ -83,7 +84,7 @@ export const Dormant: Story = { }, }; -export const WithDeadline: Story = { +export const WithExceededDeadline: Story = { args: { workspace: { ...MockWorkspace, @@ -95,6 +96,85 @@ export const WithDeadline: Story = { }, }; +const in30Minutes = new Date(); +in30Minutes.setMinutes(in30Minutes.getMinutes() + 30); +export const WithApproachingDeadline: Story = { + args: { + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + deadline: in30Minutes.toISOString(), + }, + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("activate hover trigger", async () => { + await userEvent.hover(canvas.getByTestId("schedule-controls-autostop")); + await waitFor(() => + expect(screen.getByRole("tooltip")).toHaveTextContent( + /this workspace has enabled autostop/, + ), + ); + }); + }, +}; + +const in8Hours = new Date(); +in8Hours.setHours(in8Hours.getHours() + 8); +export const WithFarAwayDeadline: Story = { + args: { + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + deadline: in8Hours.toISOString(), + }, + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("activate hover trigger", async () => { + await userEvent.hover(canvas.getByTestId("schedule-controls-autostop")); + await waitFor(() => + expect(screen.getByRole("tooltip")).toHaveTextContent( + /this workspace has enabled autostop/, + ), + ); + }); + }, +}; +export const WithFarAwayDeadlineRequiredByTemplate: Story = { + args: { + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + deadline: in8Hours.toISOString(), + }, + }, + template: { + ...MockTemplate, + allow_user_autostop: false, + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("activate hover trigger", async () => { + await userEvent.hover(canvas.getByTestId("schedule-controls-autostop")); + await waitFor(() => + expect(screen.getByRole("tooltip")).toHaveTextContent( + /template has an autostop requirment/, + ), + ); + }); + }, +}; + export const WithQuota: Story = { parameters: { queries: [ diff --git a/site/src/utils/schedule.tsx b/site/src/utils/schedule.tsx index 8227ca7f3b483..1a4378347c416 100644 --- a/site/src/utils/schedule.tsx +++ b/site/src/utils/schedule.tsx @@ -9,6 +9,10 @@ import utc from "dayjs/plugin/utc"; import { type ReactNode } from "react"; import { Link as RouterLink } from "react-router-dom"; import type { Template, Workspace } from "api/typesGenerated"; +import { + HelpTooltipText, + HelpTooltipTitle, +} from "components/HelpTooltip/HelpTooltip"; import { isWorkspaceOn } from "./workspace"; // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're @@ -114,15 +118,19 @@ export const autostopDisplay = ( }; } else { const deadlineTz = deadline.tz(dayjs.tz.guess()); - let reason: ReactNode = ` because the ${template.display_name} template has an autostop requirment`; + let title = ( + Template Autostop requirement + ); + let reason: ReactNode = ` because the ${template.display_name} template has an autostop requirment.`; if (template.autostop_requirement && template.allow_user_autostop) { + title = Autostop schedule; reason = ( <> {" "} - because this workspace has enabled autostop. You can disable it from - the{" "} + because this workspace has enabled autostop. You can disable + autostop from this workspace's{" "} - Workspace Schedule settings page + schedule settings . @@ -132,9 +140,12 @@ export const autostopDisplay = ( message: `Stop ${deadlineTz.fromNow()}`, tooltip: ( <> - This workspace will be stopped on{" "} - {deadlineTz.format("MMMM D, YYYY [at] h:mm A")} - {reason} + {title} + + This workspace will be stopped on{" "} + {deadlineTz.format("MMMM D [at] h:mm A")} + {reason} + ), }; From a5b2dbbd6369e131595a0bc9003ccfaca7dea02b Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 8 Feb 2024 00:43:22 +0000 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx | 3 ++- site/src/utils/schedule.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx index ed5a11f2b713f..cc36f4dced9b6 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx @@ -147,6 +147,7 @@ export const WithFarAwayDeadline: Story = { }); }, }; + export const WithFarAwayDeadlineRequiredByTemplate: Story = { args: { workspace: { @@ -168,7 +169,7 @@ export const WithFarAwayDeadlineRequiredByTemplate: Story = { await userEvent.hover(canvas.getByTestId("schedule-controls-autostop")); await waitFor(() => expect(screen.getByRole("tooltip")).toHaveTextContent( - /template has an autostop requirment/, + /template has an autostop requirement/, ), ); }); diff --git a/site/src/utils/schedule.tsx b/site/src/utils/schedule.tsx index 1a4378347c416..a6db6be98c2df 100644 --- a/site/src/utils/schedule.tsx +++ b/site/src/utils/schedule.tsx @@ -121,7 +121,7 @@ export const autostopDisplay = ( let title = ( Template Autostop requirement ); - let reason: ReactNode = ` because the ${template.display_name} template has an autostop requirment.`; + let reason: ReactNode = ` because the ${template.display_name} template has an autostop requirement.`; if (template.autostop_requirement && template.allow_user_autostop) { title = Autostop schedule; reason = ( From c5ad3db0fff1c14cf397259b1fe4c601c5bcd4c0 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 8 Feb 2024 21:56:41 +0000 Subject: [PATCH 06/15] notify frontend about new agent activity --- coderd/agentapi/api.go | 1 + coderd/agentapi/stats.go | 14 ++++++ coderd/workspaces.go | 44 ++++++++++++++++++- codersdk/serversentevents.go | 7 +-- codersdk/workspaces.go | 4 ++ site/src/api/typesGenerated.ts | 3 +- site/src/hooks/useTime.ts | 32 ++++++++++++++ .../pages/WorkspacePage/ActivityStatus.tsx | 13 +++--- .../src/pages/WorkspacePage/WorkspacePage.tsx | 14 ++++++ .../WorkspaceScheduleControls.tsx | 2 + .../WorkspacePage/WorkspaceTopbar.stories.tsx | 2 +- site/src/pages/WorkspacesPage/LastUsed.tsx | 4 +- 12 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 site/src/hooks/useTime.ts diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go index 73b50d9c0c446..acfe9145b2ad0 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -114,6 +114,7 @@ func New(opts Options) *API { api.StatsAPI = &StatsAPI{ AgentFn: api.agent, Database: opts.Database, + Pubsub: opts.Pubsub, Log: opts.Log, StatsBatcher: opts.StatsBatcher, TemplateScheduleStore: opts.TemplateScheduleStore, diff --git a/coderd/agentapi/stats.go b/coderd/agentapi/stats.go index 1185b99abde24..bc4507779b2e5 100644 --- a/coderd/agentapi/stats.go +++ b/coderd/agentapi/stats.go @@ -16,8 +16,10 @@ import ( "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/prometheusmetrics" "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/codersdk" ) type StatsBatcher interface { @@ -27,6 +29,7 @@ type StatsBatcher interface { type StatsAPI struct { AgentFn func(context.Context) (database.WorkspaceAgent, error) Database database.Store + Pubsub pubsub.Pubsub Log slog.Logger StatsBatcher StatsBatcher TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] @@ -130,5 +133,16 @@ func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsR return nil, xerrors.Errorf("update stats in database: %w", err) } + // Tell the frontend about the new agent report, now that everything is updated + a.publishWorkspaceAgentStats(ctx, workspace.ID) + return res, nil } + +func (a *StatsAPI) publishWorkspaceAgentStats(ctx context.Context, workspaceID uuid.UUID) { + err := a.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(workspaceID), codersdk.WorkspaceNotifyDescriptionAgentStatsOnly) + if err != nil { + a.Log.Warn(ctx, "failed to publish workspace agent stats", + slog.F("workspace_id", workspaceID), slog.Error(err)) + } +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index c185f6a900ccb..3e816108dffbf 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1,6 +1,7 @@ package coderd import ( + "bytes" "context" "database/sql" "encoding/json" @@ -1343,7 +1344,48 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) { <-senderClosed }() - sendUpdate := func(_ context.Context, _ []byte) { + sendUpdate := func(_ context.Context, description []byte) { + // The agent stats get updated frequently, so we treat these as a special case and only + // send a partial update. We primarily care about updating the `last_used_at` and + // `latest_build.deadline` properties. + if bytes.Equal(description, codersdk.WorkspaceNotifyDescriptionAgentStatsOnly) { + workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID) + if err != nil { + _ = sendEvent(ctx, codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeError, + Data: codersdk.Response{ + Message: "Internal error fetching workspace.", + Detail: err.Error(), + }, + }) + return + } + + workspaceBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + _ = sendEvent(ctx, codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeError, + Data: codersdk.Response{ + Message: "Internal error fetching workspace build.", + Detail: err.Error(), + }, + }) + return + } + + _ = sendEvent(ctx, codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypePartial, + Data: struct { + database.Workspace + LatestBuild database.WorkspaceBuild `json:"latest_build"` + }{ + Workspace: workspace, + LatestBuild: workspaceBuild, + }, + }) + return + } + workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID) if err != nil { _ = sendEvent(ctx, codersdk.ServerSentEvent{ diff --git a/codersdk/serversentevents.go b/codersdk/serversentevents.go index 8c026524c7d92..8f4df9d4e6fc1 100644 --- a/codersdk/serversentevents.go +++ b/codersdk/serversentevents.go @@ -20,9 +20,10 @@ type ServerSentEvent struct { type ServerSentEventType string const ( - ServerSentEventTypePing ServerSentEventType = "ping" - ServerSentEventTypeData ServerSentEventType = "data" - ServerSentEventTypeError ServerSentEventType = "error" + ServerSentEventTypePing ServerSentEventType = "ping" + ServerSentEventTypeData ServerSentEventType = "data" + ServerSentEventTypePartial ServerSentEventType = "partial" + ServerSentEventTypeError ServerSentEventType = "error" ) func ServerSentEventReader(ctx context.Context, rc io.ReadCloser) func() (*ServerSentEvent, error) { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index d5008b3234fa9..a325b47b955ab 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -497,6 +497,10 @@ func (c *Client) UnfavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID) return nil } +var ( + WorkspaceNotifyDescriptionAgentStatsOnly = []byte("agentStatsOnly") +) + // WorkspaceNotifyChannel is the PostgreSQL NOTIFY // channel to listen for updates on. The payload is empty, // because the size of a workspace payload can be very large. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9d63f492c29b6..2c193e0379162 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2083,10 +2083,11 @@ export const ResourceTypes: ResourceType[] = [ ]; // From codersdk/serversentevents.go -export type ServerSentEventType = "data" | "error" | "ping"; +export type ServerSentEventType = "data" | "error" | "partial" | "ping"; export const ServerSentEventTypes: ServerSentEventType[] = [ "data", "error", + "partial", "ping", ]; diff --git a/site/src/hooks/useTime.ts b/site/src/hooks/useTime.ts new file mode 100644 index 0000000000000..20e1a5669dfdd --- /dev/null +++ b/site/src/hooks/useTime.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; + +/** + * useTime allows a component to rerender over time without a corresponding state change. + * An example could be a relative timestamp (eg. "in 5 minutes") that should count down as it + * approaches. + * + * This hook should only be used in components that are very simple, and that will not + * create a lot of unnecessary work for the reconciler. Given that this hook will result in + * the entire subtree being rerendered on a frequent interval, it's important that the subtree + * remains small. + * + * @param active Can optionally be set to false in circumstances where updating over time is + * not necessary. + */ +export function useTime(active: boolean = true) { + const [, setTick] = useState(0); + + useEffect(() => { + if (!active) { + return; + } + + const interval = setInterval(() => { + setTick((i) => i + 1); + }, 1000); + + return () => { + clearInterval(interval); + }; + }, [active]); +} diff --git a/site/src/pages/WorkspacePage/ActivityStatus.tsx b/site/src/pages/WorkspacePage/ActivityStatus.tsx index fb9061f0ac743..85e00706c7459 100644 --- a/site/src/pages/WorkspacePage/ActivityStatus.tsx +++ b/site/src/pages/WorkspacePage/ActivityStatus.tsx @@ -2,6 +2,7 @@ import { type FC } from "react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import type { Workspace } from "api/typesGenerated"; +import { useTime } from "hooks/useTime"; import { Pill } from "components/Pill/Pill"; dayjs.extend(relativeTime); @@ -11,21 +12,23 @@ interface ActivityStatusProps { } export const ActivityStatus: FC = ({ workspace }) => { - const builtAt = dayjs(workspace.latest_build.created_at); + const builtAt = dayjs(workspace.latest_build.updated_at); const usedAt = dayjs(workspace.last_used_at); const now = dayjs(); // This needs to compare to `usedAt` instead of `now`, because the "grace period" for // marking a workspace as "Connected" is a lot longer. If you compared `builtAt` to `now`, // you could end up switching from "Ready" to "Connected" without ever actually connecting. - const isBuiltRecently = builtAt.isAfter(usedAt.subtract(2, "minute")); + const isBuiltRecently = builtAt.isAfter(usedAt.subtract(1, "second")); const isUsedRecently = usedAt.isAfter(now.subtract(15, "minute")); + useTime(isUsedRecently); + switch (workspace.latest_build.status) { // If the build is still "fresh", it'll be a while before the `last_used_at` gets bumped in // a significant way by the agent, so just label it as ready instead of connected. - // Wait until `last_used_at` is at least 2 minutes after the build was created, _and_ still - // make sure to check that it's recent. + // Wait until `last_used_at` is after the time that the build finished, _and_ still + // make sure to check that it's recent, so that we don't show "Ready" indefinitely. case isBuiltRecently && isUsedRecently && workspace.health.healthy && @@ -33,7 +36,7 @@ export const ActivityStatus: FC = ({ workspace }) => { return Ready; // Since the agent reports on a 10m interval, we present any connection within that period // plus a little wiggle room as an active connection. - case usedAt.isAfter(now.subtract(15, "minute")) && "running": + case isUsedRecently && "running": return Connected; case "running": case "stopping": diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index b5aa890021f79..8454057393403 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,6 +1,7 @@ import { type FC, useEffect } from "react"; import { useQuery, useQueryClient } from "react-query"; import { useParams } from "react-router-dom"; +import merge from "lodash/merge"; import { watchWorkspace } from "api/api"; import type { Workspace } from "api/typesGenerated"; import { workspaceBuildsKey } from "api/queries/workspaceBuilds"; @@ -89,6 +90,19 @@ export const WorkspacePage: FC = () => { await updateWorkspaceData(newWorkspaceData); }); + eventSource.addEventListener("partial", async (event) => { + const newWorkspaceData = JSON.parse(event.data) as Partial; + // Merge with a fresh object `{}` as the base, because `merge` uses an in-place algorithm, + // and would otherwise mutate the `queryClient`'s internal state. + await updateWorkspaceData( + merge( + {}, + queryClient.getQueryData(workspaceQueryOptions.queryKey) as Workspace, + newWorkspaceData, + ), + ); + }); + eventSource.addEventListener("error", (event) => { console.error("Error on getting workspace changes.", event); }); diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx index 63d683d3bae45..71173565d2f31 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx @@ -9,6 +9,7 @@ import { type Dayjs } from "dayjs"; import { forwardRef, type FC, useRef } from "react"; import { useMutation, useQueryClient } from "react-query"; import { Link as RouterLink } from "react-router-dom"; +import { useTime } from "hooks/useTime"; import { isWorkspaceOn } from "utils/workspace"; import type { Template, Workspace } from "api/typesGenerated"; import { @@ -138,6 +139,7 @@ interface AutoStopDisplayProps { } const AutoStopDisplay: FC = ({ workspace, template }) => { + useTime(); const { message, tooltip } = autostopDisplay(workspace, template); const display = ( diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx index cc36f4dced9b6..ab1ff5657531e 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx @@ -56,7 +56,7 @@ export const Ready: Story = { last_used_at: new Date().toISOString(), latest_build: { ...baseWorkspace.latest_build, - created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), }, }, }, diff --git a/site/src/pages/WorkspacesPage/LastUsed.tsx b/site/src/pages/WorkspacesPage/LastUsed.tsx index c4b10cc599d94..8748cb32b22ab 100644 --- a/site/src/pages/WorkspacesPage/LastUsed.tsx +++ b/site/src/pages/WorkspacesPage/LastUsed.tsx @@ -1,8 +1,9 @@ +import { useTheme } from "@emotion/react"; import { type FC } from "react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; -import { useTheme } from "@emotion/react"; import { Stack } from "components/Stack/Stack"; +import { useTime } from "hooks/useTime"; dayjs.extend(relativeTime); @@ -31,6 +32,7 @@ interface LastUsedProps { } export const LastUsed: FC = ({ lastUsedAt }) => { + useTime(); const theme = useTheme(); const t = dayjs(lastUsedAt); const now = dayjs(); From 3a4fb5e245002bc21d614b8c49ee09e959a710e3 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Thu, 8 Feb 2024 22:06:26 +0000 Subject: [PATCH 07/15] color tweaks --- site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx | 2 +- site/src/theme/dark/roles.ts | 4 ++-- site/src/theme/darkBlue/roles.ts | 4 ++-- site/src/theme/roles.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx index 71173565d2f31..5c928084dbe67 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx @@ -148,7 +148,7 @@ const AutoStopDisplay: FC = ({ workspace, template }) => { css={ isShutdownSoon(workspace) && ((theme) => ({ - color: `${theme.palette.warning.light} !important`, + color: `${theme.roles.danger.fill.outline} !important`, })) } > diff --git a/site/src/theme/dark/roles.ts b/site/src/theme/dark/roles.ts index 6a116863fd448..2931f2029b102 100644 --- a/site/src/theme/dark/roles.ts +++ b/site/src/theme/dark/roles.ts @@ -7,8 +7,8 @@ export default { outline: colors.orange[500], text: colors.orange[50], fill: { - solid: colors.orange[700], - outline: colors.orange[700], + solid: colors.orange[500], + outline: colors.orange[400], text: colors.white, }, disabled: { diff --git a/site/src/theme/darkBlue/roles.ts b/site/src/theme/darkBlue/roles.ts index d83eab54e0e28..a8aedfe8548a4 100644 --- a/site/src/theme/darkBlue/roles.ts +++ b/site/src/theme/darkBlue/roles.ts @@ -7,8 +7,8 @@ export default { outline: colors.orange[600], text: colors.orange[50], fill: { - solid: colors.orange[600], - outline: colors.orange[600], + solid: colors.orange[500], + outline: colors.orange[400], text: colors.white, }, disabled: { diff --git a/site/src/theme/roles.ts b/site/src/theme/roles.ts index 78e534984a702..efb499336c5c9 100644 --- a/site/src/theme/roles.ts +++ b/site/src/theme/roles.ts @@ -55,10 +55,10 @@ export interface Role { /** A set of more saturated colors to make things stand out */ fill: { - /** A saturated color for use as a background, or for text or icons on a neutral background */ + /** A saturated color for use as a background, or icons on a neutral background */ solid: string; - /** A color for outlining an area using the solid background color, or for an outlined icon */ + /** A color for outlining an area using the solid background color, or for text or for an outlined icon */ outline: string; /** A color for text when using the `solid` background color */ From 7c692d4f137bc48655376c809c638c71201c25fa Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Feb 2024 18:40:27 +0000 Subject: [PATCH 08/15] finishing touches --- site/src/modules/workspaces/activity.ts | 45 ++++++++++ .../pages/WorkspacePage/ActivityStatus.tsx | 66 +++++++++------ .../WorkspaceScheduleControls.tsx | 79 +++++++++++++---- .../WorkspacePage/WorkspaceTopbar.stories.tsx | 84 ++++++++++++++++--- .../pages/WorkspacePage/WorkspaceTopbar.tsx | 39 ++++----- site/src/utils/schedule.tsx | 55 ++++++++++-- 6 files changed, 279 insertions(+), 89 deletions(-) create mode 100644 site/src/modules/workspaces/activity.ts diff --git a/site/src/modules/workspaces/activity.ts b/site/src/modules/workspaces/activity.ts new file mode 100644 index 0000000000000..cc3e7361d92ff --- /dev/null +++ b/site/src/modules/workspaces/activity.ts @@ -0,0 +1,45 @@ +import dayjs from "dayjs"; +import type { Workspace } from "api/typesGenerated"; + +export type WorkspaceActivityStatus = + | "ready" + | "connected" + | "inactive" + | "notConnected" + | "notRunning"; + +export function getWorkspaceActivityStatus( + workspace: Workspace, +): WorkspaceActivityStatus { + const builtAt = dayjs(workspace.latest_build.created_at); + const usedAt = dayjs(workspace.last_used_at); + const now = dayjs(); + + if (workspace.latest_build.status !== "running") { + return "notRunning"; + } + + // This needs to compare to `usedAt` instead of `now`, because the "grace period" for + // marking a workspace as "Connected" is a lot longer. If you compared `builtAt` to `now`, + // you could end up switching from "Ready" to "Connected" without ever actually connecting. + const isBuiltRecently = builtAt.isAfter(usedAt.subtract(1, "second")); + // By default, agents report connection stats every 30 seconds, so 2 minutes should be + // plenty. Disconnection will be reflected relatively-quickly + const isUsedRecently = usedAt.isAfter(now.subtract(2, "minute")); + + // If the build is still "fresh", it'll be a while before the `last_used_at` gets bumped in + // a significant way by the agent, so just label it as ready instead of connected. + // Wait until `last_used_at` is after the time that the build finished, _and_ still + // make sure to check that it's recent, so that we don't show "Ready" indefinitely. + if (isUsedRecently && isBuiltRecently && workspace.health.healthy) { + return "ready"; + } + + if (isUsedRecently) { + return "connected"; + } + + // TODO: It'd be nice if we could differentiate between "connected but inactive" and + // "not connected", but that will require some relatively substantial backend work. + return "inactive"; +} diff --git a/site/src/pages/WorkspacePage/ActivityStatus.tsx b/site/src/pages/WorkspacePage/ActivityStatus.tsx index 85e00706c7459..8f2ae034a4c33 100644 --- a/site/src/pages/WorkspacePage/ActivityStatus.tsx +++ b/site/src/pages/WorkspacePage/ActivityStatus.tsx @@ -1,47 +1,59 @@ import { type FC } from "react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; +import Tooltip from "@mui/material/Tooltip"; import type { Workspace } from "api/typesGenerated"; import { useTime } from "hooks/useTime"; +import type { WorkspaceActivityStatus } from "modules/workspaces/activity"; import { Pill } from "components/Pill/Pill"; dayjs.extend(relativeTime); interface ActivityStatusProps { workspace: Workspace; + status: WorkspaceActivityStatus; } -export const ActivityStatus: FC = ({ workspace }) => { - const builtAt = dayjs(workspace.latest_build.updated_at); - const usedAt = dayjs(workspace.last_used_at); - const now = dayjs(); +export const ActivityStatus: FC = ({ + workspace, + status, +}) => { + const usedAt = dayjs(workspace.last_used_at).tz(dayjs.tz.guess()); - // This needs to compare to `usedAt` instead of `now`, because the "grace period" for - // marking a workspace as "Connected" is a lot longer. If you compared `builtAt` to `now`, - // you could end up switching from "Ready" to "Connected" without ever actually connecting. - const isBuiltRecently = builtAt.isAfter(usedAt.subtract(1, "second")); - const isUsedRecently = usedAt.isAfter(now.subtract(15, "minute")); + // Don't bother updating if `status` will need to change before anything can happen. + useTime(status === "ready" || status === "connected"); - useTime(isUsedRecently); - - switch (workspace.latest_build.status) { - // If the build is still "fresh", it'll be a while before the `last_used_at` gets bumped in - // a significant way by the agent, so just label it as ready instead of connected. - // Wait until `last_used_at` is after the time that the build finished, _and_ still - // make sure to check that it's recent, so that we don't show "Ready" indefinitely. - case isBuiltRecently && - isUsedRecently && - workspace.health.healthy && - "running": + switch (status) { + case "ready": return Ready; - // Since the agent reports on a 10m interval, we present any connection within that period - // plus a little wiggle room as an active connection. - case isUsedRecently && "running": + case "connected": return Connected; - case "running": - case "stopping": - case "stopped": - return Not connected; + case "inactive": + return ( + + This workspace was last active on{" "} + {usedAt.format("MMMM D [at] h:mm A")} + + } + > + Inactive + + ); + case "notConnected": + return ( + + This workspace was last active on{" "} + {usedAt.format("MMMM D [at] h:mm A")} + + } + > + Not connected + + ); } return null; diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx index 5c928084dbe67..8595fe8850e0c 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx @@ -1,8 +1,9 @@ import { type Interpolation, type Theme } from "@emotion/react"; import Link, { type LinkProps } from "@mui/material/Link"; import IconButton from "@mui/material/IconButton"; -import RemoveIcon from "@mui/icons-material/RemoveOutlined"; import AddIcon from "@mui/icons-material/AddOutlined"; +import RemoveIcon from "@mui/icons-material/RemoveOutlined"; +import ScheduleOutlined from "@mui/icons-material/ScheduleOutlined"; import Tooltip from "@mui/material/Tooltip"; import { visuallyHidden } from "@mui/utils"; import { type Dayjs } from "dayjs"; @@ -25,16 +26,54 @@ import { updateDeadline, workspaceByOwnerAndNameKey, } from "api/queries/workspaces"; +import { TopbarData, TopbarIcon } from "components/FullPageLayout/Topbar"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import type { WorkspaceActivityStatus } from "modules/workspaces/activity"; + +export interface WorkspaceScheduleProps { + status: WorkspaceActivityStatus; + workspace: Workspace; + template: Template; + canUpdateWorkspace: boolean; +} + +export const WorkspaceSchedule: FC = ({ + status, + workspace, + template, + canUpdateWorkspace, +}) => { + if (!shouldDisplayScheduleControls(workspace, status)) { + return null; + } + + return ( + + + + + + + + + ); +}; export interface WorkspaceScheduleControlsProps { workspace: Workspace; + status: WorkspaceActivityStatus; template: Template; canUpdateSchedule: boolean; } export const WorkspaceScheduleControls: FC = ({ workspace, + status, template, canUpdateSchedule, }) => { @@ -92,7 +131,11 @@ export const WorkspaceScheduleControls: FC = ({ return (
{isWorkspaceOn(workspace) ? ( - + ) : ( Starts at {autostartDisplay(workspace.autostart_schedule)} @@ -135,18 +178,27 @@ export const WorkspaceScheduleControls: FC = ({ interface AutoStopDisplayProps { workspace: Workspace; + status: WorkspaceActivityStatus; template: Template; } -const AutoStopDisplay: FC = ({ workspace, template }) => { +const AutoStopDisplay: FC = ({ + workspace, + status, + template, +}) => { useTime(); - const { message, tooltip } = autostopDisplay(workspace, template); + const { message, tooltip, danger } = autostopDisplay( + workspace, + status, + template, + ); const display = ( ({ color: `${theme.roles.danger.fill.outline} !important`, })) @@ -196,22 +248,13 @@ export const canEditDeadline = (workspace: Workspace): boolean => { export const shouldDisplayScheduleControls = ( workspace: Workspace, + status: WorkspaceActivityStatus, ): boolean => { const willAutoStop = isWorkspaceOn(workspace) && hasDeadline(workspace); const willAutoStart = !isWorkspaceOn(workspace) && hasAutoStart(workspace); - return willAutoStop || willAutoStart; -}; - -const isShutdownSoon = (workspace: Workspace): boolean => { - const deadline = workspace.latest_build.deadline; - if (!deadline) { - return false; - } - const deadlineDate = new Date(deadline); - const now = new Date(); - const diff = deadlineDate.getTime() - now.getTime(); - const oneHour = 1000 * 60 * 60; - return diff < oneHour; + const hasActivity = + status === "connected" && !workspace.latest_build.max_deadline; + return (willAutoStop || willAutoStart) && !hasActivity; }; const styles = { diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx index ab1ff5657531e..f224a62acfffc 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx @@ -8,7 +8,7 @@ import { } from "testHelpers/entities"; import { WorkspaceTopbar } from "./WorkspaceTopbar"; import { withDashboardProvider } from "testHelpers/storybook"; -import { addDays } from "date-fns"; +import { addDays, addHours, addMinutes } from "date-fns"; import { getWorkspaceQuotaQueryKey } from "api/queries/workspaceQuota"; // We want a workspace without a deadline to not pollute the screenshot @@ -53,10 +53,33 @@ export const Ready: Story = { args: { workspace: { ...baseWorkspace, - last_used_at: new Date().toISOString(), + get last_used_at() { + return new Date().toISOString(); + }, latest_build: { ...baseWorkspace.latest_build, - updated_at: new Date().toISOString(), + get created_at() { + return new Date().toISOString(); + }, + }, + }, + }, +}; +export const ReadyWithDeadline: Story = { + args: { + workspace: { + ...MockWorkspace, + get last_used_at() { + return new Date().toISOString(); + }, + latest_build: { + ...MockWorkspace.latest_build, + get created_at() { + return new Date().toISOString(); + }, + get deadline() { + return addHours(new Date(), 8).toISOString(); + }, }, }, }, @@ -66,7 +89,44 @@ export const Connected: Story = { args: { workspace: { ...baseWorkspace, - last_used_at: new Date().toISOString(), + get last_used_at() { + return new Date().toISOString(); + }, + }, + }, +}; +export const ConnectedWithDeadline: Story = { + args: { + workspace: { + ...MockWorkspace, + get last_used_at() { + return new Date().toISOString(); + }, + latest_build: { + ...MockWorkspace.latest_build, + get deadline() { + return addHours(new Date(), 8).toISOString(); + }, + }, + }, + }, +}; +export const ConnectedWithMaxDeadline: Story = { + args: { + workspace: { + ...MockWorkspace, + get last_used_at() { + return new Date().toISOString(); + }, + latest_build: { + ...MockWorkspace.latest_build, + get deadline() { + return addHours(new Date(), 1).toISOString(); + }, + get max_deadline() { + return addHours(new Date(), 1).toISOString(); + }, + }, }, }, }; @@ -96,15 +156,15 @@ export const WithExceededDeadline: Story = { }, }; -const in30Minutes = new Date(); -in30Minutes.setMinutes(in30Minutes.getMinutes() + 30); export const WithApproachingDeadline: Story = { args: { workspace: { ...MockWorkspace, latest_build: { ...MockWorkspace.latest_build, - deadline: in30Minutes.toISOString(), + get deadline() { + return addMinutes(new Date(), 30).toISOString(); + }, }, }, }, @@ -122,15 +182,15 @@ export const WithApproachingDeadline: Story = { }, }; -const in8Hours = new Date(); -in8Hours.setHours(in8Hours.getHours() + 8); export const WithFarAwayDeadline: Story = { args: { workspace: { ...MockWorkspace, latest_build: { ...MockWorkspace.latest_build, - deadline: in8Hours.toISOString(), + get deadline() { + return addHours(new Date(), 8).toISOString(); + }, }, }, }, @@ -154,7 +214,9 @@ export const WithFarAwayDeadlineRequiredByTemplate: Story = { ...MockWorkspace, latest_build: { ...MockWorkspace.latest_build, - deadline: in8Hours.toISOString(), + get deadline() { + return addHours(new Date(), 8).toISOString(); + }, }, }, template: { diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index 425e030601cdf..b77060c70731d 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -3,12 +3,16 @@ import Link from "@mui/material/Link"; import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined"; import DeleteOutline from "@mui/icons-material/DeleteOutline"; import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined"; -import ScheduleOutlined from "@mui/icons-material/ScheduleOutlined"; import { useTheme } from "@emotion/react"; import { type FC } from "react"; import { useQuery } from "react-query"; import { Link as RouterLink } from "react-router-dom"; import type * as TypesGen from "api/typesGenerated"; +import { workspaceQuota } from "api/queries/workspaceQuota"; +import { WorkspaceStatusBadge } from "modules/workspaces/WorkspaceStatusBadge/WorkspaceStatusBadge"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import { getWorkspaceActivityStatus } from "modules/workspaces/activity"; +import { displayDormantDeletion } from "utils/dormant"; import { Topbar, TopbarAvatar, @@ -17,10 +21,6 @@ import { TopbarIcon, TopbarIconButton, } from "components/FullPageLayout/Topbar"; -import { WorkspaceStatusBadge } from "modules/workspaces/WorkspaceStatusBadge/WorkspaceStatusBadge"; -import { workspaceQuota } from "api/queries/workspaceQuota"; -import { useDashboard } from "modules/dashboard/useDashboard"; -import { displayDormantDeletion } from "utils/dormant"; import { Popover, PopoverTrigger } from "components/Popover/Popover"; import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip"; import { AvatarData } from "components/AvatarData/AvatarData"; @@ -28,12 +28,9 @@ import { ExternalAvatar } from "components/Avatar/Avatar"; import { UserAvatar } from "components/UserAvatar/UserAvatar"; import { WorkspaceActions } from "./WorkspaceActions/WorkspaceActions"; import { WorkspaceNotifications } from "./WorkspaceNotifications/WorkspaceNotifications"; -import { - WorkspaceScheduleControls, - shouldDisplayScheduleControls, -} from "./WorkspaceScheduleControls"; import { WorkspacePermissions } from "./permissions"; import { ActivityStatus } from "./ActivityStatus"; +import { WorkspaceSchedule } from "./WorkspaceScheduleControls"; export type WorkspaceError = | "getBuildsError" @@ -111,6 +108,8 @@ export const WorkspaceTopbar: FC = ({ allowAdvancedScheduling, ); + const activityStatus = getWorkspaceActivityStatus(workspace); + return ( @@ -201,22 +200,14 @@ export const WorkspaceTopbar: FC = ({ - + - {shouldDisplayScheduleControls(workspace) && ( - - - - - - - - - )} + {shouldDisplayDormantData && ( diff --git a/site/src/utils/schedule.tsx b/site/src/utils/schedule.tsx index a6db6be98c2df..18ada0aa53290 100644 --- a/site/src/utils/schedule.tsx +++ b/site/src/utils/schedule.tsx @@ -14,6 +14,10 @@ import { HelpTooltipTitle, } from "components/HelpTooltip/HelpTooltip"; import { isWorkspaceOn } from "./workspace"; +import { + WorkspaceActivityStatus, + getWorkspaceActivityStatus, +} from "modules/workspaces/activity"; // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're // sorted alphabetically. @@ -97,10 +101,12 @@ export const isShuttingDown = ( export const autostopDisplay = ( workspace: Workspace, + activityStatus: WorkspaceActivityStatus, template: Template, ): { message: ReactNode; tooltip?: ReactNode; + danger?: boolean; } => { const ttl = workspace.ttl_ms; @@ -111,13 +117,33 @@ export const autostopDisplay = ( // represent the previously defined ttl. Thus, we always derive from the // deadline as the source of truth. - const deadline = dayjs(workspace.latest_build.deadline).utc(); + const deadline = dayjs(workspace.latest_build.deadline).tz( + dayjs.tz.guess(), + ); + const now = dayjs(workspace.latest_build.deadline).utc(); if (isShuttingDown(workspace, deadline)) { return { message: Language.workspaceShuttingDownLabel, }; + } else if ( + activityStatus === "connected" && + deadline.isBefore(now.add(2, "hour")) + ) { + return { + message: `Required to stop soon`, + tooltip: ( + <> + Upcoming autostop required + This workspace will be required to stop by{" "} + {dayjs(workspace.latest_build.max_deadline).format( + "MMMM D [at] h:mm A", + )} + . You can restart your workspace before then to avoid interruption. + + ), + danger: true, + }; } else { - const deadlineTz = deadline.tz(dayjs.tz.guess()); let title = ( Template Autostop requirement ); @@ -137,17 +163,16 @@ export const autostopDisplay = ( ); } return { - message: `Stop ${deadlineTz.fromNow()}`, + message: `Stop ${deadline.fromNow()}`, tooltip: ( <> {title} - - This workspace will be stopped on{" "} - {deadlineTz.format("MMMM D [at] h:mm A")} - {reason} - + This workspace will be stopped on{" "} + {deadline.format("MMMM D [at] h:mm A")} + {reason} ), + danger: isShutdownSoon(workspace), }; } } else if (!ttl || ttl < 1) { @@ -161,11 +186,23 @@ export const autostopDisplay = ( // not running. Therefore, we derive from workspace.ttl. const duration = dayjs.duration(ttl, "milliseconds"); return { - message: `${duration.humanize()} ${Language.afterStart}`, + message: `Stop ${duration.humanize()} ${Language.afterStart}`, }; } }; +const isShutdownSoon = (workspace: Workspace): boolean => { + const deadline = workspace.latest_build.deadline; + if (!deadline) { + return false; + } + const deadlineDate = new Date(deadline); + const now = new Date(); + const diff = deadlineDate.getTime() - now.getTime(); + const oneHour = 1000 * 60 * 60; + return diff < oneHour; +}; + export const deadlineExtensionMin = dayjs.duration(30, "minutes"); export const deadlineExtensionMax = dayjs.duration(24, "hours"); From 2fe1d457efbd1b7249cc30312472eb8dbf7fd759 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Feb 2024 19:02:24 +0000 Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/WorkspacePage/WorkspacePage.tsx | 17 +++++++---- .../WorkspaceScheduleControls.test.tsx | 28 ++++++++++--------- site/src/utils/schedule.tsx | 14 +++------- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 8454057393403..ec6ec72b42709 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -77,6 +77,15 @@ export const WorkspacePage: FC = () => { } }, ); + const getWorkspaceData = useEffectEvent(() => { + if (!workspace) { + throw new Error("Applying an update for a workspace that is undefined."); + } + + return queryClient.getQueryData( + workspaceQueryOptions.queryKey, + ) as Workspace; + }); const workspaceId = workspace?.id; useEffect(() => { if (!workspaceId) { @@ -95,11 +104,7 @@ export const WorkspacePage: FC = () => { // Merge with a fresh object `{}` as the base, because `merge` uses an in-place algorithm, // and would otherwise mutate the `queryClient`'s internal state. await updateWorkspaceData( - merge( - {}, - queryClient.getQueryData(workspaceQueryOptions.queryKey) as Workspace, - newWorkspaceData, - ), + merge({}, getWorkspaceData(), newWorkspaceData), ); }); @@ -110,7 +115,7 @@ export const WorkspacePage: FC = () => { return () => { eventSource.close(); }; - }, [updateWorkspaceData, workspaceId]); + }, [updateWorkspaceData, getWorkspaceData, workspaceId]); // Page statuses const pageError = diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx index 94d57caa6c850..9aeb3c12b295c 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx @@ -1,25 +1,15 @@ import { render, screen } from "@testing-library/react"; -import { type FC } from "react"; +import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "react-query"; import { RouterProvider, createMemoryRouter } from "react-router-dom"; -import userEvent from "@testing-library/user-event"; import dayjs from "dayjs"; import * as API from "api/api"; import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; import { ThemeProvider } from "contexts/ThemeProvider"; import { MockTemplate, MockWorkspace } from "testHelpers/entities"; +import { getWorkspaceActivityStatus } from "modules/workspaces/activity"; import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls"; -const Wrapper: FC = () => { - return ( - - ); -}; - const BASE_DEADLINE = dayjs().add(3, "hour"); const renderScheduleControls = async () => { @@ -27,7 +17,19 @@ const renderScheduleControls = async () => { }])} + router={createMemoryRouter([ + { + path: "/", + element: ( + + ), + }, + ])} /> diff --git a/site/src/utils/schedule.tsx b/site/src/utils/schedule.tsx index 18ada0aa53290..48bd5da500f0f 100644 --- a/site/src/utils/schedule.tsx +++ b/site/src/utils/schedule.tsx @@ -9,15 +9,9 @@ import utc from "dayjs/plugin/utc"; import { type ReactNode } from "react"; import { Link as RouterLink } from "react-router-dom"; import type { Template, Workspace } from "api/typesGenerated"; -import { - HelpTooltipText, - HelpTooltipTitle, -} from "components/HelpTooltip/HelpTooltip"; +import { HelpTooltipTitle } from "components/HelpTooltip/HelpTooltip"; +import type { WorkspaceActivityStatus } from "modules/workspaces/activity"; import { isWorkspaceOn } from "./workspace"; -import { - WorkspaceActivityStatus, - getWorkspaceActivityStatus, -} from "modules/workspaces/activity"; // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're // sorted alphabetically. @@ -133,7 +127,7 @@ export const autostopDisplay = ( message: `Required to stop soon`, tooltip: ( <> - Upcoming autostop required + Upcoming stop required This workspace will be required to stop by{" "} {dayjs(workspace.latest_build.max_deadline).format( "MMMM D [at] h:mm A", @@ -154,7 +148,7 @@ export const autostopDisplay = ( <> {" "} because this workspace has enabled autostop. You can disable - autostop from this workspace's{" "} + autostop from this workspace's{" "} schedule settings From 201eef61dc049a5829a06404bf0d4564080821a0 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Feb 2024 19:32:47 +0000 Subject: [PATCH 10/15] =?UTF-8?q?=F0=9F=A7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkspaceScheduleControls.test.tsx | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx index 9aeb3c12b295c..44a24d7cf1649 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx @@ -1,35 +1,63 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "react-query"; +import { type FC } from "react"; +import { QueryClient, QueryClientProvider, useQuery } from "react-query"; import { RouterProvider, createMemoryRouter } from "react-router-dom"; import dayjs from "dayjs"; +import { rest } from "msw"; import * as API from "api/api"; -import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; +import { workspaceByOwnerAndName } from "api/queries/workspaces"; import { ThemeProvider } from "contexts/ThemeProvider"; import { MockTemplate, MockWorkspace } from "testHelpers/entities"; -import { getWorkspaceActivityStatus } from "modules/workspaces/activity"; +import { server } from "testHelpers/server"; +import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls"; +import { getWorkspaceActivityStatus } from "modules/workspaces/activity"; + +const Wrapper: FC = () => { + const { data: workspace } = useQuery( + workspaceByOwnerAndName(MockWorkspace.owner_name, MockWorkspace.name), + ); + + if (!workspace) { + return null; + } + + return ( + + ); +}; const BASE_DEADLINE = dayjs().add(3, "hour"); const renderScheduleControls = async () => { + server.use( + rest.get( + "/api/v2/users/:username/workspace/:workspaceName", + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + deadline: BASE_DEADLINE.toISOString(), + }, + }), + ); + }, + ), + ); render( - ), - }, - ])} + router={createMemoryRouter([{ path: "/", element: }])} /> From 4a9d862a1d1ab0240cbedb31bda06ba4e8907ef9 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Feb 2024 19:40:40 +0000 Subject: [PATCH 11/15] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- codersdk/workspaces.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index a325b47b955ab..68cc848b77a07 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -497,9 +497,7 @@ func (c *Client) UnfavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID) return nil } -var ( - WorkspaceNotifyDescriptionAgentStatsOnly = []byte("agentStatsOnly") -) +var WorkspaceNotifyDescriptionAgentStatsOnly = []byte("agentStatsOnly") // WorkspaceNotifyChannel is the PostgreSQL NOTIFY // channel to listen for updates on. The payload is empty, From b2ce1dd143fcd311fe0efd34ef48f4253a64ab82 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Feb 2024 20:33:11 +0000 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=A7=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- coderd/agentapi/stats_test.go | 48 +++++++++++++++++++++-------------- site/src/utils/schedule.tsx | 2 +- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/coderd/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index a26e7fbf6ae7a..5816e01875cca 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/prometheusmetrics" "github.com/coder/coder/v2/coderd/schedule" ) @@ -78,8 +79,10 @@ func TestUpdateStates(t *testing.T) { t.Parallel() var ( - now = dbtime.Now() - dbM = dbmock.NewMockStore(gomock.NewController(t)) + now = dbtime.Now() + db = dbmock.NewMockStore(gomock.NewController(t)) + ps = pubsub.NewInMemory() + templateScheduleStore = schedule.MockTemplateScheduleStore{ GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) { panic("should not be called") @@ -124,7 +127,8 @@ func TestUpdateStates(t *testing.T) { AgentFn: func(context.Context) (database.WorkspaceAgent, error) { return agent, nil }, - Database: dbM, + Database: db, + Pubsub: ps, StatsBatcher: batcher, TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), AgentStatsRefreshInterval: 10 * time.Second, @@ -144,25 +148,25 @@ func TestUpdateStates(t *testing.T) { } // Workspace gets fetched. - dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{ + db.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{ Workspace: workspace, TemplateName: template.Name, }, nil) // We expect an activity bump because ConnectionCount > 0. - dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ + db.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ WorkspaceID: workspace.ID, NextAutostart: time.Time{}.UTC(), }).Return(nil) // Workspace last used at gets bumped. - dbM.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{ + db.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{ ID: workspace.ID, LastUsedAt: now, }).Return(nil) // User gets fetched to hit the UpdateAgentMetricsFn. - dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) resp, err := api.UpdateStats(context.Background(), req) require.NoError(t, err) @@ -188,7 +192,8 @@ func TestUpdateStates(t *testing.T) { var ( now = dbtime.Now() - dbM = dbmock.NewMockStore(gomock.NewController(t)) + db = dbmock.NewMockStore(gomock.NewController(t)) + ps = pubsub.NewInMemory() templateScheduleStore = schedule.MockTemplateScheduleStore{ GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) { panic("should not be called") @@ -213,7 +218,8 @@ func TestUpdateStates(t *testing.T) { AgentFn: func(context.Context) (database.WorkspaceAgent, error) { return agent, nil }, - Database: dbM, + Database: db, + Pubsub: ps, StatsBatcher: batcher, TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), AgentStatsRefreshInterval: 10 * time.Second, @@ -225,13 +231,13 @@ func TestUpdateStates(t *testing.T) { } // Workspace gets fetched. - dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{ + db.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{ Workspace: workspace, TemplateName: template.Name, }, nil) // Workspace last used at gets bumped. - dbM.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{ + db.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{ ID: workspace.ID, LastUsedAt: now, }).Return(nil) @@ -244,7 +250,8 @@ func TestUpdateStates(t *testing.T) { t.Parallel() var ( - dbM = dbmock.NewMockStore(gomock.NewController(t)) + db = dbmock.NewMockStore(gomock.NewController(t)) + ps = pubsub.NewInMemory() req = &agentproto.UpdateStatsRequest{ Stats: &agentproto.Stats{ ConnectionsByProto: map[string]int64{}, // len() == 0 @@ -255,7 +262,8 @@ func TestUpdateStates(t *testing.T) { AgentFn: func(context.Context) (database.WorkspaceAgent, error) { return agent, nil }, - Database: dbM, + Database: db, + Pubsub: ps, StatsBatcher: nil, // should not be called TemplateScheduleStore: nil, // should not be called AgentStatsRefreshInterval: 10 * time.Second, @@ -290,7 +298,8 @@ func TestUpdateStates(t *testing.T) { nextAutostart := now.Add(30 * time.Minute).UTC() // always sent to DB as UTC var ( - dbM = dbmock.NewMockStore(gomock.NewController(t)) + db = dbmock.NewMockStore(gomock.NewController(t)) + ps = pubsub.NewInMemory() templateScheduleStore = schedule.MockTemplateScheduleStore{ GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) { return schedule.TemplateScheduleOptions{ @@ -321,7 +330,8 @@ func TestUpdateStates(t *testing.T) { AgentFn: func(context.Context) (database.WorkspaceAgent, error) { return agent, nil }, - Database: dbM, + Database: db, + Pubsub: ps, StatsBatcher: batcher, TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), AgentStatsRefreshInterval: 15 * time.Second, @@ -341,26 +351,26 @@ func TestUpdateStates(t *testing.T) { } // Workspace gets fetched. - dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{ + db.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{ Workspace: workspace, TemplateName: template.Name, }, nil) // We expect an activity bump because ConnectionCount > 0. However, the // next autostart time will be set on the bump. - dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ + db.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ WorkspaceID: workspace.ID, NextAutostart: nextAutostart, }).Return(nil) // Workspace last used at gets bumped. - dbM.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{ + db.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{ ID: workspace.ID, LastUsedAt: now, }).Return(nil) // User gets fetched to hit the UpdateAgentMetricsFn. - dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) + db.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) resp, err := api.UpdateStats(context.Background(), req) require.NoError(t, err) diff --git a/site/src/utils/schedule.tsx b/site/src/utils/schedule.tsx index 48bd5da500f0f..8ffe6223c0710 100644 --- a/site/src/utils/schedule.tsx +++ b/site/src/utils/schedule.tsx @@ -114,7 +114,7 @@ export const autostopDisplay = ( const deadline = dayjs(workspace.latest_build.deadline).tz( dayjs.tz.guess(), ); - const now = dayjs(workspace.latest_build.deadline).utc(); + const now = dayjs(workspace.latest_build.deadline); if (isShuttingDown(workspace, deadline)) { return { message: Language.workspaceShuttingDownLabel, From d13f91e3459ea80f9c71b4cd7092a950b5705e42 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Feb 2024 20:59:31 +0000 Subject: [PATCH 13/15] fmt --- coderd/agentapi/stats_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/coderd/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index 5816e01875cca..59afb64edf9b5 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -298,8 +298,9 @@ func TestUpdateStates(t *testing.T) { nextAutostart := now.Add(30 * time.Minute).UTC() // always sent to DB as UTC var ( - db = dbmock.NewMockStore(gomock.NewController(t)) - ps = pubsub.NewInMemory() + db = dbmock.NewMockStore(gomock.NewController(t)) + ps = pubsub.NewInMemory() + templateScheduleStore = schedule.MockTemplateScheduleStore{ GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) { return schedule.TemplateScheduleOptions{ From 8a52a23dddace90d98c47895ec2012bef5f750f4 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 12 Feb 2024 23:46:14 +0000 Subject: [PATCH 14/15] `dbM` --- coderd/agentapi/stats_test.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/coderd/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index 59afb64edf9b5..d8f0a2bcd2214 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -80,7 +80,7 @@ func TestUpdateStates(t *testing.T) { var ( now = dbtime.Now() - db = dbmock.NewMockStore(gomock.NewController(t)) + dbM = dbmock.NewMockStore(gomock.NewController(t)) ps = pubsub.NewInMemory() templateScheduleStore = schedule.MockTemplateScheduleStore{ @@ -127,7 +127,7 @@ func TestUpdateStates(t *testing.T) { AgentFn: func(context.Context) (database.WorkspaceAgent, error) { return agent, nil }, - Database: db, + Database: dbM, Pubsub: ps, StatsBatcher: batcher, TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), @@ -148,25 +148,25 @@ func TestUpdateStates(t *testing.T) { } // Workspace gets fetched. - db.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{ + dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{ Workspace: workspace, TemplateName: template.Name, }, nil) // We expect an activity bump because ConnectionCount > 0. - db.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ + dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ WorkspaceID: workspace.ID, NextAutostart: time.Time{}.UTC(), }).Return(nil) // Workspace last used at gets bumped. - db.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{ + dbM.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{ ID: workspace.ID, LastUsedAt: now, }).Return(nil) // User gets fetched to hit the UpdateAgentMetricsFn. - db.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) + dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) resp, err := api.UpdateStats(context.Background(), req) require.NoError(t, err) @@ -192,7 +192,7 @@ func TestUpdateStates(t *testing.T) { var ( now = dbtime.Now() - db = dbmock.NewMockStore(gomock.NewController(t)) + dbM = dbmock.NewMockStore(gomock.NewController(t)) ps = pubsub.NewInMemory() templateScheduleStore = schedule.MockTemplateScheduleStore{ GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) { @@ -218,7 +218,7 @@ func TestUpdateStates(t *testing.T) { AgentFn: func(context.Context) (database.WorkspaceAgent, error) { return agent, nil }, - Database: db, + Database: dbM, Pubsub: ps, StatsBatcher: batcher, TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), @@ -231,13 +231,13 @@ func TestUpdateStates(t *testing.T) { } // Workspace gets fetched. - db.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{ + dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{ Workspace: workspace, TemplateName: template.Name, }, nil) // Workspace last used at gets bumped. - db.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{ + dbM.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{ ID: workspace.ID, LastUsedAt: now, }).Return(nil) From 3972eb95576e685987bab54657e34cc22fb3a924 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Tue, 13 Feb 2024 17:40:02 +0000 Subject: [PATCH 15/15] test pubsub delivery --- coderd/agentapi/stats_test.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/coderd/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index d8f0a2bcd2214..4c456a377593b 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -1,6 +1,7 @@ package agentapi_test import ( + "bytes" "context" "database/sql" "sync" @@ -22,6 +23,8 @@ import ( "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/prometheusmetrics" "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) type statsBatcher struct { @@ -168,6 +171,15 @@ func TestUpdateStates(t *testing.T) { // User gets fetched to hit the UpdateAgentMetricsFn. dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) + // Ensure that pubsub notifications are sent. + publishAgentStats := make(chan bool) + ps.Subscribe(codersdk.WorkspaceNotifyChannel(workspace.ID), func(_ context.Context, description []byte) { + go func() { + publishAgentStats <- bytes.Equal(description, codersdk.WorkspaceNotifyDescriptionAgentStatsOnly) + close(publishAgentStats) + }() + }) + resp, err := api.UpdateStats(context.Background(), req) require.NoError(t, err) require.Equal(t, &agentproto.UpdateStatsResponse{ @@ -183,7 +195,13 @@ func TestUpdateStates(t *testing.T) { require.Equal(t, user.ID, batcher.lastUserID) require.Equal(t, workspace.ID, batcher.lastWorkspaceID) require.Equal(t, req.Stats, batcher.lastStats) - + ctx := testutil.Context(t, testutil.WaitShort) + select { + case <-ctx.Done(): + t.Error("timed out while waiting for pubsub notification") + case wasAgentStatsOnly := <-publishAgentStats: + require.Equal(t, wasAgentStatsOnly, true) + } require.True(t, updateAgentMetricsFnCalled) })