diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go index 73b50d9c0c..acfe9145b2 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 1185b99abd..59706da305 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), []byte{}) + 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/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index a26e7fbf6a..46e706c3e1 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -19,8 +19,11 @@ 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" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" ) type statsBatcher struct { @@ -78,8 +81,10 @@ func TestUpdateStates(t *testing.T) { t.Parallel() var ( - now = dbtime.Now() - dbM = dbmock.NewMockStore(gomock.NewController(t)) + now = dbtime.Now() + dbM = 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") @@ -125,6 +130,7 @@ func TestUpdateStates(t *testing.T) { return agent, nil }, Database: dbM, + Pubsub: ps, StatsBatcher: batcher, TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), AgentStatsRefreshInterval: 10 * time.Second, @@ -164,6 +170,14 @@ 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. + notifyDescription := make(chan []byte) + ps.Subscribe(codersdk.WorkspaceNotifyChannel(workspace.ID), func(_ context.Context, description []byte) { + go func() { + notifyDescription <- description + }() + }) + resp, err := api.UpdateStats(context.Background(), req) require.NoError(t, err) require.Equal(t, &agentproto.UpdateStatsResponse{ @@ -179,7 +193,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 description := <-notifyDescription: + require.Equal(t, description, []byte{}) + } require.True(t, updateAgentMetricsFnCalled) }) @@ -189,6 +209,7 @@ func TestUpdateStates(t *testing.T) { var ( now = dbtime.Now() dbM = 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") @@ -214,6 +235,7 @@ func TestUpdateStates(t *testing.T) { return agent, nil }, Database: dbM, + Pubsub: ps, StatsBatcher: batcher, TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), AgentStatsRefreshInterval: 10 * time.Second, @@ -245,6 +267,7 @@ func TestUpdateStates(t *testing.T) { var ( dbM = dbmock.NewMockStore(gomock.NewController(t)) + ps = pubsub.NewInMemory() req = &agentproto.UpdateStatsRequest{ Stats: &agentproto.Stats{ ConnectionsByProto: map[string]int64{}, // len() == 0 @@ -256,6 +279,7 @@ func TestUpdateStates(t *testing.T) { return agent, nil }, Database: dbM, + Pubsub: ps, StatsBatcher: nil, // should not be called TemplateScheduleStore: nil, // should not be called AgentStatsRefreshInterval: 10 * time.Second, @@ -290,7 +314,9 @@ 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)) + dbM = 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{ @@ -322,6 +348,7 @@ func TestUpdateStates(t *testing.T) { return agent, nil }, Database: dbM, + Pubsub: ps, StatsBatcher: batcher, TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), AgentStatsRefreshInterval: 15 * time.Second, diff --git a/site/src/hooks/useTime.ts b/site/src/hooks/useTime.ts new file mode 100644 index 0000000000..1cddccc432 --- /dev/null +++ b/site/src/hooks/useTime.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { useEffectEvent } from "./hookPolyfills"; + +interface UseTimeOptions { + /** + * Can be set to `true` to disable checking for updates in circumstances where it is known + * that there is no work to do. + */ + disabled?: boolean; + + /** + * The amount of time in milliseconds that should pass between checking for updates. + */ + interval?: number; +} + +/** + * 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. + */ +export function useTime(func: () => T, options: UseTimeOptions = {}): T { + const [computedValue, setComputedValue] = useState(() => func()); + const { disabled = false, interval = 1000 } = options; + + const thunk = useEffectEvent(func); + + useEffect(() => { + if (disabled) { + return; + } + + const handle = setInterval(() => { + setComputedValue(() => thunk()); + }, interval); + + return () => { + clearInterval(handle); + }; + }, [thunk, disabled, interval]); + + return computedValue; +} diff --git a/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx b/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx index e0292e6583..2f2d87ecd0 100644 --- a/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx +++ b/site/src/modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip.tsx @@ -87,11 +87,7 @@ export const WorkspaceOutdatedTooltipContent: FC = ({
Message
{activeVersion ? ( - activeVersion.message === "" ? ( - "No message" - ) : ( - activeVersion.message - ) + activeVersion.message || "No message" ) : ( )} diff --git a/site/src/modules/workspaces/activity.ts b/site/src/modules/workspaces/activity.ts new file mode 100644 index 0000000000..cc3e7361d9 --- /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/UserSettingsPage/AccountPage/AccountPage.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx index 910b131e3a..3dc3683ae3 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/WorkspaceScheduleControls.test.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx index 87599a49e8..c13d3ffe5b 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx @@ -1,18 +1,19 @@ 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 { RouterProvider, createMemoryRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event"; -import { server } from "testHelpers/server"; -import { rest } from "msw"; +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 { workspaceByOwnerAndName } from "api/queries/workspaces"; +import { ThemeProvider } from "contexts/ThemeProvider"; +import { MockTemplate, MockWorkspace } from "testHelpers/entities"; +import { server } from "testHelpers/server"; import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; +import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls"; -const Wrapper = () => { +const Wrapper: FC = () => { const { data: workspace } = useQuery( workspaceByOwnerAndName(MockWorkspace.owner_name, MockWorkspace.name), ); @@ -21,7 +22,13 @@ const Wrapper = () => { return null; } - return ; + return ( + + ); }; const BASE_DEADLINE = dayjs().add(3, "hour"); @@ -75,7 +82,7 @@ test("add 3 hours to deadline", async () => { await screen.findByText( "Workspace shutdown time has been successfully updated.", ); - expect(screen.getByText("Stop in 6 hours")).toBeInTheDocument(); + expect(await screen.findByText("Stop in 6 hours")).toBeInTheDocument(); // Mocks are used here because the 'usedDeadline' is a dayjs object, which // can't be directly compared. @@ -87,7 +94,7 @@ test("add 3 hours to deadline", async () => { ); }); -test("remove 3 hours to deadline", async () => { +test("remove 2 hours to deadline", async () => { const user = userEvent.setup(); const updateDeadlineSpy = jest .spyOn(API, "putWorkspaceExtension") @@ -103,7 +110,7 @@ test("remove 3 hours to deadline", async () => { await screen.findByText( "Workspace shutdown time has been successfully updated.", ); - expect(screen.getByText("Stop in an hour")).toBeInTheDocument(); + expect(await screen.findByText("Stop in an hour")).toBeInTheDocument(); // Mocks are used here because the 'usedDeadline' is a dayjs object, which // can't be directly compared. diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx index 1241ce769b..7b027e9e36 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx @@ -1,9 +1,18 @@ import { type Interpolation, type Theme } from "@emotion/react"; -import Link, { LinkProps } from "@mui/material/Link"; -import { forwardRef, type FC, useRef } from "react"; +import Link, { type LinkProps } from "@mui/material/Link"; +import IconButton from "@mui/material/IconButton"; +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 dayjs, { type Dayjs } from "dayjs"; +import { forwardRef, type FC, useRef, useState, ReactNode } 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 { Workspace } from "api/typesGenerated"; +import type { Template, Workspace } from "api/typesGenerated"; import { autostartDisplay, autostopDisplay, @@ -12,27 +21,93 @@ 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 { getErrorMessage } from "api/errors"; import { updateDeadline, workspaceByOwnerAndNameKey, } from "api/queries/workspaces"; +import { TopbarData, TopbarIcon } from "components/FullPageLayout/Topbar"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; -import { useMutation, useQueryClient } from "react-query"; -import { Dayjs } from "dayjs"; -import { visuallyHidden } from "@mui/utils"; +import { getWorkspaceActivityStatus } from "modules/workspaces/activity"; +import { Pill } from "components/Pill/Pill"; -export interface WorkspaceScheduleControlsProps { +export interface WorkspaceScheduleContainerProps { + children?: ReactNode; + onClickIcon?: () => void; +} + +export const WorkspaceScheduleContainer: FC< + WorkspaceScheduleContainerProps +> = ({ children, onClickIcon }) => { + const icon = ( + + + + ); + + return ( + + + {onClickIcon ? ( + + ) : ( + icon + )} + + {children} + + ); +}; + +interface WorkspaceScheduleControlsProps { workspace: Workspace; + template: Template; canUpdateSchedule: boolean; } export const WorkspaceScheduleControls: FC = ({ workspace, + template, + canUpdateSchedule, +}) => { + if (!shouldDisplayScheduleControls(workspace)) { + return null; + } + + return ( +
+ {isWorkspaceOn(workspace) ? ( + + ) : ( + + + Starts at {autostartDisplay(workspace.autostart_schedule)} + + + )} +
+ ); +}; + +interface AutostopDisplayProps { + workspace: Workspace; + template: Template; + canUpdateSchedule: boolean; +} + +const AutostopDisplay: FC = ({ + workspace, + template, canUpdateSchedule, }) => { const queryClient = useQueryClient(); @@ -86,74 +161,103 @@ export const WorkspaceScheduleControls: FC = ({ }, 500); }; - return ( -
- {isWorkspaceOn(workspace) ? ( - - ) : ( - - Starts at {autostartDisplay(workspace.autostart_schedule)} - - )} + const activityStatus = useTime(() => getWorkspaceActivityStatus(workspace)); + const { message, tooltip, danger } = autostopDisplay( + workspace, + activityStatus, + template, + ); - {canUpdateSchedule && canEditDeadline(workspace) && ( -
- - { - handleDeadlineChange(deadline.subtract(1, "h")); - }} - > - - Subtract 1 hour - - - - { - handleDeadlineChange(deadline.add(1, "h")); - }} - > - - Add 1 hour - - -
- )} + const [showControlsAnyway, setShowControlsAnyway] = useState(false); + let onClickScheduleIcon: (() => void) | undefined; + let activity: ReactNode = null; + + if (activityStatus === "connected") { + onClickScheduleIcon = () => setShowControlsAnyway((it) => !it); + activity = Connected; + + const now = dayjs(); + const noRequiredStopSoon = + !workspace.latest_build.max_deadline || + dayjs(workspace.latest_build.max_deadline).isAfter(now.add(2, "hour")); + + // User has shown controls manually, or we should warn about a nearby required stop + if (!showControlsAnyway && noRequiredStopSoon) { + return ( + <> + {activity} + + + ); + } + } + + const display = ( + ({ + color: `${theme.roles.danger.fill.outline} !important`, + })) + } + > + {message} + + ); + + const controls = canUpdateSchedule && canEditDeadline(workspace) && ( +
+ + { + handleDeadlineChange(deadline.subtract(1, "h")); + }} + > + + Subtract 1 hour + + + + { + handleDeadlineChange(deadline.add(1, "h")); + }} + > + + Add 1 hour + +
); -}; -interface AutoStopDisplayProps { - workspace: Workspace; -} - -const AutoStopDisplay: FC = ({ workspace }) => { - const display = autostopDisplay(workspace); - - if (display.tooltip) { + if (tooltip) { return ( - - ({ - color: isShutdownSoon(workspace) - ? `${theme.palette.warning.light} !important` - : undefined, - })} - > - Stop {display.message} - - + <> + {activity} + + {display} + {controls} + + ); } - return {display.message}; + return ( + <> + {activity} + + {display} + {controls} + + + ); }; const ScheduleSettingsLink = forwardRef( @@ -195,19 +299,17 @@ export const shouldDisplayScheduleControls = ( 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 styles = { + scheduleIconButton: { + display: "flex", + alignItems: "center", + background: "transparent", + border: 0, + padding: 0, + fontSize: "inherit", + lineHeight: "inherit", + }, + scheduleValue: { display: "flex", alignItems: "center", diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx index d1ed77cef9..3671801fa4 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, @@ -7,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 @@ -27,6 +28,7 @@ const meta: Meta = { workspace: baseWorkspace, template: MockTemplate, latestVersion: MockTemplateVersion, + canUpdateWorkspace: true, }, parameters: { layout: "fullscreen", @@ -42,12 +44,117 @@ export const Example: Story = {}; export const Outdated: Story = { args: { workspace: { - ...MockWorkspace, + ...baseWorkspace, outdated: true, }, }, }; +export const ReadyWithDeadline: Story = { + args: { + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + get deadline() { + return addHours(new Date(), 8).toISOString(); + }, + }, + }, + }, +}; + +export const Connected: 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(); + }, + }, + }, + }, + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement); + const autostopText = "Stop in 8 hours"; + + await step("show controls", async () => { + await userEvent.click(screen.getByTestId("schedule-icon-button")); + await waitFor(() => + expect(screen.getByText(autostopText)).toBeInTheDocument(), + ); + }); + + await step("hide controls", async () => { + await userEvent.click(screen.getByTestId("schedule-icon-button")); + await waitFor(() => + expect(screen.queryByText(autostopText)).not.toBeInTheDocument(), + ); + }); + }, +}; +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(), 8).toISOString(); + }, + }, + }, + }, + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement); + const autostopText = "Stop in an hour"; + + await step("show controls", async () => { + await userEvent.click(screen.getByTestId("schedule-icon-button")); + await waitFor(() => + expect(screen.getByText(autostopText)).toBeInTheDocument(), + ); + }); + + await step("hide controls", async () => { + await userEvent.click(screen.getByTestId("schedule-icon-button")); + await waitFor(() => + expect(screen.queryByText(autostopText)).not.toBeInTheDocument(), + ); + }); + }, +}; +export const ConnectedWithMaxDeadlineSoon: 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(); + }, + }, + }, + }, +}; + export const Dormant: Story = { args: { workspace: { @@ -61,7 +168,7 @@ export const Dormant: Story = { }, }; -export const WithDeadline: Story = { +export const WithExceededDeadline: Story = { args: { workspace: { ...MockWorkspace, @@ -73,6 +180,88 @@ export const WithDeadline: Story = { }, }; +export const WithApproachingDeadline: Story = { + args: { + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + get deadline() { + return addMinutes(new Date(), 30).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 WithFarAwayDeadline: Story = { + args: { + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + get deadline() { + return addHours(new Date(), 8).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, + get deadline() { + return addHours(new Date(), 8).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 requirement/, + ), + ); + }); + }, +}; + export const WithQuota: Story = { parameters: { queries: [ diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index 805fda059d..982f3dd8f6 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -3,12 +3,15 @@ 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 { displayDormantDeletion } from "utils/dormant"; import { Topbar, TopbarAvatar, @@ -17,10 +20,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,11 +27,8 @@ 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 { WorkspaceScheduleControls } from "./WorkspaceScheduleControls"; export type WorkspaceError = | "getBuildsError" @@ -200,6 +196,12 @@ export const WorkspaceTopbar: FC = ({ + + {shouldDisplayDormantData && ( @@ -219,20 +221,6 @@ export const WorkspaceTopbar: FC = ({ )} - {shouldDisplayScheduleControls(workspace) && ( - - - - - - - - - )} - {quota && quota.budget > 0 && ( diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 4805566f36..6dae6ab01e 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/pages/WorkspacesPage/LastUsed.tsx b/site/src/pages/WorkspacesPage/LastUsed.tsx index c4b10cc599..92fc549ab6 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); @@ -32,27 +33,32 @@ interface LastUsedProps { export const LastUsed: 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"; - } + const [circle, message] = useTime(() => { + 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]; + }); return ( { const ttl = workspace.ttl_ms; @@ -103,16 +111,67 @@ 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); + + if (activityStatus === "connected") { + const hasMaxDeadline = Boolean(workspace.latest_build.max_deadline); + const maxDeadline = dayjs(workspace.latest_build.max_deadline); + if (hasMaxDeadline && maxDeadline.isBefore(now.add(2, "hour"))) { + return { + message: `Required to stop soon`, + tooltip: ( + <> + Upcoming stop 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, + }; + } + } + if (isShuttingDown(workspace, deadline)) { return { message: Language.workspaceShuttingDownLabel, }; } else { - const deadlineTz = deadline.tz(dayjs.tz.guess()); + let title = ( + Template Autostop requirement + ); + 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 = ( + <> + {" "} + because this workspace has enabled autostop. You can disable + autostop from this workspace's{" "} + + schedule settings + + . + + ); + } return { - message: deadlineTz.fromNow(), - tooltip: deadlineTz.format("MMMM D, YYYY h:mm A"), + message: `Stop ${deadline.fromNow()}`, + tooltip: ( + <> + {title} + This workspace will be stopped on{" "} + {deadline.format("MMMM D [at] h:mm A")} + {reason} + + ), + danger: isShutdownSoon(workspace), }; } } else if (!ttl || ttl < 1) { @@ -126,11 +185,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");