feat(coderd): allow workspace owners to mark workspaces as favorite (#11791)

- Adds column `favorite` to workspaces table
- Adds API endpoints to favorite/unfavorite workspaces
- Modifies sorting order to return owners' favorite workspaces first
This commit is contained in:
Cian Johnston 2024-01-24 13:39:19 +00:00 committed by GitHub
parent 6145da8a9e
commit f92336c4d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 646 additions and 50 deletions

View File

@ -61,6 +61,7 @@
"failing_agents": []
},
"automatic_updates": "never",
"allow_renames": false
"allow_renames": false,
"favorite": false
}
]

59
coderd/apidoc/docs.go generated
View File

@ -6999,6 +6999,62 @@ const docTemplate = `{
}
}
},
"/workspaces/{workspace}/favorite": {
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": [
"Workspaces"
],
"summary": "Favorite workspace by ID.",
"operationId": "favorite-workspace-by-id",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Workspace ID",
"name": "workspace",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
},
"delete": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": [
"Workspaces"
],
"summary": "Unfavorite workspace by ID.",
"operationId": "unfavorite-workspace-by-id",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Workspace ID",
"name": "workspace",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/workspaces/{workspace}/resolve-autostart": {
"get": {
"security": [
@ -11926,6 +11982,9 @@ const docTemplate = `{
"type": "string",
"format": "date-time"
},
"favorite": {
"type": "boolean"
},
"health": {
"description": "Health shows the health of the workspace and information about\nwhat is causing an unhealthy status.",
"allOf": [

View File

@ -6175,6 +6175,58 @@
}
}
},
"/workspaces/{workspace}/favorite": {
"put": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": ["Workspaces"],
"summary": "Favorite workspace by ID.",
"operationId": "favorite-workspace-by-id",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Workspace ID",
"name": "workspace",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
},
"delete": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": ["Workspaces"],
"summary": "Unfavorite workspace by ID.",
"operationId": "unfavorite-workspace-by-id",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Workspace ID",
"name": "workspace",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/workspaces/{workspace}/resolve-autostart": {
"get": {
"security": [
@ -10810,6 +10862,9 @@
"type": "string",
"format": "date-time"
},
"favorite": {
"type": "boolean"
},
"health": {
"description": "Health shows the health of the workspace and information about\nwhat is causing an unhealthy status.",
"allOf": [

View File

@ -950,6 +950,8 @@ func New(options *Options) *API {
r.Get("/watch", api.watchWorkspace)
r.Put("/extend", api.putExtendWorkspace)
r.Put("/dormant", api.putWorkspaceDormant)
r.Put("/favorite", api.putFavoriteWorkspace)
r.Delete("/favorite", api.deleteFavoriteWorkspace)
r.Put("/autoupdates", api.putWorkspaceAutoupdates)
r.Get("/resolve-autostart", api.resolveAutostart)
})

View File

@ -891,6 +891,13 @@ func (q *querier) DeleteTailnetTunnel(ctx context.Context, arg database.DeleteTa
return q.db.DeleteTailnetTunnel(ctx, arg)
}
func (q *querier) FavoriteWorkspace(ctx context.Context, id uuid.UUID) error {
fetch := func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
return q.db.GetWorkspaceByID(ctx, id)
}
return update(q.log, q.auth, fetch, q.db.FavoriteWorkspace)(ctx, id)
}
func (q *querier) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
return fetch(q.log, q.auth, q.db.GetAPIKeyByID)(ctx, id)
}
@ -2509,6 +2516,13 @@ func (q *querier) UnarchiveTemplateVersion(ctx context.Context, arg database.Una
return q.db.UnarchiveTemplateVersion(ctx, arg)
}
func (q *querier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error {
fetch := func(ctx context.Context, id uuid.UUID) (database.Workspace, error) {
return q.db.GetWorkspaceByID(ctx, id)
}
return update(q.log, q.auth, fetch, q.db.UnfavoriteWorkspace)(ctx, id)
}
func (q *querier) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error {
fetch := func(ctx context.Context, arg database.UpdateAPIKeyByIDParams) (database.APIKey, error) {
return q.db.GetAPIKeyByID(ctx, arg.ID)

View File

@ -1578,6 +1578,16 @@ func (s *MethodTestSuite) TestWorkspace() {
WorkspaceID: ws.ID,
}).Asserts(ws, rbac.ActionUpdate).Returns()
}))
s.Run("FavoriteWorkspace", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID})
check.Args(ws.ID).Asserts(ws, rbac.ActionUpdate).Returns()
}))
s.Run("UnfavoriteWorkspace", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
ws := dbgen.Workspace(s.T(), db, database.Workspace{OwnerID: u.ID})
check.Args(ws.ID).Asserts(ws, rbac.ActionUpdate).Returns()
}))
}
func (s *MethodTestSuite) TestExtraMethods() {

View File

@ -359,6 +359,7 @@ func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspac
DeletingAt: w.DeletingAt,
Count: count,
AutomaticUpdates: w.AutomaticUpdates,
Favorite: w.Favorite,
}
for _, t := range q.templates {
@ -1315,6 +1316,25 @@ func (*FakeQuerier) DeleteTailnetTunnel(_ context.Context, arg database.DeleteTa
return database.DeleteTailnetTunnelRow{}, ErrUnimplemented
}
func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg uuid.UUID) error {
err := validateDatabaseType(arg)
if err != nil {
return err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for i := 0; i < len(q.workspaces); i++ {
if q.workspaces[i].ID != arg {
continue
}
q.workspaces[i].Favorite = true
return nil
}
return nil
}
func (q *FakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIKey, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -5984,6 +6004,26 @@ func (q *FakeQuerier) UnarchiveTemplateVersion(_ context.Context, arg database.U
return sql.ErrNoRows
}
func (q *FakeQuerier) UnfavoriteWorkspace(_ context.Context, arg uuid.UUID) error {
err := validateDatabaseType(arg)
if err != nil {
return err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for i := 0; i < len(q.workspaces); i++ {
if q.workspaces[i].ID != arg {
continue
}
q.workspaces[i].Favorite = false
return nil
}
return nil
}
func (q *FakeQuerier) UpdateAPIKeyByID(_ context.Context, arg database.UpdateAPIKeyByIDParams) error {
if err := validateDatabaseType(arg); err != nil {
return err
@ -7713,7 +7753,15 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
w1 := workspaces[i]
w2 := workspaces[j]
// Order by: running first
// Order by: favorite first
if arg.RequesterID == w1.OwnerID && w1.Favorite {
return true
}
if arg.RequesterID == w2.OwnerID && w2.Favorite {
return false
}
// Order by: running
w1IsRunning := isRunning(preloadedWorkspaceBuilds[w1.ID], preloadedProvisionerJobs[w1.ID])
w2IsRunning := isRunning(preloadedWorkspaceBuilds[w2.ID], preloadedProvisionerJobs[w2.ID])
@ -7726,12 +7774,12 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
}
// Order by: usernames
if w1.ID != w2.ID {
return sort.StringsAreSorted([]string{preloadedUsers[w1.ID].Username, preloadedUsers[w2.ID].Username})
if strings.Compare(preloadedUsers[w1.ID].Username, preloadedUsers[w2.ID].Username) < 0 {
return true
}
// Order by: workspace names
return sort.StringsAreSorted([]string{w1.Name, w2.Name})
return strings.Compare(w1.Name, w2.Name) < 0
})
beforePageCount := len(workspaces)

View File

@ -300,6 +300,13 @@ func (m metricsStore) DeleteTailnetTunnel(ctx context.Context, arg database.Dele
return r0, r1
}
func (m metricsStore) FavoriteWorkspace(ctx context.Context, arg uuid.UUID) error {
start := time.Now()
r0 := m.s.FavoriteWorkspace(ctx, arg)
m.queryLatencies.WithLabelValues("FavoriteWorkspace").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) GetAPIKeyByID(ctx context.Context, id string) (database.APIKey, error) {
start := time.Now()
apiKey, err := m.s.GetAPIKeyByID(ctx, id)
@ -1614,6 +1621,13 @@ func (m metricsStore) UnarchiveTemplateVersion(ctx context.Context, arg database
return r0
}
func (m metricsStore) UnfavoriteWorkspace(ctx context.Context, arg uuid.UUID) error {
start := time.Now()
r0 := m.s.UnfavoriteWorkspace(ctx, arg)
m.queryLatencies.WithLabelValues("UnfavoriteWorkspace").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) UpdateAPIKeyByID(ctx context.Context, arg database.UpdateAPIKeyByIDParams) error {
start := time.Now()
err := m.s.UpdateAPIKeyByID(ctx, arg)

View File

@ -500,6 +500,20 @@ func (mr *MockStoreMockRecorder) DeleteTailnetTunnel(arg0, arg1 any) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTailnetTunnel", reflect.TypeOf((*MockStore)(nil).DeleteTailnetTunnel), arg0, arg1)
}
// FavoriteWorkspace mocks base method.
func (m *MockStore) FavoriteWorkspace(arg0 context.Context, arg1 uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FavoriteWorkspace", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// FavoriteWorkspace indicates an expected call of FavoriteWorkspace.
func (mr *MockStoreMockRecorder) FavoriteWorkspace(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).FavoriteWorkspace), arg0, arg1)
}
// GetAPIKeyByID mocks base method.
func (m *MockStore) GetAPIKeyByID(arg0 context.Context, arg1 string) (database.APIKey, error) {
m.ctrl.T.Helper()
@ -3410,6 +3424,20 @@ func (mr *MockStoreMockRecorder) UnarchiveTemplateVersion(arg0, arg1 any) *gomoc
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnarchiveTemplateVersion", reflect.TypeOf((*MockStore)(nil).UnarchiveTemplateVersion), arg0, arg1)
}
// UnfavoriteWorkspace mocks base method.
func (m *MockStore) UnfavoriteWorkspace(arg0 context.Context, arg1 uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UnfavoriteWorkspace", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// UnfavoriteWorkspace indicates an expected call of UnfavoriteWorkspace.
func (mr *MockStoreMockRecorder) UnfavoriteWorkspace(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnfavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).UnfavoriteWorkspace), arg0, arg1)
}
// UpdateAPIKeyByID mocks base method.
func (m *MockStore) UpdateAPIKeyByID(arg0 context.Context, arg1 database.UpdateAPIKeyByIDParams) error {
m.ctrl.T.Helper()

View File

@ -1235,9 +1235,12 @@ CREATE TABLE workspaces (
last_used_at timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL,
dormant_at timestamp with time zone,
deleting_at timestamp with time zone,
automatic_updates automatic_updates DEFAULT 'never'::automatic_updates NOT NULL
automatic_updates automatic_updates DEFAULT 'never'::automatic_updates NOT NULL,
favorite boolean DEFAULT false NOT NULL
);
COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.';
ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('licenses_id_seq'::regclass);
ALTER TABLE ONLY provisioner_job_logs ALTER COLUMN id SET DEFAULT nextval('provisioner_job_logs_id_seq'::regclass);

View File

@ -0,0 +1 @@
ALTER TABLE ONLY workspaces DROP COLUMN favorite;

View File

@ -0,0 +1,3 @@
ALTER TABLE ONLY workspaces
ADD COLUMN favorite boolean NOT NULL DEFAULT false;
COMMENT ON COLUMN workspaces.favorite IS 'Favorite is true if the workspace owner has favorited the workspace.';

View File

@ -373,6 +373,7 @@ func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace {
DormantAt: r.DormantAt,
DeletingAt: r.DeletingAt,
AutomaticUpdates: r.AutomaticUpdates,
Favorite: r.Favorite,
}
}

