diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index d1874e6f7ca14..319ac80c1554c 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -61,6 +61,7 @@ "failing_agents": [] }, "automatic_updates": "never", - "allow_renames": false + "allow_renames": false, + "favorite": false } ] diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e17e2d8081180..098ea767e4ffe 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 34b4bd36df2a3..24bc5e29cc05c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index e3c935971f3e3..7afbbc1f44418 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) }) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index a5b295e2e35eb..97743186f1356 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -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) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index d9444278722e7..9668497dbfbae 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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() { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index e3572a2e10ff1..fa07cf01da11f 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -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) diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 625871500dbeb..15c7492fb9b61 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index bfb93405f5524..2b1c864b5adbf 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f9d1e4311b2b2..d13d615f9f196 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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); diff --git a/coderd/database/migrations/000186_user_favorite_workspaces.down.sql b/coderd/database/migrations/000186_user_favorite_workspaces.down.sql new file mode 100644 index 0000000000000..dd2fe23bb3436 --- /dev/null +++ b/coderd/database/migrations/000186_user_favorite_workspaces.down.sql @@ -0,0 +1 @@ +ALTER TABLE ONLY workspaces DROP COLUMN favorite; diff --git a/coderd/database/migrations/000186_user_favorite_workspaces.up.sql b/coderd/database/migrations/000186_user_favorite_workspaces.up.sql new file mode 100644 index 0000000000000..998c396ce58e7 --- /dev/null +++ b/coderd/database/migrations/000186_user_favorite_workspaces.up.sql @@ -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.'; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 685c138c95288..fd679115f4762 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -373,6 +373,7 @@ func ConvertWorkspaceRows(rows []GetWorkspacesRow) []Workspace { DormantAt: r.DormantAt, DeletingAt: r.DeletingAt, AutomaticUpdates: r.AutomaticUpdates, + Favorite: r.Favorite, } } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 7586be1834c98..fd4e1459d3c78 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -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, diff --git a/coderd/database/models.go b/coderd/database/models.go index 5308f88b35a79..e0c310b2a7baf 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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 { diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 8947ba185d14d..7996e3ca22f15 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b18662df3537e..55ee373540743 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index c60af4f3ce909..592aefb1acce9 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -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; diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0bcd8bb6dd9e8..7854689f0eb9a 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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, } } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 9dfc18a395876..d6fc6af253bd5 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -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()) +} diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 11f07b91aa789..5cdd8cd1c6885 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -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. diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index c09c829f3b765..3c148ae77f78c 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -19,7 +19,7 @@ We track the following resources: | Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
use_max_ttltrue
user_acltrue
| | TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| | User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
nametrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
theme_preferencefalse
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| | WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 3ec2e2ede886d..8114d0750b65e 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -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 diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index da06dc4874e90..f4c1b6957f527 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -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 diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index c7e9272adfe40..de3e51c3b20b3 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -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, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 48d05be9d9e73..eeab0f373bba6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 59d60fda1aad5..b3ba287be5060 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1020,6 +1020,7 @@ export const MockWorkspace: TypesGen.Workspace = { }, automatic_updates: "never", allow_renames: true, + favorite: true, }; export const MockStoppedWorkspace: TypesGen.Workspace = {