chore: reimplement activity status and autostop improvements (#12175)

This commit is contained in:
Kayla Washburn-Love 2024-02-27 11:06:26 -07:00 committed by GitHub
parent 4e7beee102
commit b2413a593c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 660 additions and 175 deletions

View File

@ -114,6 +114,7 @@ func New(opts Options) *API {
api.StatsAPI = &StatsAPI{ api.StatsAPI = &StatsAPI{
AgentFn: api.agent, AgentFn: api.agent,
Database: opts.Database, Database: opts.Database,
Pubsub: opts.Pubsub,
Log: opts.Log, Log: opts.Log,
StatsBatcher: opts.StatsBatcher, StatsBatcher: opts.StatsBatcher,
TemplateScheduleStore: opts.TemplateScheduleStore, TemplateScheduleStore: opts.TemplateScheduleStore,

View File

@ -16,8 +16,10 @@ import (
"github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/autobuild"
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime" "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/prometheusmetrics"
"github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/codersdk"
) )
type StatsBatcher interface { type StatsBatcher interface {
@ -27,6 +29,7 @@ type StatsBatcher interface {
type StatsAPI struct { type StatsAPI struct {
AgentFn func(context.Context) (database.WorkspaceAgent, error) AgentFn func(context.Context) (database.WorkspaceAgent, error)
Database database.Store Database database.Store
Pubsub pubsub.Pubsub
Log slog.Logger Log slog.Logger
StatsBatcher StatsBatcher StatsBatcher StatsBatcher
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] 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) 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 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))
}
}

View File

@ -19,8 +19,11 @@ import (
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtime" "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/prometheusmetrics"
"github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
) )
type statsBatcher struct { type statsBatcher struct {
@ -78,8 +81,10 @@ func TestUpdateStates(t *testing.T) {
t.Parallel() t.Parallel()
var ( var (
now = dbtime.Now() now = dbtime.Now()
dbM = dbmock.NewMockStore(gomock.NewController(t)) dbM = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()
templateScheduleStore = schedule.MockTemplateScheduleStore{ templateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) { GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
panic("should not be called") panic("should not be called")
@ -125,6 +130,7 @@ func TestUpdateStates(t *testing.T) {
return agent, nil return agent, nil
}, },
Database: dbM, Database: dbM,
Pubsub: ps,
StatsBatcher: batcher, StatsBatcher: batcher,
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
AgentStatsRefreshInterval: 10 * time.Second, AgentStatsRefreshInterval: 10 * time.Second,
@ -164,6 +170,14 @@ func TestUpdateStates(t *testing.T) {
// User gets fetched to hit the UpdateAgentMetricsFn. // User gets fetched to hit the UpdateAgentMetricsFn.
dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) 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) resp, err := api.UpdateStats(context.Background(), req)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, &agentproto.UpdateStatsResponse{ require.Equal(t, &agentproto.UpdateStatsResponse{
@ -179,7 +193,13 @@ func TestUpdateStates(t *testing.T) {
require.Equal(t, user.ID, batcher.lastUserID) require.Equal(t, user.ID, batcher.lastUserID)
require.Equal(t, workspace.ID, batcher.lastWorkspaceID) require.Equal(t, workspace.ID, batcher.lastWorkspaceID)
require.Equal(t, req.Stats, batcher.lastStats) 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) require.True(t, updateAgentMetricsFnCalled)
}) })
@ -189,6 +209,7 @@ func TestUpdateStates(t *testing.T) {
var ( var (
now = dbtime.Now() now = dbtime.Now()
dbM = dbmock.NewMockStore(gomock.NewController(t)) dbM = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()
templateScheduleStore = schedule.MockTemplateScheduleStore{ templateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) { GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
panic("should not be called") panic("should not be called")
@ -214,6 +235,7 @@ func TestUpdateStates(t *testing.T) {
return agent, nil return agent, nil
}, },
Database: dbM, Database: dbM,
Pubsub: ps,
StatsBatcher: batcher, StatsBatcher: batcher,
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
AgentStatsRefreshInterval: 10 * time.Second, AgentStatsRefreshInterval: 10 * time.Second,
@ -245,6 +267,7 @@ func TestUpdateStates(t *testing.T) {
var ( var (
dbM = dbmock.NewMockStore(gomock.NewController(t)) dbM = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()
req = &agentproto.UpdateStatsRequest{ req = &agentproto.UpdateStatsRequest{
Stats: &agentproto.Stats{ Stats: &agentproto.Stats{
ConnectionsByProto: map[string]int64{}, // len() == 0 ConnectionsByProto: map[string]int64{}, // len() == 0
@ -256,6 +279,7 @@ func TestUpdateStates(t *testing.T) {
return agent, nil return agent, nil
}, },
Database: dbM, Database: dbM,
Pubsub: ps,
StatsBatcher: nil, // should not be called StatsBatcher: nil, // should not be called
TemplateScheduleStore: nil, // should not be called TemplateScheduleStore: nil, // should not be called
AgentStatsRefreshInterval: 10 * time.Second, 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 nextAutostart := now.Add(30 * time.Minute).UTC() // always sent to DB as UTC
var ( var (
dbM = dbmock.NewMockStore(gomock.NewController(t)) dbM = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()
templateScheduleStore = schedule.MockTemplateScheduleStore{ templateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) { GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{ return schedule.TemplateScheduleOptions{
@ -322,6 +348,7 @@ func TestUpdateStates(t *testing.T) {
return agent, nil return agent, nil
}, },
Database: dbM, Database: dbM,
Pubsub: ps,
StatsBatcher: batcher, StatsBatcher: batcher,
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore), TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
AgentStatsRefreshInterval: 15 * time.Second, AgentStatsRefreshInterval: 15 * time.Second,

43
site/src/hooks/useTime.ts Normal file
View File

@ -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<T>(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;
}

View File

@ -87,11 +87,7 @@ export const WorkspaceOutdatedTooltipContent: FC<TooltipProps> = ({
<div css={styles.bold}>Message</div> <div css={styles.bold}>Message</div>
<div> <div>
{activeVersion ? ( {activeVersion ? (
activeVersion.message === "" ? ( activeVersion.message || "No message"
"No message"
) : (
activeVersion.message
)
) : ( ) : (
<Skeleton variant="text" height={20} width={150} /> <Skeleton variant="text" height={20} width={150} />
)} )}

View File

@ -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";
}

View File

@ -29,12 +29,12 @@ describe("AccountPage", () => {
Promise.resolve({ Promise.resolve({
id: userId, id: userId,
email: "user@coder.com", email: "user@coder.com",
created_at: new Date().toString(), created_at: new Date().toISOString(),
status: "active", status: "active",
organization_ids: ["123"], organization_ids: ["123"],
roles: [], roles: [],
avatar_url: "", avatar_url: "",
last_seen_at: new Date().toString(), last_seen_at: new Date().toISOString(),
login_type: "password", login_type: "password",
theme_preference: "", theme_preference: "",
...data, ...data,

View File

@ -1,18 +1,19 @@
import { render, screen } from "@testing-library/react"; 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 userEvent from "@testing-library/user-event";
import { server } from "testHelpers/server"; import { type FC } from "react";
import { rest } from "msw"; import { QueryClient, QueryClientProvider, useQuery } from "react-query";
import { RouterProvider, createMemoryRouter } from "react-router-dom";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { rest } from "msw";
import * as API from "api/api"; 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 { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls";
const Wrapper = () => { const Wrapper: FC = () => {
const { data: workspace } = useQuery( const { data: workspace } = useQuery(
workspaceByOwnerAndName(MockWorkspace.owner_name, MockWorkspace.name), workspaceByOwnerAndName(MockWorkspace.owner_name, MockWorkspace.name),
); );
@ -21,7 +22,13 @@ const Wrapper = () => {
return null; return null;
} }
return <WorkspaceScheduleControls workspace={workspace} canUpdateSchedule />; return (
<WorkspaceScheduleControls
workspace={workspace}
template={MockTemplate}
canUpdateSchedule
/>
);
}; };
const BASE_DEADLINE = dayjs().add(3, "hour"); const BASE_DEADLINE = dayjs().add(3, "hour");
@ -75,7 +82,7 @@ test("add 3 hours to deadline", async () => {
await screen.findByText( await screen.findByText(
"Workspace shutdown time has been successfully updated.", "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 // Mocks are used here because the 'usedDeadline' is a dayjs object, which
// can't be directly compared. // 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 user = userEvent.setup();
const updateDeadlineSpy = jest const updateDeadlineSpy = jest
.spyOn(API, "putWorkspaceExtension") .spyOn(API, "putWorkspaceExtension")
@ -103,7 +110,7 @@ test("remove 3 hours to deadline", async () => {
await screen.findByText( await screen.findByText(
"Workspace shutdown time has been successfully updated.", "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 // Mocks are used here because the 'usedDeadline' is a dayjs object, which
// can't be directly compared. // can't be directly compared.

View File

@ -1,9 +1,18 @@
import { type Interpolation, type Theme } from "@emotion/react"; import { type Interpolation, type Theme } from "@emotion/react";
import Link, { LinkProps } from "@mui/material/Link"; import Link, { type LinkProps } from "@mui/material/Link";
import { forwardRef, type FC, useRef } from "react"; 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 { Link as RouterLink } from "react-router-dom";
import { useTime } from "hooks/useTime";
import { isWorkspaceOn } from "utils/workspace"; import { isWorkspaceOn } from "utils/workspace";
import type { Workspace } from "api/typesGenerated"; import type { Template, Workspace } from "api/typesGenerated";
import { import {
autostartDisplay, autostartDisplay,
autostopDisplay, autostopDisplay,
@ -12,27 +21,93 @@ import {
getMaxDeadlineChange, getMaxDeadlineChange,
getMinDeadline, getMinDeadline,
} from "utils/schedule"; } 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 { getErrorMessage } from "api/errors";
import { import {
updateDeadline, updateDeadline,
workspaceByOwnerAndNameKey, workspaceByOwnerAndNameKey,
} from "api/queries/workspaces"; } from "api/queries/workspaces";
import { TopbarData, TopbarIcon } from "components/FullPageLayout/Topbar";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { useMutation, useQueryClient } from "react-query"; import { getWorkspaceActivityStatus } from "modules/workspaces/activity";
import { Dayjs } from "dayjs"; import { Pill } from "components/Pill/Pill";
import { visuallyHidden } from "@mui/utils";
export interface WorkspaceScheduleControlsProps { export interface WorkspaceScheduleContainerProps {
children?: ReactNode;
onClickIcon?: () => void;
}
export const WorkspaceScheduleContainer: FC<
WorkspaceScheduleContainerProps
> = ({ children, onClickIcon }) => {
const icon = (
<TopbarIcon>
<ScheduleOutlined aria-label="Schedule" />
</TopbarIcon>
);
return (
<TopbarData>
<Tooltip title="Schedule">
{onClickIcon ? (
<button
data-testid="schedule-icon-button"
onClick={onClickIcon}
css={styles.scheduleIconButton}
>
{icon}
</button>
) : (
icon
)}
</Tooltip>
{children}
</TopbarData>
);
};
interface WorkspaceScheduleControlsProps {
workspace: Workspace; workspace: Workspace;
template: Template;
canUpdateSchedule: boolean; canUpdateSchedule: boolean;
} }
export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
workspace, workspace,
template,
canUpdateSchedule,
}) => {
if (!shouldDisplayScheduleControls(workspace)) {
return null;
}
return (
<div css={styles.scheduleValue} data-testid="schedule-controls">
{isWorkspaceOn(workspace) ? (
<AutostopDisplay
workspace={workspace}
template={template}
canUpdateSchedule={canUpdateSchedule}
/>
) : (
<WorkspaceScheduleContainer>
<ScheduleSettingsLink>
Starts at {autostartDisplay(workspace.autostart_schedule)}
</ScheduleSettingsLink>
</WorkspaceScheduleContainer>
)}
</div>
);
};
interface AutostopDisplayProps {
workspace: Workspace;
template: Template;
canUpdateSchedule: boolean;
}
const AutostopDisplay: FC<AutostopDisplayProps> = ({
workspace,
template,
canUpdateSchedule, canUpdateSchedule,
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -86,74 +161,103 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
}, 500); }, 500);
}; };
return ( const activityStatus = useTime(() => getWorkspaceActivityStatus(workspace));
<div css={styles.scheduleValue} data-testid="schedule-controls"> const { message, tooltip, danger } = autostopDisplay(
{isWorkspaceOn(workspace) ? ( workspace,
<AutoStopDisplay workspace={workspace} /> activityStatus,
) : ( template,
<ScheduleSettingsLink> );
Starts at {autostartDisplay(workspace.autostart_schedule)}
</ScheduleSettingsLink>
)}
{canUpdateSchedule && canEditDeadline(workspace) && ( const [showControlsAnyway, setShowControlsAnyway] = useState(false);
<div css={styles.scheduleControls}> let onClickScheduleIcon: (() => void) | undefined;
<Tooltip title="Subtract 1 hour from deadline"> let activity: ReactNode = null;
<IconButton
disabled={!deadlineMinusEnabled} if (activityStatus === "connected") {
size="small" onClickScheduleIcon = () => setShowControlsAnyway((it) => !it);
css={styles.scheduleButton} activity = <Pill type="active">Connected</Pill>;
onClick={() => {
handleDeadlineChange(deadline.subtract(1, "h")); const now = dayjs();
}} const noRequiredStopSoon =
> !workspace.latest_build.max_deadline ||
<RemoveIcon /> dayjs(workspace.latest_build.max_deadline).isAfter(now.add(2, "hour"));
<span style={visuallyHidden}>Subtract 1 hour</span>
</IconButton> // User has shown controls manually, or we should warn about a nearby required stop
</Tooltip> if (!showControlsAnyway && noRequiredStopSoon) {
<Tooltip title="Add 1 hour to deadline"> return (
<IconButton <>
disabled={!deadlinePlusEnabled} {activity}
size="small" <WorkspaceScheduleContainer onClickIcon={onClickScheduleIcon} />
css={styles.scheduleButton} </>
onClick={() => { );
handleDeadlineChange(deadline.add(1, "h")); }
}} }
>
<AddIcon /> const display = (
<span style={visuallyHidden}>Add 1 hour</span> <ScheduleSettingsLink
</IconButton> data-testid="schedule-controls-autostop"
</Tooltip> css={
</div> danger &&
)} ((theme) => ({
color: `${theme.roles.danger.fill.outline} !important`,
}))
}
>
{message}
</ScheduleSettingsLink>
);
const controls = canUpdateSchedule && canEditDeadline(workspace) && (
<div css={styles.scheduleControls}>
<Tooltip title="Subtract 1 hour from deadline">
<IconButton
disabled={!deadlineMinusEnabled}
size="small"
css={styles.scheduleButton}
onClick={() => {
handleDeadlineChange(deadline.subtract(1, "h"));
}}
>
<RemoveIcon />
<span style={visuallyHidden}>Subtract 1 hour</span>
</IconButton>
</Tooltip>
<Tooltip title="Add 1 hour to deadline">
<IconButton
disabled={!deadlinePlusEnabled}
size="small"
css={styles.scheduleButton}
onClick={() => {
handleDeadlineChange(deadline.add(1, "h"));
}}
>
<AddIcon />
<span style={visuallyHidden}>Add 1 hour</span>
</IconButton>
</Tooltip>
</div> </div>
); );
};
interface AutoStopDisplayProps { if (tooltip) {
workspace: Workspace;
}
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace }) => {
const display = autostopDisplay(workspace);
if (display.tooltip) {
return ( return (
<Tooltip title={display.tooltip}> <>
<ScheduleSettingsLink {activity}
css={(theme) => ({ <WorkspaceScheduleContainer onClickIcon={onClickScheduleIcon}>
color: isShutdownSoon(workspace) <Tooltip title={tooltip}>{display}</Tooltip>
? `${theme.palette.warning.light} !important` {controls}
: undefined, </WorkspaceScheduleContainer>
})} </>
>
Stop {display.message}
</ScheduleSettingsLink>
</Tooltip>
); );
} }
return <ScheduleSettingsLink>{display.message}</ScheduleSettingsLink>; return (
<>
{activity}
<WorkspaceScheduleContainer onClickIcon={onClickScheduleIcon}>
{display}
{controls}
</WorkspaceScheduleContainer>
</>
);
}; };
const ScheduleSettingsLink = forwardRef<HTMLAnchorElement, LinkProps>( const ScheduleSettingsLink = forwardRef<HTMLAnchorElement, LinkProps>(
@ -195,19 +299,17 @@ export const shouldDisplayScheduleControls = (
return willAutoStop || willAutoStart; 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 = { const styles = {
scheduleIconButton: {
display: "flex",
alignItems: "center",
background: "transparent",
border: 0,
padding: 0,
fontSize: "inherit",
lineHeight: "inherit",
},
scheduleValue: { scheduleValue: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",

View File

@ -1,4 +1,5 @@
import { Meta, StoryObj } from "@storybook/react"; import { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, waitFor, within, screen } from "@storybook/test";
import { import {
MockTemplate, MockTemplate,
MockTemplateVersion, MockTemplateVersion,
@ -7,7 +8,7 @@ import {
} from "testHelpers/entities"; } from "testHelpers/entities";
import { WorkspaceTopbar } from "./WorkspaceTopbar"; import { WorkspaceTopbar } from "./WorkspaceTopbar";
import { withDashboardProvider } from "testHelpers/storybook"; import { withDashboardProvider } from "testHelpers/storybook";
import { addDays } from "date-fns"; import { addDays, addHours, addMinutes } from "date-fns";
import { getWorkspaceQuotaQueryKey } from "api/queries/workspaceQuota"; import { getWorkspaceQuotaQueryKey } from "api/queries/workspaceQuota";
// We want a workspace without a deadline to not pollute the screenshot // We want a workspace without a deadline to not pollute the screenshot
@ -27,6 +28,7 @@ const meta: Meta<typeof WorkspaceTopbar> = {
workspace: baseWorkspace, workspace: baseWorkspace,
template: MockTemplate, template: MockTemplate,
latestVersion: MockTemplateVersion, latestVersion: MockTemplateVersion,
canUpdateWorkspace: true,
}, },
parameters: { parameters: {
layout: "fullscreen", layout: "fullscreen",
@ -42,12 +44,117 @@ export const Example: Story = {};
export const Outdated: Story = { export const Outdated: Story = {
args: { args: {
workspace: { workspace: {
...MockWorkspace, ...baseWorkspace,
outdated: true, 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 = { export const Dormant: Story = {
args: { args: {
workspace: { workspace: {
@ -61,7 +168,7 @@ export const Dormant: Story = {
}, },
}; };
export const WithDeadline: Story = { export const WithExceededDeadline: Story = {
args: { args: {
workspace: { workspace: {
...MockWorkspace, ...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 = { export const WithQuota: Story = {
parameters: { parameters: {
queries: [ queries: [

View File

@ -3,12 +3,15 @@ import Link from "@mui/material/Link";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined"; import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import DeleteOutline from "@mui/icons-material/DeleteOutline"; import DeleteOutline from "@mui/icons-material/DeleteOutline";
import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined"; import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
import ScheduleOutlined from "@mui/icons-material/ScheduleOutlined";
import { useTheme } from "@emotion/react"; import { useTheme } from "@emotion/react";
import { type FC } from "react"; import { type FC } from "react";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import type * as TypesGen from "api/typesGenerated"; 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 { import {
Topbar, Topbar,
TopbarAvatar, TopbarAvatar,
@ -17,10 +20,6 @@ import {
TopbarIcon, TopbarIcon,
TopbarIconButton, TopbarIconButton,
} from "components/FullPageLayout/Topbar"; } 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 { Popover, PopoverTrigger } from "components/Popover/Popover";
import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip"; import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip";
import { AvatarData } from "components/AvatarData/AvatarData"; import { AvatarData } from "components/AvatarData/AvatarData";
@ -28,11 +27,8 @@ import { ExternalAvatar } from "components/Avatar/Avatar";
import { UserAvatar } from "components/UserAvatar/UserAvatar"; import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { WorkspaceActions } from "./WorkspaceActions/WorkspaceActions"; import { WorkspaceActions } from "./WorkspaceActions/WorkspaceActions";
import { WorkspaceNotifications } from "./WorkspaceNotifications/WorkspaceNotifications"; import { WorkspaceNotifications } from "./WorkspaceNotifications/WorkspaceNotifications";
import {
WorkspaceScheduleControls,
shouldDisplayScheduleControls,
} from "./WorkspaceScheduleControls";
import { WorkspacePermissions } from "./permissions"; import { WorkspacePermissions } from "./permissions";
import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls";
export type WorkspaceError = export type WorkspaceError =
| "getBuildsError" | "getBuildsError"
@ -200,6 +196,12 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
</Popover> </Popover>
</TopbarData> </TopbarData>
<WorkspaceScheduleControls
workspace={workspace}
template={template}
canUpdateSchedule={canUpdateWorkspace}
/>
{shouldDisplayDormantData && ( {shouldDisplayDormantData && (
<TopbarData> <TopbarData>
<TopbarIcon> <TopbarIcon>
@ -219,20 +221,6 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
</TopbarData> </TopbarData>
)} )}
{shouldDisplayScheduleControls(workspace) && (
<TopbarData>
<TopbarIcon>
<Tooltip title="Schedule">
<ScheduleOutlined aria-label="Schedule" />
</Tooltip>
</TopbarIcon>
<WorkspaceScheduleControls
workspace={workspace}
canUpdateSchedule={canUpdateWorkspace}
/>
</TopbarData>
)}
{quota && quota.budget > 0 && ( {quota && quota.budget > 0 && (
<TopbarData> <TopbarData>
<TopbarIcon> <TopbarIcon>

View File

@ -80,11 +80,7 @@ export const WorkspaceSchedulePage: FC = () => {
<Helmet> <Helmet>
<title>{pageTitle([workspaceName, "Schedule"])}</title> <title>{pageTitle([workspaceName, "Schedule"])}</title>
</Helmet> </Helmet>
<PageHeader <PageHeader css={{ paddingTop: 0 }}>
css={{
paddingTop: 0,
}}
>
<PageHeaderTitle>Workspace Schedule</PageHeaderTitle> <PageHeaderTitle>Workspace Schedule</PageHeaderTitle>
</PageHeader> </PageHeader>

View File

@ -1,8 +1,9 @@
import { useTheme } from "@emotion/react";
import { type FC } from "react"; import { type FC } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { useTheme } from "@emotion/react";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
import { useTime } from "hooks/useTime";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@ -32,27 +33,32 @@ interface LastUsedProps {
export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => { export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
const theme = useTheme(); const theme = useTheme();
const t = dayjs(lastUsedAt);
const now = dayjs();
let message = t.fromNow();
let circle = (
<Circle color={theme.palette.text.secondary} variant="outlined" />
);
if (t.isAfter(now.subtract(1, "hour"))) { const [circle, message] = useTime(() => {
circle = <Circle color={theme.roles.success.fill.solid} />; const t = dayjs(lastUsedAt);
// Since the agent reports on a 10m interval, const now = dayjs();
// the last_used_at can be inaccurate when recent. let message = t.fromNow();
message = "Now"; let circle = (
} else if (t.isAfter(now.subtract(3, "day"))) { <Circle color={theme.palette.text.secondary} variant="outlined" />
circle = <Circle color={theme.palette.text.secondary} />; );
} else if (t.isAfter(now.subtract(1, "month"))) {
circle = <Circle color={theme.roles.warning.fill.solid} />; if (t.isAfter(now.subtract(1, "hour"))) {
} else if (t.isAfter(now.subtract(100, "year"))) { circle = <Circle color={theme.roles.success.fill.solid} />;
circle = <Circle color={theme.roles.error.fill.solid} />; // Since the agent reports on a 10m interval,
} else { // the last_used_at can be inaccurate when recent.
message = "Never"; message = "Now";
} } else if (t.isAfter(now.subtract(3, "day"))) {
circle = <Circle color={theme.palette.text.secondary} />;
} else if (t.isAfter(now.subtract(1, "month"))) {
circle = <Circle color={theme.roles.warning.fill.solid} />;
} else if (t.isAfter(now.subtract(100, "year"))) {
circle = <Circle color={theme.roles.error.fill.solid} />;
} else {
message = "Never";
}
return [circle, message];
});
return ( return (
<Stack <Stack

View File

@ -7,8 +7,8 @@ export default {
outline: colors.orange[500], outline: colors.orange[500],
text: colors.orange[50], text: colors.orange[50],
fill: { fill: {
solid: colors.orange[700], solid: colors.orange[500],
outline: colors.orange[700], outline: colors.orange[400],
text: colors.white, text: colors.white,
}, },
disabled: { disabled: {

View File

@ -7,8 +7,8 @@ export default {
outline: colors.orange[600], outline: colors.orange[600],
text: colors.orange[50], text: colors.orange[50],
fill: { fill: {
solid: colors.orange[600], solid: colors.orange[500],
outline: colors.orange[600], outline: colors.orange[400],
text: colors.white, text: colors.white,
}, },
disabled: { disabled: {

View File

@ -55,10 +55,10 @@ export interface Role {
/** A set of more saturated colors to make things stand out */ /** A set of more saturated colors to make things stand out */
fill: { 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; 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; outline: string;
/** A color for text when using the `solid` background color */ /** A color for text when using the `solid` background color */

View File

@ -1,12 +1,17 @@
import Link from "@mui/material/Link";
import cronstrue from "cronstrue"; 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 duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import { Template, Workspace } from "api/typesGenerated"; import { type ReactNode } from "react";
import { Link as RouterLink } from "react-router-dom";
import type { Template, Workspace } from "api/typesGenerated";
import { HelpTooltipTitle } from "components/HelpTooltip/HelpTooltip";
import type { WorkspaceActivityStatus } from "modules/workspaces/activity";
import { isWorkspaceOn } from "./workspace"; import { isWorkspaceOn } from "./workspace";
import cronParser from "cron-parser";
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're // REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
// sorted alphabetically. // sorted alphabetically.
@ -90,9 +95,12 @@ export const isShuttingDown = (
export const autostopDisplay = ( export const autostopDisplay = (
workspace: Workspace, workspace: Workspace,
activityStatus: WorkspaceActivityStatus,
template: Template,
): { ): {
message: string; message: ReactNode;
tooltip?: string; tooltip?: ReactNode;
danger?: boolean;
} => { } => {
const ttl = workspace.ttl_ms; const ttl = workspace.ttl_ms;
@ -103,16 +111,67 @@ export const autostopDisplay = (
// represent the previously defined ttl. Thus, we always derive from the // represent the previously defined ttl. Thus, we always derive from the
// deadline as the source of truth. // 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: (
<>
<HelpTooltipTitle>Upcoming stop required</HelpTooltipTitle>
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)) { if (isShuttingDown(workspace, deadline)) {
return { return {
message: Language.workspaceShuttingDownLabel, message: Language.workspaceShuttingDownLabel,
}; };
} else { } else {
const deadlineTz = deadline.tz(dayjs.tz.guess()); let title = (
<HelpTooltipTitle>Template Autostop requirement</HelpTooltipTitle>
);
let reason: ReactNode = ` because the ${template.display_name} template has an autostop requirement.`;
if (template.autostop_requirement && template.allow_user_autostop) {
title = <HelpTooltipTitle>Autostop schedule</HelpTooltipTitle>;
reason = (
<>
{" "}
because this workspace has enabled autostop. You can disable
autostop from this workspace&apos;s{" "}
<Link component={RouterLink} to="settings/schedule">
schedule settings
</Link>
.
</>
);
}
return { return {
message: deadlineTz.fromNow(), message: `Stop ${deadline.fromNow()}`,
tooltip: deadlineTz.format("MMMM D, YYYY h:mm A"), 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) { } else if (!ttl || ttl < 1) {
@ -126,11 +185,23 @@ export const autostopDisplay = (
// not running. Therefore, we derive from workspace.ttl. // not running. Therefore, we derive from workspace.ttl.
const duration = dayjs.duration(ttl, "milliseconds"); const duration = dayjs.duration(ttl, "milliseconds");
return { 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 deadlineExtensionMin = dayjs.duration(30, "minutes");
export const deadlineExtensionMax = dayjs.duration(24, "hours"); export const deadlineExtensionMax = dayjs.duration(24, "hours");