View File

@ -226,6 +226,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
arg.LastUsedBefore,
arg.LastUsedAfter,
arg.UsingActive,
arg.RequesterID,
arg.Offset,
arg.Limit,
)
@ -251,6 +252,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
&i.DormantAt,
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
&i.TemplateName,
&i.TemplateVersionID,
&i.TemplateVersionName,

View File

@ -2185,6 +2185,8 @@ type Workspace struct {
DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"`
DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"`
AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"`
// Favorite is true if the workspace owner has favorited the workspace.
Favorite bool `db:"favorite" json:"favorite"`
}
type WorkspaceAgent struct {

View File

@ -75,6 +75,7 @@ type sqlcQuerier interface {
DeleteTailnetClientSubscription(ctx context.Context, arg DeleteTailnetClientSubscriptionParams) error
DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error)
DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error)
FavoriteWorkspace(ctx context.Context, id uuid.UUID) error
GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
// there is no unique constraint on empty token names
GetAPIKeyByName(ctx context.Context, arg GetAPIKeyByNameParams) (APIKey, error)
@ -321,6 +322,7 @@ type sqlcQuerier interface {
TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error)
// This will always work regardless of the current state of the template version.
UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error
UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateExternalAuthLink(ctx context.Context, arg UpdateExternalAuthLinkParams) (ExternalAuthLink, error)
UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error)

