From 12959344afb94387b813ef8c165d60a97bcc6a4c Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 6 Dec 2023 01:15:42 +0000 Subject: [PATCH 1/7] feat: show dormant workspaces by default --- coderd/database/dbmem/dbmem.go | 7 +- coderd/database/modelqueries.go | 2 +- coderd/database/queries.sql.go | 12 ++-- coderd/database/queries/workspaces.sql | 8 +-- coderd/searchquery/search.go | 2 +- .../DormantWorkspaceBanner.tsx | 61 ++++-------------- .../WorkspaceStatusBadge.tsx | 64 +++++++++++++++++++ site/src/pages/WorkspacePage/Workspace.tsx | 2 +- .../pages/WorkspacesPage/WorkspacesPage.tsx | 37 +---------- .../WorkspacesPage/WorkspacesPageView.tsx | 26 -------- .../pages/WorkspacesPage/WorkspacesTable.tsx | 8 ++- .../pages/WorkspacesPage/filter/filter.tsx | 2 +- 12 files changed, 99 insertions(+), 132 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 9fdd028225819..0e2e5d7d4e645 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -7221,12 +7221,7 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } } - // We omit locked workspaces by default. - if arg.IsDormant == "" && workspace.DormantAt.Valid { - continue - } - - if arg.IsDormant != "" && !workspace.DormantAt.Valid { + if arg.Dormant && !workspace.DormantAt.Valid { continue } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index a050997a17ba1..8cb7c32d3d864 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -221,7 +221,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, - arg.IsDormant, + arg.Dormant, arg.LastUsedBefore, arg.LastUsedAfter, arg.Offset, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fb7b15cf26866..6b8d9924fd23e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -10900,13 +10900,11 @@ WHERE ) > 0 ELSE true END - -- Filter by dormant workspaces. By default we do not return dormant - -- workspaces since they are considered soft-deleted. + -- Filter by dormant workspaces. AND CASE - WHEN $10 :: text != '' THEN + WHEN $10 :: boolean != 'false' THEN dormant_at IS NOT NULL - ELSE - dormant_at IS NULL + ELSE true END -- Filter by last_used AND CASE @@ -10947,7 +10945,7 @@ type GetWorkspacesParams struct { Name string `db:"name" json:"name"` HasAgent string `db:"has_agent" json:"has_agent"` AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` - IsDormant string `db:"is_dormant" json:"is_dormant"` + Dormant bool `db:"dormant" json:"dormant"` LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` Offset int32 `db:"offset_" json:"offset_"` @@ -10986,7 +10984,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, - arg.IsDormant, + arg.Dormant, arg.LastUsedBefore, arg.LastUsedAfter, arg.Offset, diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index ac3a1fd86c11b..2d46b939f2bfe 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -239,13 +239,11 @@ WHERE ) > 0 ELSE true END - -- Filter by dormant workspaces. By default we do not return dormant - -- workspaces since they are considered soft-deleted. + -- Filter by dormant workspaces. AND CASE - WHEN @is_dormant :: text != '' THEN + WHEN @dormant :: boolean != 'false' THEN dormant_at IS NOT NULL - ELSE - dormant_at IS NULL + ELSE true END -- Filter by last_used AND CASE diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 66f947dbaa313..405c59403fffe 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -107,7 +107,7 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT filter.Name = parser.String(values, "", "name") filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus])) filter.HasAgent = parser.String(values, "", "has-agent") - filter.IsDormant = parser.String(values, "", "is-dormant") + filter.Dormant = parser.Boolean(values, false, "dormant") filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after") filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before") diff --git a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx index 2022751555542..a8e921996eb9b 100644 --- a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx +++ b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx @@ -1,7 +1,5 @@ import { formatDistanceToNow } from "date-fns"; -import Link from "@mui/material/Link"; import { type FC } from "react"; -import { Link as RouterLink } from "react-router-dom"; import type { Workspace } from "api/typesGenerated"; import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"; import { Alert } from "components/Alert/Alert"; @@ -12,36 +10,22 @@ export enum Count { } interface DormantWorkspaceBannerProps { - workspaces?: Workspace[]; + workspace: Workspace; onDismiss: () => void; shouldRedisplayBanner: boolean; - count?: Count; } export const DormantWorkspaceBanner: FC = ({ - workspaces, + workspace, onDismiss, shouldRedisplayBanner, - count = Count.Singular, }) => { const experimentEnabled = useIsWorkspaceActionsEnabled(); - if (!workspaces) { - return null; - } - - const hasDormantWorkspaces = workspaces.find( - (workspace) => workspace.dormant_at, - ); - - const hasDeletionScheduledWorkspaces = workspaces.find( - (workspace) => workspace.deleting_at, - ); - if ( // Only show this if the experiment is included. !experimentEnabled || - !hasDormantWorkspaces || + !workspace.dormant_at || // Banners should be redisplayed after dismissal when additional workspaces are newly scheduled for deletion !shouldRedisplayBanner ) { @@ -58,44 +42,27 @@ export const DormantWorkspaceBanner: FC = ({ }; const alertText = (): string => { - if (workspaces.length === 1) { - if ( - hasDeletionScheduledWorkspaces && - hasDeletionScheduledWorkspaces.deleting_at && - hasDeletionScheduledWorkspaces.dormant_at - ) { - return `This workspace has been dormant for ${formatDistanceToNow( - Date.parse(hasDeletionScheduledWorkspaces.dormant_at), - )} and is scheduled to be deleted on ${formatDate( - hasDeletionScheduledWorkspaces.deleting_at, - )} . To keep it you must activate the workspace.`; - } else if (hasDormantWorkspaces && hasDormantWorkspaces.dormant_at) { - return `This workspace has been dormant for ${formatDistanceToNow( - Date.parse(hasDormantWorkspaces.dormant_at), - )} + if (workspace.deleting_at) { + return `This workspace has been dormant for ${formatDistanceToNow( + Date.parse(workspace.last_used_at), + )} and is scheduled to be deleted on ${formatDate( + workspace.deleting_at, + )} . To keep it you must activate the workspace.`; + } else if (workspace.dormant_at) { + return `This workspace has been dormant for ${formatDistanceToNow( + Date.parse(workspace.dormant_at), + )} and cannot be interacted with. Dormant workspaces are eligible for permanent deletion. To prevent deletion, activate the workspace.`; - } } return ""; }; return ( - {count === Count.Singular ? ( - alertText() - ) : ( - <> - There are{" "} - - workspaces - {" "} - that may be deleted soon due to inactivity. Activate the workspaces - you wish to retain. - - )} + {alertText()} ); }; diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 119d403612919..6cffdab4242da 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -3,6 +3,8 @@ import Tooltip, { tooltipClasses, } from "@mui/material/Tooltip"; import ErrorOutline from "@mui/icons-material/ErrorOutline"; +import RecyclingIcon from "@mui/icons-material/Recycling"; +import AutoDeleteIcon from "@mui/icons-material/AutoDelete"; import { type FC, type ReactNode } from "react"; import type { Workspace } from "api/typesGenerated"; import { Pill } from "components/Pill/Pill"; @@ -10,6 +12,7 @@ import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; import { DormantDeletionText } from "components/WorkspaceDeletion"; import { getDisplayWorkspaceStatus } from "utils/workspace"; import { useClassName } from "hooks/useClassName"; +import { formatDistanceToNow } from "date-fns"; export type WorkspaceStatusBadgeProps = { workspace: Workspace; @@ -56,6 +59,67 @@ export const WorkspaceStatusBadge: FC = ({ ); }; +export type DormantStatusBadgeProps = { + workspace: Workspace; + className?: string; +}; + +export const DormantStatusBadge: FC = ({ + workspace, + className, +}) => { + if (!workspace.dormant_at) { + return <>; + } + + const formatDate = (dateStr: string): string => { + const date = new Date(dateStr); + return date.toLocaleDateString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }); + }; + + return workspace.deleting_at ? ( + + {`This workspace has not been used for ${formatDistanceToNow( + Date.parse(workspace.last_used_at), + )} and is scheduled to be deleted on ${formatDate( + workspace.deleting_at, + )}.`} + + } + > + } + text="Deletion Pending" + type="error" + /> + + ) : ( + + {`This workspace has not been used for ${formatDistanceToNow( + Date.parse(workspace.last_used_at), + )} and is at risk of being deleted.`} + + } + > + } + text="Dormant" + type="warning" + /> + + ); +}; + export const WorkspaceStatusText: FC = ({ workspace, className, diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index cab58bab690d8..0aa7a3d74214a 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -282,7 +282,7 @@ export const Workspace: FC> = ({ )} {/* determines its own visibility */} { - const [dormantWorkspaces, setDormantWorkspaces] = useState([]); // If we use a useSearchParams for each hook, the values will not be in sync. // So we have to use a single one, centralizing the values, and pass it to // each hook. @@ -56,35 +51,6 @@ const WorkspacesPage: FC = () => { query: filterProps.filter.query, }); - const experimentEnabled = useIsWorkspaceActionsEnabled(); - // If workspace actions are enabled we need to fetch the dormant - // workspaces as well. This lets us determine whether we should - // show a banner to the user indicating that some of their workspaces - // are at risk of being deleted. - useEffect(() => { - if (experimentEnabled) { - const includesDormant = filterProps.filter.query.includes("dormant_at"); - const dormantQuery = includesDormant - ? filterProps.filter.query - : filterProps.filter.query + " is-dormant:true"; - - if (includesDormant && data) { - setDormantWorkspaces(data.workspaces); - } else { - getWorkspaces({ q: dormantQuery }) - .then((resp) => { - setDormantWorkspaces(resp.workspaces); - }) - .catch(() => { - // TODO - }); - } - } else { - // If the experiment isn't included then we'll pretend - // like dormant workspaces don't exist. - setDormantWorkspaces([]); - } - }, [experimentEnabled, data, filterProps.filter.query]); const updateWorkspace = useWorkspaceUpdate(queryKey); const [checkedWorkspaces, setCheckedWorkspaces] = useState([]); const [isConfirmingDeleteAll, setIsConfirmingDeleteAll] = useState(false); @@ -120,7 +86,6 @@ const WorkspacesPage: FC = () => { templates={templatesQuery.data} templatesFetchStatus={templatesQuery.status} workspaces={data?.workspaces} - dormantWorkspaces={dormantWorkspaces} error={error} count={data?.count} page={pagination.page} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index c68e1cdfbcae6..435f4f4c26d9b 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -6,8 +6,6 @@ import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { Stack } from "components/Stack/Stack"; import { WorkspaceHelpTooltip } from "./WorkspaceHelpTooltip"; import { WorkspacesTable } from "pages/WorkspacesPage/WorkspacesTable"; -import { useLocalStorage } from "hooks"; -import { DormantWorkspaceBanner, Count } from "components/WorkspaceDeletion"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { WorkspacesFilter } from "./filter/filter"; import { hasError, isApiValidationError } from "api/errors"; @@ -43,7 +41,6 @@ type TemplateQuery = UseQueryResult; export interface WorkspacesPageViewProps { error: unknown; workspaces?: Workspace[]; - dormantWorkspaces?: Workspace[]; checkedWorkspaces: Workspace[]; count?: number; filterProps: ComponentProps; @@ -64,7 +61,6 @@ export interface WorkspacesPageViewProps { export const WorkspacesPageView = ({ workspaces, - dormantWorkspaces, error, limit, count, @@ -83,15 +79,6 @@ export const WorkspacesPageView = ({ templatesFetchStatus, canCreateTemplate, }: WorkspacesPageViewProps) => { - const { saveLocal } = useLocalStorage(); - - const workspacesDeletionScheduled = dormantWorkspaces - ?.filter((workspace) => workspace.deleting_at) - .map((workspace) => workspace.id); - - const hasDormantWorkspace = - dormantWorkspaces !== undefined && dormantWorkspaces.length > 0; - return ( )} - {/* determines its own visibility */} - - saveLocal( - "dismissedWorkspaceList", - JSON.stringify(workspacesDeletionScheduled), - ) - } - count={Count.Multiple} - /> - diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index c08563f8bc63f..701f184b48c3e 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -21,7 +21,10 @@ import { Avatar } from "components/Avatar/Avatar"; import { Stack } from "components/Stack/Stack"; import { LastUsed } from "pages/WorkspacesPage/LastUsed"; import { WorkspaceOutdatedTooltip } from "components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip"; -import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge"; +import { + DormantStatusBadge, + WorkspaceStatusBadge, +} from "components/WorkspaceStatusBadge/WorkspaceStatusBadge"; import { getDisplayWorkspaceTemplateName } from "utils/workspace"; import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"; import { InfoTooltip } from "components/InfoTooltip/InfoTooltip"; @@ -197,6 +200,9 @@ export const WorkspacesTable: FC = ({ message="Your workspace is running but some agents are unhealthy." /> )} + {workspace.dormant_at && ( + + )} diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/filter.tsx index 3bc7c33240a6f..f89e2992c4a6d 100644 --- a/site/src/pages/WorkspacesPage/filter/filter.tsx +++ b/site/src/pages/WorkspacesPage/filter/filter.tsx @@ -21,7 +21,7 @@ export const workspaceFilterQuery = { all: "", running: "status:running", failed: "status:failed", - dormant: "is-dormant:true", + dormant: "dormant:true", }; type FilterPreset = { From 452f00c49f788682a3b80f031c6786f56b1b094e Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 6 Dec 2023 23:26:01 +0000 Subject: [PATCH 2/7] fix tests --- coderd/workspaces_test.go | 34 +++++++++--------- enterprise/coderd/workspaces_test.go | 54 ++++++++++++++-------------- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 71feaf5fc36c8..304571e0021d6 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1503,32 +1503,32 @@ func TestWorkspaceFilterManual(t *testing.T) { }, testutil.IntervalMedium, "agent status timeout") }) - t.Run("IsDormant", func(t *testing.T) { + t.Run("Dormant", func(t *testing.T) { // this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - }) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{}) user := coderdtest.CreateFirstUser(t, client) - authToken := uuid.NewString() - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.PlanComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(authToken), - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{ + OrganizationID: user.OrganizationID, + CreatedBy: user.UserID, + }).Do().Template // update template with inactivity ttl ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - dormantWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, dormantWorkspace.LatestBuild.ID) + dormantWorkspace := dbfake.WorkspaceBuild(t, db, database.Workspace{ + TemplateID: template.ID, + OwnerID: user.UserID, + OrganizationID: user.OrganizationID, + }).Do().Workspace // Create another workspace to validate that we do not return active workspaces. - _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, dormantWorkspace.LatestBuild.ID) + _ = dbfake.WorkspaceBuild(t, db, database.Workspace{ + TemplateID: template.ID, + OwnerID: user.UserID, + OrganizationID: user.OrganizationID, + }).Do() err := client.UpdateWorkspaceDormancy(ctx, dormantWorkspace.ID, codersdk.UpdateWorkspaceDormancy{ Dormant: true, @@ -1536,7 +1536,7 @@ func TestWorkspaceFilterManual(t *testing.T) { require.NoError(t, err) res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ - FilterQuery: "is-dormant:true", + FilterQuery: "dormant:true", }) require.NoError(t, err) require.Len(t, res.Workspaces, 1) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index f168e148a2b40..10a6424f3613b 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -991,53 +991,55 @@ func TestExecutorAutostartBlocked(t *testing.T) { func TestWorkspacesFiltering(t *testing.T) { t.Parallel() - t.Run("IsDormant", func(t *testing.T) { + t.Run("Dormant", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - client, user := coderdenttest.New(t, &coderdenttest.Options{ + client, db, owner := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ Options: &coderdtest.Options{ - IncludeProvisionerDaemon: true, - TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, }, }) - templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleTemplateAdmin()) + templateAdminClient, templateAdmin := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) - // Create a template version that passes to get a functioning workspace. - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - Parse: echo.ParseComplete, - ProvisionPlan: echo.PlanComplete, - ProvisionApply: echo.ApplyComplete, - }) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + resp := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{ + OrganizationID: owner.OrganizationID, + CreatedBy: owner.UserID, + }).Do() - dormantWS1 := coderdtest.CreateWorkspace(t, templateAdmin, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, dormantWS1.LatestBuild.ID) + dormantWS1 := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OwnerID: templateAdmin.ID, + OrganizationID: owner.OrganizationID, + }).Do().Workspace - dormantWS2 := coderdtest.CreateWorkspace(t, templateAdmin, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, dormantWS2.LatestBuild.ID) + dormantWS2 := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OwnerID: templateAdmin.ID, + OrganizationID: owner.OrganizationID, + TemplateID: resp.Template.ID, + }).Do().Workspace - activeWS := coderdtest.CreateWorkspace(t, templateAdmin, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, activeWS.LatestBuild.ID) + _ = dbfake.WorkspaceBuild(t, db, database.Workspace{ + OwnerID: templateAdmin.ID, + OrganizationID: owner.OrganizationID, + TemplateID: resp.Template.ID, + }).Do().Workspace - err := templateAdmin.UpdateWorkspaceDormancy(ctx, dormantWS1.ID, codersdk.UpdateWorkspaceDormancy{Dormant: true}) + err := templateAdminClient.UpdateWorkspaceDormancy(ctx, dormantWS1.ID, codersdk.UpdateWorkspaceDormancy{Dormant: true}) require.NoError(t, err) - err = templateAdmin.UpdateWorkspaceDormancy(ctx, dormantWS2.ID, codersdk.UpdateWorkspaceDormancy{Dormant: true}) + err = templateAdminClient.UpdateWorkspaceDormancy(ctx, dormantWS2.ID, codersdk.UpdateWorkspaceDormancy{Dormant: true}) require.NoError(t, err) - resp, err := templateAdmin.Workspaces(ctx, codersdk.WorkspaceFilter{ - FilterQuery: "is-dormant:true", + workspaces, err := templateAdminClient.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: "dormant:true", }) require.NoError(t, err) - require.Len(t, resp.Workspaces, 2) + require.Len(t, workspaces.Workspaces, 2) - for _, ws := range resp.Workspaces { + for _, ws := range workspaces.Workspaces { if ws.ID != dormantWS1.ID && ws.ID != dormantWS2.ID { t.Fatalf("Unexpected workspace %+v", ws) } From dc9efe70b9210271f26e18a56800603c4e849e54 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 7 Dec 2023 00:01:58 +0000 Subject: [PATCH 3/7] add storybook --- .../WorkspaceStatusBadge.tsx | 6 +++-- .../WorkspacesPageView.stories.tsx | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 6cffdab4242da..340d22ef89678 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -78,6 +78,8 @@ export const DormantStatusBadge: FC = ({ month: "long", day: "numeric", year: "numeric", + hour: "numeric", + minute: "numeric", }); }; @@ -87,7 +89,7 @@ export const DormantStatusBadge: FC = ({
{`This workspace has not been used for ${formatDistanceToNow( Date.parse(workspace.last_used_at), - )} and is scheduled to be deleted on ${formatDate( + )} and has been marked dormant. It is scheduled to be deleted on ${formatDate( workspace.deleting_at, )}.`}
@@ -106,7 +108,7 @@ export const DormantStatusBadge: FC = ({
{`This workspace has not been used for ${formatDistanceToNow( Date.parse(workspace.last_used_at), - )} and is at risk of being deleted.`} + )} and has been marked dormant. It is not scheduled for auto-deletion but will become a candidate if auto-deletion is enabled on this template.`}
} > diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 03699c2779a5a..3701cc57d979a 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -30,6 +30,8 @@ const createWorkspace = ( status: WorkspaceStatus, outdated = false, lastUsedAt = "0001-01-01", + deletingAt?: string, + dormantAt?: string, ): Workspace => { return { ...MockWorkspace, @@ -44,6 +46,8 @@ const createWorkspace = ( : MockWorkspace.latest_build.job, }, last_used_at: lastUsedAt, + dormant_at: dormantAt, + deleting_at: deletingAt, }; }; @@ -66,6 +70,22 @@ const additionalWorkspaces: Record = { ), }; +const dormantWorkspaces: Record = { + dormantNoDelete: createWorkspace( + "stopped", + false, + dayjs().subtract(1, "month").toString(), + dayjs().subtract(1, "month").toString(), + ), + dormantAutoDelete: createWorkspace( + "stopped", + false, + dayjs().subtract(1, "month").toString(), + dayjs().subtract(1, "month").toString(), + dayjs().add(29, "day").toString(), + ), +}; + const allWorkspaces = [ ...Object.values(workspaces), ...Object.values(additionalWorkspaces), @@ -208,6 +228,13 @@ export const UnhealthyWorkspace: Story = { }, }; +export const DormantWorkspaces: Story = { + args: { + workspaces: Object.values(dormantWorkspaces), + count: Object.values(dormantWorkspaces).length, + }, +}; + export const Error: Story = { args: { error: mockApiError({ message: "Something went wrong" }), From 838569af96c829e70798456a03c4618315515338 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 7 Dec 2023 00:22:20 +0000 Subject: [PATCH 4/7] update story --- site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx index 3701cc57d979a..1598d56e9749e 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.stories.tsx @@ -30,8 +30,8 @@ const createWorkspace = ( status: WorkspaceStatus, outdated = false, lastUsedAt = "0001-01-01", - deletingAt?: string, dormantAt?: string, + deletingAt?: string, ): Workspace => { return { ...MockWorkspace, From f0e7b0b24225327404edb92f4a9a22183190f12e Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 7 Dec 2023 23:28:16 +0000 Subject: [PATCH 5/7] pr comments --- coderd/workspaces_test.go | 3 +- .../DormantWorkspaceBanner.tsx | 32 +++++++++++-------- .../WorkspaceStatusBadge.tsx | 24 +++++++------- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 304571e0021d6..b5a75e3a33b94 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1506,7 +1506,7 @@ func TestWorkspaceFilterManual(t *testing.T) { t.Run("Dormant", func(t *testing.T) { // this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed t.Parallel() - client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{}) + client, db := coderdtest.NewWithDatabase(t, nil) user := coderdtest.CreateFirstUser(t, client) template := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{ OrganizationID: user.OrganizationID, @@ -1540,6 +1540,7 @@ func TestWorkspaceFilterManual(t *testing.T) { }) require.NoError(t, err) require.Len(t, res.Workspaces, 1) + require.Equal(t, dormantWorkspace.ID, res.Workspaces[0].ID) require.NotNil(t, res.Workspaces[0].DormantAt) }) diff --git a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx index a8e921996eb9b..d16dfc577442f 100644 --- a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx +++ b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx @@ -1,5 +1,5 @@ import { formatDistanceToNow } from "date-fns"; -import { type FC } from "react"; +import { ReactNode, type FC } from "react"; import type { Workspace } from "api/typesGenerated"; import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"; import { Alert } from "components/Alert/Alert"; @@ -41,21 +41,25 @@ export const DormantWorkspaceBanner: FC = ({ }); }; - const alertText = (): string => { + const alertText = (): ReactNode => { if (workspace.deleting_at) { - return `This workspace has been dormant for ${formatDistanceToNow( - Date.parse(workspace.last_used_at), - )} and is scheduled to be deleted on ${formatDate( - workspace.deleting_at, - )} . To keep it you must activate the workspace.`; + return ( + <> + This workspace has been dormant for $ + {formatDistanceToNow(Date.parse(workspace.last_used_at))} and is + scheduled to be deleted on ${formatDate(workspace.deleting_at)}. To + keep it you must activate the workspace. + + ); } else if (workspace.dormant_at) { - return `This workspace has been dormant for ${formatDistanceToNow( - Date.parse(workspace.dormant_at), - )} - and cannot be interacted - with. Dormant workspaces are eligible for - permanent deletion. To prevent deletion, activate - the workspace.`; + return ( + <> + This workspace has been dormant for $ + {formatDistanceToNow(Date.parse(workspace.dormant_at))} + and cannot be interacted with. Dormant workspaces are eligible for + permanent deletion. To prevent deletion, activate the workspace.`; + + ); } return ""; }; diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 340d22ef89678..5170f12a756a0 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -86,13 +86,12 @@ export const DormantStatusBadge: FC = ({ return workspace.deleting_at ? ( - {`This workspace has not been used for ${formatDistanceToNow( - Date.parse(workspace.last_used_at), - )} and has been marked dormant. It is scheduled to be deleted on ${formatDate( - workspace.deleting_at, - )}.`} - + <> + This workspace has not been used for $ + {formatDistanceToNow(Date.parse(workspace.last_used_at))} and has been + marked dormant. It is scheduled to be deleted on $ + {formatDate(workspace.deleting_at)}. + } > = ({ ) : ( - {`This workspace has not been used for ${formatDistanceToNow( - Date.parse(workspace.last_used_at), - )} and has been marked dormant. It is not scheduled for auto-deletion but will become a candidate if auto-deletion is enabled on this template.`} - + <> + This workspace has not been used for $ + {formatDistanceToNow(Date.parse(workspace.last_used_at))} and has been + marked dormant. It is not scheduled for auto-deletion but will become + a candidate if auto-deletion is enabled on this template. + } > Date: Thu, 7 Dec 2023 23:30:41 +0000 Subject: [PATCH 6/7] slightly improve test --- coderd/workspaces_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index b5a75e3a33b94..9c6129b5373f8 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1535,7 +1535,13 @@ func TestWorkspaceFilterManual(t *testing.T) { }) require.NoError(t, err) - res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + // Test that no filter returns both workspaces. + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, res.Workspaces, 2) + + // Test that filtering for dormant only returns our dormant workspace. + res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{ FilterQuery: "dormant:true", }) require.NoError(t, err) From b15bcb7a7f89a6653b12ff91cbd7f97fd8644e21 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 7 Dec 2023 23:46:11 +0000 Subject: [PATCH 7/7] fix text --- .../DormantWorkspaceBanner.tsx | 24 +++++++++++-------- .../WorkspaceStatusBadge.tsx | 6 ++--- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx index d16dfc577442f..6c03ccdbad74a 100644 --- a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx +++ b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx @@ -32,32 +32,36 @@ export const DormantWorkspaceBanner: FC = ({ return null; } - const formatDate = (dateStr: string): string => { + const formatDate = (dateStr: string, timestamp: boolean): string => { const date = new Date(dateStr); return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric", + ...(timestamp ? { hour: "numeric", minute: "numeric" } : {}), }); }; const alertText = (): ReactNode => { - if (workspace.deleting_at) { + if (workspace.deleting_at && workspace.dormant_at) { return ( <> - This workspace has been dormant for $ - {formatDistanceToNow(Date.parse(workspace.last_used_at))} and is - scheduled to be deleted on ${formatDate(workspace.deleting_at)}. To - keep it you must activate the workspace. + This workspace has not been used for{" "} + {formatDistanceToNow(Date.parse(workspace.last_used_at))} and was + marked dormant on {formatDate(workspace.dormant_at, false)}. It is + scheduled to be deleted on {formatDate(workspace.deleting_at, true)}. + To keep it you must activate the workspace. ); } else if (workspace.dormant_at) { return ( <> - This workspace has been dormant for $ - {formatDistanceToNow(Date.parse(workspace.dormant_at))} - and cannot be interacted with. Dormant workspaces are eligible for - permanent deletion. To prevent deletion, activate the workspace.`; + This workspace has not been used for{" "} + {formatDistanceToNow(Date.parse(workspace.last_used_at))} and was + marked dormant on {formatDate(workspace.dormant_at, false)}. It is not + scheduled for auto-deletion but will become a candidate if + auto-deletion is enabled on this template. To keep it you must + activate the workspace. ); } diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 5170f12a756a0..b889e9cd1dbb9 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -87,9 +87,9 @@ export const DormantStatusBadge: FC = ({ - This workspace has not been used for $ + This workspace has not been used for{" "} {formatDistanceToNow(Date.parse(workspace.last_used_at))} and has been - marked dormant. It is scheduled to be deleted on $ + marked dormant. It is scheduled to be deleted on{" "} {formatDate(workspace.deleting_at)}. } @@ -105,7 +105,7 @@ export const DormantStatusBadge: FC = ({ - This workspace has not been used for $ + This workspace has not been used for{" "} {formatDistanceToNow(Date.parse(workspace.last_used_at))} and has been marked dormant. It is not scheduled for auto-deletion but will become a candidate if auto-deletion is enabled on this template.