feat: add frontend for locked workspaces (#8655)

- Fix workspaces query for locked workspaces.
This commit is contained in:
Jon Ayers
2023-08-03 19:46:02 -05:00
committed by GitHub
parent 502c7680a2
commit e43608395c
36 changed files with 665 additions and 193 deletions
+1 -1
View File
@@ -6097,7 +6097,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.Response"
"$ref": "#/definitions/codersdk.Workspace"
}
}
}
+1 -1
View File
@@ -5379,7 +5379,7 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.Response"
"$ref": "#/definitions/codersdk.Workspace"
}
}
}
+1 -1
View File
@@ -178,7 +178,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
// Lock the workspace if it has breached the template's
// threshold for inactivity.
if reason == database.BuildReasonAutolock {
err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{
ws, err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{
ID: ws.ID,
LockedAt: sql.NullTime{
Time: database.Now(),
+2 -2
View File
@@ -2587,11 +2587,11 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg)
}
func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error {
func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
fetch := func(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
return q.db.GetWorkspaceByID(ctx, arg.ID)
}
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg)
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg)
}
func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
+15 -5
View File
@@ -5250,9 +5250,9 @@ func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.
return sql.ErrNoRows
}
func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error {
func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
if err := validateDatabaseType(arg); err != nil {
return err
return database.Workspace{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
@@ -5274,7 +5274,7 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat
}
}
if template.ID == uuid.Nil {
return xerrors.Errorf("unable to find workspace template")
return database.Workspace{}, xerrors.Errorf("unable to find workspace template")
}
if template.LockedTTL > 0 {
workspace.DeletingAt = sql.NullTime{
@@ -5284,9 +5284,9 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat
}
}
q.workspaces[index] = workspace
return nil
return workspace, nil
}
return sql.ErrNoRows
return database.Workspace{}, sql.ErrNoRows
}
func (q *FakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
@@ -5730,6 +5730,16 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
}
}
// We omit locked workspaces by default.
if arg.LockedAt.IsZero() && workspace.LockedAt.Valid {
continue
}
// Filter out workspaces that are locked after the timestamp.
if !arg.LockedAt.IsZero() && workspace.LockedAt.Time.Before(arg.LockedAt) {
continue
}
if len(arg.TemplateIDs) > 0 {
match := false
for _, id := range arg.TemplateIDs {
+3 -3
View File
@@ -1572,11 +1572,11 @@ func (m metricsStore) UpdateWorkspaceLastUsedAt(ctx context.Context, arg databas
return err
}
func (m metricsStore) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error {
func (m metricsStore) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
start := time.Now()
r0 := m.s.UpdateWorkspaceLockedDeletingAt(ctx, arg)
ws, r0 := m.s.UpdateWorkspaceLockedDeletingAt(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateWorkspaceLockedDeletingAt").Observe(time.Since(start).Seconds())
return r0
return ws, r0
}
func (m metricsStore) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
+4 -3
View File
@@ -3307,11 +3307,12 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceLastUsedAt(arg0, arg1 interface{
}
// UpdateWorkspaceLockedDeletingAt mocks base method.
func (m *MockStore) UpdateWorkspaceLockedDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedDeletingAtParams) error {
func (m *MockStore) UpdateWorkspaceLockedDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWorkspaceLockedDeletingAt", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
ret0, _ := ret[0].(database.Workspace)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateWorkspaceLockedDeletingAt indicates an expected call of UpdateWorkspaceLockedDeletingAt.
+1
View File
@@ -217,6 +217,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
arg.Name,
arg.HasAgent,
arg.AgentInactiveDisconnectTimeoutSeconds,
arg.LockedAt,
arg.Offset,
arg.Limit,
)
+1 -1
View File
@@ -277,7 +277,7 @@ type sqlcQuerier interface {
UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error
UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error
UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error
UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) error
UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) (Workspace, error)
// This allows editing the properties of a workspace proxy.
UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error)
UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error
+34 -7
View File
@@ -9001,6 +9001,14 @@ WHERE
) > 0
ELSE true
END
-- Filter by locked workspaces. By default we do not return locked
-- workspaces since they are considered soft-deleted.
AND CASE
WHEN $10 :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN
locked_at IS NOT NULL AND locked_at >= $10
ELSE
locked_at IS NULL
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
ORDER BY
@@ -9012,11 +9020,11 @@ ORDER BY
LOWER(workspaces.name) ASC
LIMIT
CASE
WHEN $11 :: integer > 0 THEN
$11
WHEN $12 :: integer > 0 THEN
$12
END
OFFSET
$10
$11
`
type GetWorkspacesParams struct {
@@ -9029,6 +9037,7 @@ type GetWorkspacesParams struct {
Name string `db:"name" json:"name"`
HasAgent string `db:"has_agent" json:"has_agent"`
AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"`
LockedAt time.Time `db:"locked_at" json:"locked_at"`
Offset int32 `db:"offset_" json:"offset_"`
Limit int32 `db:"limit_" json:"limit_"`
}
@@ -9064,6 +9073,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
arg.Name,
arg.HasAgent,
arg.AgentInactiveDisconnectTimeoutSeconds,
arg.LockedAt,
arg.Offset,
arg.Limit,
)
@@ -9366,7 +9376,7 @@ func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWo
return err
}
const updateWorkspaceLockedDeletingAt = `-- name: UpdateWorkspaceLockedDeletingAt :exec
const updateWorkspaceLockedDeletingAt = `-- name: UpdateWorkspaceLockedDeletingAt :one
UPDATE
workspaces
SET
@@ -9383,6 +9393,7 @@ WHERE
workspaces.template_id = templates.id
AND
workspaces.id = $1
RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.locked_at, workspaces.deleting_at
`
type UpdateWorkspaceLockedDeletingAtParams struct {
@@ -9390,9 +9401,25 @@ type UpdateWorkspaceLockedDeletingAtParams struct {
LockedAt sql.NullTime `db:"locked_at" json:"locked_at"`
}
func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceLockedDeletingAt, arg.ID, arg.LockedAt)
return err
func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) (Workspace, error) {
row := q.db.QueryRowContext(ctx, updateWorkspaceLockedDeletingAt, arg.ID, arg.LockedAt)
var i Workspace
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerID,
&i.OrganizationID,
&i.TemplateID,
&i.Deleted,
&i.Name,
&i.AutostartSchedule,
&i.Ttl,
&i.LastUsedAt,
&i.LockedAt,
&i.DeletingAt,
)
return i, err
}
const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec
+11 -2
View File
@@ -259,6 +259,14 @@ WHERE
) > 0
ELSE true
END
-- Filter by locked workspaces. By default we do not return locked
-- workspaces since they are considered soft-deleted.
AND CASE
WHEN @locked_at :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN
locked_at IS NOT NULL AND locked_at >= @locked_at
ELSE
locked_at IS NULL
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
ORDER BY
@@ -474,7 +482,7 @@ WHERE
)
) AND workspaces.deleted = 'false';
-- name: UpdateWorkspaceLockedDeletingAt :exec
-- name: UpdateWorkspaceLockedDeletingAt :one
UPDATE
workspaces
SET
@@ -490,7 +498,8 @@ FROM
WHERE
workspaces.template_id = templates.id
AND
workspaces.id = $1;
workspaces.id = $1
RETURNING workspaces.*;
-- name: UpdateWorkspacesDeletingAtByTemplateID :exec
UPDATE
+6
View File
@@ -114,9 +114,15 @@ 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")
filter.LockedAt = parser.Time(values, time.Time{}, "locked_at", "2006-01-02")
if _, ok := values["deleting_by"]; ok {
postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02"))
// We want to make sure to grab locked workspaces since they
// are omitted by default.
if filter.LockedAt.IsZero() {
filter.LockedAt = time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
}
}
parser.ErrorExcessParams(values)
+17 -9
View File
@@ -768,7 +768,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceLock true "Lock or unlock a workspace"
// @Success 200 {object} codersdk.Response
// @Success 200 {object} codersdk.Workspace
// @Router /workspaces/{workspace}/lock [put]
func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -779,9 +779,6 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
return
}
code := http.StatusOK
resp := codersdk.Response{}
// If the workspace is already in the desired state do nothing!
if workspace.LockedAt.Valid == req.Lock {
httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{
@@ -797,7 +794,7 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
lockedAt.Time = database.Now()
}
err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{
workspace, err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{
ID: workspace.ID,
LockedAt: lockedAt,
})
@@ -809,10 +806,21 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
return
}
// TODO should we kick off a build to stop the workspace if it's started
// from this endpoint? I'm leaning no to keep things simple and kick
// the responsibility back to the client.
httpapi.Write(ctx, rw, code, resp)
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace resources.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace(
workspace,
data.builds[0],
data.templates[0],
findUser(workspace.OwnerID, data.users),
))
}
// @Summary Extend workspace deadline by ID
+40
View File
@@ -1407,6 +1407,46 @@ func TestWorkspaceFilterManual(t *testing.T) {
// and template.InactivityTTL should be 0
assert.Len(t, res.Workspaces, 0)
})
t.Run("LockedAt", func(t *testing.T) {
// this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
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()
lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID)
// Create another workspace to validate that we do not return unlocked workspaces.
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID)
err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{
Lock: true,
})
require.NoError(t, err)
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
FilterQuery: fmt.Sprintf("locked_at:%s", time.Now().Add(-time.Minute).Format("2006-01-02")),
})
require.NoError(t, err)
require.Len(t, res.Workspaces, 1)
require.NotNil(t, res.Workspaces[0].LockedAt)
})
}
func TestOffsetLimit(t *testing.T) {
+153 -11
View File
@@ -931,22 +931,164 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/lock \
```json
{
"detail": "string",
"message": "string",
"validations": [
{
"detail": "string",
"field": "string"
}
]
"autostart_schedule": "string",
"created_at": "2019-08-24T14:15:22Z",
"deleting_at": "2019-08-24T14:15:22Z",
"health": {
"failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"healthy": false
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"last_used_at": "2019-08-24T14:15:22Z",
"latest_build": {
"build_number": 0,
"created_at": "2019-08-24T14:15:22Z",
"daily_cost": 0,
"deadline": "2019-08-24T14:15:22Z",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3",
"initiator_name": "string",
"job": {
"canceled_at": "2019-08-24T14:15:22Z",
"completed_at": "2019-08-24T14:15:22Z",
"created_at": "2019-08-24T14:15:22Z",
"error": "string",
"error_code": "MISSING_TEMPLATE_PARAMETER",
"file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"queue_position": 0,
"queue_size": 0,
"started_at": "2019-08-24T14:15:22Z",
"status": "pending",
"tags": {
"property1": "string",
"property2": "string"
},
"worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b"
},
"max_deadline": "2019-08-24T14:15:22Z",
"reason": "initiator",
"resources": [
{
"agents": [
{
"apps": [
{
"command": "string",
"display_name": "string",
"external": true,
"health": "disabled",
"healthcheck": {
"interval": 0,
"threshold": 0,
"url": "string"
},
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"sharing_level": "owner",
"slug": "string",
"subdomain": true,
"url": "string"
}
],
"architecture": "string",
"connection_timeout_seconds": 0,
"created_at": "2019-08-24T14:15:22Z",
"directory": "string",
"disconnected_at": "2019-08-24T14:15:22Z",
"environment_variables": {
"property1": "string",
"property2": "string"
},
"expanded_directory": "string",
"first_connected_at": "2019-08-24T14:15:22Z",
"health": {
"healthy": false,
"reason": "agent has lost connection"
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"instance_id": "string",
"last_connected_at": "2019-08-24T14:15:22Z",
"latency": {
"property1": {
"latency_ms": 0,
"preferred": true
},
"property2": {
"latency_ms": 0,
"preferred": true
}
},
"lifecycle_state": "created",
"login_before_ready": true,
"logs_length": 0,
"logs_overflowed": true,
"name": "string",
"operating_system": "string",
"ready_at": "2019-08-24T14:15:22Z",
"resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f",
"shutdown_script": "string",
"shutdown_script_timeout_seconds": 0,
"started_at": "2019-08-24T14:15:22Z",
"startup_script": "string",
"startup_script_behavior": "blocking",
"startup_script_timeout_seconds": 0,
"status": "connecting",
"subsystem": "envbox",
"troubleshooting_url": "string",
"updated_at": "2019-08-24T14:15:22Z",
"version": "string"
}
],
"created_at": "2019-08-24T14:15:22Z",
"daily_cost": 0,
"hide": true,
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f",
"metadata": [
{
"key": "string",
"sensitive": true,
"value": "string"
}
],
"name": "string",
"type": "string",
"workspace_transition": "start"
}
],
"status": "pending",
"template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1",
"template_version_name": "string",
"transition": "start",
"updated_at": "2019-08-24T14:15:22Z",
"workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9",
"workspace_name": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string"
},
"locked_at": "2019-08-24T14:15:22Z",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"outdated": true,
"owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05",
"owner_name": "string",
"template_allow_user_cancel_workspace_jobs": true,
"template_display_name": "string",
"template_icon": "string",
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"template_name": "string",
"ttl_ms": 0,
"updated_at": "2019-08-24T14:15:22Z"
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) |
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Workspace](schemas.md#codersdkworkspace) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+15
View File
@@ -554,6 +554,21 @@ export const cancelWorkspaceBuild = async (
return response.data
}
export const updateWorkspaceLock = async (
workspaceId: string,
lock: boolean,
): Promise<TypesGen.Workspace> => {
const data: TypesGen.UpdateWorkspaceLock = {
lock: lock,
}
const response = await axios.put(
`/api/v2/workspaces/${workspaceId}/lock`,
data,
)
return response.data
}
export const restartWorkspace = async ({
workspace,
buildParameters,
@@ -87,3 +87,13 @@ export const useDashboard = (): DashboardProviderValue => {
return context
}
export const useIsWorkspaceActionsEnabled = (): boolean => {
const { entitlements, experiments } = useDashboard()
const allowAdvancedScheduling =
entitlements.features["advanced_template_scheduling"].enabled
// This check can be removed when https://github.com/coder/coder/milestone/19
// is merged up
const allowWorkspaceActions = experiments.includes("workspace_actions")
return allowWorkspaceActions && allowAdvancedScheduling
}
+20 -11
View File
@@ -1,5 +1,6 @@
import Button from "@mui/material/Button"
import { makeStyles } from "@mui/styles"
import LockIcon from "@mui/icons-material/Lock"
import { Avatar } from "components/Avatar/Avatar"
import { AgentRow } from "components/Resources/AgentRow"
import {
@@ -26,7 +27,7 @@ import {
} from "components/PageHeader/FullWidthPageHeader"
import { TemplateVersionWarnings } from "components/TemplateVersionWarnings/TemplateVersionWarnings"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { ImpendingDeletionBanner } from "components/WorkspaceDeletion"
import { LockedWorkspaceBanner } from "components/WorkspaceDeletion"
import { useLocalStorage } from "hooks"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import AlertTitle from "@mui/material/AlertTitle"
@@ -53,6 +54,7 @@ export interface WorkspaceProps {
handleCancel: () => void
handleSettings: () => void
handleChangeVersion: () => void
handleUnlock: () => void
isUpdating: boolean
isRestarting: boolean
workspace: TypesGen.Workspace
@@ -86,6 +88,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleCancel,
handleSettings,
handleChangeVersion,
handleUnlock,
workspace,
isUpdating,
isRestarting,
@@ -167,14 +170,19 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
<>
<FullWidthPageHeader>
<Stack direction="row" spacing={3} alignItems="center">
<Avatar
size="md"
src={workspace.template_icon}
variant={workspace.template_icon ? "square" : undefined}
fitImage={Boolean(workspace.template_icon)}
>
{workspace.name}
</Avatar>
{workspace.locked_at ? (
<LockIcon fontSize="large" color="error" />
) : (
<Avatar
size="md"
src={workspace.template_icon}
variant={workspace.template_icon ? "square" : undefined}
fitImage={Boolean(workspace.template_icon)}
>
{workspace.name}
</Avatar>
)}
<div>
<PageHeaderTitle>{workspace.name}</PageHeaderTitle>
<PageHeaderSubtitle>{workspace.owner_name}</PageHeaderSubtitle>
@@ -203,6 +211,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleCancel={handleCancel}
handleSettings={handleSettings}
handleChangeVersion={handleChangeVersion}
handleUnlock={handleUnlock}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
@@ -253,8 +262,8 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
</Cond>
<Cond>
{/* <ImpendingDeletionBanner/> determines its own visibility */}
<ImpendingDeletionBanner
workspace={workspace}
<LockedWorkspaceBanner
workspaces={[workspace]}
shouldRedisplayBanner={
getLocal("dismissedWorkspace") !== workspace.id
}
@@ -3,6 +3,7 @@ import BlockIcon from "@mui/icons-material/Block"
import CloudQueueIcon from "@mui/icons-material/CloudQueue"
import CropSquareIcon from "@mui/icons-material/CropSquare"
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"
import LockOpenIcon from "@mui/icons-material/LockOpen"
import ReplayIcon from "@mui/icons-material/Replay"
import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { FC } from "react"
@@ -34,6 +35,23 @@ export const UpdateButton: FC<WorkspaceAction> = ({
)
}
export const UnlockButton: FC<WorkspaceAction> = ({
handleAction,
loading,
}) => {
return (
<LoadingButton
loading={loading}
loadingIndicator="Unlocking..."
loadingPosition="start"
startIcon={<LockOpenIcon />}
onClick={handleAction}
>
Unlock
</LoadingButton>
)
}
export const StartButton: FC<
Omit<WorkspaceAction, "handleAction"> & {
workspace: Workspace
@@ -12,6 +12,7 @@ import {
StopButton,
RestartButton,
UpdateButton,
UnlockButton,
} from "./Buttons"
import {
ButtonMapping,
@@ -33,6 +34,7 @@ export interface WorkspaceActionsProps {
handleCancel: () => void
handleSettings: () => void
handleChangeVersion: () => void
handleUnlock: () => void
isUpdating: boolean
isRestarting: boolean
children?: ReactNode
@@ -49,6 +51,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
handleCancel,
handleSettings,
handleChangeVersion,
handleUnlock,
isUpdating,
isRestarting,
canChangeVersions,
@@ -58,7 +61,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
canCancel,
canAcceptJobs,
actions: actionsByStatus,
} = actionsByWorkspaceStatus(workspace.latest_build.status)
} = actionsByWorkspaceStatus(workspace, workspace.latest_build.status)
const canBeUpdated = workspace.outdated && canAcceptJobs
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
@@ -93,6 +96,10 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
[ButtonTypesEnum.canceling]: <DisabledButton label="Canceling..." />,
[ButtonTypesEnum.deleted]: <DisabledButton label="Deleted" />,
[ButtonTypesEnum.pending]: <ActionLoadingButton label="Pending..." />,
[ButtonTypesEnum.unlock]: <UnlockButton handleAction={handleUnlock} />,
[ButtonTypesEnum.unlocking]: (
<UnlockButton loading handleAction={handleUnlock} />
),
}
// Returns a function that will execute the action and close the menu
@@ -1,4 +1,4 @@
import { WorkspaceStatus } from "api/typesGenerated"
import { Workspace, WorkspaceStatus } from "api/typesGenerated"
import { ReactNode } from "react"
// the button types we have
@@ -12,6 +12,8 @@ export enum ButtonTypesEnum {
deleting = "deleting",
update = "update",
updating = "updating",
unlock = "lock",
unlocking = "unlocking",
// disabled buttons
canceling = "canceling",
deleted = "deleted",
@@ -29,8 +31,16 @@ interface WorkspaceAbilities {
}
export const actionsByWorkspaceStatus = (
workspace: Workspace,
status: WorkspaceStatus,
): WorkspaceAbilities => {
if (workspace.locked_at) {
return {
actions: [ButtonTypesEnum.unlock],
canCancel: false,
canAcceptJobs: false,
}
}
return statusToActions[status]
}
@@ -1,31 +1,17 @@
import { Workspace } from "api/typesGenerated"
import { displayImpendingDeletion } from "./utils"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"
import { Pill } from "components/Pill/Pill"
import ErrorIcon from "@mui/icons-material/ErrorOutline"
import LockIcon from "@mui/icons-material/Lock"
export const ImpendingDeletionBadge = ({
export const LockedBadge = ({
workspace,
}: {
workspace: Workspace
}): JSX.Element | null => {
const { entitlements, experiments } = useDashboard()
const allowAdvancedScheduling =
entitlements.features["advanced_template_scheduling"].enabled
// This check can be removed when https://github.com/coder/coder/milestone/19
// is merged up
const allowWorkspaceActions = experiments.includes("workspace_actions")
// return null
if (
!displayImpendingDeletion(
workspace,
allowAdvancedScheduling,
allowWorkspaceActions,
)
) {
const experimentEnabled = useIsWorkspaceActionsEnabled()
if (!workspace.locked_at || !experimentEnabled) {
return null
}
return <Pill icon={<ErrorIcon />} text="Impending deletion" type="error" />
return <Pill icon={<LockIcon />} text="Locked" type="error" />
}
@@ -1,8 +1,7 @@
import { Workspace } from "api/typesGenerated"
import { displayImpendingDeletion } from "./utils"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"
import { Alert } from "components/Alert/Alert"
import { formatDistanceToNow, differenceInDays, add, format } from "date-fns"
import { formatDistanceToNow } from "date-fns"
import Link from "@mui/material/Link"
import { Link as RouterLink } from "react-router-dom"
@@ -11,69 +10,90 @@ export enum Count {
Multiple,
}
export const ImpendingDeletionBanner = ({
workspace,
export const LockedWorkspaceBanner = ({
workspaces,
onDismiss,
shouldRedisplayBanner,
count = Count.Singular,
}: {
workspace?: Workspace
workspaces?: Workspace[]
onDismiss: () => void
shouldRedisplayBanner: boolean
count?: Count
}): JSX.Element | null => {
const { entitlements, experiments } = useDashboard()
const allowAdvancedScheduling =
entitlements.features["advanced_template_scheduling"].enabled
// This check can be removed when https://github.com/coder/coder/milestone/19
// is merged up
const allowWorkspaceActions = experiments.includes("workspace_actions")
const experimentEnabled = useIsWorkspaceActionsEnabled()
if (!workspaces) {
return null
}
const hasLockedWorkspaces = workspaces.find(
(workspace) => workspace.locked_at,
)
const hasDeletionScheduledWorkspaces = workspaces.find(
(workspace) => workspace.deleting_at,
)
if (
!workspace ||
!displayImpendingDeletion(
workspace,
allowAdvancedScheduling,
allowWorkspaceActions,
) ||
// Only show this if the experiment is included.
!experimentEnabled ||
!hasLockedWorkspaces ||
// Banners should be redisplayed after dismissal when additional workspaces are newly scheduled for deletion
!shouldRedisplayBanner
) {
return null
}
// if deleting_at is 7 days away or less, display an 'error' banner to convey urgency to user
const daysUntilDelete = differenceInDays(
Date.parse(workspace.last_used_at),
new Date(),
)
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr)
return date.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
})
}
const plusFourteen = add(new Date(), { days: 14 })
const alertText = (): string => {
if (workspaces.length === 1) {
if (
hasDeletionScheduledWorkspaces &&
hasDeletionScheduledWorkspaces.deleting_at &&
hasDeletionScheduledWorkspaces.locked_at
) {
return `This workspace has been locked since ${formatDistanceToNow(
Date.parse(hasDeletionScheduledWorkspaces.locked_at),
)} and is scheduled to be deleted at ${formatDate(
hasDeletionScheduledWorkspaces.deleting_at,
)} . To keep it you must unlock the workspace.`
} else if (hasLockedWorkspaces && hasLockedWorkspaces.locked_at) {
return `This workspace has been locked since ${formatDate(
hasLockedWorkspaces.locked_at,
)}
and cannot be interacted
with. Locked workspaces are eligible for
permanent deletion. To prevent deletion, unlock
the workspace.`
}
}
return ""
}
return (
<Alert
severity={daysUntilDelete <= 7 ? "warning" : "info"}
onDismiss={onDismiss}
dismissible
>
<Alert severity="warning" 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.`
alertText()
) : (
<>
<span>There are</span>{" "}
<Link
component={RouterLink}
to={`/workspaces?filter=deleting_by:${format(
plusFourteen,
"y-MM-dd",
)}`}
to="/workspaces?filter=locked_at:1970-01-01"
>
workspaces
</Link>{" "}
that will be deleted soon due to inactivity. To keep these workspaces,
connect to them via SSH or the web terminal.
that may be deleted soon due to inactivity. Unlock the workspaces you
wish to retain.
</>
)}
</Alert>
@@ -5,7 +5,7 @@ import { makeStyles } from "@mui/styles"
import { combineClasses } from "utils/combineClasses"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import {
ImpendingDeletionBadge,
LockedBadge,
ImpendingDeletionText,
} from "components/WorkspaceDeletion"
import { getDisplayWorkspaceStatus } from "utils/workspace"
@@ -25,8 +25,8 @@ export const WorkspaceStatusBadge: FC<
return (
<ChooseOne>
{/* <ImpendingDeletionBadge/> determines its own visibility */}
<Cond condition={Boolean(ImpendingDeletionBadge({ workspace }))}>
<ImpendingDeletionBadge workspace={workspace} />
<Cond condition={Boolean(LockedBadge({ workspace }))}>
<LockedBadge workspace={workspace} />
</Cond>
<Cond>
<Pill className={className} icon={icon} text={text} type={type} />
+3 -3
View File
@@ -22,9 +22,9 @@
"failureTTLHelperText_zero": "Coder will not automatically stop failed workspaces",
"failureTTLHelperText_one": "Coder will attempt to stop failed workspaces after {{count}} day.",
"failureTTLHelperText_other": "Coder will attempt to stop failed workspaces after {{count}} days.",
"inactivityTTLHelperText_zero": "Coder will not automatically delete inactive workspaces",
"inactivityTTLHelperText_one": "Coder will automatically delete inactive workspaces after {{count}} day.",
"inactivityTTLHelperText_other": "Coder will automatically delete inactive workspaces after {{count}} days.",
"inactivityTTLHelperText_zero": "Coder will not automatically lock inactive workspaces",
"inactivityTTLHelperText_one": "Coder will automatically lock inactive workspaces after {{count}} day.",
"inactivityTTLHelperText_other": "Coder will automatically lock inactive workspaces after {{count}} days.",
"lockedTTLHelperText_zero": "Coder will not automatically delete locked workspaces",
"lockedTTLHelperText_one": "Coder will automatically delete locked workspaces after {{count}} day.",
"lockedTTLHelperText_other": "Coder will automatically delete locked workspaces after {{count}} days.",
@@ -14,6 +14,6 @@ export const OpenDialog: Story = {
submitValues: () => null,
isInactivityDialogOpen: true,
setIsInactivityDialogOpen: () => null,
workspacesToBeDeletedToday: 2,
workspacesToBeLockedToday: 2,
},
}
@@ -4,12 +4,12 @@ export const InactivityDialog = ({
submitValues,
isInactivityDialogOpen,
setIsInactivityDialogOpen,
workspacesToBeDeletedToday,
workspacesToBeLockedToday,
}: {
submitValues: () => void
isInactivityDialogOpen: boolean
setIsInactivityDialogOpen: (arg0: boolean) => void
workspacesToBeDeletedToday: number
workspacesToBeLockedToday: number
}) => {
return (
<ConfirmDialog
@@ -20,7 +20,36 @@ export const InactivityDialog = ({
setIsInactivityDialogOpen(false)
}}
onClose={() => setIsInactivityDialogOpen(false)}
title="Delete inactive workspaces"
title="Lock inactive workspaces"
confirmText="Lock Workspaces"
description={`There are ${
workspacesToBeLockedToday ? workspacesToBeLockedToday : ""
} workspaces that already match this filter and will be locked upon form submission. Are you sure you want to proceed?`}
/>
)
}
export const DeleteLockedDialog = ({
submitValues,
isLockedDialogOpen,
setIsLockedDialogOpen,
workspacesToBeDeletedToday,
}: {
submitValues: () => void
isLockedDialogOpen: boolean
setIsLockedDialogOpen: (arg0: boolean) => void
workspacesToBeDeletedToday: number
}) => {
return (
<ConfirmDialog
type="delete"
open={isLockedDialogOpen}
onConfirm={() => {
submitValues()
setIsLockedDialogOpen(false)
}}
onClose={() => setIsLockedDialogOpen(false)}
title="Delete Locked Workspaces"
confirmText="Delete Workspaces"
description={`There are ${
workspacesToBeDeletedToday ? workspacesToBeDeletedToday : ""
@@ -16,8 +16,11 @@ 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 { DeleteLockedDialog, InactivityDialog } from "./InactivityDialog"
import {
useWorkspacesToBeLocked,
useWorkspacesToBeDeleted,
} from "./useWorkspacesToBeDeleted"
import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers"
import { TTLHelperText } from "./TTLHelperText"
import { docs } from "utils/docs"
@@ -89,10 +92,16 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
onSubmit: () => {
if (
form.values.inactivity_cleanup_enabled &&
workspacesToBeLockedToday &&
workspacesToBeLockedToday.length > 0
) {
setIsInactivityDialogOpen(true)
} else if (
form.values.locked_cleanup_enabled &&
workspacesToBeDeletedToday &&
workspacesToBeDeletedToday.length > 0
) {
setIsInactivityDialogOpen(true)
setIsLockedDialogOpen(true)
} else {
submitValues()
}
@@ -106,10 +115,18 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
const { t } = useTranslation("templateSettingsPage")
const styles = useStyles()
const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(form.values)
const workspacesToBeLockedToday = useWorkspacesToBeLocked(
template,
form.values,
)
const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(
template,
form.values,
)
const [isInactivityDialogOpen, setIsInactivityDialogOpen] =
useState<boolean>(false)
const [isLockedDialogOpen, setIsLockedDialogOpen] = useState<boolean>(false)
const submitValues = () => {
// on submit, convert from hours => ms
@@ -324,12 +341,11 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
inputProps={{ min: 0, step: "any" }}
label="Time until cleanup (days)"
type="number"
aria-label="Failure Cleanup"
/>
</FormFields>
</FormSection>
<FormSection
title="Inactivity Cleanup"
title="Inactivity TTL"
description="When enabled, Coder will lock workspaces that have not been accessed after a specified number of days."
>
<FormFields>
@@ -341,7 +357,7 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
onChange={handleToggleInactivityCleanup}
/>
}
label="Enable Inactivity Cleanup"
label="Enable Inactivity TTL"
/>
<TextField
{...getFieldHelpers(
@@ -358,12 +374,11 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
inputProps={{ min: 0, step: "any" }}
label="Time until cleanup (days)"
type="number"
aria-label="Inactivity Cleanup"
/>
</FormFields>
</FormSection>
<FormSection
title="Locked Cleanup"
title="Deletion Grace Period"
description="When enabled, Coder will permanently delete workspaces that have been locked for a specified number of days."
>
<FormFields>
@@ -375,7 +390,7 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
onChange={handleToggleLockedCleanup}
/>
}
label="Enable Locked Cleanup"
label="Enable Locked TTL"
/>
<TextField
{...getFieldHelpers(
@@ -390,18 +405,28 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
inputProps={{ min: 0, step: "any" }}
label="Time until cleanup (days)"
type="number"
aria-label="Locked Cleanup"
/>
</FormFields>
</FormSection>
</>
)}
<InactivityDialog
submitValues={submitValues}
isInactivityDialogOpen={isInactivityDialogOpen}
setIsInactivityDialogOpen={setIsInactivityDialogOpen}
workspacesToBeDeletedToday={workspacesToBeDeletedToday?.length ?? 0}
/>
{workspacesToBeLockedToday && workspacesToBeLockedToday.length > 0 && (
<InactivityDialog
submitValues={submitValues}
isInactivityDialogOpen={isInactivityDialogOpen}
setIsInactivityDialogOpen={setIsInactivityDialogOpen}
workspacesToBeLockedToday={workspacesToBeLockedToday?.length ?? 0}
/>
)}
{workspacesToBeDeletedToday && workspacesToBeDeletedToday.length > 0 && (
<DeleteLockedDialog
submitValues={submitValues}
isLockedDialogOpen={isLockedDialogOpen}
setIsLockedDialogOpen={setIsLockedDialogOpen}
workspacesToBeDeletedToday={workspacesToBeDeletedToday?.length ?? 0}
/>
)}
<FormFooter
onCancel={onCancel}
isLoading={isSubmitting}
@@ -1,32 +1,65 @@
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 { compareAsc } from "date-fns"
import { Workspace, Template } from "api/typesGenerated"
import { TemplateScheduleFormValues } from "./formHelpers"
const inactiveStatuses: WorkspaceStatus[] = [
"stopped",
"canceled",
"failed",
"deleted",
]
export const useWorkspacesToBeDeleted = (
export const useWorkspacesToBeLocked = (
template: Template,
formValues: TemplateScheduleFormValues,
) => {
const { data: workspacesData } = useQuery({
queryKey: ["workspaces"],
queryFn: () => getWorkspaces({}),
queryFn: () =>
getWorkspaces({
q: "template:" + template.name,
}),
enabled: formValues.inactivity_cleanup_enabled,
})
return workspacesData?.workspaces?.filter((workspace: Workspace) => {
const isInactive = inactiveStatuses.includes(workspace.latest_build.status)
if (!formValues.inactivity_ttl_ms) {
return
}
const proposedDeletion = add(new Date(workspace.last_used_at), {
days: formValues.inactivity_ttl_ms,
})
if (workspace.locked_at) {
return
}
if (isInactive && compareAsc(proposedDeletion, endOfToday()) < 1) {
const proposedLocking = new Date(
new Date(workspace.last_used_at).getTime() +
formValues.inactivity_ttl_ms * 86400000,
)
if (compareAsc(proposedLocking, new Date()) < 1) {
return workspace
}
})
}
export const useWorkspacesToBeDeleted = (
template: Template,
formValues: TemplateScheduleFormValues,
) => {
const { data: workspacesData } = useQuery({
queryKey: ["workspaces"],
queryFn: () =>
getWorkspaces({
q: "template:" + template.name,
}),
enabled: formValues.locked_cleanup_enabled,
})
return workspacesData?.workspaces?.filter((workspace: Workspace) => {
if (!workspace.locked_at || !formValues.locked_ttl_ms) {
return false
}
const proposedLocking = new Date(
new Date(workspace.locked_at).getTime() +
formValues.locked_ttl_ms * 86400000,
)
if (compareAsc(proposedLocking, new Date()) < 1) {
return workspace
}
})
@@ -63,12 +63,12 @@ const fillAndSubmitForm = async ({
await user.type(failureTtlField, failure_ttl_ms.toString())
const inactivityTtlField = screen.getByRole("checkbox", {
name: /Inactivity Cleanup/i,
name: /Inactivity TTL/i,
})
await user.type(inactivityTtlField, inactivity_ttl_ms.toString())
const lockedTtlField = screen.getByRole("checkbox", {
name: /Locked Cleanup/i,
name: /Locked TTL/i,
})
await user.type(lockedTtlField, locked_ttl_ms.toString())
@@ -76,6 +76,10 @@ const fillAndSubmitForm = async ({
FooterFormLanguage.defaultSubmitLabel,
)
await user.click(submitButton)
// User needs to confirm inactivity and locked ttl
const confirmButton = await screen.findByTestId("confirm-button")
await user.click(confirmButton)
}
describe("TemplateSchedulePage", () => {
@@ -176,6 +176,7 @@ export const WorkspaceReadyPage = ({
handleChangeVersion={() => {
setChangeVersionDialogOpen(true)
}}
handleUnlock={() => workspaceSend({ type: "UNLOCK" })}
resources={workspace.latest_build.resources}
builds={builds}
canUpdateWorkspace={canUpdateWorkspace}
@@ -1,5 +1,7 @@
import { usePagination } from "hooks/usePagination"
import { FC } from "react"
import { Workspace } from "api/typesGenerated"
import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"
import { FC, useEffect, useState } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "utils/page"
import { useWorkspacesData, useWorkspaceUpdate } from "./data"
@@ -9,8 +11,10 @@ import { useTemplateFilterMenu, useStatusFilterMenu } from "./filter/menus"
import { useSearchParams } from "react-router-dom"
import { useFilter } from "components/Filter/filter"
import { useUserFilterMenu } from "components/Filter/UserFilter"
import { getWorkspaces } from "api/api"
const WorkspacesPage: FC = () => {
const [lockedWorkspaces, setLockedWorkspaces] = useState<Workspace[]>([])
// If we use a useSearchParams for each hook, the values will not be in sync.
// So we have to use a single one, centralizing the values, and pass it to
// each hook.
@@ -21,6 +25,37 @@ const WorkspacesPage: FC = () => {
...pagination,
query: filterProps.filter.query,
})
const experimentEnabled = useIsWorkspaceActionsEnabled()
// If workspace actions are enabled we need to fetch the locked
// workspaces as well. This lets us determine whether we should
// show a banner to the user indicating that some of their workspaces
// are at risk of being deleted.
useEffect(() => {
if (experimentEnabled) {
const includesLocked = filterProps.filter.query.includes("locked_at")
const lockedQuery = includesLocked
? filterProps.filter.query
: filterProps.filter.query + " locked_at:1970-01-01"
if (includesLocked && data) {
setLockedWorkspaces(data.workspaces)
} else {
getWorkspaces({ q: lockedQuery })
.then((resp) => {
setLockedWorkspaces(resp.workspaces)
})
.catch(() => {
// TODO
})
}
} else {
// If the experiment isn't included then we'll pretend
// like locked workspaces don't exist.
setLockedWorkspaces([])
}
}, [experimentEnabled, data, filterProps.filter.query])
const updateWorkspace = useWorkspaceUpdate(queryKey)
return (
@@ -31,6 +66,7 @@ const WorkspacesPage: FC = () => {
<WorkspacesPageView
workspaces={data?.workspaces}
lockedWorkspaces={lockedWorkspaces}
error={error}
count={data?.count}
page={pagination.page}
@@ -14,8 +14,7 @@ import { Stack } from "components/Stack/Stack"
import { WorkspaceHelpTooltip } from "components/Tooltips"
import { WorkspacesTable } from "pages/WorkspacesPage/WorkspacesTable"
import { useLocalStorage } from "hooks"
import difference from "lodash/difference"
import { ImpendingDeletionBanner, Count } from "components/WorkspaceDeletion"
import { LockedWorkspaceBanner, Count } from "components/WorkspaceDeletion"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { WorkspacesFilter } from "./filter/filter"
import { hasError, isApiValidationError } from "api/errors"
@@ -33,6 +32,7 @@ export const Language = {
export interface WorkspacesPageViewProps {
error: unknown
workspaces?: Workspace[]
lockedWorkspaces?: Workspace[]
count?: number
filterProps: ComponentProps<typeof WorkspacesFilter>
page: number
@@ -45,6 +45,7 @@ export const WorkspacesPageView: FC<
React.PropsWithChildren<WorkspacesPageViewProps>
> = ({
workspaces,
lockedWorkspaces,
error,
limit,
count,
@@ -53,32 +54,14 @@ export const WorkspacesPageView: FC<
onUpdateWorkspace,
page,
}) => {
const { saveLocal, getLocal } = useLocalStorage()
const { saveLocal } = useLocalStorage()
const workspaceIdsWithImpendingDeletions = workspaces
const workspacesDeletionScheduled = lockedWorkspaces
?.filter((workspace) => workspace.deleting_at)
.map((workspace) => workspace.id)
/**
* Returns a boolean indicating if there are workspaces that have been
* recently marked for deletion but are not in local storage.
* If there are, we want to alert the user so they can potentially take action
* before deletion takes place.
* @returns {boolean}
*/
const isNewWorkspacesImpendingDeletion = (): boolean => {
const dismissedList = getLocal("dismissedWorkspaceList")
if (!dismissedList) {
return true
}
const diff = difference(
workspaceIdsWithImpendingDeletions,
JSON.parse(dismissedList),
)
return diff && diff.length > 0
}
const hasLockedWorkspace =
lockedWorkspaces !== undefined && lockedWorkspaces.length > 0
return (
<Margins>
@@ -104,13 +87,13 @@ export const WorkspacesPageView: FC<
<ErrorAlert error={error} />
</Maybe>
{/* <ImpendingDeletionBanner/> determines its own visibility */}
<ImpendingDeletionBanner
workspace={workspaces?.find((workspace) => workspace.deleting_at)}
shouldRedisplayBanner={isNewWorkspacesImpendingDeletion()}
<LockedWorkspaceBanner
workspaces={lockedWorkspaces}
shouldRedisplayBanner={hasLockedWorkspace}
onDismiss={() =>
saveLocal(
"dismissedWorkspaceList",
JSON.stringify(workspaceIdsWithImpendingDeletions),
JSON.stringify(workspacesDeletionScheduled),
)
}
count={Count.Multiple}
@@ -1,5 +1,6 @@
import { FC } from "react"
import Box from "@mui/material/Box"
import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"
import { Avatar, AvatarProps } from "components/Avatar/Avatar"
import { Palette, PaletteColor } from "@mui/material/styles"
import { TemplateFilterMenu, StatusFilterMenu } from "./menus"
@@ -43,9 +44,17 @@ export const WorkspacesFilter = ({
status: StatusFilterMenu
}
}) => {
const presets = [...PRESET_FILTERS]
if (useIsWorkspaceActionsEnabled()) {
presets.push({
query: workspaceFilterQuery.locked,
name: "Locked workspaces",
})
}
return (
<Filter
presets={PRESET_FILTERS}
presets={presets}
isLoading={menus.status.isInitializing}
filter={filter}
error={error}
+1
View File
@@ -14,6 +14,7 @@ export const workspaceFilterQuery = {
all: "",
running: "status:running",
failed: "status:failed",
locked: "locked_at:1970-01-01",
}
export const userFilterQuery = {
@@ -96,6 +96,7 @@ export type WorkspaceEvent =
| { type: "INCREASE_DEADLINE"; hours: number }
| { type: "DECREASE_DEADLINE"; hours: number }
| { type: "RETRY_BUILD" }
| { type: "UNLOCK" }
export const checks = {
readWorkspace: "readWorkspace",
@@ -170,6 +171,9 @@ export const workspaceMachine = createMachine(
cancelWorkspace: {
data: Types.Message
}
unlockWorkspace: {
data: Types.Message
}
listening: {
data: TypesGen.ServerSentEvent
}
@@ -260,6 +264,7 @@ export const workspaceMachine = createMachine(
actions: ["enableDebugMode"],
},
],
UNLOCK: "requestingUnlock",
},
},
askingDelete: {
@@ -405,6 +410,18 @@ export const workspaceMachine = createMachine(
],
},
},
requestingUnlock: {
entry: ["clearBuildError"],
invoke: {
src: "unlockWorkspace",
id: "unlockWorkspace",
onDone: "idle",
onError: {
target: "idle",
actions: ["displayUnlockError"],
},
},
},
},
},
timeline: {
@@ -559,7 +576,10 @@ export const workspaceMachine = createMachine(
)
displayError(message)
},
displayUnlockError: (_, { data }) => {
const message = getErrorMessage(data, "Error unlocking workspace.")
displayError(message)
},
assignMissedParameters: assign({
missedParameters: (_, { data }) => {
if (!(data instanceof API.MissingBuildParameters)) {
@@ -675,6 +695,18 @@ export const workspaceMachine = createMachine(
throw Error("Cannot cancel workspace without build id")
}
},
unlockWorkspace: (context) => async (send) => {
if (context.workspace) {
const unlockWorkspacePromise = await API.updateWorkspaceLock(
context.workspace.id,
false,
)
send({ type: "REFRESH_WORKSPACE", data: unlockWorkspacePromise })
return unlockWorkspacePromise
} else {
throw Error("Cannot unlock workspace without workspace id")
}
},
listening: (context) => (send) => {
if (!context.eventSource) {
send({ type: "EVENT_SOURCE_ERROR", error: "error initializing sse" })