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
- })
})