Skip to content

feat: add impending deletion filter to workspaces page #7860

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jun 12, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 19 additions & 4 deletions coderd/searchquery/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -66,16 +67,24 @@ 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()),

Offset: int32(page.Offset),
Limit: int32(page.Limit),
}

postFilter := PostFilter{
DeletingBy: nil,
}

if query == "" {
return filter, nil
return filter, postFilter, nil
}

// Always lowercase for all searches.
Expand All @@ -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()
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions coderd/searchquery/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
})
Expand Down
22 changes: 20 additions & 2 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.",
Expand Down Expand Up @@ -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 {
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)
}
}
}

httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
Workspaces: wss,
Workspaces: filteredWorkspaces,
Count: int(workspaceRows[0].Count),
})
}
Expand Down
55 changes: 55 additions & 0 deletions coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -1007,6 +1008,60 @@ 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) {
Expand Down
15 changes: 8 additions & 7 deletions docs/api/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,13 +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 |
| 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

Expand Down
31 changes: 25 additions & 6 deletions site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -46,17 +48,34 @@ export const ImpendingDeletionBanner = ({
new Date(),
)

const plusFourteen = add(new Date(), { days: 14 })

return (
<Alert
severity={daysUntilDelete <= 7 ? "warning" : "info"}
onDismiss={onDismiss}
dismissible
>
{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.`
) : (
<>
<span>There are</span>{" "}
<Link
component={RouterLink}
to={`/workspaces?filter=deleting_by:${format(
plusFourteen,
"y-MM-dd",
)}`}
>
workspaces
</Link>{" "}
that will be deleted soon due to inactivity. To keep these workspaces,
connect to them via SSH or the web terminal.
</>
)}
</Alert>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react"
import { InactivityDialog } from "./InactivityDialog"

const meta: Meta<typeof InactivityDialog> = {
title: "InactivityDialog",
component: InactivityDialog,
}

export default meta
type Story = StoryObj<typeof InactivityDialog>

export const OpenDialog: Story = {
args: {
submitValues: () => null,
isInactivityDialogOpen: true,
setIsInactivityDialogOpen: () => null,
workspacesToBeDeletedToday: 2,
},
}
Original file line number Diff line number Diff line change
@@ -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 (
<ConfirmDialog
type="delete"
open={isInactivityDialogOpen}
onConfirm={() => {
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?`}
/>
)
}
Original file line number Diff line number Diff line change
@@ -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
<Maybe condition={count >= 0}>
<span>{t(translationName, { count })}</span>
</Maybe>
)
}
Loading