mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: add frontend for locked workspaces (#8655)
- Fix workspaces query for locked workspaces.
This commit is contained in:
Generated
+1
-1
@@ -6097,7 +6097,7 @@ const docTemplate = `{
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Response"
|
||||
"$ref": "#/definitions/codersdk.Workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -5379,7 +5379,7 @@
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.Response"
|
||||
"$ref": "#/definitions/codersdk.Workspace"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+153
-11
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+1
-1
@@ -14,6 +14,6 @@ export const OpenDialog: Story = {
|
||||
submitValues: () => null,
|
||||
isInactivityDialogOpen: true,
|
||||
setIsInactivityDialogOpen: () => null,
|
||||
workspacesToBeDeletedToday: 2,
|
||||
workspacesToBeLockedToday: 2,
|
||||
},
|
||||
}
|
||||
|
||||
+32
-3
@@ -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 : ""
|
||||
|
||||
+42
-17
@@ -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}
|
||||
|
||||
+49
-16
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
+6
-2
@@ -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}
|
||||
|
||||
@@ -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" })
|
||||
|
||||
Reference in New Issue
Block a user