From 55ece93395bcfc0518370242a5956b697d85c773 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Thu, 1 Jun 2023 19:55:13 +0000 Subject: [PATCH 01/14] add workspace deletion dialog --- .../TemplateScheduleForm.tsx | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 245f3934c5dc9..1d5201a6d5f8c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -1,7 +1,7 @@ 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" @@ -19,6 +19,7 @@ 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 { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" const TTLHelperText = ({ ttl, @@ -145,24 +146,28 @@ export const TemplateScheduleForm: FC = ({ }, 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, + if (form.values.inactivity_cleanup_enabled) { + setIsInactivityDialogOpen(true) + } else { + // 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, - }) + allow_user_autostart: formData.allow_user_autostart, + allow_user_autostop: formData.allow_user_autostop, + }) + } }, initialTouched, }) @@ -172,6 +177,8 @@ export const TemplateScheduleForm: FC = ({ ) const { t } = useTranslation("templateSettingsPage") const styles = useStyles() + const [isInactivityDialogOpen, setIsInactivityDialogOpen] = + useState(false) const handleToggleFailureCleanup = async (e: ChangeEvent) => { form.handleChange(e) @@ -390,6 +397,15 @@ export const TemplateScheduleForm: FC = ({ isLoading={isSubmitting} submitDisabled={!form.isValid || !form.dirty} /> + setIsInactivityDialogOpen(false)} + title="Delete inactive workspaces" + confirmText="Delete Workspaces" + description="There are workspaces that already match this filter and will be deleted upon form submission. Are you sure you want to proceed?" + /> ) } From 39c143561579d5a6217f2221aa321b5b1cc2aa19 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 2 Jun 2023 19:28:47 +0000 Subject: [PATCH 02/14] add deleting_by query param --- coderd/apidoc/docs.go | 6 ++ coderd/apidoc/swagger.json | 6 ++ coderd/searchquery/search.go | 23 ++++++-- coderd/workspaces.go | 22 +++++++- docs/api/workspaces.md | 15 ++--- .../TemplateScheduleForm.tsx | 56 +++++++++++++------ 6 files changed, 98 insertions(+), 30 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 08ef7df926c76..4e3d35ea9acc4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5408,6 +5408,12 @@ const docTemplate = `{ "description": "Filter by agent status", "name": "has_agent", "in": "query" + }, + { + "type": "string", + "description": "Filter by DeletingAt time", + "name": "deleting_by", + "in": "query" } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1437f446d94d6..7baf2ab4ebd8a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4753,6 +4753,12 @@ "description": "Filter by agent status", "name": "has_agent", "in": "query" + }, + { + "type": "string", + "description": "Filter by DeletingAt time", + "name": "deleting_by", + "in": "query" } ], "responses": { diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 419b73117598a..c2ced61f1816c 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,12 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT Limit: int32(page.Limit), } + postFilter := PostFilter{ + DeletingBy: nil, + } + if query == "" { - return filter, nil + return filter, postFilter, nil } // Always lowercase for all searches. @@ -95,7 +104,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 +113,14 @@ 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 { + db := parser.Time(values, time.Time{}, "deleting_by", "2006-01-02") + postFilter.DeletingBy = ptr.Ref(db) + } + 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/workspaces.go b/coderd/workspaces.go index 627c431ae7964..11bbf8a6f3c99 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 by DeletingAt 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,25 @@ 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 { + break + } + // get the beginning of the day on which deletion is scheduled + truncatedDeletionAt := v.DeletingAt.Truncate(24 * time.Hour) + if v.DeletingAt != nil && (truncatedDeletionAt.Before(*postFilter.DeletingBy) || truncatedDeletionAt.Equal(*postFilter.DeletingBy)) { + filteredWorkspaces = append(filteredWorkspaces, v) + } + } + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{ - Workspaces: wss, + Workspaces: filteredWorkspaces, Count: int(workspaceRows[0].Count), }) } diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index de27221b8060f..5f7de83dab401 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -390,13 +390,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 by DeletingAt time | #### Enumerated Values diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 1d5201a6d5f8c..3b508b574aad7 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -149,24 +149,25 @@ export const TemplateScheduleForm: FC = ({ if (form.values.inactivity_cleanup_enabled) { setIsInactivityDialogOpen(true) } else { + actuallySubmit(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, + // 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, - }) + // allow_user_autostart: formData.allow_user_autostart, + // allow_user_autostop: formData.allow_user_autostop, + // }) } }, initialTouched, @@ -218,6 +219,27 @@ export const TemplateScheduleForm: FC = ({ } } + const actuallySubmit = (formData: any) => { + // 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, + }) + } + return ( = ({ actuallySubmit(form.values)} onClose={() => setIsInactivityDialogOpen(false)} title="Delete inactive workspaces" confirmText="Delete Workspaces" From c1c3040de0442336be2b54898ea4cf2634fe3fb4 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 5 Jun 2023 12:40:15 +0000 Subject: [PATCH 03/14] added test --- coderd/workspaces_test.go | 56 ++++++++++++++ .../TemplateScheduleForm.tsx | 73 ++++++++----------- site/src/pages/WorkspacesPage/data.ts | 6 +- 3 files changed, 91 insertions(+), 44 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 36227d411acc4..d1c5495bf422a 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strings" + "sync/atomic" "testing" "time" @@ -1007,6 +1008,61 @@ func TestWorkspaceFilterManual(t *testing.T) { return workspaces.Count == 1 }, testutil.IntervalMedium, "agent status timeout") }) + + t.Run("FilterQueryHasDeletingBy", func(t *testing.T) { + 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 { + require.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(), + }) + + require.NoError(t, err) + require.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")), + }) + + require.NoError(t, err) + require.Len(t, res.Workspaces, 1) + require.Equal(t, workspace.ID, res.Workspaces[0].ID) + }) + } func TestOffsetLimit(t *testing.T) { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 3b508b574aad7..fcca228324be2 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -20,6 +20,7 @@ import Checkbox from "@mui/material/Checkbox" import FormControlLabel from "@mui/material/FormControlLabel" import Switch from "@mui/material/Switch" import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" +import { useWorkspacesData } from "pages/WorkspacesPage/data" const TTLHelperText = ({ ttl, @@ -149,25 +150,7 @@ export const TemplateScheduleForm: FC = ({ if (form.values.inactivity_cleanup_enabled) { setIsInactivityDialogOpen(true) } else { - actuallySubmit(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, - // }) + submitValues(formData) } }, initialTouched, @@ -181,6 +164,35 @@ export const TemplateScheduleForm: FC = ({ const [isInactivityDialogOpen, setIsInactivityDialogOpen] = useState(false) + const submitValues = (formData: TemplateScheduleFormValues) => { + // 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, + }) + } + + const { data: workspacesData, error: getWorkspacesError } = useWorkspacesData( + { + query: "deleting_by:2023-05-12", + }, + ) + + console.log("workspacesData", workspacesData) + const handleToggleFailureCleanup = async (e: ChangeEvent) => { form.handleChange(e) if (!form.values.failure_cleanup_enabled) { @@ -219,27 +231,6 @@ export const TemplateScheduleForm: FC = ({ } } - const actuallySubmit = (formData: any) => { - // 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, - }) - } - return ( = ({ actuallySubmit(form.values)} + onConfirm={() => submitValues(form.values)} onClose={() => setIsInactivityDialogOpen(false)} title="Delete inactive workspaces" confirmText="Delete Workspaces" diff --git a/site/src/pages/WorkspacesPage/data.ts b/site/src/pages/WorkspacesPage/data.ts index e3d71ab80853f..a98b701dd6240 100644 --- a/site/src/pages/WorkspacesPage/data.ts +++ b/site/src/pages/WorkspacesPage/data.ts @@ -16,8 +16,8 @@ import { useState } from "react" import { useTranslation } from "react-i18next" type UseWorkspacesDataParams = { - page: number - limit: number + page?: number + limit?: number query: string } @@ -34,7 +34,7 @@ export const useWorkspacesData = ({ getWorkspaces({ q: query, limit: limit, - offset: page <= 0 ? 0 : (page - 1) * limit, + offset: page && limit && (page <= 0 ? 0 : (page - 1) * limit), }), onSuccess: () => { setShouldRefetch(true) From e48b3cc949ae7adabdcfc20636aa96770ed2c9fe Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 5 Jun 2023 16:04:16 +0000 Subject: [PATCH 04/14] filtering on workspaces to be deleted --- .../TemplateScheduleForm.tsx | 57 +++++++++++++++---- site/src/pages/WorkspacesPage/data.ts | 6 +- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index fcca228324be2..9402dba01df48 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -1,5 +1,10 @@ import TextField from "@mui/material/TextField" -import { Template, UpdateTemplateMeta } from "api/typesGenerated" +import { + Template, + UpdateTemplateMeta, + WorkspaceStatus, + Workspace, +} from "api/typesGenerated" import { FormikTouched, useFormik } from "formik" import { FC, ChangeEvent, useState } from "react" import { getFormHelpers } from "utils/formUtils" @@ -20,7 +25,9 @@ import Checkbox from "@mui/material/Checkbox" import FormControlLabel from "@mui/material/FormControlLabel" import Switch from "@mui/material/Switch" import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" -import { useWorkspacesData } from "pages/WorkspacesPage/data" +import { useQuery } from "@tanstack/react-query" +import { getWorkspaces } from "api/api" +import { compareAsc, add, endOfToday } from "date-fns" const TTLHelperText = ({ ttl, @@ -147,7 +154,11 @@ export const TemplateScheduleForm: FC = ({ }, validationSchema, onSubmit: (formData) => { - if (form.values.inactivity_cleanup_enabled) { + if ( + form.values.inactivity_cleanup_enabled && + workspacesToBeDeletedToday && + workspacesToBeDeletedToday.length > 0 + ) { setIsInactivityDialogOpen(true) } else { submitValues(formData) @@ -185,14 +196,35 @@ export const TemplateScheduleForm: FC = ({ }) } - const { data: workspacesData, error: getWorkspacesError } = useWorkspacesData( - { - query: "deleting_by:2023-05-12", + const { data: workspacesData } = useQuery({ + queryKey: ["workspaces"], + queryFn: () => getWorkspaces({}), + enabled: form.values.inactivity_cleanup_enabled, + }) + + const inactiveStatuses: WorkspaceStatus[] = [ + "stopped", + "canceled", + "failed", + "deleted", + ] + + const workspacesToBeDeletedToday = workspacesData?.workspaces?.filter( + (workspace: Workspace) => { + const isInactive = inactiveStatuses.includes( + workspace.latest_build.status, + ) + + const proposedDeletion = add(new Date(workspace.last_used_at), { + days: form.values.inactivity_ttl_ms, + }) + + if (isInactive && compareAsc(proposedDeletion, endOfToday()) < 1) { + return workspace + } }, ) - console.log("workspacesData", workspacesData) - const handleToggleFailureCleanup = async (e: ChangeEvent) => { form.handleChange(e) if (!form.values.failure_cleanup_enabled) { @@ -413,11 +445,16 @@ export const TemplateScheduleForm: FC = ({ submitValues(form.values)} + onConfirm={() => { + submitValues(form.values) + setIsInactivityDialogOpen(false) + }} onClose={() => setIsInactivityDialogOpen(false)} title="Delete inactive workspaces" confirmText="Delete Workspaces" - description="There are workspaces that already match this filter and will be deleted upon form submission. Are you sure you want to proceed?" + description={`There are ${ + workspacesToBeDeletedToday?.length ?? "" + } 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/WorkspacesPage/data.ts b/site/src/pages/WorkspacesPage/data.ts index a98b701dd6240..e3d71ab80853f 100644 --- a/site/src/pages/WorkspacesPage/data.ts +++ b/site/src/pages/WorkspacesPage/data.ts @@ -16,8 +16,8 @@ import { useState } from "react" import { useTranslation } from "react-i18next" type UseWorkspacesDataParams = { - page?: number - limit?: number + page: number + limit: number query: string } @@ -34,7 +34,7 @@ export const useWorkspacesData = ({ getWorkspaces({ q: query, limit: limit, - offset: page && limit && (page <= 0 ? 0 : (page - 1) * limit), + offset: page <= 0 ? 0 : (page - 1) * limit, }), onSuccess: () => { setShouldRefetch(true) From 7eb45e57b7732f762274815894f7b4f73c0a4517 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 5 Jun 2023 19:48:48 +0000 Subject: [PATCH 05/14] cleaned up form --- .../TemplateScheduleForm/InactivityDialog.tsx | 34 +++++ .../TemplateScheduleForm/TTLHelperText.tsx | 19 +++ .../TemplateScheduleForm.tsx | 141 ++---------------- .../TemplateScheduleForm/formHelpers.tsx | 58 +++++++ .../TemplateScheduleForm/useWorkspacesData.ts | 37 +++++ .../TemplateSchedulePage.test.tsx | 2 +- .../TemplateSchedulePageView.tsx | 2 +- 7 files changed, 164 insertions(+), 129 deletions(-) create mode 100644 site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx create mode 100644 site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TTLHelperText.tsx rename site/src/pages/TemplateSettingsPage/TemplateSchedulePage/{ => TemplateScheduleForm}/TemplateScheduleForm.tsx (74%) create mode 100644 site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx create mode 100644 site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesData.ts 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..e11dc63800867 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx @@ -0,0 +1,34 @@ +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" +import { useWorkspacesData } from "./useWorkspacesData" +import { TemplateScheduleFormValues } from "./formHelpers" + +export const InactivityDialog = ({ + formValues, + submitValues, + isInactivityDialogOpen, + setIsInactivityDialogOpen, +}: { + formValues: TemplateScheduleFormValues + submitValues: (arg0: TemplateScheduleFormValues) => void + isInactivityDialogOpen: boolean + setIsInactivityDialogOpen: (arg0: boolean) => void +}) => { + const workspacesToBeDeletedToday = useWorkspacesData(formValues) + + return ( + { + submitValues(formValues) + setIsInactivityDialogOpen(false) + }} + onClose={() => setIsInactivityDialogOpen(false)} + title="Delete inactive workspaces" + confirmText="Delete Workspaces" + description={`There are ${ + workspacesToBeDeletedToday?.length ?? "" + } 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 74% rename from site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx rename to site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 9402dba01df48..dd27a15a0d338 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -1,17 +1,9 @@ import TextField from "@mui/material/TextField" -import { - Template, - UpdateTemplateMeta, - WorkspaceStatus, - Workspace, -} from "api/typesGenerated" +import { Template, UpdateTemplateMeta } from "api/typesGenerated" import { FormikTouched, useFormik } from "formik" 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, @@ -24,87 +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 { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" -import { useQuery } from "@tanstack/react-query" -import { getWorkspaces } from "api/api" -import { compareAsc, add, endOfToday } from "date-fns" - -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 })} - - ) -} +import { InactivityDialog } from "./InactivityDialog" +import { useWorkspacesData } from "./useWorkspacesData" +import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers" +import { TTLHelperText } from "./TTLHelperText" -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" })) - .max( - 24 * MAX_TTL_DAYS /* 7 days in hours */, - i18next.t("defaultTTLMaxError", { ns: "templateSettingsPage" }), - ), - max_ttl_ms: Yup.number() - .integer() - .min(0, i18next.t("maxTTLMinError", { ns: "templateSettingsPage" })) - .max( - 24 * MAX_TTL_DAYS /* 7 days in hours */, - i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }), - ), - 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 @@ -172,6 +93,9 @@ export const TemplateScheduleForm: FC = ({ ) const { t } = useTranslation("templateSettingsPage") const styles = useStyles() + + const workspacesToBeDeletedToday = useWorkspacesData(form.values) + const [isInactivityDialogOpen, setIsInactivityDialogOpen] = useState(false) @@ -196,35 +120,6 @@ export const TemplateScheduleForm: FC = ({ }) } - const { data: workspacesData } = useQuery({ - queryKey: ["workspaces"], - queryFn: () => getWorkspaces({}), - enabled: form.values.inactivity_cleanup_enabled, - }) - - const inactiveStatuses: WorkspaceStatus[] = [ - "stopped", - "canceled", - "failed", - "deleted", - ] - - const workspacesToBeDeletedToday = workspacesData?.workspaces?.filter( - (workspace: Workspace) => { - const isInactive = inactiveStatuses.includes( - workspace.latest_build.status, - ) - - const proposedDeletion = add(new Date(workspace.last_used_at), { - days: form.values.inactivity_ttl_ms, - }) - - if (isInactive && compareAsc(proposedDeletion, endOfToday()) < 1) { - return workspace - } - }, - ) - const handleToggleFailureCleanup = async (e: ChangeEvent) => { form.handleChange(e) if (!form.values.failure_cleanup_enabled) { @@ -437,25 +332,17 @@ export const TemplateScheduleForm: FC = ({ )} + - { - submitValues(form.values) - setIsInactivityDialogOpen(false) - }} - onClose={() => setIsInactivityDialogOpen(false)} - title="Delete inactive workspaces" - confirmText="Delete Workspaces" - description={`There are ${ - workspacesToBeDeletedToday?.length ?? "" - } 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/formHelpers.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx new file mode 100644 index 0000000000000..715c08db00816 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx @@ -0,0 +1,58 @@ +import { UpdateTemplateMeta } from "api/typesGenerated" +import * as Yup from "yup" +import i18next from "i18next" + +export interface TemplateScheduleFormValues extends UpdateTemplateMeta { + failure_cleanup_enabled: boolean + inactivity_cleanup_enabled: boolean +} + +const MAX_TTL_DAYS = 7 + +export const getValidationSchema = (): Yup.AnyObjectSchema => + Yup.object({ + default_ttl_ms: Yup.number() + .integer() + .min(0, i18next.t("defaultTTLMinError", { ns: "templateSettingsPage" })) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("defaultTTLMaxError", { ns: "templateSettingsPage" }), + ), + max_ttl_ms: Yup.number() + .integer() + .min(0, i18next.t("maxTTLMinError", { ns: "templateSettingsPage" })) + .max( + 24 * MAX_TTL_DAYS /* 7 days in hours */, + i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }), + ), + 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/useWorkspacesData.ts b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesData.ts new file mode 100644 index 0000000000000..de61a6d460987 --- /dev/null +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesData.ts @@ -0,0 +1,37 @@ +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 useWorkspacesData = (formValues: TemplateScheduleFormValues) => { + const { data: workspacesData } = useQuery({ + queryKey: ["workspaces"], + queryFn: () => getWorkspaces({}), + enabled: formValues.inactivity_cleanup_enabled, + }) + const workspacesToBeDeletedToday = 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 + } + }, + ) + + return workspacesToBeDeletedToday +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index c767c4c71398c..235c322022479 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.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" From dde782c7351f03bd275196543c5e76711967d2f3 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 5 Jun 2023 20:14:25 +0000 Subject: [PATCH 06/14] added story --- .../InactivityDialog.stories.tsx | 19 +++++++++++++ .../TemplateScheduleForm/InactivityDialog.tsx | 14 ++++------ .../TemplateScheduleForm.tsx | 28 +++++++++---------- 3 files changed, 38 insertions(+), 23 deletions(-) create mode 100644 site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx 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 index e11dc63800867..3f5ec252b08be 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx @@ -1,33 +1,29 @@ import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" -import { useWorkspacesData } from "./useWorkspacesData" -import { TemplateScheduleFormValues } from "./formHelpers" export const InactivityDialog = ({ - formValues, submitValues, isInactivityDialogOpen, setIsInactivityDialogOpen, + workspacesToBeDeletedToday, }: { - formValues: TemplateScheduleFormValues - submitValues: (arg0: TemplateScheduleFormValues) => void + submitValues: () => void isInactivityDialogOpen: boolean setIsInactivityDialogOpen: (arg0: boolean) => void + workspacesToBeDeletedToday: number }) => { - const workspacesToBeDeletedToday = useWorkspacesData(formValues) - return ( { - submitValues(formValues) + submitValues() setIsInactivityDialogOpen(false) }} onClose={() => setIsInactivityDialogOpen(false)} title="Delete inactive workspaces" confirmText="Delete Workspaces" description={`There are ${ - workspacesToBeDeletedToday?.length ?? "" + 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/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index dd27a15a0d338..88f21c6fa56b5 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -74,7 +74,7 @@ export const TemplateScheduleForm: FC = ({ allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms), }, validationSchema, - onSubmit: (formData) => { + onSubmit: () => { if ( form.values.inactivity_cleanup_enabled && workspacesToBeDeletedToday && @@ -82,7 +82,7 @@ export const TemplateScheduleForm: FC = ({ ) { setIsInactivityDialogOpen(true) } else { - submitValues(formData) + submitValues() } }, initialTouched, @@ -99,24 +99,24 @@ export const TemplateScheduleForm: FC = ({ const [isInactivityDialogOpen, setIsInactivityDialogOpen] = useState(false) - const submitValues = (formData: TemplateScheduleFormValues) => { + const submitValues = () => { // on submit, convert from hours => ms onSubmit({ - default_ttl_ms: formData.default_ttl_ms - ? formData.default_ttl_ms * MS_HOUR_CONVERSION + default_ttl_ms: form.values.default_ttl_ms + ? form.values.default_ttl_ms * MS_HOUR_CONVERSION : undefined, - max_ttl_ms: formData.max_ttl_ms - ? formData.max_ttl_ms * MS_HOUR_CONVERSION + max_ttl_ms: form.values.max_ttl_ms + ? form.values.max_ttl_ms * MS_HOUR_CONVERSION : undefined, - failure_ttl_ms: formData.failure_ttl_ms - ? formData.failure_ttl_ms * MS_DAY_CONVERSION + failure_ttl_ms: form.values.failure_ttl_ms + ? form.values.failure_ttl_ms * MS_DAY_CONVERSION : undefined, - inactivity_ttl_ms: formData.inactivity_ttl_ms - ? formData.inactivity_ttl_ms * MS_DAY_CONVERSION + inactivity_ttl_ms: form.values.inactivity_ttl_ms + ? form.values.inactivity_ttl_ms * MS_DAY_CONVERSION : undefined, - allow_user_autostart: formData.allow_user_autostart, - allow_user_autostop: formData.allow_user_autostop, + allow_user_autostart: form.values.allow_user_autostart, + allow_user_autostop: form.values.allow_user_autostop, }) } @@ -333,10 +333,10 @@ export const TemplateScheduleForm: FC = ({ )} Date: Mon, 5 Jun 2023 21:40:59 +0000 Subject: [PATCH 07/14] added banner filter --- coderd/workspaces.go | 2 +- .../ImpendingDeletionBanner.tsx | 31 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 9c5e8ec9e8335..dc7e41ac559a8 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -186,7 +186,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { } else { for _, v := range wss { if v.DeletingAt == nil { - break + continue } // get the beginning of the day on which deletion is scheduled truncatedDeletionAt := v.DeletingAt.Truncate(24 * time.Hour) 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. + + )} ) } From ef6be22cd79528754fd9d1a276d1fae4cd7653fb Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Tue, 6 Jun 2023 13:31:53 +0000 Subject: [PATCH 08/14] PR feedback --- .../TemplateScheduleForm.tsx | 4 +- .../TemplateScheduleForm/useWorkspacesData.ts | 37 ------------------- .../useWorkspacesToBeDeleted.ts | 33 +++++++++++++++++ 3 files changed, 35 insertions(+), 39 deletions(-) delete mode 100644 site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesData.ts create mode 100644 site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 88f21c6fa56b5..1e10025c94e33 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -17,7 +17,7 @@ import Checkbox from "@mui/material/Checkbox" import FormControlLabel from "@mui/material/FormControlLabel" import Switch from "@mui/material/Switch" import { InactivityDialog } from "./InactivityDialog" -import { useWorkspacesData } from "./useWorkspacesData" +import { useWorkspacesToBeDeleted } from "./useWorkspacesToBeDeleted" import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers" import { TTLHelperText } from "./TTLHelperText" @@ -94,7 +94,7 @@ export const TemplateScheduleForm: FC = ({ const { t } = useTranslation("templateSettingsPage") const styles = useStyles() - const workspacesToBeDeletedToday = useWorkspacesData(form.values) + const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(form.values) const [isInactivityDialogOpen, setIsInactivityDialogOpen] = useState(false) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesData.ts b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesData.ts deleted file mode 100644 index de61a6d460987..0000000000000 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesData.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 useWorkspacesData = (formValues: TemplateScheduleFormValues) => { - const { data: workspacesData } = useQuery({ - queryKey: ["workspaces"], - queryFn: () => getWorkspaces({}), - enabled: formValues.inactivity_cleanup_enabled, - }) - const workspacesToBeDeletedToday = 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 - } - }, - ) - - return workspacesToBeDeletedToday -} 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 + } + }) +} From a4b81ce45727e338d0153ce4250370e7ca349072 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Tue, 6 Jun 2023 14:21:39 +0000 Subject: [PATCH 09/14] fix lint and stories --- coderd/searchquery/search_test.go | 4 +- coderd/workspaces_test.go | 1 - .../TemplateSchedulePageView.stories.tsx | 57 ++++++++++++------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index bb7d135c055db..6afa63d5c9c4e 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -148,7 +148,7 @@ 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, _, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0) if c.ExpectedErrorContains != "" { require.True(t, len(errs) > 0, "expect some errors") var s strings.Builder @@ -167,7 +167,7 @@ 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) }) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index d1c5495bf422a..cea82a9aa1e24 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1062,7 +1062,6 @@ func TestWorkspaceFilterManual(t *testing.T) { require.Len(t, res.Workspaces, 1) require.Equal(t, workspace.ID, res.Workspaces[0].ID) }) - } func TestOffsetLimit(t *testing.T) { 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 }, } From 96971202280684097320a73b98ca28dd9913f3f6 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 9 Jun 2023 15:57:41 +0000 Subject: [PATCH 10/14] PR feedback --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/searchquery/search.go | 7 ++----- coderd/workspaces.go | 9 +++++---- coderd/workspaces_test.go | 12 ++++++------ docs/api/workspaces.md | 16 ++++++++-------- 6 files changed, 23 insertions(+), 25 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e151c53a00f9a..2b90d6b25c8c8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5232,7 +5232,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "Filter by DeletingAt time", + "description": "Filter workspaces scheduled to be deleted by this time", "name": "deleting_by", "in": "query" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index adecc800db015..037754fc388ed 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4607,7 +4607,7 @@ }, { "type": "string", - "description": "Filter by DeletingAt time", + "description": "Filter workspaces scheduled to be deleted by this time", "name": "deleting_by", "in": "query" } diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index c2ced61f1816c..a96d2cbaec9ff 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -79,9 +79,7 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT Limit: int32(page.Limit), } - postFilter := PostFilter{ - DeletingBy: nil, - } + var postFilter PostFilter if query == "" { return filter, postFilter, nil @@ -115,8 +113,7 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT filter.HasAgent = parser.String(values, "", "has-agent") if _, ok := values["deleting_by"]; ok { - db := parser.Time(values, time.Time{}, "deleting_by", "2006-01-02") - postFilter.DeletingBy = ptr.Ref(db) + postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02")) } parser.ErrorExcessParams(values) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index dc7e41ac559a8..a1bed7c9d1f45 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -106,7 +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 by DeletingAt time" +// @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) { @@ -189,10 +189,11 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { continue } // get the beginning of the day on which deletion is scheduled - truncatedDeletionAt := v.DeletingAt.Truncate(24 * time.Hour) - if v.DeletingAt != nil && (truncatedDeletionAt.Before(*postFilter.DeletingBy) || truncatedDeletionAt.Equal(*postFilter.DeletingBy)) { - filteredWorkspaces = append(filteredWorkspaces, v) + 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) } } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index cea82a9aa1e24..695ba38337f0c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1019,7 +1019,7 @@ func TestWorkspaceFilterManual(t *testing.T) { 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 { - require.Equal(t, inactivityTTL, options.InactivityTTL) + assert.Equal(t, inactivityTTL, options.InactivityTTL) } template.InactivityTTL = int64(options.InactivityTTL) return template, nil @@ -1044,8 +1044,8 @@ func TestWorkspaceFilterManual(t *testing.T) { InactivityTTLMillis: inactivityTTL.Milliseconds(), }) - require.NoError(t, err) - require.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis) + 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) @@ -1058,9 +1058,9 @@ func TestWorkspaceFilterManual(t *testing.T) { FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(inactivityTTL).Format("2006-01-02")), }) - require.NoError(t, err) - require.Len(t, res.Workspaces, 1) - require.Equal(t, workspace.ID, res.Workspaces[0].ID) + assert.NoError(t, err) + assert.Len(t, res.Workspaces, 1) + assert.Equal(t, workspace.ID, res.Workspaces[0].ID) }) } diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 91182f09b362e..f86c3ee33f5ab 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -381,14 +381,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 | -| `deleting_by` | query | string | false | Filter by DeletingAt time | +| 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 From a08a0c92c58589cb5180a22ca4b75b4f25b1ee0e Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 9 Jun 2023 17:05:09 +0000 Subject: [PATCH 11/14] added enterprise test --- coderd/workspaces_test.go | 8 ++-- enterprise/coderd/workspaces_test.go | 56 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 695ba38337f0c..061b52ba05c33 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1009,7 +1009,8 @@ func TestWorkspaceFilterManual(t *testing.T) { }, testutil.IntervalMedium, "agent status timeout") }) - t.Run("FilterQueryHasDeletingBy", func(t *testing.T) { + 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 @@ -1059,8 +1060,9 @@ func TestWorkspaceFilterManual(t *testing.T) { }) assert.NoError(t, err) - assert.Len(t, res.Workspaces, 1) - assert.Equal(t, workspace.ID, res.Workspaces[0].ID) + // we are expecting that no workspaces are returned as user is unlicensed + // and template.InactivityTTL should be 0 + assert.Len(t, res.Workspaces, 0) }) } 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) + }) +} From 0075964afaa11846529292898fa34cfa2902a8a1 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 12 Jun 2023 13:10:17 +0000 Subject: [PATCH 12/14] added unit tests in search_test.go --- coderd/searchquery/search_test.go | 61 ++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 6afa63d5c9c4e..f004b89525fb0 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") } }) } @@ -171,6 +174,47 @@ func TestSearchWorkspace(t *testing.T) { 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, 06, 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, 06, 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) { @@ -342,3 +386,10 @@ func TestSearchUsers(t *testing.T) { }) } } + +func must[T any](value T, err error) T { + if err != nil { + panic(err) + } + return value +} From a0f14dd5a18df253e57cd6d0a707eb40c15259f5 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 12 Jun 2023 13:29:48 +0000 Subject: [PATCH 13/14] remove unused fn --- coderd/searchquery/search_test.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index f004b89525fb0..6e4f3f6782b87 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -386,10 +386,3 @@ func TestSearchUsers(t *testing.T) { }) } } - -func must[T any](value T, err error) T { - if err != nil { - panic(err) - } - return value -} From e03e443d00f5eb28e694cd336ff0c67d5381904c Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 12 Jun 2023 13:53:16 +0000 Subject: [PATCH 14/14] unstaged changes --- coderd/searchquery/search_test.go | 4 +-- .../WorkspacesPage/WorkspacesPage.test.tsx | 29 +------------------ 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 6e4f3f6782b87..0948aa2d7f30a 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -192,7 +192,7 @@ func TestSearchWorkspace(t *testing.T) { Query: "deleting_by:2023-06-09", Expected: searchquery.PostFilter{ DeletingBy: ptr.Ref(time.Date( - 2023, 06, 9, 0, 0, 0, 0, time.UTC)), + 2023, 6, 9, 0, 0, 0, 0, time.UTC)), }, }, { @@ -200,7 +200,7 @@ func TestSearchWorkspace(t *testing.T) { Query: "deleting_by:2023-06-09 name:workspace-name", Expected: searchquery.PostFilter{ DeletingBy: ptr.Ref(time.Date( - 2023, 06, 9, 0, 0, 0, 0, time.UTC)), + 2023, 6, 9, 0, 0, 0, 0, time.UTC)), }, }, } 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 - }) })