mirror of https://github.com/coder/coder.git
feat: Allow user to cancel workspace jobs (#5115)
* Add database column allow_user_cancel_workspace_jobs * Adjust API * site: typesGenerated.ts * Expose template.allow_ in Workspaces API * Fix: site tests * Fix: make fmt/prettier * Fix: enterprise * Database tests * Add CLI tests * Add checkbox * i18n * Logic: block cancelling * Unit tests for conditional cancel * Fix: message * Address PR comment * Address PR comments * Fix: make
This commit is contained in:
parent
5fa3fdeca0
commit
e86539db11
|
@ -13,11 +13,12 @@ import (
|
|||
|
||||
func templateEdit() *cobra.Command {
|
||||
var (
|
||||
name string
|
||||
displayName string
|
||||
description string
|
||||
icon string
|
||||
defaultTTL time.Duration
|
||||
name string
|
||||
displayName string
|
||||
description string
|
||||
icon string
|
||||
defaultTTL time.Duration
|
||||
allowUserCancelWorkspaceJobs bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
|
@ -40,11 +41,12 @@ func templateEdit() *cobra.Command {
|
|||
|
||||
// NOTE: coderd will ignore empty fields.
|
||||
req := codersdk.UpdateTemplateMeta{
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
Description: description,
|
||||
Icon: icon,
|
||||
DefaultTTLMillis: defaultTTL.Milliseconds(),
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
Description: description,
|
||||
Icon: icon,
|
||||
DefaultTTLMillis: defaultTTL.Milliseconds(),
|
||||
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
|
||||
}
|
||||
|
||||
_, err = client.UpdateTemplateMeta(cmd.Context(), template.ID, req)
|
||||
|
@ -61,6 +63,7 @@ func templateEdit() *cobra.Command {
|
|||
cmd.Flags().StringVarP(&description, "description", "", "", "Edit the template description")
|
||||
cmd.Flags().StringVarP(&icon, "icon", "", "", "Edit the template icon path")
|
||||
cmd.Flags().DurationVarP(&defaultTTL, "default-ttl", "", 0, "Edit the template default time before shutdown - workspaces created from this template to this value.")
|
||||
cmd.Flags().BoolVarP(&allowUserCancelWorkspaceJobs, "allow-user-cancel-workspace-jobs", "", true, "Allow users to cancel in-progress workspace jobs.")
|
||||
cliui.AllowSkipPrompt(cmd)
|
||||
|
||||
return cmd
|
||||
|
|
|
@ -2,6 +2,7 @@ package cli_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
@ -31,6 +32,8 @@ func TestTemplateEdit(t *testing.T) {
|
|||
desc := "lorem ipsum dolor sit amet et cetera"
|
||||
icon := "/icons/new-icon.png"
|
||||
defaultTTL := 12 * time.Hour
|
||||
allowUserCancelWorkspaceJobs := false
|
||||
|
||||
cmdArgs := []string{
|
||||
"templates",
|
||||
"edit",
|
||||
|
@ -40,6 +43,7 @@ func TestTemplateEdit(t *testing.T) {
|
|||
"--description", desc,
|
||||
"--icon", icon,
|
||||
"--default-ttl", defaultTTL.String(),
|
||||
"--allow-user-cancel-workspace-jobs=" + strconv.FormatBool(allowUserCancelWorkspaceJobs),
|
||||
}
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
@ -57,6 +61,7 @@ func TestTemplateEdit(t *testing.T) {
|
|||
assert.Equal(t, desc, updated.Description)
|
||||
assert.Equal(t, icon, updated.Icon)
|
||||
assert.Equal(t, defaultTTL.Milliseconds(), updated.DefaultTTLMillis)
|
||||
assert.Equal(t, allowUserCancelWorkspaceJobs, updated.AllowUserCancelWorkspaceJobs)
|
||||
})
|
||||
t.Run("FirstEmptyThenNotModified", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
@ -75,6 +80,7 @@ func TestTemplateEdit(t *testing.T) {
|
|||
"--description", template.Description,
|
||||
"--icon", template.Icon,
|
||||
"--default-ttl", (time.Duration(template.DefaultTTLMillis) * time.Millisecond).String(),
|
||||
"--allow-user-cancel-workspace-jobs=" + strconv.FormatBool(template.AllowUserCancelWorkspaceJobs),
|
||||
}
|
||||
cmd, root := clitest.New(t, cmdArgs...)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
@ -91,6 +97,7 @@ func TestTemplateEdit(t *testing.T) {
|
|||
assert.Equal(t, template.Description, updated.Description)
|
||||
assert.Equal(t, template.Icon, updated.Icon)
|
||||
assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
assert.Equal(t, template.AllowUserCancelWorkspaceJobs, updated.AllowUserCancelWorkspaceJobs)
|
||||
})
|
||||
t.Run("InvalidDisplayName", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
|
|
@ -358,13 +358,16 @@ CREATE TABLE templates (
|
|||
icon character varying(256) DEFAULT ''::character varying NOT NULL,
|
||||
user_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
group_acl jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
display_name character varying(64) DEFAULT ''::character varying NOT NULL
|
||||
display_name character varying(64) DEFAULT ''::character varying NOT NULL,
|
||||
allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for auto-stop for workspaces created from this template.';
|
||||
|
||||
COMMENT ON COLUMN templates.display_name IS 'Display name is a custom, human-friendly template name that user can set.';
|
||||
|
||||
COMMENT ON COLUMN templates.allow_user_cancel_workspace_jobs IS 'Allow users to cancel in-progress workspace jobs.';
|
||||
|
||||
CREATE TABLE user_links (
|
||||
user_id uuid NOT NULL,
|
||||
login_type login_type NOT NULL,
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE templates DROP COLUMN allow_user_cancel_workspace_jobs;
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE templates ADD COLUMN allow_user_cancel_workspace_jobs boolean NOT NULL DEFAULT true;
|
||||
|
||||
COMMENT ON COLUMN templates.allow_user_cancel_workspace_jobs
|
||||
IS 'Allow users to cancel in-progress workspace jobs.';
|
|
@ -596,6 +596,8 @@ type Template struct {
|
|||
GroupACL TemplateACL `db:"group_acl" json:"group_acl"`
|
||||
// Display name is a custom, human-friendly template name that user can set.
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
// Allow users to cancel in-progress workspace jobs.
|
||||
AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"`
|
||||
}
|
||||
|
||||
type TemplateVersion struct {
|
||||
|
|
|
@ -3130,7 +3130,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem
|
|||
|
||||
const getTemplateByID = `-- name: GetTemplateByID :one
|
||||
SELECT
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
FROM
|
||||
templates
|
||||
WHERE
|
||||
|
@ -3158,13 +3158,14 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat
|
|||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one
|
||||
SELECT
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
FROM
|
||||
templates
|
||||
WHERE
|
||||
|
@ -3200,12 +3201,13 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G
|
|||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTemplates = `-- name: GetTemplates :many
|
||||
SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name FROM templates
|
||||
SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs FROM templates
|
||||
ORDER BY (name, id) ASC
|
||||
`
|
||||
|
||||
|
@ -3234,6 +3236,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) {
|
|||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -3250,7 +3253,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) {
|
|||
|
||||
const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many
|
||||
SELECT
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
FROM
|
||||
templates
|
||||
WHERE
|
||||
|
@ -3314,6 +3317,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate
|
|||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -3344,27 +3348,29 @@ INSERT INTO
|
|||
icon,
|
||||
user_acl,
|
||||
group_acl,
|
||||
display_name
|
||||
display_name,
|
||||
allow_user_cancel_workspace_jobs
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
`
|
||||
|
||||
type InsertTemplateParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Provisioner ProvisionerType `db:"provisioner" json:"provisioner"`
|
||||
ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"`
|
||||
Description string `db:"description" json:"description"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
UserACL TemplateACL `db:"user_acl" json:"user_acl"`
|
||||
GroupACL TemplateACL `db:"group_acl" json:"group_acl"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Provisioner ProvisionerType `db:"provisioner" json:"provisioner"`
|
||||
ActiveVersionID uuid.UUID `db:"active_version_id" json:"active_version_id"`
|
||||
Description string `db:"description" json:"description"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
UserACL TemplateACL `db:"user_acl" json:"user_acl"`
|
||||
GroupACL TemplateACL `db:"group_acl" json:"group_acl"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) {
|
||||
|
@ -3383,6 +3389,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam
|
|||
arg.UserACL,
|
||||
arg.GroupACL,
|
||||
arg.DisplayName,
|
||||
arg.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
var i Template
|
||||
err := row.Scan(
|
||||
|
@ -3401,6 +3408,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam
|
|||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -3414,7 +3422,7 @@ SET
|
|||
WHERE
|
||||
id = $3
|
||||
RETURNING
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
`
|
||||
|
||||
type UpdateTemplateACLByIDParams struct {
|
||||
|
@ -3442,6 +3450,7 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla
|
|||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -3497,21 +3506,23 @@ SET
|
|||
default_ttl = $4,
|
||||
name = $5,
|
||||
icon = $6,
|
||||
display_name = $7
|
||||
display_name = $7,
|
||||
allow_user_cancel_workspace_jobs = $8
|
||||
WHERE
|
||||
id = $1
|
||||
RETURNING
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name
|
||||
id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs
|
||||
`
|
||||
|
||||
type UpdateTemplateMetaByIDParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Description string `db:"description" json:"description"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Description string `db:"description" json:"description"`
|
||||
DefaultTTL int64 `db:"default_ttl" json:"default_ttl"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Icon string `db:"icon" json:"icon"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
AllowUserCancelWorkspaceJobs bool `db:"allow_user_cancel_workspace_jobs" json:"allow_user_cancel_workspace_jobs"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error) {
|
||||
|
@ -3523,6 +3534,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl
|
|||
arg.Name,
|
||||
arg.Icon,
|
||||
arg.DisplayName,
|
||||
arg.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
var i Template
|
||||
err := row.Scan(
|
||||
|
@ -3541,6 +3553,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl
|
|||
&i.UserACL,
|
||||
&i.GroupACL,
|
||||
&i.DisplayName,
|
||||
&i.AllowUserCancelWorkspaceJobs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
@ -70,10 +70,11 @@ INSERT INTO
|
|||
icon,
|
||||
user_acl,
|
||||
group_acl,
|
||||
display_name
|
||||
display_name,
|
||||
allow_user_cancel_workspace_jobs
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *;
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *;
|
||||
|
||||
-- name: UpdateTemplateActiveVersionByID :exec
|
||||
UPDATE
|
||||
|
@ -102,7 +103,8 @@ SET
|
|||
default_ttl = $4,
|
||||
name = $5,
|
||||
icon = $6,
|
||||
display_name = $7
|
||||
display_name = $7,
|
||||
allow_user_cancel_workspace_jobs = $8
|
||||
WHERE
|
||||
id = $1
|
||||
RETURNING
|
||||
|
|
|
@ -220,6 +220,11 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
|
|||
return
|
||||
}
|
||||
|
||||
var allowUserCancelWorkspaceJobs bool
|
||||
if createTemplate.AllowUserCancelWorkspaceJobs != nil {
|
||||
allowUserCancelWorkspaceJobs = *createTemplate.AllowUserCancelWorkspaceJobs
|
||||
}
|
||||
|
||||
var dbTemplate database.Template
|
||||
var template codersdk.Template
|
||||
err = api.Database.InTx(func(tx database.Store) error {
|
||||
|
@ -239,8 +244,9 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque
|
|||
GroupACL: database.TemplateACL{
|
||||
organization.ID.String(): []rbac.Action{rbac.ActionRead},
|
||||
},
|
||||
DisplayName: createTemplate.DisplayName,
|
||||
Icon: createTemplate.Icon,
|
||||
DisplayName: createTemplate.DisplayName,
|
||||
Icon: createTemplate.Icon,
|
||||
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert template: %s", err)
|
||||
|
@ -476,6 +482,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
req.Description == template.Description &&
|
||||
req.DisplayName == template.DisplayName &&
|
||||
req.Icon == template.Icon &&
|
||||
req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs &&
|
||||
req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() {
|
||||
return nil
|
||||
}
|
||||
|
@ -488,6 +495,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
desc := req.Description
|
||||
icon := req.Icon
|
||||
maxTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond
|
||||
allowUserCancelWorkspaceJobs := req.AllowUserCancelWorkspaceJobs
|
||||
|
||||
if name == "" {
|
||||
name = template.Name
|
||||
|
@ -497,13 +505,14 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
updated, err = tx.UpdateTemplateMetaByID(ctx, database.UpdateTemplateMetaByIDParams{
|
||||
ID: template.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
Description: desc,
|
||||
Icon: icon,
|
||||
DefaultTTL: int64(maxTTL),
|
||||
ID: template.ID,
|
||||
UpdatedAt: database.Now(),
|
||||
Name: name,
|
||||
DisplayName: displayName,
|
||||
Description: desc,
|
||||
Icon: icon,
|
||||
DefaultTTL: int64(maxTTL),
|
||||
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -740,21 +749,22 @@ func (api *API) convertTemplate(
|
|||
buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID)
|
||||
|
||||
return codersdk.Template{
|
||||
ID: template.ID,
|
||||
CreatedAt: template.CreatedAt,
|
||||
UpdatedAt: template.UpdatedAt,
|
||||
OrganizationID: template.OrganizationID,
|
||||
Name: template.Name,
|
||||
DisplayName: template.DisplayName,
|
||||
Provisioner: codersdk.ProvisionerType(template.Provisioner),
|
||||
ActiveVersionID: template.ActiveVersionID,
|
||||
WorkspaceOwnerCount: workspaceOwnerCount,
|
||||
ActiveUserCount: activeCount,
|
||||
BuildTimeStats: buildTimeStats,
|
||||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(),
|
||||
CreatedByID: template.CreatedBy,
|
||||
CreatedByName: createdByName,
|
||||
ID: template.ID,
|
||||
CreatedAt: template.CreatedAt,
|
||||
UpdatedAt: template.UpdatedAt,
|
||||
OrganizationID: template.OrganizationID,
|
||||
Name: template.Name,
|
||||
DisplayName: template.DisplayName,
|
||||
Provisioner: codersdk.ProvisionerType(template.Provisioner),
|
||||
ActiveVersionID: template.ActiveVersionID,
|
||||
WorkspaceOwnerCount: workspaceOwnerCount,
|
||||
ActiveUserCount: activeCount,
|
||||
BuildTimeStats: buildTimeStats,
|
||||
Description: template.Description,
|
||||
Icon: template.Icon,
|
||||
DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(),
|
||||
CreatedByID: template.CreatedBy,
|
||||
CreatedByName: createdByName,
|
||||
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -285,11 +285,12 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
req := codersdk.UpdateTemplateMeta{
|
||||
Name: "new-template-name",
|
||||
DisplayName: "Displayed Name 456",
|
||||
Description: "lorem ipsum dolor sit amet et cetera",
|
||||
Icon: "/icons/new-icon.png",
|
||||
DefaultTTLMillis: 12 * time.Hour.Milliseconds(),
|
||||
Name: "new-template-name",
|
||||
DisplayName: "Displayed Name 456",
|
||||
Description: "lorem ipsum dolor sit amet et cetera",
|
||||
Icon: "/icons/new-icon.png",
|
||||
DefaultTTLMillis: 12 * time.Hour.Milliseconds(),
|
||||
AllowUserCancelWorkspaceJobs: false,
|
||||
}
|
||||
// It is unfortunate we need to sleep, but the test can fail if the
|
||||
// updatedAt is too close together.
|
||||
|
@ -306,6 +307,7 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
assert.Equal(t, req.Description, updated.Description)
|
||||
assert.Equal(t, req.Icon, updated.Icon)
|
||||
assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
assert.False(t, req.AllowUserCancelWorkspaceJobs)
|
||||
|
||||
// Extra paranoid: did it _really_ happen?
|
||||
updated, err = client.Template(ctx, template.ID)
|
||||
|
@ -316,6 +318,7 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
assert.Equal(t, req.Description, updated.Description)
|
||||
assert.Equal(t, req.Icon, updated.Icon)
|
||||
assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
assert.False(t, req.AllowUserCancelWorkspaceJobs)
|
||||
|
||||
require.Len(t, auditor.AuditLogs, 4)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[3].Action)
|
||||
|
|
|
@ -599,6 +599,21 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
|
|||
return
|
||||
}
|
||||
|
||||
valid, err := api.verifyUserCanCancelWorkspaceBuilds(ctx, httpmw.APIKey(r).UserID, workspace.TemplateID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error verifying permission to cancel workspace build.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "User is not allowed to cancel workspace builds. Owner role is required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
job, err := api.Database.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
|
@ -646,6 +661,23 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
|
|||
})
|
||||
}
|
||||
|
||||
func (api *API) verifyUserCanCancelWorkspaceBuilds(ctx context.Context, userID uuid.UUID, templateID uuid.UUID) (bool, error) {
|
||||
template, err := api.Database.GetTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
return false, xerrors.New("no template exists for this workspace")
|
||||
}
|
||||
|
||||
if template.AllowUserCancelWorkspaceJobs {
|
||||
return true, nil // all users can cancel workspace builds
|
||||
}
|
||||
|
||||
user, err := api.Database.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return false, xerrors.New("user does not exist")
|
||||
}
|
||||
return slices.Contains(user.RBACRoles, rbac.RoleOwner()), nil // only user with "owner" role can cancel workspace builds
|
||||
}
|
||||
|
||||
func (api *API) workspaceBuildResources(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
workspaceBuild := httpmw.WorkspaceBuildParam(r)
|
||||
|
|
|
@ -367,41 +367,79 @@ func TestWorkspaceBuildsProvisionerState(t *testing.T) {
|
|||
|
||||
func TestPatchCancelWorkspaceBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
}},
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
t.Run("User is allowed to cancel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
}},
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
var build codersdk.WorkspaceBuild
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
build, err = client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
|
||||
return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
err := client.CancelWorkspaceBuild(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
build, err = client.WorkspaceBuild(ctx, build.ID)
|
||||
return assert.NoError(t, err) &&
|
||||
// The job will never actually cancel successfully because it will never send a
|
||||
// provision complete response.
|
||||
assert.Empty(t, build.Job.Error) &&
|
||||
build.Job.Status == codersdk.ProvisionerJobCanceling
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
var build codersdk.WorkspaceBuild
|
||||
t.Run("User is not allowed to cancel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
}},
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
build, err = client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
|
||||
return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
err := client.CancelWorkspaceBuild(ctx, build.ID)
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
build, err = client.WorkspaceBuild(ctx, build.ID)
|
||||
return assert.NoError(t, err) &&
|
||||
// The job will never actually cancel successfully because it will never send a
|
||||
// provision complete response.
|
||||
assert.Empty(t, build.Job.Error) &&
|
||||
build.Job.Status == codersdk.ProvisionerJobCanceling
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
userClient := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
workspace := coderdtest.CreateWorkspace(t, userClient, owner.OrganizationID, template.ID)
|
||||
var build codersdk.WorkspaceBuild
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
build, err = userClient.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
|
||||
return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
err := userClient.CancelWorkspaceBuild(ctx, build.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkspaceBuildResources(t *testing.T) {
|
||||
|
|
|
@ -1010,21 +1010,22 @@ func convertWorkspace(
|
|||
|
||||
ttlMillis := convertWorkspaceTTLMillis(workspace.Ttl)
|
||||
return codersdk.Workspace{
|
||||
ID: workspace.ID,
|
||||
CreatedAt: workspace.CreatedAt,
|
||||
UpdatedAt: workspace.UpdatedAt,
|
||||
OwnerID: workspace.OwnerID,
|
||||
OwnerName: owner.Username,
|
||||
TemplateID: workspace.TemplateID,
|
||||
LatestBuild: workspaceBuild,
|
||||
TemplateName: template.Name,
|
||||
TemplateIcon: template.Icon,
|
||||
TemplateDisplayName: template.DisplayName,
|
||||
Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(),
|
||||
Name: workspace.Name,
|
||||
AutostartSchedule: autostartSchedule,
|
||||
TTLMillis: ttlMillis,
|
||||
LastUsedAt: workspace.LastUsedAt,
|
||||
ID: workspace.ID,
|
||||
CreatedAt: workspace.CreatedAt,
|
||||
UpdatedAt: workspace.UpdatedAt,
|
||||
OwnerID: workspace.OwnerID,
|
||||
OwnerName: owner.Username,
|
||||
TemplateID: workspace.TemplateID,
|
||||
LatestBuild: workspaceBuild,
|
||||
TemplateName: template.Name,
|
||||
TemplateIcon: template.Icon,
|
||||
TemplateDisplayName: template.DisplayName,
|
||||
TemplateAllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||||
Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(),
|
||||
Name: workspace.Name,
|
||||
AutostartSchedule: autostartSchedule,
|
||||
TTLMillis: ttlMillis,
|
||||
LastUsedAt: workspace.LastUsedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -125,13 +125,16 @@ func TestWorkspace(t *testing.T) {
|
|||
|
||||
const templateIcon = "/img/icon.svg"
|
||||
const templateDisplayName = "This is template"
|
||||
var templateAllowUserCancelWorkspaceJobs = false
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
|
||||
ctr.Icon = templateIcon
|
||||
ctr.DisplayName = templateDisplayName
|
||||
ctr.AllowUserCancelWorkspaceJobs = &templateAllowUserCancelWorkspaceJobs
|
||||
})
|
||||
require.NotEmpty(t, template.Name)
|
||||
require.NotEmpty(t, template.DisplayName)
|
||||
require.NotEmpty(t, template.Icon)
|
||||
require.False(t, template.AllowUserCancelWorkspaceJobs)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
|
@ -144,6 +147,7 @@ func TestWorkspace(t *testing.T) {
|
|||
assert.Equal(t, template.Name, ws.TemplateName)
|
||||
assert.Equal(t, templateIcon, ws.TemplateIcon)
|
||||
assert.Equal(t, templateDisplayName, ws.TemplateDisplayName)
|
||||
assert.Equal(t, templateAllowUserCancelWorkspaceJobs, ws.TemplateAllowUserCancelWorkspaceJobs)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -72,6 +72,10 @@ type CreateTemplateRequest struct {
|
|||
// DefaultTTLMillis allows optionally specifying the default TTL
|
||||
// for all workspaces created from this template.
|
||||
DefaultTTLMillis *int64 `json:"default_ttl_ms,omitempty"`
|
||||
|
||||
// Allow users to cancel in-progress workspace jobs.
|
||||
// *bool as the default value is "true".
|
||||
AllowUserCancelWorkspaceJobs *bool `json:"allow_user_cancel_workspace_jobs"`
|
||||
}
|
||||
|
||||
// CreateWorkspaceRequest provides options for creating a new workspace.
|
||||
|
|
|
@ -31,6 +31,8 @@ type Template struct {
|
|||
DefaultTTLMillis int64 `json:"default_ttl_ms"`
|
||||
CreatedByID uuid.UUID `json:"created_by_id"`
|
||||
CreatedByName string `json:"created_by_name"`
|
||||
|
||||
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"`
|
||||
}
|
||||
|
||||
type TransitionStats struct {
|
||||
|
@ -72,11 +74,12 @@ type UpdateTemplateACL struct {
|
|||
}
|
||||
|
||||
type UpdateTemplateMeta struct {
|
||||
Name string `json:"name,omitempty" validate:"omitempty,template_name"`
|
||||
DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"`
|
||||
Name string `json:"name,omitempty" validate:"omitempty,template_name"`
|
||||
DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"`
|
||||
AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"`
|
||||
}
|
||||
|
||||
// Template returns a single template.
|
||||
|
|
|
@ -17,21 +17,22 @@ import (
|
|||
// Workspace is a deployment of a template. It references a specific
|
||||
// version and can be updated.
|
||||
type Workspace struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
TemplateID uuid.UUID `json:"template_id"`
|
||||
TemplateName string `json:"template_name"`
|
||||
TemplateDisplayName string `json:"template_display_name"`
|
||||
TemplateIcon string `json:"template_icon"`
|
||||
LatestBuild WorkspaceBuild `json:"latest_build"`
|
||||
Outdated bool `json:"outdated"`
|
||||
Name string `json:"name"`
|
||||
AutostartSchedule *string `json:"autostart_schedule,omitempty"`
|
||||
TTLMillis *int64 `json:"ttl_ms,omitempty"`
|
||||
LastUsedAt time.Time `json:"last_used_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
TemplateID uuid.UUID `json:"template_id"`
|
||||
TemplateName string `json:"template_name"`
|
||||
TemplateDisplayName string `json:"template_display_name"`
|
||||
TemplateIcon string `json:"template_icon"`
|
||||
TemplateAllowUserCancelWorkspaceJobs bool `json:"template_allow_user_cancel_workspace_jobs"`
|
||||
LatestBuild WorkspaceBuild `json:"latest_build"`
|
||||
Outdated bool `json:"outdated"`
|
||||
Name string `json:"name"`
|
||||
AutostartSchedule *string `json:"autostart_schedule,omitempty"`
|
||||
TTLMillis *int64 `json:"ttl_ms,omitempty"`
|
||||
LastUsedAt time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
type WorkspacesRequest struct {
|
||||
|
|
|
@ -48,23 +48,24 @@ var AuditableResources = auditMap(map[any]map[string]Action{
|
|||
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
|
||||
},
|
||||
&database.Template{}: {
|
||||
"id": ActionTrack,
|
||||
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
|
||||
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
|
||||
"organization_id": ActionIgnore, /// Never changes.
|
||||
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
|
||||
"name": ActionTrack,
|
||||
"display_name": ActionTrack,
|
||||
"provisioner": ActionTrack,
|
||||
"active_version_id": ActionTrack,
|
||||
"description": ActionTrack,
|
||||
"icon": ActionTrack,
|
||||
"default_ttl": ActionTrack,
|
||||
"min_autostart_interval": ActionTrack,
|
||||
"created_by": ActionTrack,
|
||||
"is_private": ActionTrack,
|
||||
"group_acl": ActionTrack,
|
||||
"user_acl": ActionTrack,
|
||||
"id": ActionTrack,
|
||||
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
|
||||
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
|
||||
"organization_id": ActionIgnore, /// Never changes.
|
||||
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
|
||||
"name": ActionTrack,
|
||||
"display_name": ActionTrack,
|
||||
"provisioner": ActionTrack,
|
||||
"active_version_id": ActionTrack,
|
||||
"description": ActionTrack,
|
||||
"icon": ActionTrack,
|
||||
"default_ttl": ActionTrack,
|
||||
"min_autostart_interval": ActionTrack,
|
||||
"created_by": ActionTrack,
|
||||
"is_private": ActionTrack,
|
||||
"group_acl": ActionTrack,
|
||||
"user_acl": ActionTrack,
|
||||
"allow_user_cancel_workspace_jobs": ActionTrack,
|
||||
},
|
||||
&database.TemplateVersion{}: {
|
||||
"id": ActionTrack,
|
||||
|
|
|
@ -182,6 +182,7 @@ export interface CreateTemplateRequest {
|
|||
readonly template_version_id: string
|
||||
readonly parameter_values?: CreateParameterRequest[]
|
||||
readonly default_ttl_ms?: number
|
||||
readonly allow_user_cancel_workspace_jobs?: boolean
|
||||
}
|
||||
|
||||
// From codersdk/templateversions.go
|
||||
|
@ -642,6 +643,7 @@ export interface Template {
|
|||
readonly default_ttl_ms: number
|
||||
readonly created_by_id: string
|
||||
readonly created_by_name: string
|
||||
readonly allow_user_cancel_workspace_jobs: boolean
|
||||
}
|
||||
|
||||
// From codersdk/templates.go
|
||||
|
@ -725,6 +727,7 @@ export interface UpdateTemplateMeta {
|
|||
readonly description?: string
|
||||
readonly icon?: string
|
||||
readonly default_ttl_ms?: number
|
||||
readonly allow_user_cancel_workspace_jobs?: boolean
|
||||
}
|
||||
|
||||
// From codersdk/users.go
|
||||
|
@ -799,6 +802,7 @@ export interface Workspace {
|
|||
readonly template_name: string
|
||||
readonly template_display_name: string
|
||||
readonly template_icon: string
|
||||
readonly template_allow_user_cancel_workspace_jobs: boolean
|
||||
readonly latest_build: WorkspaceBuild
|
||||
readonly outdated: boolean
|
||||
readonly name: string
|
||||
|
|
|
@ -10,5 +10,7 @@
|
|||
"deleteCta": "Delete Template"
|
||||
}
|
||||
},
|
||||
"displayNameLabel": "Display name"
|
||||
"displayNameLabel": "Display name",
|
||||
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
|
||||
"allowUserCancelWorkspaceJobsNotice": "It is advised to keep the option disabled when canceling a workspace job may leave the workspace in an unhealthy state, and extra permissions are required to manually repair its resources."
|
||||
}
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import Box from "@material-ui/core/Box"
|
||||
import Checkbox from "@material-ui/core/Checkbox"
|
||||
import Typography from "@material-ui/core/Typography"
|
||||
import data from "@emoji-mart/data/sets/14/twitter.json"
|
||||
import Picker from "@emoji-mart/react"
|
||||
import Button from "@material-ui/core/Button"
|
||||
|
@ -56,6 +59,7 @@ export const validationSchema = Yup.object({
|
|||
.integer()
|
||||
.min(0)
|
||||
.max(24 * MAX_TTL_DAYS /* 7 days in hours */, Language.ttlMaxError),
|
||||
allow_user_cancel_workspace_jobs: Yup.boolean(),
|
||||
})
|
||||
|
||||
export interface TemplateSettingsForm {
|
||||
|
@ -86,6 +90,8 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
// on display, convert from ms => hours
|
||||
default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION,
|
||||
icon: template.icon,
|
||||
allow_user_cancel_workspace_jobs:
|
||||
template.allow_user_cancel_workspace_jobs,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: (formData) => {
|
||||
|
@ -212,6 +218,27 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
{form.values.default_ttl_ms && !form.errors.default_ttl_ms && (
|
||||
<span>{Language.ttlHelperText(form.values.default_ttl_ms)}</span>
|
||||
)}
|
||||
|
||||
<Box display="flex">
|
||||
<div>
|
||||
{/*"getFieldHelpers" can't be used as it requires "helperText" property to be present.*/}
|
||||
<Checkbox
|
||||
id="allow_user_cancel_workspace_jobs"
|
||||
name="allow_user_cancel_workspace_jobs"
|
||||
disabled={isSubmitting}
|
||||
checked={form.values.allow_user_cancel_workspace_jobs}
|
||||
onChange={form.handleChange}
|
||||
/>
|
||||
</div>
|
||||
<Box>
|
||||
<Typography variant="h6" style={{ fontSize: 14 }}>
|
||||
{t("allowUserCancelWorkspaceJobsLabel")}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{t("allowUserCancelWorkspaceJobsNotice")}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
|
||||
|
|
|
@ -28,6 +28,7 @@ const validFormValues = {
|
|||
description: "A description",
|
||||
icon: "A string",
|
||||
default_ttl_ms: 1,
|
||||
allow_user_cancel_workspace_jobs: false,
|
||||
}
|
||||
|
||||
const fillAndSubmitForm = async ({
|
||||
|
@ -36,15 +37,14 @@ const fillAndSubmitForm = async ({
|
|||
description,
|
||||
default_ttl_ms,
|
||||
icon,
|
||||
allow_user_cancel_workspace_jobs,
|
||||
}: Required<UpdateTemplateMeta>) => {
|
||||
const nameField = await screen.findByLabelText(FormLanguage.nameLabel)
|
||||
await userEvent.clear(nameField)
|
||||
await userEvent.type(nameField, name)
|
||||
|
||||
const { t } = i18next
|
||||
const displayNameLabel = t("displayNameLabel", {
|
||||
ns: "templatePage",
|
||||
})
|
||||
const displayNameLabel = t("displayNameLabel", { ns: "templatePage" })
|
||||
|
||||
const displayNameField = await screen.findByLabelText(displayNameLabel)
|
||||
await userEvent.clear(displayNameField)
|
||||
|
@ -64,6 +64,12 @@ const fillAndSubmitForm = async ({
|
|||
await userEvent.clear(maxTtlField)
|
||||
await userEvent.type(maxTtlField, default_ttl_ms.toString())
|
||||
|
||||
const allowCancelJobsField = await screen.getByRole("checkbox")
|
||||
// checkbox is checked by default, so it must be clicked to get unchecked
|
||||
if (!allow_user_cancel_workspace_jobs) {
|
||||
await userEvent.click(allowCancelJobsField)
|
||||
}
|
||||
|
||||
const submitButton = await screen.findByText(
|
||||
FooterFormLanguage.defaultSubmitLabel,
|
||||
)
|
||||
|
|
|
@ -211,6 +211,7 @@ export const MockTemplate: TypesGen.Template = {
|
|||
created_by_id: "test-creator-id",
|
||||
created_by_name: "test_creator",
|
||||
icon: "/icon/code.svg",
|
||||
allow_user_cancel_workspace_jobs: true,
|
||||
}
|
||||
|
||||
export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
|
||||
|
@ -446,6 +447,8 @@ export const MockWorkspace: TypesGen.Workspace = {
|
|||
template_name: MockTemplate.name,
|
||||
template_icon: MockTemplate.icon,
|
||||
template_display_name: MockTemplate.display_name,
|
||||
template_allow_user_cancel_workspace_jobs:
|
||||
MockTemplate.allow_user_cancel_workspace_jobs,
|
||||
outdated: false,
|
||||
owner_id: MockUser.id,
|
||||
owner_name: MockUser.username,
|
||||
|
|
Loading…
Reference in New Issue