diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6be8ea8e2b..dbf906bea4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10927,7 +10927,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent.", + "description": "Search query in the format ` + "`" + `key:value` + "`" + `. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy.", "name": "q", "in": "query" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2c44e36263..4e462beb26 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9665,7 +9665,7 @@ "parameters": [ { "type": "string", - "description": "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent.", + "description": "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy.", "name": "q", "in": "query" }, diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index fe33341cb7..761218e15c 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -268,7 +268,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa pq.Array(arg.TemplateIDs), pq.Array(arg.WorkspaceIds), arg.Name, - arg.HasAgent, + pq.Array(arg.HasAgentStatuses), arg.AgentInactiveDisconnectTimeoutSeconds, arg.Dormant, arg.LastUsedBefore, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 61388d44fd..61d42e34b2 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -22977,7 +22977,7 @@ WHERE -- Filter by agent status -- has-agent: is only applicable for workspaces in "start" transition. Stopped and deleted workspaces don't have agents. AND CASE - WHEN $13 :: text != '' THEN + WHEN array_length($13 :: text[], 1) > 0 THEN ( SELECT COUNT(*) FROM @@ -22991,7 +22991,7 @@ WHERE latest_build.transition = 'start'::workspace_transition AND -- Filter out deleted sub agents. workspace_agents.deleted = FALSE AND - $13 = ( + ( CASE WHEN workspace_agents.first_connected_at IS NULL THEN CASE @@ -23009,7 +23009,7 @@ WHERE ELSE NULL END - ) + ) = ANY($13 :: text[]) ) > 0 ELSE true END @@ -23074,6 +23074,7 @@ WHERE workspaces.group_acl ? ($23 :: uuid) :: text ELSE true END + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ), filtered_workspaces_order AS ( @@ -23177,7 +23178,7 @@ type GetWorkspacesParams struct { TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` WorkspaceIds []uuid.UUID `db:"workspace_ids" json:"workspace_ids"` Name string `db:"name" json:"name"` - HasAgent string `db:"has_agent" json:"has_agent"` + HasAgentStatuses []string `db:"has_agent_statuses" json:"has_agent_statuses"` AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` Dormant bool `db:"dormant" json:"dormant"` LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` @@ -23255,7 +23256,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) pq.Array(arg.TemplateIDs), pq.Array(arg.WorkspaceIds), arg.Name, - arg.HasAgent, + pq.Array(arg.HasAgentStatuses), arg.AgentInactiveDisconnectTimeoutSeconds, arg.Dormant, arg.LastUsedBefore, diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index c410281870..56bf5de752 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -292,7 +292,7 @@ WHERE -- Filter by agent status -- has-agent: is only applicable for workspaces in "start" transition. Stopped and deleted workspaces don't have agents. AND CASE - WHEN @has_agent :: text != '' THEN + WHEN array_length(@has_agent_statuses :: text[], 1) > 0 THEN ( SELECT COUNT(*) FROM @@ -306,7 +306,7 @@ WHERE latest_build.transition = 'start'::workspace_transition AND -- Filter out deleted sub agents. workspace_agents.deleted = FALSE AND - @has_agent = ( + ( CASE WHEN workspace_agents.first_connected_at IS NULL THEN CASE @@ -324,7 +324,7 @@ WHERE ELSE NULL END - ) + ) = ANY(@has_agent_statuses :: text[]) ) > 0 ELSE true END @@ -389,6 +389,7 @@ WHERE workspaces.group_acl ? (@shared_with_group_id :: uuid) :: text ELSE true END + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ), filtered_workspaces_order AS ( diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index f42d7cfd84..6c34686ff1 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -254,7 +254,7 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder filter.TemplateName = parser.String(values, "", "template") filter.Name = parser.String(values, "", "name") filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus])) - filter.HasAgent = parser.String(values, "", "has-agent") + filter.HasAgentStatuses = parser.Strings(values, []string{}, "has-agent") filter.Dormant = parser.Boolean(values, false, "dormant") filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after") filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before") @@ -273,6 +273,15 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder // TODO: support "me" by passing in the actorID filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user", uuid.Nil) filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group") + // Translate healthy filter to has-agent statuses + // healthy:true = connected, healthy:false = disconnected or timeout + if healthy := parser.NullableBoolean(values, sql.NullBool{}, "healthy"); healthy.Valid { + if healthy.Bool { + filter.HasAgentStatuses = append(filter.HasAgentStatuses, "connected") + } else { + filter.HasAgentStatuses = append(filter.HasAgentStatuses, "disconnected", "timeout") + } + } type paramMatch struct { name string diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 8dba7ce593..7e7196ca93 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -312,6 +312,34 @@ func TestSearchWorkspace(t *testing.T) { }, }, }, + { + Name: "HealthyTrue", + Query: "healthy:true", + Expected: database.GetWorkspacesParams{ + HasAgentStatuses: []string{"connected"}, + }, + }, + { + Name: "HealthyFalse", + Query: "healthy:false", + Expected: database.GetWorkspacesParams{ + HasAgentStatuses: []string{"disconnected", "timeout"}, + }, + }, + { + Name: "HealthyMissing", + Query: "", + Expected: database.GetWorkspacesParams{ + HasAgentStatuses: []string{}, + }, + }, + { + Name: "HealthyAndHasAgent", + Query: "has-agent:connecting healthy:true", + Expected: database.GetWorkspacesParams{ + HasAgentStatuses: []string{"connecting", "connected"}, + }, + }, { Name: "SharedWithUser", Query: `shared_with_user:3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf`, @@ -474,6 +502,10 @@ func TestSearchWorkspace(t *testing.T) { // nil slice vs 0 len slice is equivalent for our purposes. c.Expected.HasParam = values.HasParam } + if len(c.Expected.HasAgentStatuses) == len(values.HasAgentStatuses) { + // nil slice vs 0 len slice is equivalent for our purposes. + c.Expected.HasAgentStatuses = values.HasAgentStatuses + } assert.Len(t, errs, 0, "expected no error") assert.Equal(t, c.Expected, values, "expected values") } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 96cb57fadf..c0dc00ed80 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -141,7 +141,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Produce json // @Tags Workspaces -// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent." +// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy." // @Param limit query int false "Page limit" // @Param offset query int false "Page offset" // @Success 200 {object} codersdk.WorkspacesResponse diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index ceb7a7409e..7078770852 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -2519,6 +2519,152 @@ func TestWorkspaceFilterManual(t *testing.T) { require.Len(t, res.Workspaces, 1) require.Equal(t, workspace.ID, res.Workspaces[0].ID) }) + + t.Run("HealthyFilter", func(t *testing.T) { + t.Parallel() + + t.Run("Healthy", func(t *testing.T) { + t.Parallel() + + // healthy:true should return workspaces with connected agents + // and exclude workspaces with disconnected agents + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + // Create a workspace with a connected agent + connectedBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + Name: "connected-workspace", + }).WithAgent().Do() + + // Mark the agent as connected + now := time.Now() + require.Len(t, connectedBuild.Agents, 1) + //nolint:gocritic // This is a test, we need system context to update agent connection + ctx := dbauthz.AsSystemRestricted(context.Background()) + err := db.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{ + ID: connectedBuild.Agents[0].ID, + FirstConnectedAt: sql.NullTime{Time: now, Valid: true}, + LastConnectedAt: sql.NullTime{Time: now, Valid: true}, + DisconnectedAt: sql.NullTime{}, + UpdatedAt: now, + LastConnectedReplicaID: uuid.NullUUID{}, + }) + require.NoError(t, err) + + // Create a workspace with a disconnected agent + disconnectedBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + Name: "disconnected-workspace", + }).WithAgent().Do() + + // Mark the agent as disconnected + require.Len(t, disconnectedBuild.Agents, 1) + disconnectedTime := now.Add(-time.Hour) + err = db.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{ + ID: disconnectedBuild.Agents[0].ID, + FirstConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true}, + LastConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true}, + DisconnectedAt: sql.NullTime{Time: now, Valid: true}, + UpdatedAt: now, + LastConnectedReplicaID: uuid.NullUUID{}, + }) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // healthy:true should only return the connected workspace + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: "healthy:true", + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, 1) + require.Equal(t, connectedBuild.Workspace.ID, res.Workspaces[0].ID) + }) + + t.Run("Unhealthy", func(t *testing.T) { + t.Parallel() + + // healthy:false should return workspaces with disconnected or timed out agents + // and exclude workspaces with connected agents + store, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: store, + Pubsub: ps, + }) + user := coderdtest.CreateFirstUser(t, client) + now := time.Now() + + //nolint:gocritic // This is a test, we need system context to update agent connection + ctx := dbauthz.AsSystemRestricted(context.Background()) + + // Create a workspace with a connected agent (should be excluded) + connectedBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + Name: "connected-workspace", + }).WithAgent().Do() + require.Len(t, connectedBuild.Agents, 1) + err := store.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{ + ID: connectedBuild.Agents[0].ID, + FirstConnectedAt: sql.NullTime{Time: now, Valid: true}, + LastConnectedAt: sql.NullTime{Time: now, Valid: true}, + DisconnectedAt: sql.NullTime{}, + UpdatedAt: now, + LastConnectedReplicaID: uuid.NullUUID{}, + }) + require.NoError(t, err) + + // Create a workspace with a disconnected agent + disconnectedBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + Name: "disconnected-workspace", + }).WithAgent().Do() + require.Len(t, disconnectedBuild.Agents, 1) + disconnectedTime := now.Add(-time.Hour) + err = store.UpdateWorkspaceAgentConnectionByID(ctx, database.UpdateWorkspaceAgentConnectionByIDParams{ + ID: disconnectedBuild.Agents[0].ID, + FirstConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true}, + LastConnectedAt: sql.NullTime{Time: disconnectedTime, Valid: true}, + DisconnectedAt: sql.NullTime{Time: now, Valid: true}, + UpdatedAt: now, + LastConnectedReplicaID: uuid.NullUUID{}, + }) + require.NoError(t, err) + + // Create a workspace with a timed out agent (never connected, timeout exceeded) + timedOutBuild := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + Name: "timeout-workspace", + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].ConnectionTimeoutSeconds = 1 + return agents + }).Do() + require.Len(t, timedOutBuild.Agents, 1) + // Set created_at to the past so the timeout is exceeded + _, err = sqlDB.ExecContext(ctx, "UPDATE workspace_agents SET created_at = $1 WHERE id = $2", + now.Add(-time.Hour), timedOutBuild.Agents[0].ID) + require.NoError(t, err) + + testCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // healthy:false should return both disconnected and timed out workspaces + res, err := client.Workspaces(testCtx, codersdk.WorkspaceFilter{ + FilterQuery: "healthy:false", + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, 2) + workspaceIDs := []uuid.UUID{res.Workspaces[0].ID, res.Workspaces[1].ID} + require.Contains(t, workspaceIDs, disconnectedBuild.Workspace.ID) + require.Contains(t, workspaceIDs, timedOutBuild.Workspace.ID) + }) + }) t.Run("Params", func(t *testing.T) { t.Parallel() diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 76bb762bde..43bf00bc43 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -981,11 +981,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ ### Parameters -| Name | In | Type | Required | Description | -|----------|-------|---------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `q` | query | string | false | Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent. | -| `limit` | query | integer | false | Page limit | -| `offset` | query | integer | false | Page offset | +| Name | In | Type | Required | Description | +|----------|-------|---------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `q` | query | string | false | Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy. | +| `limit` | query | integer | false | Page limit | +| `offset` | query | integer | false | Page offset | ### Example responses diff --git a/docs/user-guides/workspace-management.md b/docs/user-guides/workspace-management.md index ad9bd3466b..8226c1a7ea 100644 --- a/docs/user-guides/workspace-management.md +++ b/docs/user-guides/workspace-management.md @@ -66,8 +66,9 @@ The following filters are supported: - `dormant` - Filters workspaces based on the dormant state, e.g `dormant:true` - `has-agent` - Only applicable for workspaces in "start" transition. Stopped and deleted workspaces don't have agents. List of supported values - `connecting|connected|timeout`, e.g, `has-agent:connecting` + `connecting|connected|timeout|disconnected`, e.g, `has-agent:connecting` - `id` - Workspace UUID +- `healthy` - Only applicable for workspaces in "start" transition. `healthy:false` is an alias for `has-agent:timeout,disconnected`, `healthy:true` is an alias for `has-agent:connected`. ## Updating workspaces