mirror of https://github.com/coder/coder.git
chore: reimplement activity status and autostop improvements (#12175)
This commit is contained in:
parent
4e7beee102
commit
b2413a593c
|
@ -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,
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -87,11 +87,7 @@ export const WorkspaceOutdatedTooltipContent: FC<TooltipProps> = ({
|
|||
<div css={styles.bold}>Message</div>
|
||||
<div>
|
||||
{activeVersion ? (
|
||||
activeVersion.message === "" ? (
|
||||
"No message"
|
||||
) : (
|
||||
activeVersion.message
|
||||
)
|
||||
activeVersion.message || "No message"
|
||||
) : (
|
||||
<Skeleton variant="text" height={20} width={150} />
|
||||
)}
|
||||
|
|
|
@ -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";
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 <WorkspaceScheduleControls workspace={workspace} canUpdateSchedule />;
|
||||
return (
|
||||
<WorkspaceScheduleControls
|
||||
workspace={workspace}
|
||||
template={MockTemplate}
|
||||
canUpdateSchedule
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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.
|
||||
|
|
|
@ -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 = (
|
||||
<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;
|
||||
template: Template;
|
||||
canUpdateSchedule: boolean;
|
||||
}
|
||||
|
||||
export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
|
||||
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,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
@ -86,74 +161,103 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
|
|||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div css={styles.scheduleValue} data-testid="schedule-controls">
|
||||
{isWorkspaceOn(workspace) ? (
|
||||
<AutoStopDisplay workspace={workspace} />
|
||||
) : (
|
||||
<ScheduleSettingsLink>
|
||||
Starts at {autostartDisplay(workspace.autostart_schedule)}
|
||||
</ScheduleSettingsLink>
|
||||
)}
|
||||
const activityStatus = useTime(() => getWorkspaceActivityStatus(workspace));
|
||||
const { message, tooltip, danger } = autostopDisplay(
|
||||
workspace,
|
||||
activityStatus,
|
||||
template,
|
||||
);
|
||||
|
||||
{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>
|
||||
)}
|
||||
const [showControlsAnyway, setShowControlsAnyway] = useState(false);
|
||||
let onClickScheduleIcon: (() => void) | undefined;
|
||||
let activity: ReactNode = null;
|
||||
|
||||
if (activityStatus === "connected") {
|
||||
onClickScheduleIcon = () => setShowControlsAnyway((it) => !it);
|
||||
activity = <Pill type="active">Connected</Pill>;
|
||||
|
||||
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}
|
||||
<WorkspaceScheduleContainer onClickIcon={onClickScheduleIcon} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const display = (
|
||||
<ScheduleSettingsLink
|
||||
data-testid="schedule-controls-autostop"
|
||||
css={
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
interface AutoStopDisplayProps {
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace }) => {
|
||||
const display = autostopDisplay(workspace);
|
||||
|
||||
if (display.tooltip) {
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip title={display.tooltip}>
|
||||
<ScheduleSettingsLink
|
||||
css={(theme) => ({
|
||||
color: isShutdownSoon(workspace)
|
||||
? `${theme.palette.warning.light} !important`
|
||||
: undefined,
|
||||
})}
|
||||
>
|
||||
Stop {display.message}
|
||||
</ScheduleSettingsLink>
|
||||
</Tooltip>
|
||||
<>
|
||||
{activity}
|
||||
<WorkspaceScheduleContainer onClickIcon={onClickScheduleIcon}>
|
||||
<Tooltip title={tooltip}>{display}</Tooltip>
|
||||
{controls}
|
||||
</WorkspaceScheduleContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <ScheduleSettingsLink>{display.message}</ScheduleSettingsLink>;
|
||||
return (
|
||||
<>
|
||||
{activity}
|
||||
<WorkspaceScheduleContainer onClickIcon={onClickScheduleIcon}>
|
||||
{display}
|
||||
{controls}
|
||||
</WorkspaceScheduleContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ScheduleSettingsLink = forwardRef<HTMLAnchorElement, LinkProps>(
|
||||
|
@ -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",
|
||||
|
|
|
@ -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<typeof WorkspaceTopbar> = {
|
|||
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: [
|
||||
|
|
|
@ -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<WorkspaceProps> = ({
|
|||
</Popover>
|
||||
</TopbarData>
|
||||
|
||||
<WorkspaceScheduleControls
|
||||
workspace={workspace}
|
||||
template={template}
|
||||
canUpdateSchedule={canUpdateWorkspace}
|
||||
/>
|
||||
|
||||
{shouldDisplayDormantData && (
|
||||
<TopbarData>
|
||||
<TopbarIcon>
|
||||
|
@ -219,20 +221,6 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
|
|||
</TopbarData>
|
||||
)}
|
||||
|
||||
{shouldDisplayScheduleControls(workspace) && (
|
||||
<TopbarData>
|
||||
<TopbarIcon>
|
||||
<Tooltip title="Schedule">
|
||||
<ScheduleOutlined aria-label="Schedule" />
|
||||
</Tooltip>
|
||||
</TopbarIcon>
|
||||
<WorkspaceScheduleControls
|
||||
workspace={workspace}
|
||||
canUpdateSchedule={canUpdateWorkspace}
|
||||
/>
|
||||
</TopbarData>
|
||||
)}
|
||||
|
||||
{quota && quota.budget > 0 && (
|
||||
<TopbarData>
|
||||
<TopbarIcon>
|
||||
|
|
|
@ -80,11 +80,7 @@ export const WorkspaceSchedulePage: FC = () => {
|
|||
<Helmet>
|
||||
<title>{pageTitle([workspaceName, "Schedule"])}</title>
|
||||
</Helmet>
|
||||
<PageHeader
|
||||
css={{
|
||||
paddingTop: 0,
|
||||
}}
|
||||
>
|
||||
<PageHeader css={{ paddingTop: 0 }}>
|
||||
<PageHeaderTitle>Workspace Schedule</PageHeaderTitle>
|
||||
</PageHeader>
|
||||
|
||||
|
|
|
@ -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<LastUsedProps> = ({ lastUsedAt }) => {
|
||||
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"))) {
|
||||
circle = <Circle color={theme.roles.success.fill.solid} />;
|
||||
// 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 = <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";
|
||||
}
|
||||
const [circle, message] = useTime(() => {
|
||||
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"))) {
|
||||
circle = <Circle color={theme.roles.success.fill.solid} />;
|
||||
// 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 = <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 (
|
||||
<Stack
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
import Link from "@mui/material/Link";
|
||||
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 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 cronParser from "cron-parser";
|
||||
|
||||
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
|
||||
// sorted alphabetically.
|
||||
|
@ -90,9 +95,12 @@ export const isShuttingDown = (
|
|||
|
||||
export const autostopDisplay = (
|
||||
workspace: Workspace,
|
||||
activityStatus: WorkspaceActivityStatus,
|
||||
template: Template,
|
||||
): {
|
||||
message: string;
|
||||
tooltip?: string;
|
||||
message: ReactNode;
|
||||
tooltip?: ReactNode;
|
||||
danger?: boolean;
|
||||
} => {
|
||||
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: (
|
||||
<>
|
||||
<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)) {
|
||||
return {
|
||||
message: Language.workspaceShuttingDownLabel,
|
||||
};
|
||||
} 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's{" "}
|
||||
<Link component={RouterLink} to="settings/schedule">
|
||||
schedule settings
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
);
|
||||
}
|
||||
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");
|
||||
|
Loading…
Reference in New Issue