Revert "feat: add activity status and autostop reason to workspace overview (#11987)" (#12144)

Related to https://github.com/coder/coder/pull/11987

This reverts commit d37b131.
This commit is contained in:
Cian Johnston 2024-02-14 17:14:49 +00:00 committed by GitHub
parent 04991f425a
commit d6b025db14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 120 additions and 648 deletions

View File

@ -114,7 +114,6 @@ 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,

View File

@ -16,10 +16,8 @@ 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 {
@ -29,7 +27,6 @@ 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]
@ -133,16 +130,5 @@ func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsR
return nil, xerrors.Errorf("update stats in database: %w", err)
}
// Tell the frontend about the new agent report, now that everything is updated
a.publishWorkspaceAgentStats(ctx, workspace.ID)
return res, nil
}
func (a *StatsAPI) publishWorkspaceAgentStats(ctx context.Context, workspaceID uuid.UUID) {
err := a.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(workspaceID), codersdk.WorkspaceNotifyDescriptionAgentStatsOnly)
if err != nil {
a.Log.Warn(ctx, "failed to publish workspace agent stats",
slog.F("workspace_id", workspaceID), slog.Error(err))
}
}

View File

@ -1,7 +1,6 @@
package agentapi_test
import (
"bytes"
"context"
"database/sql"
"sync"
@ -20,11 +19,8 @@ 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 {
@ -82,10 +78,8 @@ func TestUpdateStates(t *testing.T) {
t.Parallel()
var (
now = dbtime.Now()
dbM = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()
now = dbtime.Now()
dbM = dbmock.NewMockStore(gomock.NewController(t))
templateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
panic("should not be called")
@ -131,7 +125,6 @@ func TestUpdateStates(t *testing.T) {
return agent, nil
},
Database: dbM,
Pubsub: ps,
StatsBatcher: batcher,
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
AgentStatsRefreshInterval: 10 * time.Second,
@ -171,15 +164,6 @@ func TestUpdateStates(t *testing.T) {
// User gets fetched to hit the UpdateAgentMetricsFn.
dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)
// Ensure that pubsub notifications are sent.
publishAgentStats := make(chan bool)
ps.Subscribe(codersdk.WorkspaceNotifyChannel(workspace.ID), func(_ context.Context, description []byte) {
go func() {
publishAgentStats <- bytes.Equal(description, codersdk.WorkspaceNotifyDescriptionAgentStatsOnly)
close(publishAgentStats)
}()
})
resp, err := api.UpdateStats(context.Background(), req)
require.NoError(t, err)
require.Equal(t, &agentproto.UpdateStatsResponse{
@ -195,13 +179,7 @@ func TestUpdateStates(t *testing.T) {
require.Equal(t, user.ID, batcher.lastUserID)
require.Equal(t, workspace.ID, batcher.lastWorkspaceID)
require.Equal(t, req.Stats, batcher.lastStats)
ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ctx.Done():
t.Error("timed out while waiting for pubsub notification")
case wasAgentStatsOnly := <-publishAgentStats:
require.Equal(t, wasAgentStatsOnly, true)
}
require.True(t, updateAgentMetricsFnCalled)
})
@ -211,7 +189,6 @@ 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")
@ -237,7 +214,6 @@ func TestUpdateStates(t *testing.T) {
return agent, nil
},
Database: dbM,
Pubsub: ps,
StatsBatcher: batcher,
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
AgentStatsRefreshInterval: 10 * time.Second,
@ -268,8 +244,7 @@ func TestUpdateStates(t *testing.T) {
t.Parallel()
var (
db = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()
dbM = dbmock.NewMockStore(gomock.NewController(t))
req = &agentproto.UpdateStatsRequest{
Stats: &agentproto.Stats{
ConnectionsByProto: map[string]int64{}, // len() == 0
@ -280,8 +255,7 @@ func TestUpdateStates(t *testing.T) {
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
return agent, nil
},
Database: db,
Pubsub: ps,
Database: dbM,
StatsBatcher: nil, // should not be called
TemplateScheduleStore: nil, // should not be called
AgentStatsRefreshInterval: 10 * time.Second,
@ -316,9 +290,7 @@ func TestUpdateStates(t *testing.T) {
nextAutostart := now.Add(30 * time.Minute).UTC() // always sent to DB as UTC
var (
db = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()
dbM = dbmock.NewMockStore(gomock.NewController(t))
templateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
@ -349,8 +321,7 @@ func TestUpdateStates(t *testing.T) {
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
return agent, nil
},
Database: db,
Pubsub: ps,
Database: dbM,
StatsBatcher: batcher,
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
AgentStatsRefreshInterval: 15 * time.Second,
@ -370,26 +341,26 @@ func TestUpdateStates(t *testing.T) {
}
// Workspace gets fetched.
db.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{
Workspace: workspace,
TemplateName: template.Name,
}, nil)
// We expect an activity bump because ConnectionCount > 0. However, the
// next autostart time will be set on the bump.
db.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{
dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{
WorkspaceID: workspace.ID,
NextAutostart: nextAutostart,
}).Return(nil)
// Workspace last used at gets bumped.
db.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{
dbM.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{
ID: workspace.ID,
LastUsedAt: now,
}).Return(nil)
// User gets fetched to hit the UpdateAgentMetricsFn.
db.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)
dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)
resp, err := api.UpdateStats(context.Background(), req)
require.NoError(t, err)

View File

@ -1,7 +1,6 @@
package coderd
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@ -1344,48 +1343,7 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) {
<-senderClosed
}()
sendUpdate := func(_ context.Context, description []byte) {
// The agent stats get updated frequently, so we treat these as a special case and only
// send a partial update. We primarily care about updating the `last_used_at` and
// `latest_build.deadline` properties.
if bytes.Equal(description, codersdk.WorkspaceNotifyDescriptionAgentStatsOnly) {
workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID)
if err != nil {
_ = sendEvent(ctx, codersdk.ServerSentEvent{
Type: codersdk.ServerSentEventTypeError,
Data: codersdk.Response{
Message: "Internal error fetching workspace.",
Detail: err.Error(),
},
})
return
}
workspaceBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
_ = sendEvent(ctx, codersdk.ServerSentEvent{
Type: codersdk.ServerSentEventTypeError,
Data: codersdk.Response{
Message: "Internal error fetching workspace build.",
Detail: err.Error(),
},
})
return
}
_ = sendEvent(ctx, codersdk.ServerSentEvent{
Type: codersdk.ServerSentEventTypePartial,
Data: struct {
database.Workspace
LatestBuild database.WorkspaceBuild `json:"latest_build"`
}{
Workspace: workspace,
LatestBuild: workspaceBuild,
},
})
return
}
sendUpdate := func(_ context.Context, _ []byte) {
workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID)
if err != nil {
_ = sendEvent(ctx, codersdk.ServerSentEvent{

View File

@ -20,10 +20,9 @@ type ServerSentEvent struct {
type ServerSentEventType string
const (
ServerSentEventTypePing ServerSentEventType = "ping"
ServerSentEventTypeData ServerSentEventType = "data"
ServerSentEventTypePartial ServerSentEventType = "partial"
ServerSentEventTypeError ServerSentEventType = "error"
ServerSentEventTypePing ServerSentEventType = "ping"
ServerSentEventTypeData ServerSentEventType = "data"
ServerSentEventTypeError ServerSentEventType = "error"
)
func ServerSentEventReader(ctx context.Context, rc io.ReadCloser) func() (*ServerSentEvent, error) {

View File

@ -497,8 +497,6 @@ func (c *Client) UnfavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID)
return nil
}
var WorkspaceNotifyDescriptionAgentStatsOnly = []byte("agentStatsOnly")
// WorkspaceNotifyChannel is the PostgreSQL NOTIFY
// channel to listen for updates on. The payload is empty,
// because the size of a workspace payload can be very large.

View File

@ -2116,11 +2116,10 @@ export const ResourceTypes: ResourceType[] = [
];
// From codersdk/serversentevents.go
export type ServerSentEventType = "data" | "error" | "partial" | "ping";
export type ServerSentEventType = "data" | "error" | "ping";
export const ServerSentEventTypes: ServerSentEventType[] = [
"data",
"error",
"partial",
"ping",
];

View File

@ -1,32 +0,0 @@
import { useEffect, useState } from "react";
/**
* useTime allows a component to rerender over time without a corresponding state change.
* An example could be a relative timestamp (eg. "in 5 minutes") that should count down as it
* approaches.
*
* This hook should only be used in components that are very simple, and that will not
* create a lot of unnecessary work for the reconciler. Given that this hook will result in
* the entire subtree being rerendered on a frequent interval, it's important that the subtree
* remains small.
*
* @param active Can optionally be set to false in circumstances where updating over time is
* not necessary.
*/
export function useTime(active: boolean = true) {
const [, setTick] = useState(0);
useEffect(() => {
if (!active) {
return;
}
const interval = setInterval(() => {
setTick((i) => i + 1);
}, 1000);
return () => {
clearInterval(interval);
};
}, [active]);
}

View File

@ -1,45 +0,0 @@
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({
id: userId,
email: "user@coder.com",
created_at: new Date().toISOString(),
created_at: new Date().toString(),
status: "active",
organization_ids: ["123"],
roles: [],
avatar_url: "",
last_seen_at: new Date().toISOString(),
last_seen_at: new Date().toString(),
login_type: "password",
theme_preference: "",
...data,

View File

@ -1,60 +0,0 @@
import { type FC } from "react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import Tooltip from "@mui/material/Tooltip";
import type { Workspace } from "api/typesGenerated";
import { useTime } from "hooks/useTime";
import type { WorkspaceActivityStatus } from "modules/workspaces/activity";
import { Pill } from "components/Pill/Pill";
dayjs.extend(relativeTime);
interface ActivityStatusProps {
workspace: Workspace;
status: WorkspaceActivityStatus;
}
export const ActivityStatus: FC<ActivityStatusProps> = ({
workspace,
status,
}) => {
const usedAt = dayjs(workspace.last_used_at).tz(dayjs.tz.guess());
// Don't bother updating if `status` will need to change before anything can happen.
useTime(status === "ready" || status === "connected");
switch (status) {
case "ready":
return <Pill type="active">Ready</Pill>;
case "connected":
return <Pill type="active">Connected</Pill>;
case "inactive":
return (
<Tooltip
title={
<>
This workspace was last active on{" "}
{usedAt.format("MMMM D [at] h:mm A")}
</>
}
>
<Pill type="inactive">Inactive</Pill>
</Tooltip>
);
case "notConnected":
return (
<Tooltip
title={
<>
This workspace was last active on{" "}
{usedAt.format("MMMM D [at] h:mm A")}
</>
}
>
<Pill type="inactive">Not connected</Pill>
</Tooltip>
);
}
return null;
};

View File

@ -1,7 +1,6 @@
import { type FC, useEffect } from "react";
import { useQuery, useQueryClient } from "react-query";
import { useParams } from "react-router-dom";
import merge from "lodash/merge";
import { watchWorkspace } from "api/api";
import type { Workspace } from "api/typesGenerated";
import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
@ -77,15 +76,6 @@ export const WorkspacePage: FC = () => {
}
},
);
const getWorkspaceData = useEffectEvent(() => {
if (!workspace) {
throw new Error("Applying an update for a workspace that is undefined.");
}
return queryClient.getQueryData(
workspaceQueryOptions.queryKey,
) as Workspace;
});
const workspaceId = workspace?.id;
useEffect(() => {
if (!workspaceId) {
@ -99,15 +89,6 @@ export const WorkspacePage: FC = () => {
await updateWorkspaceData(newWorkspaceData);
});
eventSource.addEventListener("partial", async (event) => {
const newWorkspaceData = JSON.parse(event.data) as Partial<Workspace>;
// Merge with a fresh object `{}` as the base, because `merge` uses an in-place algorithm,
// and would otherwise mutate the `queryClient`'s internal state.
await updateWorkspaceData(
merge({}, getWorkspaceData(), newWorkspaceData),
);
});
eventSource.addEventListener("error", (event) => {
console.error("Error on getting workspace changes.", event);
});
@ -115,7 +96,7 @@ export const WorkspacePage: FC = () => {
return () => {
eventSource.close();
};
}, [updateWorkspaceData, getWorkspaceData, workspaceId]);
}, [updateWorkspaceData, workspaceId]);
// Page statuses
const pageError =

View File

@ -1,20 +1,18 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
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 { QueryClient, QueryClientProvider, useQuery } from "react-query";
import { MockWorkspace } from "testHelpers/entities";
import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls";
import { getWorkspaceActivityStatus } from "modules/workspaces/activity";
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 dayjs from "dayjs";
import * as API from "api/api";
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
const Wrapper: FC = () => {
const Wrapper = () => {
const { data: workspace } = useQuery(
workspaceByOwnerAndName(MockWorkspace.owner_name, MockWorkspace.name),
);
@ -23,14 +21,7 @@ const Wrapper: FC = () => {
return null;
}
return (
<WorkspaceScheduleControls
workspace={workspace}
status={getWorkspaceActivityStatus(workspace)}
template={MockTemplate}
canUpdateSchedule
/>
);
return <WorkspaceScheduleControls workspace={workspace} canUpdateSchedule />;
};
const BASE_DEADLINE = dayjs().add(3, "hour");

View File

@ -1,18 +1,9 @@
import { type Interpolation, type Theme } from "@emotion/react";
import Link, { type LinkProps } from "@mui/material/Link";
import IconButton from "@mui/material/IconButton";
import AddIcon from "@mui/icons-material/AddOutlined";
import RemoveIcon from "@mui/icons-material/RemoveOutlined";
import ScheduleOutlined from "@mui/icons-material/ScheduleOutlined";
import Tooltip from "@mui/material/Tooltip";
import { visuallyHidden } from "@mui/utils";
import { type Dayjs } from "dayjs";
import Link, { LinkProps } from "@mui/material/Link";
import { forwardRef, type FC, useRef } from "react";
import { useMutation, useQueryClient } from "react-query";
import { Link as RouterLink } from "react-router-dom";
import { useTime } from "hooks/useTime";
import { isWorkspaceOn } from "utils/workspace";
import type { Template, Workspace } from "api/typesGenerated";
import type { Workspace } from "api/typesGenerated";
import {
autostartDisplay,
autostopDisplay,
@ -21,60 +12,28 @@ import {
getMaxDeadlineChange,
getMinDeadline,
} from "utils/schedule";
import IconButton from "@mui/material/IconButton";
import RemoveIcon from "@mui/icons-material/RemoveOutlined";
import AddIcon from "@mui/icons-material/AddOutlined";
import Tooltip from "@mui/material/Tooltip";
import _ from "lodash";
import { getErrorMessage } from "api/errors";
import {
updateDeadline,
workspaceByOwnerAndNameKey,
} from "api/queries/workspaces";
import { TopbarData, TopbarIcon } from "components/FullPageLayout/Topbar";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import type { WorkspaceActivityStatus } from "modules/workspaces/activity";
export interface WorkspaceScheduleProps {
status: WorkspaceActivityStatus;
workspace: Workspace;
template: Template;
canUpdateWorkspace: boolean;
}
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({
status,
workspace,
template,
canUpdateWorkspace,
}) => {
if (!shouldDisplayScheduleControls(workspace, status)) {
return null;
}
return (
<TopbarData>
<TopbarIcon>
<Tooltip title="Schedule">
<ScheduleOutlined aria-label="Schedule" />
</Tooltip>
</TopbarIcon>
<WorkspaceScheduleControls
workspace={workspace}
status={status}
template={template}
canUpdateSchedule={canUpdateWorkspace}
/>
</TopbarData>
);
};
import { useMutation, useQueryClient } from "react-query";
import { Dayjs } from "dayjs";
import { visuallyHidden } from "@mui/utils";
export interface WorkspaceScheduleControlsProps {
workspace: Workspace;
status: WorkspaceActivityStatus;
template: Template;
canUpdateSchedule: boolean;
}
export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
workspace,
status,
template,
canUpdateSchedule,
}) => {
const queryClient = useQueryClient();
@ -131,11 +90,7 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
return (
<div css={styles.scheduleValue} data-testid="schedule-controls">
{isWorkspaceOn(workspace) ? (
<AutoStopDisplay
workspace={workspace}
status={status}
template={template}
/>
<AutoStopDisplay workspace={workspace} />
) : (
<ScheduleSettingsLink>
Starts at {autostartDisplay(workspace.autostart_schedule)}
@ -178,41 +133,28 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
interface AutoStopDisplayProps {
workspace: Workspace;
status: WorkspaceActivityStatus;
template: Template;
}
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({
workspace,
status,
template,
}) => {
useTime();
const { message, tooltip, danger } = autostopDisplay(
workspace,
status,
template,
);
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace }) => {
const display = autostopDisplay(workspace);
const display = (
<ScheduleSettingsLink
data-testid="schedule-controls-autostop"
css={
danger &&
((theme) => ({
color: `${theme.roles.danger.fill.outline} !important`,
}))
}
>
{message}
</ScheduleSettingsLink>
);
if (tooltip) {
return <Tooltip title={tooltip}>{display}</Tooltip>;
if (display.tooltip) {
return (
<Tooltip title={display.tooltip}>
<ScheduleSettingsLink
css={(theme) => ({
color: isShutdownSoon(workspace)
? `${theme.palette.warning.light} !important`
: undefined,
})}
>
Stop {display.message}
</ScheduleSettingsLink>
</Tooltip>
);
}
return display;
return <ScheduleSettingsLink>{display.message}</ScheduleSettingsLink>;
};
const ScheduleSettingsLink = forwardRef<HTMLAnchorElement, LinkProps>(
@ -248,13 +190,22 @@ export const canEditDeadline = (workspace: Workspace): boolean => {
export const shouldDisplayScheduleControls = (
workspace: Workspace,
status: WorkspaceActivityStatus,
): boolean => {
const willAutoStop = isWorkspaceOn(workspace) && hasDeadline(workspace);
const willAutoStart = !isWorkspaceOn(workspace) && hasAutoStart(workspace);
const hasActivity =
status === "connected" && !workspace.latest_build.max_deadline;
return (willAutoStop || willAutoStart) && !hasActivity;
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 = {

View File

@ -1,5 +1,4 @@
import { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, waitFor, within, screen } from "@storybook/test";
import {
MockTemplate,
MockTemplateVersion,
@ -8,7 +7,7 @@ import {
} from "testHelpers/entities";
import { WorkspaceTopbar } from "./WorkspaceTopbar";
import { withDashboardProvider } from "testHelpers/storybook";
import { addDays, addHours, addMinutes } from "date-fns";
import { addDays } from "date-fns";
import { getWorkspaceQuotaQueryKey } from "api/queries/workspaceQuota";
// We want a workspace without a deadline to not pollute the screenshot
@ -43,94 +42,12 @@ export const Example: Story = {};
export const Outdated: Story = {
args: {
workspace: {
...baseWorkspace,
...MockWorkspace,
outdated: true,
},
},
};
export const Ready: Story = {
args: {
workspace: {
...baseWorkspace,
get last_used_at() {
return new Date().toISOString();
},
latest_build: {
...baseWorkspace.latest_build,
get created_at() {
return new Date().toISOString();
},
},
},
},
};
export const ReadyWithDeadline: Story = {
args: {
workspace: {
...MockWorkspace,
get last_used_at() {
return new Date().toISOString();
},
latest_build: {
...MockWorkspace.latest_build,
get created_at() {
return new Date().toISOString();
},
get deadline() {
return addHours(new Date(), 8).toISOString();
},
},
},
},
};
export const Connected: Story = {
args: {
workspace: {
...baseWorkspace,
get last_used_at() {
return new Date().toISOString();
},
},
},
};
export const ConnectedWithDeadline: Story = {
args: {
workspace: {
...MockWorkspace,
get last_used_at() {
return new Date().toISOString();
},
latest_build: {
...MockWorkspace.latest_build,
get deadline() {
return addHours(new Date(), 8).toISOString();
},
},
},
},
};
export const ConnectedWithMaxDeadline: Story = {
args: {
workspace: {
...MockWorkspace,
get last_used_at() {
return new Date().toISOString();
},
latest_build: {
...MockWorkspace.latest_build,
get deadline() {
return addHours(new Date(), 1).toISOString();
},
get max_deadline() {
return addHours(new Date(), 1).toISOString();
},
},
},
},
};
export const Dormant: Story = {
args: {
workspace: {
@ -144,7 +61,7 @@ export const Dormant: Story = {
},
};
export const WithExceededDeadline: Story = {
export const WithDeadline: Story = {
args: {
workspace: {
...MockWorkspace,
@ -156,88 +73,6 @@ export const WithExceededDeadline: 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: [

View File

@ -3,16 +3,12 @@ import Link from "@mui/material/Link";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import DeleteOutline from "@mui/icons-material/DeleteOutline";
import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
import ScheduleOutlined from "@mui/icons-material/ScheduleOutlined";
import { useTheme } from "@emotion/react";
import { type FC } from "react";
import { useQuery } from "react-query";
import { Link as RouterLink } from "react-router-dom";
import type * as TypesGen from "api/typesGenerated";
import { workspaceQuota } from "api/queries/workspaceQuota";
import { WorkspaceStatusBadge } from "modules/workspaces/WorkspaceStatusBadge/WorkspaceStatusBadge";
import { useDashboard } from "modules/dashboard/useDashboard";
import { getWorkspaceActivityStatus } from "modules/workspaces/activity";
import { displayDormantDeletion } from "utils/dormant";
import {
Topbar,
TopbarAvatar,
@ -21,6 +17,10 @@ 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,9 +28,11 @@ import { ExternalAvatar } from "components/Avatar/Avatar";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { WorkspaceActions } from "./WorkspaceActions/WorkspaceActions";
import { WorkspaceNotifications } from "./WorkspaceNotifications/WorkspaceNotifications";
import {
WorkspaceScheduleControls,
shouldDisplayScheduleControls,
} from "./WorkspaceScheduleControls";
import { WorkspacePermissions } from "./permissions";
import { ActivityStatus } from "./ActivityStatus";
import { WorkspaceSchedule } from "./WorkspaceScheduleControls";
export type WorkspaceError =
| "getBuildsError"
@ -108,8 +110,6 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
allowAdvancedScheduling,
);
const activityStatus = getWorkspaceActivityStatus(workspace);
return (
<Topbar css={{ gridArea: "topbar" }}>
<Tooltip title="Back to workspaces">
@ -200,15 +200,6 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
</Popover>
</TopbarData>
<ActivityStatus workspace={workspace} status={activityStatus} />
<WorkspaceSchedule
status={activityStatus}
workspace={workspace}
template={template}
canUpdateWorkspace={canUpdateWorkspace}
/>
{shouldDisplayDormantData && (
<TopbarData>
<TopbarIcon>
@ -228,6 +219,20 @@ 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>

View File

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

View File

@ -1,9 +1,8 @@
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,7 +31,6 @@ interface LastUsedProps {
}
export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
useTime();
const theme = useTheme();
const t = dayjs(lastUsedAt);
const now = dayjs();

View File

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

View File

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

View File

@ -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 icons on a neutral background */
/** A saturated color for use as a background, or for text or icons on a neutral background */
solid: string;
/** A color for outlining an area using the solid background color, or for text or for an outlined icon */
/** A color for outlining an area using the solid background color, or for an outlined icon */
outline: string;
/** A color for text when using the `solid` background color */

View File

@ -1,17 +1,12 @@
import Link from "@mui/material/Link";
import cronstrue from "cronstrue";
import cronParser from "cron-parser";
import dayjs, { type Dayjs } from "dayjs";
import dayjs, { 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 { 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 { Template, Workspace } from "api/typesGenerated";
import { isWorkspaceOn } from "./workspace";
import cronParser from "cron-parser";
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
// sorted alphabetically.
@ -95,12 +90,9 @@ export const isShuttingDown = (
export const autostopDisplay = (
workspace: Workspace,
activityStatus: WorkspaceActivityStatus,
template: Template,
): {
message: ReactNode;
tooltip?: ReactNode;
danger?: boolean;
message: string;
tooltip?: string;
} => {
const ttl = workspace.ttl_ms;
@ -111,62 +103,16 @@ 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).tz(
dayjs.tz.guess(),
);
const now = dayjs(workspace.latest_build.deadline);
const deadline = dayjs(workspace.latest_build.deadline).utc();
if (isShuttingDown(workspace, deadline)) {
return {
message: Language.workspaceShuttingDownLabel,
};
} else if (
activityStatus === "connected" &&
deadline.isBefore(now.add(2, "hour"))
) {
return {
message: `Required to stop soon`,
tooltip: (
<>
<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,
};
} else {
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>
.
</>
);
}
const deadlineTz = deadline.tz(dayjs.tz.guess());
return {
message: `Stop ${deadline.fromNow()}`,
tooltip: (
<>
{title}
This workspace will be stopped on{" "}
{deadline.format("MMMM D [at] h:mm A")}
{reason}
</>
),
danger: isShutdownSoon(workspace),
message: deadlineTz.fromNow(),
tooltip: deadlineTz.format("MMMM D, YYYY h:mm A"),
};
}
} else if (!ttl || ttl < 1) {
@ -180,23 +126,11 @@ export const autostopDisplay = (
// not running. Therefore, we derive from workspace.ttl.
const duration = dayjs.duration(ttl, "milliseconds");
return {
message: `Stop ${duration.humanize()} ${Language.afterStart}`,
message: `${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");