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