mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: add impending deletion filter to workspaces page (#7860)
* add workspace deletion dialog * add deleting_by query param * added test * filtering on workspaces to be deleted * cleaned up form * added story * added banner filter * PR feedback * fix lint and stories * PR feedback * added enterprise test * added unit tests in search_test.go * remove unused fn * unstaged changes
This commit is contained in:
6
coderd/apidoc/docs.go
generated
6
coderd/apidoc/docs.go
generated
@ -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": {
|
||||
|
6
coderd/apidoc/swagger.json
generated
6
coderd/apidoc/swagger.json
generated
@ -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": {
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
@ -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) {
|
||||
|
Reference in New Issue
Block a user