View File

@ -10849,6 +10849,15 @@ func (q *sqlQuerier) BatchUpdateWorkspaceLastUsedAt(ctx context.Context, arg Bat
return err
}
const favoriteWorkspace = `-- name: FavoriteWorkspace :exec
UPDATE workspaces SET favorite = true WHERE id = $1
`
func (q *sqlQuerier) FavoriteWorkspace(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, favoriteWorkspace, id)
return err
}
const getDeploymentWorkspaceStats = `-- name: GetDeploymentWorkspaceStats :one
WITH workspaces_with_jobs AS (
SELECT
@ -10935,7 +10944,7 @@ func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploy
const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one
SELECT
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.dormant_at, workspaces.deleting_at, workspaces.automatic_updates,
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.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite,
templates.name as template_name
FROM
workspaces
@ -10989,6 +10998,7 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI
&i.Workspace.DormantAt,
&i.Workspace.DeletingAt,
&i.Workspace.AutomaticUpdates,
&i.Workspace.Favorite,
&i.TemplateName,
)
return i, err
@ -10996,7 +11006,7 @@ func (q *sqlQuerier) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUI
const getWorkspaceByID = `-- name: GetWorkspaceByID :one
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite
FROM
workspaces
WHERE
@ -11023,13 +11033,14 @@ func (q *sqlQuerier) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Worksp
&i.DormantAt,
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
)
return i, err
}
const getWorkspaceByOwnerIDAndName = `-- name: GetWorkspaceByOwnerIDAndName :one
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite
FROM
workspaces
WHERE
@ -11063,13 +11074,14 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo
&i.DormantAt,
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
)
return i, err
}
const getWorkspaceByWorkspaceAppID = `-- name: GetWorkspaceByWorkspaceAppID :one
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite
FROM
workspaces
WHERE
@ -11122,6 +11134,7 @@ func (q *sqlQuerier) GetWorkspaceByWorkspaceAppID(ctx context.Context, workspace
&i.DormantAt,
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
)
return i, err
}
@ -11166,7 +11179,7 @@ func (q *sqlQuerier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Conte
const getWorkspaces = `-- name: GetWorkspaces :many
SELECT
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.dormant_at, workspaces.deleting_at, workspaces.automatic_updates,
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.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite,
COALESCE(template.name, 'unknown') as template_name,
latest_build.template_version_id,
latest_build.template_version_name,
@ -11355,6 +11368,8 @@ WHERE
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
ORDER BY
-- To ensure that 'favorite' workspaces show up first in the list only for their owner.
CASE WHEN workspaces.owner_id = $14 AND workspaces.favorite THEN 0 ELSE 1 END ASC,
(latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
@ -11363,11 +11378,11 @@ ORDER BY
LOWER(workspaces.name) ASC
LIMIT
CASE
WHEN $15 :: integer > 0 THEN
$15
WHEN $16 :: integer > 0 THEN
$16
END
OFFSET
$14
$15
`
type GetWorkspacesParams struct {
@ -11384,6 +11399,7 @@ type GetWorkspacesParams struct {
LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"`
LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"`
UsingActive sql.NullBool `db:"using_active" json:"using_active"`
RequesterID uuid.UUID `db:"requester_id" json:"requester_id"`
Offset int32 `db:"offset_" json:"offset_"`
Limit int32 `db:"limit_" json:"limit_"`
}
@ -11403,6 +11419,7 @@ type GetWorkspacesRow struct {
DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"`
DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"`
AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"`
Favorite bool `db:"favorite" json:"favorite"`
TemplateName string `db:"template_name" json:"template_name"`
TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"`
TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"`
@ -11424,6 +11441,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
arg.LastUsedBefore,
arg.LastUsedAfter,
arg.UsingActive,
arg.RequesterID,
arg.Offset,
arg.Limit,
)
@ -11449,6 +11467,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
&i.DormantAt,
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
&i.TemplateName,
&i.TemplateVersionID,
&i.TemplateVersionName,
@ -11469,7 +11488,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
const getWorkspacesEligibleForTransition = `-- name: GetWorkspacesEligibleForTransition :many
SELECT
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.dormant_at, workspaces.deleting_at, workspaces.automatic_updates
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.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite
FROM
workspaces
LEFT JOIN
@ -11557,6 +11576,7 @@ func (q *sqlQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, now
&i.DormantAt,
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
); err != nil {
return nil, err
}
@ -11587,7 +11607,7 @@ INSERT INTO
automatic_updates
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite
`
type InsertWorkspaceParams struct {
@ -11634,10 +11654,20 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar
&i.DormantAt,
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
)
return i, err
}
const unfavoriteWorkspace = `-- name: UnfavoriteWorkspace :exec
UPDATE workspaces SET favorite = false WHERE id = $1
`
func (q *sqlQuerier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, unfavoriteWorkspace, id)
return err
}
const updateTemplateWorkspacesLastUsedAt = `-- name: UpdateTemplateWorkspacesLastUsedAt :exec
UPDATE workspaces
SET
@ -11664,7 +11694,7 @@ SET
WHERE
id = $1
AND deleted = false
RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates
RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite
`
type UpdateWorkspaceParams struct {
@ -11690,6 +11720,7 @@ func (q *sqlQuerier) UpdateWorkspace(ctx context.Context, arg UpdateWorkspacePar
&i.DormantAt,
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
)
return i, err
}
@ -11776,7 +11807,7 @@ WHERE
workspaces.id = $1
AND templates.id = workspaces.template_id
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.dormant_at, workspaces.deleting_at, workspaces.automatic_updates
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.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite
`
type UpdateWorkspaceDormantDeletingAtParams struct {
@ -11802,6 +11833,7 @@ func (q *sqlQuerier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg U
&i.DormantAt,
&i.DeletingAt,
&i.AutomaticUpdates,
&i.Favorite,
)
return i, err
}

View File

@ -267,6 +267,8 @@ WHERE
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
ORDER BY
-- To ensure that 'favorite' workspaces show up first in the list only for their owner.
CASE WHEN workspaces.owner_id = @requester_id AND workspaces.favorite THEN 0 ELSE 1 END ASC,
(latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
@ -552,3 +554,9 @@ SET
automatic_updates = $2
WHERE
id = $1;
-- name: FavoriteWorkspace :exec
UPDATE workspaces SET favorite = true WHERE id = @id;
-- name: UnfavoriteWorkspace :exec
UPDATE workspaces SET favorite = false WHERE id = @id;

View File

@ -55,6 +55,7 @@ var (
func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspace := httpmw.WorkspaceParam(r)
apiKey := httpmw.APIKey(r)
var (
deletedStr = r.URL.Query().Get("include_deleted")
@ -102,6 +103,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace(
apiKey.UserID,
workspace,
data.builds[0],
data.templates[0],
@ -157,6 +159,10 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
return
}
// To show the requester's favorite workspaces first, we pass their userID and compare it to
// the workspace owner_id when ordering the rows.
filter.RequesterID = apiKey.UserID
workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@ -184,7 +190,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
return
}
wss, err := convertWorkspaces(workspaces, data)
wss, err := convertWorkspaces(apiKey.UserID, workspaces, data)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting workspaces.",
@ -213,6 +219,7 @@ func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request)
ctx := r.Context()
owner := httpmw.UserParam(r)
workspaceName := chi.URLParam(r, "workspacename")
apiKey := httpmw.APIKey(r)
includeDeleted := false
if s := r.URL.Query().Get("include_deleted"); s != "" {
@ -274,6 +281,7 @@ func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request)
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace(
apiKey.UserID,
workspace,
data.builds[0],
data.templates[0],
@ -583,6 +591,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
}
httpapi.Write(ctx, rw, http.StatusCreated, convertWorkspace(
apiKey.UserID,
workspace,
apiBuild,
template,
@ -854,6 +863,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
workspace = httpmw.WorkspaceParam(r)
apiKey = httpmw.APIKey(r)
oldWorkspace = workspace
auditor = api.Auditor.Load()
aReq, commitAudit = audit.InitRequest[database.Workspace](rw, &audit.RequestParams{
@ -922,6 +932,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
aReq.New = workspace
httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace(
apiKey.UserID,
workspace,
data.builds[0],
data.templates[0],
@ -1021,6 +1032,98 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, code, resp)
}
// @Summary Favorite workspace by ID.
// @ID favorite-workspace-by-id
// @Security CoderSessionToken
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Success 204
// @Router /workspaces/{workspace}/favorite [put]
func (api *API) putFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
workspace = httpmw.WorkspaceParam(r)
apiKey = httpmw.APIKey(r)
auditor = api.Auditor.Load()
)
if apiKey.UserID != workspace.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "You can only favorite workspaces that you own.",
})
return
}
aReq, commitAudit := audit.InitRequest[database.Workspace](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit()
aReq.Old = workspace
err := api.Database.FavoriteWorkspace(ctx, workspace.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error setting workspace as favorite",
Detail: err.Error(),
})
return
}
aReq.New = workspace
aReq.New.Favorite = true
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Unfavorite workspace by ID.
// @ID unfavorite-workspace-by-id
// @Security CoderSessionToken
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Success 204
// @Router /workspaces/{workspace}/favorite [delete]
func (api *API) deleteFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
workspace = httpmw.WorkspaceParam(r)
apiKey = httpmw.APIKey(r)
auditor = api.Auditor.Load()
)
if apiKey.UserID != workspace.OwnerID {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "You can only un-favorite workspaces that you own.",
})
return
}
aReq, commitAudit := audit.InitRequest[database.Workspace](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit()
aReq.Old = workspace
err := api.Database.UnfavoriteWorkspace(ctx, workspace.ID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error unsetting workspace as favorite",
Detail: err.Error(),
})
return
}
aReq.New = workspace
aReq.New.Favorite = false
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Update workspace automatic updates by ID
// @ID update-workspace-automatic-updates-by-id
// @Security CoderSessionToken
@ -1186,6 +1289,7 @@ func (api *API) resolveAutostart(rw http.ResponseWriter, r *http.Request) {
func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspace := httpmw.WorkspaceParam(r)
apiKey := httpmw.APIKey(r)
sendEvent, senderClosed, err := httpapi.ServerSentEventSender(rw, r)
if err != nil {
@ -1248,6 +1352,7 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) {
_ = sendEvent(ctx, codersdk.ServerSentEvent{
Type: codersdk.ServerSentEventTypeData,
Data: convertWorkspace(
apiKey.UserID,
workspace,
data.builds[0],
data.templates[0],
@ -1366,7 +1471,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa
}, nil
}
func convertWorkspaces(workspaces []database.Workspace, data workspaceData) ([]codersdk.Workspace, error) {
func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, data workspaceData) ([]codersdk.Workspace, error) {
buildByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceBuild{}
for _, workspaceBuild := range data.builds {
buildByWorkspaceID[workspaceBuild.WorkspaceID] = workspaceBuild
@ -1401,6 +1506,7 @@ func convertWorkspaces(workspaces []database.Workspace, data workspaceData) ([]c
}
apiWorkspaces = append(apiWorkspaces, convertWorkspace(
requesterID,
workspace,
build,
template,
@ -1412,6 +1518,7 @@ func convertWorkspaces(workspaces []database.Workspace, data workspaceData) ([]c
}
func convertWorkspace(
requesterID uuid.UUID,
workspace database.Workspace,
workspaceBuild codersdk.WorkspaceBuild,
template database.Template,
@ -1444,6 +1551,9 @@ func convertWorkspace(
ttlMillis := convertWorkspaceTTLMillis(workspace.Ttl)
// Only show favorite status if you own the workspace.
requesterFavorite := workspace.OwnerID == requesterID && workspace.Favorite
return codersdk.Workspace{
ID: workspace.ID,
CreatedAt: workspace.CreatedAt,
@ -1472,6 +1582,7 @@ func convertWorkspace(
},
AutomaticUpdates: codersdk.AutomaticUpdates(workspace.AutomaticUpdates),
AllowRenames: allowRenames,
Favorite: requesterFavorite,
}
}

View File

@ -479,55 +479,85 @@ func TestAdminViewAllWorkspaces(t *testing.T) {
func TestWorkspacesSortOrder(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
client, db := coderdtest.NewWithDatabase(t, nil)
firstUser := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
secondUserClient, secondUser := coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, []string{"owner"}, func(r *codersdk.CreateUserRequest) {
r.Username = "zzz"
})
// c-workspace should be running
workspace1 := coderdtest.CreateWorkspace(t, client, firstUser.OrganizationID, template.ID, func(ctr *codersdk.CreateWorkspaceRequest) {
ctr.Name = "c-workspace"
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace1.LatestBuild.ID)
wsbC := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "c-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Do()
// b-workspace should be stopped
workspace2 := coderdtest.CreateWorkspace(t, client, firstUser.OrganizationID, template.ID, func(ctr *codersdk.CreateWorkspaceRequest) {
ctr.Name = "b-workspace"
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace2.LatestBuild.ID)
build2 := coderdtest.CreateWorkspaceBuild(t, client, workspace2, database.WorkspaceTransitionStop)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build2.ID)
wsbB := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "b-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do()
// a-workspace should be running
workspace3 := coderdtest.CreateWorkspace(t, client, firstUser.OrganizationID, template.ID, func(ctr *codersdk.CreateWorkspaceRequest) {
ctr.Name = "a-workspace"
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace3.LatestBuild.ID)
wsbA := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "a-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Do()
// d-workspace should be stopped
wsbD := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "d-workspace", OwnerID: secondUser.ID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do()
// e-workspace should also be stopped
wsbE := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "e-workspace", OwnerID: secondUser.ID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do()
// f-workspace is also stopped, but is marked as favorite
wsbF := dbfake.WorkspaceBuild(t, db, database.Workspace{Name: "f-workspace", OwnerID: firstUser.UserID, OrganizationID: firstUser.OrganizationID}).Seed(database.WorkspaceBuild{Transition: database.WorkspaceTransitionStop}).Do()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
require.NoError(t, client.FavoriteWorkspace(ctx, wsbF.Workspace.ID)) // need to do this via API call for now
workspacesResponse, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{})
require.NoError(t, err, "(first) fetch workspaces")
workspaces := workspacesResponse.Workspaces
expected := []string{
workspace3.Name,
workspace1.Name,
workspace2.Name,
expectedNames := []string{
wsbF.Workspace.Name, // favorite
wsbA.Workspace.Name, // running
wsbC.Workspace.Name, // running
wsbB.Workspace.Name, // stopped, testuser < zzz
wsbD.Workspace.Name, // stopped, zzz > testuser
wsbE.Workspace.Name, // stopped, zzz > testuser
}
var actual []string
actualNames := make([]string, 0, len(expectedNames))
for _, w := range workspaces {
actual = append(actual, w.Name)
actualNames = append(actualNames, w.Name)
}
// the correct sorting order is:
// 1. Running workspaces
// 2. Sort by usernames
// 3. Sort by workspace names
require.Equal(t, expected, actual)
// 1. Favorite workspaces (we have one, workspace-f)
// 2. Running workspaces
// 3. Sort by usernames
// 4. Sort by workspace names
assert.Equal(t, expectedNames, actualNames)
// Once again but this time as a different user. This time we do not expect to see another
// user's favorites first.
workspacesResponse, err = secondUserClient.Workspaces(ctx, codersdk.WorkspaceFilter{})
require.NoError(t, err, "(second) fetch workspaces")
workspaces = workspacesResponse.Workspaces
expectedNames = []string{
wsbA.Workspace.Name, // running
wsbC.Workspace.Name, // running
wsbB.Workspace.Name, // stopped, testuser < zzz
wsbF.Workspace.Name, // stopped, testuser < zzz
wsbD.Workspace.Name, // stopped, zzz > testuser
wsbE.Workspace.Name, // stopped, zzz > testuser
}
actualNames = make([]string, 0, len(expectedNames))
for _, w := range workspaces {
actualNames = append(actualNames, w.Name)
}
// the correct sorting order is:
// 1. Favorite workspaces (we have none this time)
// 2. Running workspaces
// 3. Sort by usernames
// 4. Sort by workspace names
assert.Equal(t, expectedNames, actualNames)
}
func TestPostWorkspacesByOrganization(t *testing.T) {
@ -2978,3 +3008,85 @@ func TestWorkspaceDormant(t *testing.T) {
coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart)
})
}
func TestWorkspaceFavoriteUnfavorite(t *testing.T) {
t.Parallel()
// Given:
var (
auditRecorder = audit.NewMock()
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
Auditor: auditRecorder,
})
owner = coderdtest.CreateFirstUser(t, client)
memberClient, member = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// This will be our 'favorite' workspace
wsb1 = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: member.ID, OrganizationID: owner.OrganizationID}).Do()
wsb2 = dbfake.WorkspaceBuild(t, db, database.Workspace{OwnerID: owner.UserID, OrganizationID: owner.OrganizationID}).Do()
)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Initially, workspace should not be favored for member.
ws, err := memberClient.Workspace(ctx, wsb1.Workspace.ID)
require.NoError(t, err)
require.False(t, ws.Favorite)
// When user favorites workspace
err = memberClient.FavoriteWorkspace(ctx, wsb1.Workspace.ID)
require.NoError(t, err)
// Then it should be favored for them.
ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID)
require.NoError(t, err)
require.True(t, ws.Favorite)
// And it should be audited.
require.True(t, auditRecorder.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeWorkspace,
ResourceTarget: wsb1.Workspace.Name,
UserID: member.ID,
}))
auditRecorder.ResetLogs()
// This should not show for the owner.
ws, err = client.Workspace(ctx, wsb1.Workspace.ID)
require.NoError(t, err)
require.False(t, ws.Favorite)
// When member unfavorites workspace
err = memberClient.UnfavoriteWorkspace(ctx, wsb1.Workspace.ID)
require.NoError(t, err)
// Then it should no longer be favored
ws, err = memberClient.Workspace(ctx, wsb1.Workspace.ID)
require.NoError(t, err)
require.False(t, ws.Favorite, "no longer favorite")
// And it should show in the audit logs.
require.True(t, auditRecorder.Contains(t, database.AuditLog{
Action: database.AuditActionWrite,
ResourceType: database.ResourceTypeWorkspace,
ResourceTarget: wsb1.Workspace.Name,
UserID: member.ID,
}))
// Users without write access to the workspace should not be able to perform the above.
err = memberClient.FavoriteWorkspace(ctx, wsb2.Workspace.ID)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
err = memberClient.UnfavoriteWorkspace(ctx, wsb2.Workspace.ID)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
// You should not be able to favorite any workspace you do not own, even if you are the owner.
err = client.FavoriteWorkspace(ctx, wsb1.Workspace.ID)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
err = client.UnfavoriteWorkspace(ctx, wsb1.Workspace.ID)
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
}

View File

@ -58,6 +58,7 @@ type Workspace struct {
Health WorkspaceHealth `json:"health"`
AutomaticUpdates AutomaticUpdates `json:"automatic_updates" enums:"always,never"`
AllowRenames bool `json:"allow_renames"`
Favorite bool `json:"favorite"`
}
func (w Workspace) FullName() string {
@ -471,6 +472,30 @@ func (c *Client) ResolveAutostart(ctx context.Context, workspaceID string) (Reso
return response, json.NewDecoder(res.Body).Decode(&response)
}
func (c *Client) FavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID) error {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/workspaces/%s/favorite", workspaceID), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
func (c *Client) UnfavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/workspaces/%s/favorite", workspaceID), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
}
return nil
}
// WorkspaceNotifyChannel is the PostgreSQL NOTIFY
// channel to listen for updates on. The payload is empty,
// because the size of a workspace payload can be very large.

View File

@ -19,7 +19,7 @@ We track the following resources:
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>use_max_ttl</td><td>true</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>archived</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>message</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>theme_preference</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>automatic_updates</td><td>true</td></tr><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</td></tr><tr><td>favorite</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_username</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>region_id</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>version</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |

3
docs/api/schemas.md generated
View File

@ -5921,6 +5921,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"created_at": "2019-08-24T14:15:22Z",
"deleting_at": "2019-08-24T14:15:22Z",
"dormant_at": "2019-08-24T14:15:22Z",
"favorite": true,
"health": {
"failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"healthy": false
@ -6101,6 +6102,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `created_at` | string | false | | |
| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. |
| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time*til* field on its template. |
| `favorite` | boolean | false | | |
| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. |
| `id` | string | false | | |
| `last_used_at` | string | false | | |
@ -7184,6 +7186,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"created_at": "2019-08-24T14:15:22Z",
"deleting_at": "2019-08-24T14:15:22Z",
"dormant_at": "2019-08-24T14:15:22Z",
"favorite": true,
"health": {
"failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"healthy": false

57
docs/api/workspaces.md generated
View File

@ -53,6 +53,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member
"created_at": "2019-08-24T14:15:22Z",
"deleting_at": "2019-08-24T14:15:22Z",
"dormant_at": "2019-08-24T14:15:22Z",
"favorite": true,
"health": {
"failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"healthy": false
@ -264,6 +265,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
"created_at": "2019-08-24T14:15:22Z",
"deleting_at": "2019-08-24T14:15:22Z",
"dormant_at": "2019-08-24T14:15:22Z",
"favorite": true,
"health": {
"failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"healthy": false
@ -478,6 +480,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
"created_at": "2019-08-24T14:15:22Z",
"deleting_at": "2019-08-24T14:15:22Z",
"dormant_at": "2019-08-24T14:15:22Z",
"favorite": true,
"health": {
"failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"healthy": false
@ -686,6 +689,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
"created_at": "2019-08-24T14:15:22Z",
"deleting_at": "2019-08-24T14:15:22Z",
"dormant_at": "2019-08-24T14:15:22Z",
"favorite": true,
"health": {
"failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"healthy": false
@ -1013,6 +1017,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \
"created_at": "2019-08-24T14:15:22Z",
"deleting_at": "2019-08-24T14:15:22Z",
"dormant_at": "2019-08-24T14:15:22Z",
"favorite": true,
"health": {
"failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
"healthy": false
@ -1245,6 +1250,58 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/extend \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Favorite workspace by ID.
### Code samples
```shell
# Example request using curl
curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/favorite \
-H 'Coder-Session-Token: API_KEY'
```
`PUT /workspaces/{workspace}/favorite`
### Parameters
| Name | In | Type | Required | Description |
| ----------- | ---- | ------------ | -------- | ------------ |
| `workspace` | path | string(uuid) | true | Workspace ID |
### Responses
| Status | Meaning | Description | Schema |
| ------ | --------------------------------------------------------------- | ----------- | ------ |
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Unfavorite workspace by ID.
### Code samples
```shell
# Example request using curl
curl -X DELETE http://coder-server:8080/api/v2/workspaces/{workspace}/favorite \
-H 'Coder-Session-Token: API_KEY'
```
`DELETE /workspaces/{workspace}/favorite`
### Parameters
| Name | In | Type | Required | Description |
| ----------- | ---- | ------------ | -------- | ------------ |
| `workspace` | path | string(uuid) | true | Workspace ID |
### Responses
| Status | Meaning | Description | Schema |
| ------ | --------------------------------------------------------------- | ----------- | ------ |
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Resolve workspace autostart by id.
### Code samples

View File

@ -137,6 +137,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"dormant_at": ActionTrack,
"deleting_at": ActionTrack,
"automatic_updates": ActionTrack,
"favorite": ActionTrack,
},
&database.WorkspaceBuild{}: {
"id": ActionIgnore,

View File

@ -1490,6 +1490,7 @@ export interface Workspace {
readonly health: WorkspaceHealth;
readonly automatic_updates: AutomaticUpdates;
readonly allow_renames: boolean;
readonly favorite: boolean;
}
// From codersdk/workspaceagents.go

View File

@ -1020,6 +1020,7 @@ export const MockWorkspace: TypesGen.Workspace = {
},
automatic_updates: "never",
allow_renames: true,
favorite: true,
};
export const MockStoppedWorkspace: TypesGen.Workspace = {