diff --git a/coderd/agentapi/api.go b/coderd/agentapi/api.go index acfe9145b2..73b50d9c0c 100644 --- a/coderd/agentapi/api.go +++ b/coderd/agentapi/api.go @@ -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, diff --git a/coderd/agentapi/stats.go b/coderd/agentapi/stats.go index bc4507779b..1185b99abd 100644 --- a/coderd/agentapi/stats.go +++ b/coderd/agentapi/stats.go @@ -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)) - } -} diff --git a/coderd/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index 4c456a3775..a26e7fbf6a 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -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) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 3e816108df..c185f6a900 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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{ diff --git a/codersdk/serversentevents.go b/codersdk/serversentevents.go index 8f4df9d4e6..8c026524c7 100644 --- a/codersdk/serversentevents.go +++ b/codersdk/serversentevents.go @@ -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) { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 68cc848b77..d5008b3234 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -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. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4f19b8e3a8..ac605e193f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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", ]; diff --git a/site/src/hooks/useTime.ts b/site/src/hooks/useTime.ts deleted file mode 100644 index 20e1a5669d..0000000000 --- a/site/src/hooks/useTime.ts +++ /dev/null @@ -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]); -} diff --git a/site/src/modules/workspaces/activity.ts b/site/src/modules/workspaces/activity.ts deleted file mode 100644 index cc3e7361d9..0000000000 --- a/site/src/modules/workspaces/activity.ts +++ /dev/null @@ -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"; -} diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx index 3dc3683ae3..910b131e3a 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx @@ -29,12 +29,12 @@ describe("AccountPage", () => { Promise.resolve({ id: userId, email: "user@coder.com", - created_at: new Date().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, diff --git a/site/src/pages/WorkspacePage/ActivityStatus.tsx b/site/src/pages/WorkspacePage/ActivityStatus.tsx deleted file mode 100644 index 8f2ae034a4..0000000000 --- a/site/src/pages/WorkspacePage/ActivityStatus.tsx +++ /dev/null @@ -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 = ({ - 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 Ready; - case "connected": - return Connected; - case "inactive": - return ( - - This workspace was last active on{" "} - {usedAt.format("MMMM D [at] h:mm A")} - - } - > - Inactive - - ); - case "notConnected": - return ( - - This workspace was last active on{" "} - {usedAt.format("MMMM D [at] h:mm A")} - - } - > - Not connected - - ); - } - - return null; -}; diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index ec6ec72b42..b5aa890021 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -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; - // 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 = diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx index 44a24d7cf1..87599a49e8 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx @@ -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 ( - - ); + return ; }; const BASE_DEADLINE = dayjs().add(3, "hour"); diff --git a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx index 8595fe8850..4613f6d489 100644 --- a/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx @@ -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 = ({ - status, - workspace, - template, - canUpdateWorkspace, -}) => { - if (!shouldDisplayScheduleControls(workspace, status)) { - return null; - } - - return ( - - - - - - - - - ); -}; +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 = ({ workspace, - status, - template, canUpdateSchedule, }) => { const queryClient = useQueryClient(); @@ -131,11 +90,7 @@ export const WorkspaceScheduleControls: FC = ({ return (
{isWorkspaceOn(workspace) ? ( - + ) : ( Starts at {autostartDisplay(workspace.autostart_schedule)} @@ -178,41 +133,28 @@ export const WorkspaceScheduleControls: FC = ({ interface AutoStopDisplayProps { workspace: Workspace; - status: WorkspaceActivityStatus; - template: Template; } -const AutoStopDisplay: FC = ({ - workspace, - status, - template, -}) => { - useTime(); - const { message, tooltip, danger } = autostopDisplay( - workspace, - status, - template, - ); +const AutoStopDisplay: FC = ({ workspace }) => { + const display = autostopDisplay(workspace); - const display = ( - ({ - color: `${theme.roles.danger.fill.outline} !important`, - })) - } - > - {message} - - ); - - if (tooltip) { - return {display}; + if (display.tooltip) { + return ( + + ({ + color: isShutdownSoon(workspace) + ? `${theme.palette.warning.light} !important` + : undefined, + })} + > + Stop {display.message} + + + ); } - return display; + return {display.message}; }; const ScheduleSettingsLink = forwardRef( @@ -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 = { diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx index f224a62acf..d1ed77cef9 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx @@ -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: [ diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index b77060c707..805fda059d 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -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 = ({ allowAdvancedScheduling, ); - const activityStatus = getWorkspaceActivityStatus(workspace); - return ( @@ -200,15 +200,6 @@ export const WorkspaceTopbar: FC = ({ - - - - {shouldDisplayDormantData && ( @@ -228,6 +219,20 @@ export const WorkspaceTopbar: FC = ({ )} + {shouldDisplayScheduleControls(workspace) && ( + + + + + + + + + )} + {quota && quota.budget > 0 && ( diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 49153a37e3..2c1eefc545 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -80,7 +80,11 @@ export const WorkspaceSchedulePage: FC = () => { {pageTitle([workspaceName, "Schedule"])} - + Workspace Schedule diff --git a/site/src/pages/WorkspacesPage/LastUsed.tsx b/site/src/pages/WorkspacesPage/LastUsed.tsx index 8748cb32b2..c4b10cc599 100644 --- a/site/src/pages/WorkspacesPage/LastUsed.tsx +++ b/site/src/pages/WorkspacesPage/LastUsed.tsx @@ -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 = ({ lastUsedAt }) => { - useTime(); const theme = useTheme(); const t = dayjs(lastUsedAt); const now = dayjs(); diff --git a/site/src/theme/dark/roles.ts b/site/src/theme/dark/roles.ts index 2931f2029b..6a116863fd 100644 --- a/site/src/theme/dark/roles.ts +++ b/site/src/theme/dark/roles.ts @@ -7,8 +7,8 @@ export default { outline: colors.orange[500], text: colors.orange[50], fill: { - solid: colors.orange[500], - outline: colors.orange[400], + solid: colors.orange[700], + outline: colors.orange[700], text: colors.white, }, disabled: { diff --git a/site/src/theme/darkBlue/roles.ts b/site/src/theme/darkBlue/roles.ts index a8aedfe854..d83eab54e0 100644 --- a/site/src/theme/darkBlue/roles.ts +++ b/site/src/theme/darkBlue/roles.ts @@ -7,8 +7,8 @@ export default { outline: colors.orange[600], text: colors.orange[50], fill: { - solid: colors.orange[500], - outline: colors.orange[400], + solid: colors.orange[600], + outline: colors.orange[600], text: colors.white, }, disabled: { diff --git a/site/src/theme/roles.ts b/site/src/theme/roles.ts index efb499336c..78e534984a 100644 --- a/site/src/theme/roles.ts +++ b/site/src/theme/roles.ts @@ -55,10 +55,10 @@ export interface Role { /** A set of more saturated colors to make things stand out */ fill: { - /** A saturated color for use as a background, or 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 */ diff --git a/site/src/utils/schedule.tsx b/site/src/utils/schedule.ts similarity index 72% rename from site/src/utils/schedule.tsx rename to site/src/utils/schedule.ts index 8ffe6223c0..c2a9a77f9e 100644 --- a/site/src/utils/schedule.tsx +++ b/site/src/utils/schedule.ts @@ -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: ( - <> - Upcoming stop required - This workspace will be required to stop by{" "} - {dayjs(workspace.latest_build.max_deadline).format( - "MMMM D [at] h:mm A", - )} - . You can restart your workspace before then to avoid interruption. - - ), - danger: true, - }; } else { - let title = ( - Template Autostop requirement - ); - let reason: ReactNode = ` because the ${template.display_name} template has an autostop requirement.`; - if (template.autostop_requirement && template.allow_user_autostop) { - title = Autostop schedule; - reason = ( - <> - {" "} - because this workspace has enabled autostop. You can disable - autostop from this workspace's{" "} - - schedule settings - - . - - ); - } + 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");