diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fcc08069ab70a..c29243d6c6e1a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5229,6 +5229,12 @@ const docTemplate = `{ "description": "Filter by agent status", "name": "has_agent", "in": "query" + }, + { + "type": "string", + "description": "Filter workspaces scheduled to be deleted by this time", + "name": "deleting_by", + "in": "query" } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f97718458a847..38f72a070b17b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4604,6 +4604,12 @@ "description": "Filter by agent status", "name": "has_agent", "in": "query" + }, + { + "type": "string", + "description": "Filter workspaces scheduled to be deleted by this time", + "name": "deleting_by", + "in": "query" } ], "responses": { diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 419b73117598a..a96d2cbaec9ff 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -12,6 +12,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" ) @@ -66,7 +67,11 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { return filter, parser.Errors } -func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, []codersdk.ValidationError) { +type PostFilter struct { + DeletingBy *time.Time `json:"deleting_by" format:"date-time"` +} + +func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, PostFilter, []codersdk.ValidationError) { filter := database.GetWorkspacesParams{ AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()), @@ -74,8 +79,10 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT Limit: int32(page.Limit), } + var postFilter PostFilter + if query == "" { - return filter, nil + return filter, postFilter, nil } // Always lowercase for all searches. @@ -95,7 +102,7 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT return nil }) if len(errors) > 0 { - return filter, errors + return filter, postFilter, errors } parser := httpapi.NewQueryParamParser() @@ -104,8 +111,13 @@ 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") + + if _, ok := values["deleting_by"]; ok { + postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02")) + } + parser.ErrorExcessParams(values) - return filter, parser.Errors + return filter, postFilter, parser.Errors } func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index bb7d135c055db..0948aa2d7f30a 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -6,11 +6,13 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/searchquery" + "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" ) @@ -148,17 +150,18 @@ func TestSearchWorkspace(t *testing.T) { c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() - values, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0) + values, postFilter, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0) if c.ExpectedErrorContains != "" { - require.True(t, len(errs) > 0, "expect some errors") + assert.True(t, len(errs) > 0, "expect some errors") var s strings.Builder for _, err := range errs { _, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail)) } - require.Contains(t, s.String(), c.ExpectedErrorContains) + assert.Contains(t, s.String(), c.ExpectedErrorContains) } else { - require.Len(t, errs, 0, "expected no error") - require.Equal(t, c.Expected, values, "expected values") + assert.Empty(t, postFilter) + assert.Len(t, errs, 0, "expected no error") + assert.Equal(t, c.Expected, values, "expected values") } }) } @@ -167,10 +170,51 @@ func TestSearchWorkspace(t *testing.T) { query := `` timeout := 1337 * time.Second - values, errs := searchquery.Workspaces(query, codersdk.Pagination{}, timeout) + values, _, errs := searchquery.Workspaces(query, codersdk.Pagination{}, timeout) require.Empty(t, errs) require.Equal(t, int64(timeout.Seconds()), values.AgentInactiveDisconnectTimeoutSeconds) }) + + t.Run("TestSearchWorkspacePostFilter", func(t *testing.T) { + t.Parallel() + testCases := []struct { + Name string + Query string + Expected searchquery.PostFilter + }{ + { + Name: "Empty", + Query: "", + Expected: searchquery.PostFilter{}, + }, + { + Name: "DeletingBy", + Query: "deleting_by:2023-06-09", + Expected: searchquery.PostFilter{ + DeletingBy: ptr.Ref(time.Date( + 2023, 6, 9, 0, 0, 0, 0, time.UTC)), + }, + }, + { + Name: "MultipleParams", + Query: "deleting_by:2023-06-09 name:workspace-name", + Expected: searchquery.PostFilter{ + DeletingBy: ptr.Ref(time.Date( + 2023, 6, 9, 0, 0, 0, 0, time.UTC)), + }, + }, + } + + for _, c := range testCases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + _, postFilter, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0) + assert.Len(t, errs, 0, "expected no error") + assert.Equal(t, c.Expected, postFilter, "expected values") + }) + } + }) } func TestSearchAudit(t *testing.T) { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index d624340d7ff43..a1bed7c9d1f45 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -106,6 +106,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { // @Param name query string false "Filter with partial-match by workspace name" // @Param status query string false "Filter by workspace status" Enums(pending,running,stopping,stopped,failed,canceling,canceled,deleted,deleting) // @Param has_agent query string false "Filter by agent status" Enums(connected,connecting,disconnected,timeout) +// @Param deleting_by query string false "Filter workspaces scheduled to be deleted by this time" // @Success 200 {object} codersdk.WorkspacesResponse // @Router /workspaces [get] func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { @@ -118,7 +119,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { } queryStr := r.URL.Query().Get("q") - filter, errs := searchquery.Workspaces(queryStr, page, api.AgentInactiveDisconnectTimeout) + filter, postFilter, errs := searchquery.Workspaces(queryStr, page, api.AgentInactiveDisconnectTimeout) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid workspace search query.", @@ -178,8 +179,26 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { return } + var filteredWorkspaces []codersdk.Workspace + // apply post filters, if they exist + if postFilter.DeletingBy == nil { + filteredWorkspaces = append(filteredWorkspaces, wss...) + } else { + for _, v := range wss { + if v.DeletingAt == nil { + continue + } + // get the beginning of the day on which deletion is scheduled + truncatedDeletionAt := time.Date(v.DeletingAt.Year(), v.DeletingAt.Month(), v.DeletingAt.Day(), 0, 0, 0, 0, v.DeletingAt.Location()) + if truncatedDeletionAt.After(*postFilter.DeletingBy) { + continue + } + filteredWorkspaces = append(filteredWorkspaces, v) + } + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{ - Workspaces: wss, + Workspaces: filteredWorkspaces, Count: int(workspaceRows[0].Count), }) } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 1e2cc41835d59..c9bdc60afd19e 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "strings" + "sync/atomic" "testing" "time" @@ -1209,6 +1210,62 @@ func TestWorkspaceFilterManual(t *testing.T) { return workspaces.Count == 1 }, testutil.IntervalMedium, "agent status timeout") }) + + t.Run("FilterQueryHasDeletingByAndUnlicensed", func(t *testing.T) { + // this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed + t.Parallel() + inactivityTTL := 1 * 24 * time.Hour + var setCalled int64 + + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + if atomic.AddInt64(&setCalled, 1) == 2 { + assert.Equal(t, inactivityTTL, options.InactivityTTL) + } + template.InactivityTTL = int64(options.InactivityTTL) + return template, nil + }, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + // update template with inactivity ttl + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + InactivityTTLMillis: inactivityTTL.Milliseconds(), + }) + + assert.NoError(t, err) + assert.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis) + + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // stop build so workspace is inactive + stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJob(t, client, stopBuild.ID) + + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(inactivityTTL).Format("2006-01-02")), + }) + + assert.NoError(t, err) + // we are expecting that no workspaces are returned as user is unlicensed + // and template.InactivityTTL should be 0 + assert.Len(t, res.Workspaces, 0) + }) } func TestOffsetLimit(t *testing.T) { diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 3d4724a90800c..b8c31d1bf83eb 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -383,13 +383,14 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ ### Parameters -| Name | In | Type | Required | Description | -| ----------- | ----- | ------ | -------- | ------------------------------------------- | -| `owner` | query | string | false | Filter by owner username | -| `template` | query | string | false | Filter by template name | -| `name` | query | string | false | Filter with partial-match by workspace name | -| `status` | query | string | false | Filter by workspace status | -| `has_agent` | query | string | false | Filter by agent status | +| Name | In | Type | Required | Description | +| ------------- | ----- | ------ | -------- | ------------------------------------------------------ | +| `owner` | query | string | false | Filter by owner username | +| `template` | query | string | false | Filter by template name | +| `name` | query | string | false | Filter with partial-match by workspace name | +| `status` | query | string | false | Filter by workspace status | +| `has_agent` | query | string | false | Filter by agent status | +| `deleting_by` | query | string | false | Filter workspaces scheduled to be deleted by this time | #### Enumerated Values diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 843d23d53eae2..2c1a84a6f8dff 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -2,13 +2,16 @@ package coderd_test import ( "context" + "fmt" "net/http" "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/coderd/coderdenttest" @@ -70,3 +73,56 @@ func TestCreateWorkspace(t *testing.T) { require.Error(t, err) }) } + +func TestWorkspacesFiltering(t *testing.T) { + t.Parallel() + + t.Run("FilterQueryHasDeletingByAndLicensed", func(t *testing.T) { + t.Parallel() + + inactivityTTL := 1 * 24 * time.Hour + + client := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + // update template with inactivity ttl + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + InactivityTTLMillis: inactivityTTL.Milliseconds(), + }) + + assert.NoError(t, err) + assert.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis) + + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // stop build so workspace is inactive + stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJob(t, client, stopBuild.ID) + + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + // adding a second to time.Now() to give some buffer in case test runs quickly + FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(time.Second).Add(inactivityTTL).Format("2006-01-02")), + }) + assert.NoError(t, err) + assert.Len(t, res.Workspaces, 1) + assert.Equal(t, workspace.ID, res.Workspaces[0].ID) + }) +} diff --git a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx index dd110e255b361..d793c16d5dc3a 100644 --- a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx +++ b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx @@ -2,7 +2,9 @@ import { Workspace } from "api/typesGenerated" import { displayImpendingDeletion } from "./utils" import { useDashboard } from "components/Dashboard/DashboardProvider" import { Alert } from "components/Alert/Alert" -import { formatDistanceToNow, differenceInDays } from "date-fns" +import { formatDistanceToNow, differenceInDays, add, format } from "date-fns" +import Link from "@mui/material/Link" +import { Link as RouterLink } from "react-router-dom" export enum Count { Singular, @@ -46,17 +48,34 @@ export const ImpendingDeletionBanner = ({ new Date(), ) + const plusFourteen = add(new Date(), { days: 14 }) + return ( - {count === Count.Singular - ? `This workspace has been unused for ${formatDistanceToNow( - Date.parse(workspace.last_used_at), - )} and is scheduled for deletion. To keep it, connect via SSH or the web terminal.` - : "You have workspaces that will be deleted soon due to inactivity. To keep these workspaces, connect to them via SSH or the web terminal."} + {count === Count.Singular ? ( + `This workspace has been unused for ${formatDistanceToNow( + Date.parse(workspace.last_used_at), + )} and is scheduled for deletion. To keep it, connect via SSH or the web terminal.` + ) : ( + <> + There are{" "} + + workspaces + {" "} + that will be deleted soon due to inactivity. To keep these workspaces, + connect to them via SSH or the web terminal. + + )} ) } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx new file mode 100644 index 0000000000000..645c7dfb84ddd --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react" +import { InactivityDialog } from "./InactivityDialog" + +const meta: Meta = { + title: "InactivityDialog", + component: InactivityDialog, +} + +export default meta +type Story = StoryObj + +export const OpenDialog: Story = { + args: { + submitValues: () => null, + isInactivityDialogOpen: true, + setIsInactivityDialogOpen: () => null, + workspacesToBeDeletedToday: 2, + }, +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx new file mode 100644 index 0000000000000..3f5ec252b08be --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx @@ -0,0 +1,30 @@ +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" + +export const InactivityDialog = ({ + submitValues, + isInactivityDialogOpen, + setIsInactivityDialogOpen, + workspacesToBeDeletedToday, +}: { + submitValues: () => void + isInactivityDialogOpen: boolean + setIsInactivityDialogOpen: (arg0: boolean) => void + workspacesToBeDeletedToday: number +}) => { + return ( + { + submitValues() + setIsInactivityDialogOpen(false) + }} + onClose={() => setIsInactivityDialogOpen(false)} + title="Delete inactive workspaces" + confirmText="Delete Workspaces" + description={`There are ${ + workspacesToBeDeletedToday ? workspacesToBeDeletedToday : "" + } workspaces that already match this filter and will be deleted upon form submission. Are you sure you want to proceed?`} + /> + ) +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TTLHelperText.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TTLHelperText.tsx new file mode 100644 index 0000000000000..0f7a55637bf96 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TTLHelperText.tsx @@ -0,0 +1,19 @@ +import { Maybe } from "components/Conditionals/Maybe" +import { useTranslation } from "react-i18next" + +export const TTLHelperText = ({ + ttl, + translationName, +}: { + ttl?: number + translationName: string +}) => { + const { t } = useTranslation("templateSettingsPage") + const count = typeof ttl !== "number" ? 0 : ttl + return ( + // no helper text if ttl is negative - error will show once field is considered touched + = 0}> + {t(translationName, { count })} + + ) +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx similarity index 75% rename from site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx rename to site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 39dc5a2131336..5f2b330197132 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -1,12 +1,9 @@ import TextField from "@mui/material/TextField" import { Template, UpdateTemplateMeta } from "api/typesGenerated" import { FormikTouched, useFormik } from "formik" -import { FC, ChangeEvent } from "react" +import { FC, ChangeEvent, useState } from "react" import { getFormHelpers } from "utils/formUtils" -import * as Yup from "yup" -import i18next from "i18next" import { useTranslation } from "react-i18next" -import { Maybe } from "components/Conditionals/Maybe" import { FormSection, HorizontalForm, @@ -19,93 +16,16 @@ import Link from "@mui/material/Link" import Checkbox from "@mui/material/Checkbox" import FormControlLabel from "@mui/material/FormControlLabel" import Switch from "@mui/material/Switch" +import { InactivityDialog } from "./InactivityDialog" +import { useWorkspacesToBeDeleted } from "./useWorkspacesToBeDeleted" +import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers" +import { TTLHelperText } from "./TTLHelperText" -const TTLHelperText = ({ - ttl, - translationName, -}: { - ttl?: number - translationName: string -}) => { - const { t } = useTranslation("templateSettingsPage") - const count = typeof ttl !== "number" ? 0 : ttl - return ( - // no helper text if ttl is negative - error will show once field is considered touched - = 0}> - {t(translationName, { count })} - - ) -} - -const MAX_TTL_DAYS = 7 const MS_HOUR_CONVERSION = 3600000 const MS_DAY_CONVERSION = 86400000 const FAILURE_CLEANUP_DEFAULT = 7 const INACTIVITY_CLEANUP_DEFAULT = 180 -export interface TemplateScheduleFormValues extends UpdateTemplateMeta { - failure_cleanup_enabled: boolean - inactivity_cleanup_enabled: boolean -} - -export const getValidationSchema = (): Yup.AnyObjectSchema => - Yup.object({ - default_ttl_ms: Yup.number() - .integer() - .min( - 0, - i18next - .t("defaultTTLMinError", { ns: "templateSettingsPage" }) - .toString(), - ) - .max( - 24 * MAX_TTL_DAYS /* 7 days in hours */, - i18next - .t("defaultTTLMaxError", { ns: "templateSettingsPage" }) - .toString(), - ), - max_ttl_ms: Yup.number() - .integer() - .min( - 0, - i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }).toString(), - ) - .max( - 24 * MAX_TTL_DAYS /* 7 days in hours */, - i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }).toString(), - ), - failure_ttl_ms: Yup.number() - .min(0, "Failure cleanup days must not be less than 0.") - .test( - "positive-if-enabled", - "Failure cleanup days must be greater than zero when enabled.", - function (value) { - const parent = this.parent as TemplateScheduleFormValues - if (parent.failure_cleanup_enabled) { - return Boolean(value) - } else { - return true - } - }, - ), - inactivity_ttl_ms: Yup.number() - .min(0, "Inactivity cleanup days must not be less than 0.") - .test( - "positive-if-enabled", - "Inactivity cleanup days must be greater than zero when enabled.", - function (value) { - const parent = this.parent as TemplateScheduleFormValues - if (parent.inactivity_cleanup_enabled) { - return Boolean(value) - } else { - return true - } - }, - ), - allow_user_autostart: Yup.boolean(), - allow_user_autostop: Yup.boolean(), - }) - export interface TemplateScheduleForm { template: Template onSubmit: (data: UpdateTemplateMeta) => void @@ -154,25 +74,16 @@ export const TemplateScheduleForm: FC = ({ allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms), }, validationSchema, - onSubmit: (formData) => { - // on submit, convert from hours => ms - onSubmit({ - default_ttl_ms: formData.default_ttl_ms - ? formData.default_ttl_ms * MS_HOUR_CONVERSION - : undefined, - max_ttl_ms: formData.max_ttl_ms - ? formData.max_ttl_ms * MS_HOUR_CONVERSION - : undefined, - failure_ttl_ms: formData.failure_ttl_ms - ? formData.failure_ttl_ms * MS_DAY_CONVERSION - : undefined, - inactivity_ttl_ms: formData.inactivity_ttl_ms - ? formData.inactivity_ttl_ms * MS_DAY_CONVERSION - : undefined, - - allow_user_autostart: formData.allow_user_autostart, - allow_user_autostop: formData.allow_user_autostop, - }) + onSubmit: () => { + if ( + form.values.inactivity_cleanup_enabled && + workspacesToBeDeletedToday && + workspacesToBeDeletedToday.length > 0 + ) { + setIsInactivityDialogOpen(true) + } else { + submitValues() + } }, initialTouched, }) @@ -183,6 +94,32 @@ export const TemplateScheduleForm: FC = ({ const { t } = useTranslation("templateSettingsPage") const styles = useStyles() + const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(form.values) + + const [isInactivityDialogOpen, setIsInactivityDialogOpen] = + useState(false) + + const submitValues = () => { + // on submit, convert from hours => ms + onSubmit({ + default_ttl_ms: form.values.default_ttl_ms + ? form.values.default_ttl_ms * MS_HOUR_CONVERSION + : undefined, + max_ttl_ms: form.values.max_ttl_ms + ? form.values.max_ttl_ms * MS_HOUR_CONVERSION + : undefined, + failure_ttl_ms: form.values.failure_ttl_ms + ? form.values.failure_ttl_ms * MS_DAY_CONVERSION + : undefined, + inactivity_ttl_ms: form.values.inactivity_ttl_ms + ? form.values.inactivity_ttl_ms * MS_DAY_CONVERSION + : undefined, + + allow_user_autostart: form.values.allow_user_autostart, + allow_user_autostop: form.values.allow_user_autostop, + }) + } + const handleToggleFailureCleanup = async (e: ChangeEvent) => { form.handleChange(e) if (!form.values.failure_cleanup_enabled) { @@ -395,6 +332,12 @@ export const TemplateScheduleForm: FC = ({ )} + + Yup.object({ + default_ttl_ms: Yup.number() + .integer() + .min( + 0, + i18next + .t("defaultTTLMinError", { ns: "templateSettingsPage" }) + .toString(), + ) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next + .t("defaultTTLMaxError", { ns: "templateSettingsPage" }) + .toString(), + ), + max_ttl_ms: Yup.number() + .integer() + .min( + 0, + i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }).toString(), + ) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }).toString(), + ), + failure_ttl_ms: Yup.number() + .min(0, "Failure cleanup days must not be less than 0.") + .test( + "positive-if-enabled", + "Failure cleanup days must be greater than zero when enabled.", + function (value) { + const parent = this.parent as TemplateScheduleFormValues + if (parent.failure_cleanup_enabled) { + return Boolean(value) + } else { + return true + } + }, + ), + inactivity_ttl_ms: Yup.number() + .min(0, "Inactivity cleanup days must not be less than 0.") + .test( + "positive-if-enabled", + "Inactivity cleanup days must be greater than zero when enabled.", + function (value) { + const parent = this.parent as TemplateScheduleFormValues + if (parent.inactivity_cleanup_enabled) { + return Boolean(value) + } else { + return true + } + }, + ), + allow_user_autostart: Yup.boolean(), + allow_user_autostop: Yup.boolean(), + }) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts new file mode 100644 index 0000000000000..b65f0afb692c9 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts @@ -0,0 +1,33 @@ +import { useQuery } from "@tanstack/react-query" +import { getWorkspaces } from "api/api" +import { compareAsc, add, endOfToday } from "date-fns" +import { WorkspaceStatus, Workspace } from "api/typesGenerated" +import { TemplateScheduleFormValues } from "./formHelpers" + +const inactiveStatuses: WorkspaceStatus[] = [ + "stopped", + "canceled", + "failed", + "deleted", +] + +export const useWorkspacesToBeDeleted = ( + formValues: TemplateScheduleFormValues, +) => { + const { data: workspacesData } = useQuery({ + queryKey: ["workspaces"], + queryFn: () => getWorkspaces({}), + enabled: formValues.inactivity_cleanup_enabled, + }) + return workspacesData?.workspaces?.filter((workspace: Workspace) => { + const isInactive = inactiveStatuses.includes(workspace.latest_build.status) + + const proposedDeletion = add(new Date(workspace.last_used_at), { + days: formValues.inactivity_ttl_ms, + }) + + if (isInactive && compareAsc(proposedDeletion, endOfToday()) < 1) { + return workspace + } + }) +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index 241ebd852a510..d377279896000 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -11,7 +11,7 @@ import { renderWithTemplateSettingsLayout, waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers" -import { getValidationSchema } from "./TemplateScheduleForm" +import { getValidationSchema } from "./TemplateScheduleForm/formHelpers" import TemplateSchedulePage from "./TemplateSchedulePage" import i18next from "i18next" diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx index 5c5e4689429e9..130d32fafc64e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.stories.tsx @@ -1,31 +1,46 @@ import { action } from "@storybook/addon-actions" -import { Story } from "@storybook/react" +import { Meta, StoryObj } from "@storybook/react" import { MockTemplate } from "testHelpers/entities" -import { - TemplateSchedulePageView, - TemplateSchedulePageViewProps, -} from "./TemplateSchedulePageView" +import { TemplateSchedulePageView } from "./TemplateSchedulePageView" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -export default { +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + refetchOnWindowFocus: false, + networkMode: "offlineFirst", + }, + }, +}) + +const meta: Meta = { title: "pages/TemplateSchedulePageView", component: TemplateSchedulePageView, - args: { - allowAdvancedScheduling: true, - allowWorkspaceActions: true, - template: MockTemplate, - onSubmit: action("onSubmit"), - onCancel: action("cancel"), - }, + decorators: [ + (Story) => ( + + + + ), + ], } +export default meta +type Story = StoryObj -const Template: Story = (args) => ( - -) +const defaultArgs = { + allowAdvancedScheduling: true, + allowWorkspaceActions: true, + template: MockTemplate, + onSubmit: action("onSubmit"), + onCancel: action("cancel"), +} -export const Example = Template.bind({}) -Example.args = {} +export const Example: Story = { + args: { ...defaultArgs }, +} -export const CantSetMaxTTL = Template.bind({}) -CantSetMaxTTL.args = { - allowAdvancedScheduling: false, +export const CantSetMaxTTL: Story = { + args: { ...defaultArgs, allowAdvancedScheduling: false }, } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx index 8e4aa9a6c0d65..89e5a896074cb 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx @@ -1,6 +1,6 @@ import { Template, UpdateTemplateMeta } from "api/typesGenerated" import { ComponentProps, FC } from "react" -import { TemplateScheduleForm } from "./TemplateScheduleForm" +import { TemplateScheduleForm } from "./TemplateScheduleForm/TemplateScheduleForm" import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader" import { makeStyles } from "@mui/styles" diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 1d6e1ace85c47..27381f62fc729 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -1,18 +1,11 @@ import { screen } from "@testing-library/react" import { rest } from "msw" import * as CreateDayString from "utils/createDayString" -import { - MockWorkspace, - MockWorkspacesResponse, - MockEntitlementsWithScheduling, - MockWorkspacesResponseWithDeletions, -} from "testHelpers/entities" +import { MockWorkspace, MockWorkspacesResponse } from "testHelpers/entities" import { history, renderWithAuth } from "testHelpers/renderHelpers" import { server } from "testHelpers/server" import WorkspacesPage from "./WorkspacesPage" import { i18n } from "i18n" -import * as API from "api/api" -import userEvent from "@testing-library/user-event" const { t } = i18n @@ -48,24 +41,4 @@ describe("WorkspacesPage", () => { ) expect(templateDisplayNames).toHaveLength(MockWorkspacesResponse.count) }) - - it("displays banner for impending deletions", async () => { - jest - .spyOn(API, "getEntitlements") - .mockResolvedValue(MockEntitlementsWithScheduling) - - jest - .spyOn(API, "getWorkspaces") - .mockResolvedValue(MockWorkspacesResponseWithDeletions) - - renderWithAuth() - - const banner = await screen.findByText( - "You have workspaces that will be deleted soon due to inactivity. To keep these workspaces, connect to them via SSH or the web terminal.", - ) - const user = userEvent.setup() - await user.click(screen.getByTestId("dismiss-banner-btn")) - - expect(banner).toBeEmptyDOMElement - }) })