diff --git a/.vscode/settings.json b/.vscode/settings.json index eaea72e7501b5..7fef4af975bc2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -60,7 +60,5 @@ "typos.config": ".github/workflows/typos.toml", "[markdown]": { "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" - }, - "biome.configurationPath": "./site/biome.jsonc", - "biome.lsp.bin": "./site/node_modules/.bin/biome" + } } diff --git a/cli/provisioners.go b/cli/provisioners.go index 8f90a52589939..77f5e7705edd5 100644 --- a/cli/provisioners.go +++ b/cli/provisioners.go @@ -2,10 +2,12 @@ package cli import ( "fmt" + "time" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -39,7 +41,10 @@ func (r *RootCmd) provisionerList() *serpent.Command { cliui.TableFormat([]provisionerDaemonRow{}, []string{"created at", "last seen at", "key name", "name", "version", "status", "tags"}), cliui.JSONFormat(), ) - limit int64 + limit int64 + offline bool + status []string + maxAge time.Duration ) cmd := &serpent.Command{ @@ -59,7 +64,10 @@ func (r *RootCmd) provisionerList() *serpent.Command { } daemons, err := client.OrganizationProvisionerDaemons(ctx, org.ID, &codersdk.OrganizationProvisionerDaemonsOptions{ - Limit: int(limit), + Limit: int(limit), + Offline: offline, + Status: slice.StringEnums[codersdk.ProvisionerDaemonStatus](status), + MaxAge: maxAge, }) if err != nil { return xerrors.Errorf("list provisioner daemons: %w", err) @@ -98,6 +106,27 @@ func (r *RootCmd) provisionerList() *serpent.Command { Default: "50", Value: serpent.Int64Of(&limit), }, + { + Flag: "show-offline", + FlagShorthand: "f", + Env: "CODER_PROVISIONER_SHOW_OFFLINE", + Description: "Show offline provisioners.", + Value: serpent.BoolOf(&offline), + }, + { + Flag: "status", + FlagShorthand: "s", + Env: "CODER_PROVISIONER_LIST_STATUS", + Description: "Filter by provisioner status.", + Value: serpent.EnumArrayOf(&status, slice.ToStrings(codersdk.ProvisionerDaemonStatusEnums())...), + }, + { + Flag: "max-age", + FlagShorthand: "m", + Env: "CODER_PROVISIONER_LIST_MAX_AGE", + Description: "Filter provisioners by maximum age.", + Value: serpent.DurationOf(&maxAge), + }, }...) orgContext.AttachOptions(cmd) diff --git a/cli/provisioners_test.go b/cli/provisioners_test.go index 0c3fe5ae2f6d1..f70029e7fa366 100644 --- a/cli/provisioners_test.go +++ b/cli/provisioners_test.go @@ -197,6 +197,74 @@ func TestProvisioners_Golden(t *testing.T) { clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) }) + t.Run("list with offline provisioner daemons", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "list", + "--show-offline", + ) + inv.Stdout = &got + clitest.SetupConfig(t, templateAdminClient, root) + err := inv.Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) + }) + + t.Run("list provisioner daemons by status", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "list", + "--status=idle,offline,busy", + ) + inv.Stdout = &got + clitest.SetupConfig(t, templateAdminClient, root) + err := inv.Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) + }) + + t.Run("list provisioner daemons without offline", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "list", + "--status=idle,busy", + ) + inv.Stdout = &got + clitest.SetupConfig(t, templateAdminClient, root) + err := inv.Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) + }) + + t.Run("list provisioner daemons by max age", func(t *testing.T) { + t.Parallel() + + var got bytes.Buffer + inv, root := clitest.New(t, + "provisioners", + "list", + "--max-age=1h", + ) + inv.Stdout = &got + clitest.SetupConfig(t, templateAdminClient, root) + err := inv.Run() + require.NoError(t, err) + + clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) + }) + // Test jobs list with template admin as members are currently // unable to access provisioner jobs. In the future (with RBAC // changes), we may allow them to view _their_ jobs. diff --git a/cli/testdata/TestProvisioners_Golden/list.golden b/cli/testdata/TestProvisioners_Golden/list.golden index 3f50f90746744..8f10eec458f7d 100644 --- a/cli/testdata/TestProvisioners_Golden/list.golden +++ b/cli/testdata/TestProvisioners_Golden/list.golden @@ -1,5 +1,4 @@ -ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION -00000000-0000-0000-aaaa-000000000000 ====[timestamp]===== ====[timestamp]===== default-provisioner v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder -00000000-0000-0000-aaaa-000000000001 ====[timestamp]===== ====[timestamp]===== provisioner-1 v0.0.0 map[foo:bar owner: scope:organization] built-in busy 00000000-0000-0000-bbbb-000000000002 running Coder -00000000-0000-0000-aaaa-000000000002 ====[timestamp]===== ====[timestamp]===== provisioner-2 v0.0.0 map[owner: scope:organization] built-in offline 00000000-0000-0000-bbbb-000000000003 succeeded Coder -00000000-0000-0000-aaaa-000000000003 ====[timestamp]===== ====[timestamp]===== provisioner-3 v0.0.0 map[owner: scope:organization] built-in idle Coder +ID CREATED AT LAST SEEN AT NAME VERSION TAGS KEY NAME STATUS CURRENT JOB ID CURRENT JOB STATUS PREVIOUS JOB ID PREVIOUS JOB STATUS ORGANIZATION +00000000-0000-0000-aaaa-000000000000 ====[timestamp]===== ====[timestamp]===== default-provisioner v0.0.0-devel map[owner: scope:organization] built-in idle 00000000-0000-0000-bbbb-000000000001 succeeded Coder +00000000-0000-0000-aaaa-000000000001 ====[timestamp]===== ====[timestamp]===== provisioner-1 v0.0.0 map[foo:bar owner: scope:organization] built-in busy 00000000-0000-0000-bbbb-000000000002 running Coder +00000000-0000-0000-aaaa-000000000003 ====[timestamp]===== ====[timestamp]===== provisioner-3 v0.0.0 map[owner: scope:organization] built-in idle Coder diff --git a/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_max_age.golden b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_max_age.golden new file mode 100644 index 0000000000000..bc383a839408d --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_max_age.golden @@ -0,0 +1,4 @@ +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +====[timestamp]===== ====[timestamp]===== built-in default-provisioner v0.0.0-devel idle map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-1 v0.0.0 busy map[foo:bar owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-3 v0.0.0 idle map[owner: scope:organization] diff --git a/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden new file mode 100644 index 0000000000000..fd7b966d8d982 --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden @@ -0,0 +1,5 @@ +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +====[timestamp]===== ====[timestamp]===== built-in default-provisioner v0.0.0-devel idle map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-1 v0.0.0 busy map[foo:bar owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-2 v0.0.0 offline map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-3 v0.0.0 idle map[owner: scope:organization] diff --git a/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_without_offline.golden b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_without_offline.golden new file mode 100644 index 0000000000000..bc383a839408d --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_without_offline.golden @@ -0,0 +1,4 @@ +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +====[timestamp]===== ====[timestamp]===== built-in default-provisioner v0.0.0-devel idle map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-1 v0.0.0 busy map[foo:bar owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-3 v0.0.0 idle map[owner: scope:organization] diff --git a/cli/testdata/TestProvisioners_Golden/list_with_offline_provisioner_daemons.golden b/cli/testdata/TestProvisioners_Golden/list_with_offline_provisioner_daemons.golden new file mode 100644 index 0000000000000..fd7b966d8d982 --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_with_offline_provisioner_daemons.golden @@ -0,0 +1,5 @@ +CREATED AT LAST SEEN AT KEY NAME NAME VERSION STATUS TAGS +====[timestamp]===== ====[timestamp]===== built-in default-provisioner v0.0.0-devel idle map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-1 v0.0.0 busy map[foo:bar owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-2 v0.0.0 offline map[owner: scope:organization] +====[timestamp]===== ====[timestamp]===== built-in provisioner-3 v0.0.0 idle map[owner: scope:organization] diff --git a/cli/testdata/coder_provisioner_list_--help.golden b/cli/testdata/coder_provisioner_list_--help.golden index 7a1807bb012f5..ce6d0754073a4 100644 --- a/cli/testdata/coder_provisioner_list_--help.golden +++ b/cli/testdata/coder_provisioner_list_--help.golden @@ -17,8 +17,17 @@ OPTIONS: -l, --limit int, $CODER_PROVISIONER_LIST_LIMIT (default: 50) Limit the number of provisioners returned. + -m, --max-age duration, $CODER_PROVISIONER_LIST_MAX_AGE + Filter provisioners by maximum age. + -o, --output table|json (default: table) Output format. + -f, --show-offline bool, $CODER_PROVISIONER_SHOW_OFFLINE + Show offline provisioners. + + -s, --status [offline|idle|busy], $CODER_PROVISIONER_LIST_STATUS + Filter by provisioner status. + ——— Run `coder --help` for a list of global options. diff --git a/coderd/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index 3ebf99aa6bc4b..aec2d68b71c12 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -41,11 +41,12 @@ func TestUpdateStates(t *testing.T) { Name: "tpl", } workspace = database.Workspace{ - ID: uuid.New(), - OwnerID: user.ID, - TemplateID: template.ID, - Name: "xyz", - TemplateName: template.Name, + ID: uuid.New(), + OwnerID: user.ID, + OwnerUsername: user.Username, + TemplateID: template.ID, + Name: "xyz", + TemplateName: template.Name, } agent = database.WorkspaceAgent{ ID: uuid.New(), @@ -138,9 +139,6 @@ func TestUpdateStates(t *testing.T) { // Workspace gets fetched. dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(workspace, nil) - // User gets fetched to hit the UpdateAgentMetricsFn. - dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) - // We expect an activity bump because ConnectionCount > 0. dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{ WorkspaceID: workspace.ID, @@ -380,9 +378,6 @@ func TestUpdateStates(t *testing.T) { LastUsedAt: now.UTC(), }).Return(nil) - // User gets fetched to hit the UpdateAgentMetricsFn. - dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) - resp, err := api.UpdateStats(context.Background(), req) require.NoError(t, err) require.Equal(t, &agentproto.UpdateStatsResponse{ @@ -498,9 +493,6 @@ func TestUpdateStates(t *testing.T) { LastUsedAt: now, }).Return(nil) - // User gets fetched to hit the UpdateAgentMetricsFn. - dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil) - // Ensure that pubsub notifications are sent. notifyDescription := make(chan struct{}) ps.SubscribeWithErr(wspubsub.WorkspaceEventChannel(workspace.OwnerID), diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index e827ef3f02d24..ac204f85f5603 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -7,6 +7,7 @@ type CheckConstraint string // CheckConstraint enums. const ( CheckOneTimePasscodeSet CheckConstraint = "one_time_passcode_set" // users + CheckUsersUsernameMinLength CheckConstraint = "users_username_min_length" // users CheckMaxProvisionerLogsLength CheckConstraint = "max_provisioner_logs_length" // provisioner_jobs CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index aca22b6dbbb4d..066fe0b1b8847 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1015,7 +1015,8 @@ CREATE TABLE users ( hashed_one_time_passcode bytea, one_time_passcode_expires_at timestamp with time zone, is_system boolean DEFAULT false NOT NULL, - CONSTRAINT one_time_passcode_set CHECK ((((hashed_one_time_passcode IS NULL) AND (one_time_passcode_expires_at IS NULL)) OR ((hashed_one_time_passcode IS NOT NULL) AND (one_time_passcode_expires_at IS NOT NULL)))) + CONSTRAINT one_time_passcode_set CHECK ((((hashed_one_time_passcode IS NULL) AND (one_time_passcode_expires_at IS NULL)) OR ((hashed_one_time_passcode IS NOT NULL) AND (one_time_passcode_expires_at IS NOT NULL)))), + CONSTRAINT users_username_min_length CHECK ((length(username) >= 1)) ); COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.'; diff --git a/coderd/database/migrations/000361_username_length_constraint.down.sql b/coderd/database/migrations/000361_username_length_constraint.down.sql new file mode 100644 index 0000000000000..cb3fccad73098 --- /dev/null +++ b/coderd/database/migrations/000361_username_length_constraint.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +DROP CONSTRAINT IF EXISTS users_username_min_length; diff --git a/coderd/database/migrations/000361_username_length_constraint.up.sql b/coderd/database/migrations/000361_username_length_constraint.up.sql new file mode 100644 index 0000000000000..526d31c0a7246 --- /dev/null +++ b/coderd/database/migrations/000361_username_length_constraint.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE users +ADD CONSTRAINT users_username_min_length +CHECK (length(username) >= 1); diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 0e11886765da6..18c10d6388f37 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -397,6 +397,7 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, IDs: []uuid.UUID{matchingDaemon0.ID, matchingDaemon1.ID}, + Offline: sql.NullBool{Bool: true, Valid: true}, }) require.NoError(t, err) require.Len(t, daemons, 2) @@ -430,6 +431,7 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, Tags: database.StringMap{"foo": "bar"}, + Offline: sql.NullBool{Bool: true, Valid: true}, }) require.NoError(t, err) require.Len(t, daemons, 1) @@ -463,6 +465,7 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, StaleIntervalMS: 45 * time.Minute.Milliseconds(), + Offline: sql.NullBool{Bool: true, Valid: true}, }) require.NoError(t, err) require.Len(t, daemons, 2) @@ -475,6 +478,230 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { require.Equal(t, database.ProvisionerDaemonStatusOffline, daemons[0].Status) require.Equal(t, database.ProvisionerDaemonStatusIdle, daemons[1].Status) }) + + t.Run("ExcludeOffline", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "offline-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-time.Hour), + }, + }) + fooDaemon := dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "foo-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(30 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(30 * time.Minute)), + }, + }) + + daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ + OrganizationID: org.ID, + StaleIntervalMS: 45 * time.Minute.Milliseconds(), + }) + require.NoError(t, err) + require.Len(t, daemons, 1) + + require.Equal(t, fooDaemon.ID, daemons[0].ProvisionerDaemon.ID) + require.Equal(t, database.ProvisionerDaemonStatusIdle, daemons[0].Status) + }) + + t.Run("IncludeOffline", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "offline-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-time.Hour), + }, + }) + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "foo-daemon", + OrganizationID: org.ID, + Tags: database.StringMap{ + "foo": "bar", + }, + }) + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "bar-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(30 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(30 * time.Minute)), + }, + }) + + daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ + OrganizationID: org.ID, + StaleIntervalMS: 45 * time.Minute.Milliseconds(), + Offline: sql.NullBool{Bool: true, Valid: true}, + }) + require.NoError(t, err) + require.Len(t, daemons, 3) + + statusCounts := make(map[database.ProvisionerDaemonStatus]int) + for _, daemon := range daemons { + statusCounts[daemon.Status]++ + } + + require.Equal(t, 2, statusCounts[database.ProvisionerDaemonStatusIdle]) + require.Equal(t, 1, statusCounts[database.ProvisionerDaemonStatusOffline]) + }) + + t.Run("MatchesStatuses", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "offline-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-time.Hour), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-time.Hour), + }, + }) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "foo-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(30 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(30 * time.Minute)), + }, + }) + + type testCase struct { + name string + statuses []database.ProvisionerDaemonStatus + expectedNum int + } + + tests := []testCase{ + { + name: "Get idle and offline", + statuses: []database.ProvisionerDaemonStatus{ + database.ProvisionerDaemonStatusOffline, + database.ProvisionerDaemonStatusIdle, + }, + expectedNum: 2, + }, + { + name: "Get offline", + statuses: []database.ProvisionerDaemonStatus{ + database.ProvisionerDaemonStatusOffline, + }, + expectedNum: 1, + }, + // Offline daemons should not be included without Offline param + { + name: "Get idle - empty statuses", + statuses: []database.ProvisionerDaemonStatus{}, + expectedNum: 1, + }, + { + name: "Get idle - nil statuses", + statuses: nil, + expectedNum: 1, + }, + } + + for _, tc := range tests { + //nolint:tparallel,paralleltest + t.Run(tc.name, func(t *testing.T) { + daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ + OrganizationID: org.ID, + StaleIntervalMS: 45 * time.Minute.Milliseconds(), + Statuses: tc.statuses, + }) + require.NoError(t, err) + require.Len(t, daemons, tc.expectedNum) + }) + } + }) + + t.Run("FilterByMaxAge", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + org := dbgen.Organization(t, db, database.Organization{}) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "foo-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(45 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(45 * time.Minute)), + }, + }) + + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + Name: "bar-daemon", + OrganizationID: org.ID, + CreatedAt: dbtime.Now().Add(-(25 * time.Minute)), + LastSeenAt: sql.NullTime{ + Valid: true, + Time: dbtime.Now().Add(-(25 * time.Minute)), + }, + }) + + type testCase struct { + name string + maxAge sql.NullInt64 + expectedNum int + } + + tests := []testCase{ + { + name: "Max age 1 hour", + maxAge: sql.NullInt64{Int64: time.Hour.Milliseconds(), Valid: true}, + expectedNum: 2, + }, + { + name: "Max age 30 minutes", + maxAge: sql.NullInt64{Int64: (30 * time.Minute).Milliseconds(), Valid: true}, + expectedNum: 1, + }, + { + name: "Max age 15 minutes", + maxAge: sql.NullInt64{Int64: (15 * time.Minute).Milliseconds(), Valid: true}, + expectedNum: 0, + }, + { + name: "No max age", + maxAge: sql.NullInt64{Valid: false}, + expectedNum: 2, + }, + } + for _, tc := range tests { + //nolint:tparallel,paralleltest + t.Run(tc.name, func(t *testing.T) { + daemons, err := db.GetProvisionerDaemonsWithStatusByOrganization(context.Background(), database.GetProvisionerDaemonsWithStatusByOrganizationParams{ + OrganizationID: org.ID, + StaleIntervalMS: 60 * time.Minute.Milliseconds(), + MaxAgeMs: tc.maxAge, + }) + require.NoError(t, err) + require.Len(t, daemons, tc.expectedNum) + }) + } + }) } func TestGetWorkspaceAgentUsageStats(t *testing.T) { @@ -1552,8 +1779,11 @@ func TestUpdateSystemUser(t *testing.T) { // When: attempting to update a system user's name. _, err = db.UpdateUserProfile(ctx, database.UpdateUserProfileParams{ - ID: systemUser.ID, - Name: "not prebuilds", + ID: systemUser.ID, + Email: systemUser.Email, + Username: systemUser.Username, + AvatarURL: systemUser.AvatarURL, + Name: "not prebuilds", }) // Then: the attempt is rejected by a postgres trigger. // require.ErrorContains(t, err, "Cannot modify or delete system users") diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 11d129b435e3e..3a41cf63c1630 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8263,13 +8263,13 @@ const getProvisionerDaemonsWithStatusByOrganization = `-- name: GetProvisionerDa SELECT pd.id, pd.created_at, pd.name, pd.provisioners, pd.replica_id, pd.tags, pd.last_seen_at, pd.version, pd.api_version, pd.organization_id, pd.key_id, CASE - WHEN pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($1::bigint || ' ms')::interval) - THEN 'offline' - ELSE CASE - WHEN current_job.id IS NOT NULL THEN 'busy' - ELSE 'idle' - END - END::provisioner_daemon_status AS status, + WHEN current_job.id IS NOT NULL THEN 'busy'::provisioner_daemon_status + WHEN (COALESCE($1::bool, false) = true + OR 'offline'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[])) + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($3::bigint || ' ms')::interval)) + THEN 'offline'::provisioner_daemon_status + ELSE 'idle'::provisioner_daemon_status + END AS status, pk.name AS key_name, -- NOTE(mafredri): sqlc.embed doesn't support nullable tables nor renaming them. current_job.id AS current_job_id, @@ -8336,21 +8336,56 @@ LEFT JOIN AND previous_template.organization_id = pd.organization_id ) WHERE - pd.organization_id = $2::uuid - AND (COALESCE(array_length($3::uuid[], 1), 0) = 0 OR pd.id = ANY($3::uuid[])) - AND ($4::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, $4::tagset)) + pd.organization_id = $4::uuid + AND (COALESCE(array_length($5::uuid[], 1), 0) = 0 OR pd.id = ANY($5::uuid[])) + AND ($6::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, $6::tagset)) + -- Filter by max age if provided + AND ( + $7::bigint IS NULL + OR pd.last_seen_at IS NULL + OR pd.last_seen_at >= (NOW() - ($7::bigint || ' ms')::interval) + ) + AND ( + -- Always include online daemons + (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - ($3::bigint || ' ms')::interval)) + -- Include offline daemons if offline param is true or 'offline' status is requested + OR ( + (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($3::bigint || ' ms')::interval)) + AND ( + COALESCE($1::bool, false) = true + OR 'offline'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[]) + ) + ) + ) + AND ( + -- Filter daemons by any statuses if provided + COALESCE(array_length($2::provisioner_daemon_status[], 1), 0) = 0 + OR (current_job.id IS NOT NULL AND 'busy'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[])) + OR (current_job.id IS NULL AND 'idle'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[])) + OR ( + 'offline'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[]) + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($3::bigint || ' ms')::interval)) + ) + OR ( + COALESCE($1::bool, false) = true + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($3::bigint || ' ms')::interval)) + ) + ) ORDER BY pd.created_at DESC LIMIT - $5::int + $8::int ` type GetProvisionerDaemonsWithStatusByOrganizationParams struct { - StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - IDs []uuid.UUID `db:"ids" json:"ids"` - Tags StringMap `db:"tags" json:"tags"` - Limit sql.NullInt32 `db:"limit" json:"limit"` + Offline sql.NullBool `db:"offline" json:"offline"` + Statuses []ProvisionerDaemonStatus `db:"statuses" json:"statuses"` + StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + IDs []uuid.UUID `db:"ids" json:"ids"` + Tags StringMap `db:"tags" json:"tags"` + MaxAgeMs sql.NullInt64 `db:"max_age_ms" json:"max_age_ms"` + Limit sql.NullInt32 `db:"limit" json:"limit"` } type GetProvisionerDaemonsWithStatusByOrganizationRow struct { @@ -8373,10 +8408,13 @@ type GetProvisionerDaemonsWithStatusByOrganizationRow struct { // Previous job information. func (q *sqlQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.Context, arg GetProvisionerDaemonsWithStatusByOrganizationParams) ([]GetProvisionerDaemonsWithStatusByOrganizationRow, error) { rows, err := q.db.QueryContext(ctx, getProvisionerDaemonsWithStatusByOrganization, + arg.Offline, + pq.Array(arg.Statuses), arg.StaleIntervalMS, arg.OrganizationID, pq.Array(arg.IDs), arg.Tags, + arg.MaxAgeMs, arg.Limit, ) if err != nil { diff --git a/coderd/database/queries/provisionerdaemons.sql b/coderd/database/queries/provisionerdaemons.sql index 4f7c7a8b2200a..ad6c0948eb448 100644 --- a/coderd/database/queries/provisionerdaemons.sql +++ b/coderd/database/queries/provisionerdaemons.sql @@ -32,13 +32,13 @@ WHERE SELECT sqlc.embed(pd), CASE - WHEN pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval) - THEN 'offline' - ELSE CASE - WHEN current_job.id IS NOT NULL THEN 'busy' - ELSE 'idle' - END - END::provisioner_daemon_status AS status, + WHEN current_job.id IS NOT NULL THEN 'busy'::provisioner_daemon_status + WHEN (COALESCE(sqlc.narg('offline')::bool, false) = true + OR 'offline'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[])) + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + THEN 'offline'::provisioner_daemon_status + ELSE 'idle'::provisioner_daemon_status + END AS status, pk.name AS key_name, -- NOTE(mafredri): sqlc.embed doesn't support nullable tables nor renaming them. current_job.id AS current_job_id, @@ -110,6 +110,38 @@ WHERE pd.organization_id = @organization_id::uuid AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pd.id = ANY(@ids::uuid[])) AND (@tags::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, @tags::tagset)) + -- Filter by max age if provided + AND ( + sqlc.narg('max_age_ms')::bigint IS NULL + OR pd.last_seen_at IS NULL + OR pd.last_seen_at >= (NOW() - (sqlc.narg('max_age_ms')::bigint || ' ms')::interval) + ) + AND ( + -- Always include online daemons + (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + -- Include offline daemons if offline param is true or 'offline' status is requested + OR ( + (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + AND ( + COALESCE(sqlc.narg('offline')::bool, false) = true + OR 'offline'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[]) + ) + ) + ) + AND ( + -- Filter daemons by any statuses if provided + COALESCE(array_length(@statuses::provisioner_daemon_status[], 1), 0) = 0 + OR (current_job.id IS NOT NULL AND 'busy'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[])) + OR (current_job.id IS NULL AND 'idle'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[])) + OR ( + 'offline'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[]) + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + ) + OR ( + COALESCE(sqlc.narg('offline')::bool, false) = true + AND (pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + ) + ) ORDER BY pd.created_at DESC LIMIT diff --git a/coderd/database/sdk2db/sdk2db.go b/coderd/database/sdk2db/sdk2db.go new file mode 100644 index 0000000000000..02fe8578179c9 --- /dev/null +++ b/coderd/database/sdk2db/sdk2db.go @@ -0,0 +1,16 @@ +// Package sdk2db provides common conversion routines from codersdk types to database types +package sdk2db + +import ( + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/codersdk" +) + +func ProvisionerDaemonStatus(status codersdk.ProvisionerDaemonStatus) database.ProvisionerDaemonStatus { + return database.ProvisionerDaemonStatus(status) +} + +func ProvisionerDaemonStatuses(params []codersdk.ProvisionerDaemonStatus) []database.ProvisionerDaemonStatus { + return db2sdk.List(params, ProvisionerDaemonStatus) +} diff --git a/coderd/database/sdk2db/sdk2db_test.go b/coderd/database/sdk2db/sdk2db_test.go new file mode 100644 index 0000000000000..ff51dc0ffaaf4 --- /dev/null +++ b/coderd/database/sdk2db/sdk2db_test.go @@ -0,0 +1,36 @@ +package sdk2db_test + +import ( + "testing" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/sdk2db" + "github.com/coder/coder/v2/codersdk" +) + +func TestProvisionerDaemonStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input codersdk.ProvisionerDaemonStatus + expect database.ProvisionerDaemonStatus + }{ + {"busy", codersdk.ProvisionerDaemonBusy, database.ProvisionerDaemonStatusBusy}, + {"offline", codersdk.ProvisionerDaemonOffline, database.ProvisionerDaemonStatusOffline}, + {"idle", codersdk.ProvisionerDaemonIdle, database.ProvisionerDaemonStatusIdle}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := sdk2db.ProvisionerDaemonStatus(tc.input) + if !got.Valid() { + t.Errorf("ProvisionerDaemonStatus(%v) returned invalid status", tc.input) + } + if got != tc.expect { + t.Errorf("ProvisionerDaemonStatus(%v) = %v; want %v", tc.input, got, tc.expect) + } + }) + } +} diff --git a/coderd/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index 0e4a20920e526..e1bd983ea12a3 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -287,6 +287,29 @@ func (p *QueryParamParser) JSONStringMap(vals url.Values, def map[string]string, return v } +func (p *QueryParamParser) ProvisionerDaemonStatuses(vals url.Values, def []codersdk.ProvisionerDaemonStatus, queryParam string) []codersdk.ProvisionerDaemonStatus { + return ParseCustomList(p, vals, def, queryParam, func(v string) (codersdk.ProvisionerDaemonStatus, error) { + return codersdk.ProvisionerDaemonStatus(v), nil + }) +} + +func (p *QueryParamParser) Duration(vals url.Values, def time.Duration, queryParam string) time.Duration { + v, err := parseQueryParam(p, vals, func(v string) (time.Duration, error) { + d, err := time.ParseDuration(v) + if err != nil { + return 0, err + } + return d, nil + }, def, queryParam) + if err != nil { + p.Errors = append(p.Errors, codersdk.ValidationError{ + Field: queryParam, + Detail: fmt.Sprintf("Query param %q must be a valid duration (e.g., '24h', '30m', '1h30m'): %s", queryParam, err.Error()), + }) + } + return v +} + // ValidEnum represents an enum that can be parsed and validated. type ValidEnum interface { // Add more types as needed (avoid importing large dependency trees). diff --git a/coderd/prometheusmetrics/prometheusmetrics.go b/coderd/prometheusmetrics/prometheusmetrics.go index cda274145e159..6ea8615f3779a 100644 --- a/coderd/prometheusmetrics/prometheusmetrics.go +++ b/coderd/prometheusmetrics/prometheusmetrics.go @@ -328,29 +328,24 @@ func Agents(ctx context.Context, logger slog.Logger, registerer prometheus.Regis templateVersionName = "unknown" } - user, err := db.GetUserByID(ctx, workspace.OwnerID) - if err != nil { - logger.Error(ctx, "can't get user from the database", slog.F("user_id", workspace.OwnerID), slog.Error(err)) - agentsGauge.WithLabelValues(VectorOperationAdd, 0, user.Username, workspace.Name, templateName, templateVersionName) - continue - } + // username := agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID) if err != nil { logger.Error(ctx, "can't get workspace agents", slog.F("workspace_id", workspace.ID), slog.Error(err)) - agentsGauge.WithLabelValues(VectorOperationAdd, 0, user.Username, workspace.Name, templateName, templateVersionName) + agentsGauge.WithLabelValues(VectorOperationAdd, 0, workspace.OwnerUsername, workspace.Name, templateName, templateVersionName) continue } if len(agents) == 0 { logger.Debug(ctx, "workspace agents are unavailable", slog.F("workspace_id", workspace.ID)) - agentsGauge.WithLabelValues(VectorOperationAdd, 0, user.Username, workspace.Name, templateName, templateVersionName) + agentsGauge.WithLabelValues(VectorOperationAdd, 0, workspace.OwnerUsername, workspace.Name, templateName, templateVersionName) continue } for _, agent := range agents { // Collect information about agents - agentsGauge.WithLabelValues(VectorOperationAdd, 1, user.Username, workspace.Name, templateName, templateVersionName) + agentsGauge.WithLabelValues(VectorOperationAdd, 1, workspace.OwnerUsername, workspace.Name, templateName, templateVersionName) connectionStatus := agent.Status(agentInactiveDisconnectTimeout) node := (*coordinator.Load()).Node(agent.ID) @@ -360,7 +355,7 @@ func Agents(ctx context.Context, logger slog.Logger, registerer prometheus.Regis tailnetNode = node.ID.String() } - agentsConnectionsGauge.WithLabelValues(VectorOperationSet, 1, agent.Name, user.Username, workspace.Name, string(connectionStatus.Status), string(agent.LifecycleState), tailnetNode) + agentsConnectionsGauge.WithLabelValues(VectorOperationSet, 1, agent.Name, workspace.OwnerUsername, workspace.Name, string(connectionStatus.Status), string(agent.LifecycleState), tailnetNode) if node == nil { logger.Debug(ctx, "can't read in-memory node for agent", slog.F("agent_id", agent.ID)) @@ -385,7 +380,7 @@ func Agents(ctx context.Context, logger slog.Logger, registerer prometheus.Regis } } - agentsConnectionLatenciesGauge.WithLabelValues(VectorOperationSet, latency, agent.Name, user.Username, workspace.Name, region.RegionName, fmt.Sprintf("%v", node.PreferredDERP == regionID)) + agentsConnectionLatenciesGauge.WithLabelValues(VectorOperationSet, latency, agent.Name, workspace.OwnerUsername, workspace.Name, region.RegionName, fmt.Sprintf("%v", node.PreferredDERP == regionID)) } } @@ -397,7 +392,7 @@ func Agents(ctx context.Context, logger slog.Logger, registerer prometheus.Regis } for _, app := range apps { - agentsAppsGauge.WithLabelValues(VectorOperationAdd, 1, agent.Name, user.Username, workspace.Name, app.DisplayName, string(app.Health)) + agentsAppsGauge.WithLabelValues(VectorOperationAdd, 1, agent.Name, workspace.OwnerUsername, workspace.Name, app.DisplayName, string(app.Health)) } } } diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 332ae3b352e0a..67a40b88f69e9 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -6,6 +6,7 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/sdk2db" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/provisionerdserver" @@ -45,6 +46,9 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { limit := p.PositiveInt32(qp, 50, "limit") ids := p.UUIDs(qp, nil, "ids") tags := p.JSONStringMap(qp, database.StringMap{}, "tags") + includeOffline := p.NullableBoolean(qp, sql.NullBool{}, "offline") + statuses := p.ProvisionerDaemonStatuses(qp, []codersdk.ProvisionerDaemonStatus{}, "status") + maxAge := p.Duration(qp, 0, "max_age") p.ErrorExcessParams(qp) if len(p.Errors) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -54,12 +58,17 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { return } + dbStatuses := sdk2db.ProvisionerDaemonStatuses(statuses) + daemons, err := api.Database.GetProvisionerDaemonsWithStatusByOrganization( ctx, database.GetProvisionerDaemonsWithStatusByOrganizationParams{ OrganizationID: org.ID, StaleIntervalMS: provisionerdserver.StaleInterval.Milliseconds(), Limit: sql.NullInt32{Int32: limit, Valid: limit > 0}, + Offline: includeOffline, + Statuses: dbStatuses, + MaxAgeMs: sql.NullInt64{Int64: maxAge.Milliseconds(), Valid: maxAge > 0}, IDs: ids, Tags: tags, }, diff --git a/coderd/provisionerdaemons_test.go b/coderd/provisionerdaemons_test.go index 249da9d6bc922..8bbaca551a151 100644 --- a/coderd/provisionerdaemons_test.go +++ b/coderd/provisionerdaemons_test.go @@ -146,7 +146,9 @@ func TestProvisionerDaemons(t *testing.T) { t.Run("Default limit", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) - daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, nil) + daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{ + Offline: true, + }) require.NoError(t, err) require.Len(t, daemons, 50) }) @@ -155,7 +157,8 @@ func TestProvisionerDaemons(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{ - IDs: []uuid.UUID{pd1.ID, pd2.ID}, + IDs: []uuid.UUID{pd1.ID, pd2.ID}, + Offline: true, }) require.NoError(t, err) require.Len(t, daemons, 2) @@ -167,7 +170,8 @@ func TestProvisionerDaemons(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{ - Tags: map[string]string{"count": "1"}, + Tags: map[string]string{"count": "1"}, + Offline: true, }) require.NoError(t, err) require.Len(t, daemons, 1) @@ -209,7 +213,8 @@ func TestProvisionerDaemons(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{ - IDs: []uuid.UUID{pd2.ID}, + IDs: []uuid.UUID{pd2.ID}, + Offline: true, }) require.NoError(t, err) require.Len(t, daemons, 1) diff --git a/coderd/workspacestats/reporter.go b/coderd/workspacestats/reporter.go index f6b8a8dd0953b..7a6b1d50034a8 100644 --- a/coderd/workspacestats/reporter.go +++ b/coderd/workspacestats/reporter.go @@ -126,13 +126,8 @@ func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspac // update prometheus metrics if r.opts.UpdateAgentMetricsFn != nil { - user, err := r.opts.Database.GetUserByID(ctx, workspace.OwnerID) - if err != nil { - return xerrors.Errorf("get user: %w", err) - } - r.opts.UpdateAgentMetricsFn(ctx, prometheusmetrics.AgentMetricLabels{ - Username: user.Username, + Username: workspace.OwnerUsername, WorkspaceName: workspace.Name, AgentName: workspaceAgent.Name, TemplateName: templateName, diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f87d0eae188ba..bca87c7bd4591 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -344,9 +344,12 @@ func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, e } type OrganizationProvisionerDaemonsOptions struct { - Limit int - IDs []uuid.UUID - Tags map[string]string + Limit int + Offline bool + Status []ProvisionerDaemonStatus + MaxAge time.Duration + IDs []uuid.UUID + Tags map[string]string } func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizationID uuid.UUID, opts *OrganizationProvisionerDaemonsOptions) ([]ProvisionerDaemon, error) { @@ -355,6 +358,15 @@ func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizatio if opts.Limit > 0 { qp.Add("limit", strconv.Itoa(opts.Limit)) } + if opts.Offline { + qp.Add("offline", "true") + } + if len(opts.Status) > 0 { + qp.Add("status", joinSlice(opts.Status)) + } + if opts.MaxAge > 0 { + qp.Add("max_age", opts.MaxAge.String()) + } if len(opts.IDs) > 0 { qp.Add("ids", joinSliceStringer(opts.IDs)) } diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index e36f995f1688e..4bff7d7827aa1 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -49,6 +49,14 @@ const ( ProvisionerDaemonBusy ProvisionerDaemonStatus = "busy" ) +func ProvisionerDaemonStatusEnums() []ProvisionerDaemonStatus { + return []ProvisionerDaemonStatus{ + ProvisionerDaemonOffline, + ProvisionerDaemonIdle, + ProvisionerDaemonBusy, + } +} + type ProvisionerDaemon struct { ID uuid.UUID `json:"id" format:"uuid" table:"id"` OrganizationID uuid.UUID `json:"organization_id" format:"uuid" table:"organization id"` diff --git a/docs/reference/cli/provisioner_list.md b/docs/reference/cli/provisioner_list.md index 128d76caf4c7e..aa67dcd815f67 100644 --- a/docs/reference/cli/provisioner_list.md +++ b/docs/reference/cli/provisioner_list.md @@ -25,6 +25,33 @@ coder provisioner list [flags] Limit the number of provisioners returned. +### -f, --show-offline + +| | | +|-------------|----------------------------------------------| +| Type | bool | +| Environment | $CODER_PROVISIONER_SHOW_OFFLINE | + +Show offline provisioners. + +### -s, --status + +| | | +|-------------|---------------------------------------------| +| Type | [offline\|idle\|busy] | +| Environment | $CODER_PROVISIONER_LIST_STATUS | + +Filter by provisioner status. + +### -m, --max-age + +| | | +|-------------|----------------------------------------------| +| Type | duration | +| Environment | $CODER_PROVISIONER_LIST_MAX_AGE | + +Filter provisioners by maximum age. + ### -O, --org | | | diff --git a/enterprise/cli/testdata/coder_provisioner_list_--help.golden b/enterprise/cli/testdata/coder_provisioner_list_--help.golden index 7a1807bb012f5..ce6d0754073a4 100644 --- a/enterprise/cli/testdata/coder_provisioner_list_--help.golden +++ b/enterprise/cli/testdata/coder_provisioner_list_--help.golden @@ -17,8 +17,17 @@ OPTIONS: -l, --limit int, $CODER_PROVISIONER_LIST_LIMIT (default: 50) Limit the number of provisioners returned. + -m, --max-age duration, $CODER_PROVISIONER_LIST_MAX_AGE + Filter provisioners by maximum age. + -o, --output table|json (default: table) Output format. + -f, --show-offline bool, $CODER_PROVISIONER_SHOW_OFFLINE + Show offline provisioners. + + -s, --status [offline|idle|busy], $CODER_PROVISIONER_LIST_STATUS + Filter by provisioner status. + ——— Run `coder --help` for a list of global options. diff --git a/enterprise/coderd/dormancy/dormantusersjob.go b/enterprise/coderd/dormancy/dormantusersjob.go index cae442ce07507..d331001a560ff 100644 --- a/enterprise/coderd/dormancy/dormantusersjob.go +++ b/enterprise/coderd/dormancy/dormantusersjob.go @@ -37,12 +37,13 @@ func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, clk ctx, cancelFunc := context.WithCancel(ctx) tf := clk.TickerFunc(ctx, checkInterval, func() error { startTime := time.Now() - lastSeenAfter := dbtime.Now().Add(-dormancyPeriod) + now := dbtime.Time(clk.Now()).UTC() + lastSeenAfter := now.Add(-dormancyPeriod) logger.Debug(ctx, "check inactive user accounts", slog.F("dormancy_period", dormancyPeriod), slog.F("last_seen_after", lastSeenAfter)) updatedUsers, err := db.UpdateInactiveUsersToDormant(ctx, database.UpdateInactiveUsersToDormantParams{ LastSeenAfter: lastSeenAfter, - UpdatedAt: dbtime.Now(), + UpdatedAt: now, }) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { logger.Error(ctx, "can't mark inactive users as dormant", slog.Error(err)) diff --git a/enterprise/coderd/dormancy/dormantusersjob_test.go b/enterprise/coderd/dormancy/dormantusersjob_test.go index e5e5276fe67a9..885a112c6141a 100644 --- a/enterprise/coderd/dormancy/dormantusersjob_test.go +++ b/enterprise/coderd/dormancy/dormantusersjob_test.go @@ -31,20 +31,28 @@ func TestCheckInactiveUsers(t *testing.T) { ctx, cancelFunc := context.WithCancel(context.Background()) t.Cleanup(cancelFunc) - inactiveUser1 := setupUser(ctx, t, db, "dormant-user-1@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-time.Minute)) - inactiveUser2 := setupUser(ctx, t, db, "dormant-user-2@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-time.Hour)) - inactiveUser3 := setupUser(ctx, t, db, "dormant-user-3@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-6*time.Hour)) + // Use a fixed base time to avoid timing races + baseTime := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + dormancyThreshold := baseTime.Add(-dormancyPeriod) - activeUser1 := setupUser(ctx, t, db, "active-user-1@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(time.Minute)) - activeUser2 := setupUser(ctx, t, db, "active-user-2@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(time.Hour)) - activeUser3 := setupUser(ctx, t, db, "active-user-3@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(6*time.Hour)) + // Create inactive users (last seen BEFORE dormancy threshold) + inactiveUser1 := setupUser(ctx, t, db, "dormant-user-1@coder.com", database.UserStatusActive, dormancyThreshold.Add(-time.Minute)) + inactiveUser2 := setupUser(ctx, t, db, "dormant-user-2@coder.com", database.UserStatusActive, dormancyThreshold.Add(-time.Hour)) + inactiveUser3 := setupUser(ctx, t, db, "dormant-user-3@coder.com", database.UserStatusActive, dormancyThreshold.Add(-6*time.Hour)) - suspendedUser1 := setupUser(ctx, t, db, "suspended-user-1@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-time.Minute)) - suspendedUser2 := setupUser(ctx, t, db, "suspended-user-2@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-time.Hour)) - suspendedUser3 := setupUser(ctx, t, db, "suspended-user-3@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-6*time.Hour)) + // Create active users (last seen AFTER dormancy threshold) + activeUser1 := setupUser(ctx, t, db, "active-user-1@coder.com", database.UserStatusActive, baseTime.Add(-time.Minute)) + activeUser2 := setupUser(ctx, t, db, "active-user-2@coder.com", database.UserStatusActive, baseTime.Add(-time.Hour)) + activeUser3 := setupUser(ctx, t, db, "active-user-3@coder.com", database.UserStatusActive, baseTime.Add(-6*time.Hour)) + + suspendedUser1 := setupUser(ctx, t, db, "suspended-user-1@coder.com", database.UserStatusSuspended, dormancyThreshold.Add(-time.Minute)) + suspendedUser2 := setupUser(ctx, t, db, "suspended-user-2@coder.com", database.UserStatusSuspended, dormancyThreshold.Add(-time.Hour)) + suspendedUser3 := setupUser(ctx, t, db, "suspended-user-3@coder.com", database.UserStatusSuspended, dormancyThreshold.Add(-6*time.Hour)) mAudit := audit.NewMock() mClock := quartz.NewMock(t) + // Set the mock clock to the base time to ensure consistent behavior + mClock.Set(baseTime) // Run the periodic job closeFunc := dormancy.CheckInactiveUsersWithOptions(ctx, logger, mClock, db, mAudit, interval, dormancyPeriod) t.Cleanup(closeFunc) diff --git a/go.mod b/go.mod index 7c2dd7bc02f48..3f9d92aa54c0e 100644 --- a/go.mod +++ b/go.mod @@ -309,7 +309,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-test/deep v1.1.0 // indirect - github.com/go-viper/mapstructure/v2 v2.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect diff --git a/go.sum b/go.sum index bf33f1772dcd0..4bc0e0336ab06 100644 --- a/go.sum +++ b/go.sum @@ -1154,8 +1154,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= -github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 966c8902c3e73..d95d644ef7678 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -21,6 +21,7 @@ */ import globalAxios, { type AxiosInstance, isAxiosError } from "axios"; import type dayjs from "dayjs"; +import type { Task } from "modules/tasks/tasks"; import userAgentParser from "ua-parser-js"; import { delay } from "../utils/delay"; import { OneWayWebSocket } from "../utils/OneWayWebSocket"; @@ -420,6 +421,12 @@ export type GetProvisionerDaemonsParams = { // Stringified JSON Object tags?: string; limit?: number; + // Include offline provisioner daemons? + offline?: boolean; +}; + +export type TasksFilter = { + username?: string; }; /** @@ -1187,9 +1194,9 @@ class ApiMethods { }; getWorkspaces = async ( - options: TypesGen.WorkspacesRequest, + req: TypesGen.WorkspacesRequest, ): Promise => { - const url = getURLWithSearchParams("/api/v2/workspaces", options); + const url = getURLWithSearchParams("/api/v2/workspaces", req); const response = await this.axios.get(url); return response.data; }; @@ -2687,6 +2694,26 @@ class ExperimentalApiMethods { return response.data; }; + + getTasks = async (filter: TasksFilter): Promise => { + const queryExpressions = ["has-ai-task:true"]; + + if (filter.username) { + queryExpressions.push(`owner:${filter.username}`); + } + + const workspaces = await API.getWorkspaces({ + q: queryExpressions.join(" "), + }); + const prompts = await API.experimental.getAITasksPrompts( + workspaces.workspaces.map((workspace) => workspace.latest_build.id), + ); + + return workspaces.workspaces.map((workspace) => ({ + workspace, + prompt: prompts.prompts[workspace.latest_build.id], + })); + }; } // This is a hard coded CSRF token/cookie pair for local development. In prod, diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index bcfb07b75452b..1c3e82a8816c2 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -139,15 +139,14 @@ async function findMatchWorkspace(q: string): Promise { } } -function workspacesKey(config: WorkspacesRequest = {}) { - const { q, limit } = config; - return ["workspaces", { q, limit }] as const; +function workspacesKey(req: WorkspacesRequest = {}) { + return ["workspaces", req] as const; } -export function workspaces(config: WorkspacesRequest = {}) { +export function workspaces(req: WorkspacesRequest = {}) { return { - queryKey: workspacesKey(config), - queryFn: () => API.getWorkspaces(config), + queryKey: workspacesKey(req), + queryFn: () => API.getWorkspaces(req), } as const satisfies QueryOptions; } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index db840040687fc..a6610e3327cbe 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1840,6 +1840,9 @@ export interface OrganizationMemberWithUserData extends OrganizationMember { // From codersdk/organizations.go export interface OrganizationProvisionerDaemonsOptions { readonly Limit: number; + readonly Offline: boolean; + readonly Status: readonly ProvisionerDaemonStatus[]; + readonly MaxAge: number; readonly IDs: readonly string[]; readonly Tags: Record; } diff --git a/site/src/components/Badge/Badge.tsx b/site/src/components/Badge/Badge.tsx index a042b5cf7203c..c3d0b27475bf2 100644 --- a/site/src/components/Badge/Badge.tsx +++ b/site/src/components/Badge/Badge.tsx @@ -24,6 +24,7 @@ const badgeVariants = cva( "border border-solid border-border-destructive bg-surface-red text-highlight-red shadow", green: "border border-solid border-surface-green bg-surface-green text-highlight-green shadow", + info: "border border-solid border-surface-sky bg-surface-sky text-highlight-sky shadow", }, size: { xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5", diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx index 997621cdece10..95db66f2c41c4 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx @@ -20,6 +20,7 @@ const OrganizationProvisionersPage: FC = () => { const queryParams = { ids: searchParams.get("ids") ?? "", tags: searchParams.get("tags") ?? "", + offline: searchParams.get("offline") === "true", }; const { organization, organizationPermissions } = useOrganizationSettings(); const { entitlements } = useDashboard(); @@ -66,7 +67,12 @@ const OrganizationProvisionersPage: FC = () => { buildVersion={buildInfoQuery.data?.version} onRetry={provisionersQuery.refetch} filter={queryParams} - onFilterChange={setSearchParams} + onFilterChange={({ ids, offline }) => { + setSearchParams({ + ids, + offline: offline.toString(), + }); + }} /> ); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx index d1bcd7fbcb816..8dba15b4d8856 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx @@ -23,9 +23,14 @@ const meta: Meta = { ...MockProvisionerWithTags, version: "0.0.0", }, + { + ...MockUserProvisioner, + status: "offline", + }, ], filter: { ids: "", + offline: true, }, }, }; @@ -69,6 +74,17 @@ export const FilterByID: Story = { provisioners: [MockProvisioner], filter: { ids: MockProvisioner.id, + offline: true, + }, + }, +}; + +export const FilterByOffline: Story = { + args: { + provisioners: [MockProvisioner], + filter: { + ids: "", + offline: false, }, }, }; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx index 387baf31519cb..ac6e45aed24cf 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -1,6 +1,7 @@ import type { ProvisionerDaemon } from "api/typesGenerated"; import { Badge } from "components/Badge/Badge"; import { Button } from "components/Button/Button"; +import { Checkbox } from "components/Checkbox/Checkbox"; import { EmptyState } from "components/EmptyState/EmptyState"; import { Link } from "components/Link/Link"; import { Loader } from "components/Loader/Loader"; @@ -24,7 +25,7 @@ import { TooltipProvider, TooltipTrigger, } from "components/Tooltip/Tooltip"; -import { SquareArrowOutUpRightIcon, XIcon } from "lucide-react"; +import { XIcon } from "lucide-react"; import type { FC } from "react"; import { docs } from "utils/docs"; import { LastConnectionHead } from "./LastConnectionHead"; @@ -32,6 +33,7 @@ import { ProvisionerRow } from "./ProvisionerRow"; type ProvisionersFilter = { ids: string; + offline: boolean; }; interface OrganizationProvisionersPageViewProps { @@ -102,70 +104,89 @@ export const OrganizationProvisionersPageView: FC< documentationLink={docs("/")} /> ) : ( - - - - Name - Key - Version - Status - Tags - - - - - - - {provisioners ? ( - provisioners.length > 0 ? ( - provisioners.map((provisioner) => ( - - )) - ) : ( + <> +
+ { + onFilterChange({ + ...filter, + offline: checked === true, + }); + }} + /> + +
+
+ + + Name + Key + Version + Status + Tags + + + + + + + {provisioners ? ( + provisioners.length > 0 ? ( + provisioners.map((provisioner) => ( + + )) + ) : ( + + + + + Create a provisioner + + + } + /> + + + ) + ) : error ? ( - - Create a provisioner - - + } /> - ) - ) : error ? ( - - - - Retry - - } - /> - - - ) : ( - - - - - - )} - -
+ ) : ( + + + + + + )} + + + )} ); diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 7017986c7b686..4a65c6f1be993 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -2,25 +2,28 @@ import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; import { template as templateQueryOptions } from "api/queries/templates"; import type { Workspace, WorkspaceStatus } from "api/typesGenerated"; +import isChromatic from "chromatic/isChromatic"; import { Button } from "components/Button/Button"; import { Loader } from "components/Loader/Loader"; import { Margins } from "components/Margins/Margins"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs"; import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react"; import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; -import type { ReactNode } from "react"; +import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; +import { type FC, type ReactNode, useEffect, useRef } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { Link as RouterLink, useParams } from "react-router"; -import { ellipsizeText } from "utils/ellipsizeText"; import { pageTitle } from "utils/page"; import { - ActiveTransition, + getActiveTransitionStats, WorkspaceBuildProgress, } from "../WorkspacePage/WorkspaceBuildProgress"; import { TaskApps } from "./TaskApps"; import { TaskSidebar } from "./TaskSidebar"; +import { TaskTopbar } from "./TaskTopbar"; const TaskPage = () => { const { workspace: workspaceName, username } = useParams() as { @@ -37,18 +40,7 @@ const TaskPage = () => { refetchInterval: 5_000, }); - const { data: template } = useQuery({ - ...templateQueryOptions(task?.workspace.template_id ?? ""), - enabled: Boolean(task), - }); - const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"]; - const shouldStreamBuildLogs = - task && waitingStatuses.includes(task.workspace.latest_build.status); - const buildLogs = useWorkspaceBuildLogs( - task?.workspace.latest_build.id ?? "", - shouldStreamBuildLogs, - ); if (error) { return ( @@ -95,38 +87,9 @@ const TaskPage = () => { } let content: ReactNode = null; - const _terminatedStatuses: WorkspaceStatus[] = [ - "canceled", - "canceling", - "deleted", - "deleting", - "stopped", - "stopping", - ]; if (waitingStatuses.includes(task.workspace.latest_build.status)) { - // If no template yet, use an indeterminate progress bar. - const transition = (template && - ActiveTransition(template, task.workspace)) || { P50: 0, P95: null }; - const lastStage = - buildLogs?.[buildLogs.length - 1]?.stage || "Waiting for build status"; - content = ( -
-
-

- Starting your workspace -

-
{lastStage}
-
-
- -
-
- ); + content = ; } else if (task.workspace.latest_build.status === "failed") { content = (
@@ -170,14 +133,7 @@ const TaskPage = () => { ); } else { - content = ; - } - - return ( - <> - - {pageTitle(ellipsizeText(task.prompt, 64) ?? "Task")} - + content = ( @@ -185,14 +141,95 @@ const TaskPage = () => {
- {content} + + + + ); + } + + return ( + <> + + {pageTitle(ellipsizeText(task.prompt, 64))} + + +
+ + {content} +
); }; export default TaskPage; +type TaskBuildingWorkspaceProps = { task: Task }; + +const TaskBuildingWorkspace: FC = ({ task }) => { + const { data: template } = useQuery( + templateQueryOptions(task.workspace.template_id), + ); + + const buildLogs = useWorkspaceBuildLogs(task?.workspace.latest_build.id); + + // If no template yet, use an indeterminate progress bar. + const transitionStats = (template && + getActiveTransitionStats(template, task.workspace)) || { + P50: 0, + P95: null, + }; + + const scrollAreaRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: this effect should run when build logs change + useEffect(() => { + if (isChromatic()) { + return; + } + const scrollAreaEl = scrollAreaRef.current; + const scrollAreaViewportEl = scrollAreaEl?.querySelector( + "[data-radix-scroll-area-viewport]", + ); + if (scrollAreaViewportEl) { + scrollAreaViewportEl.scrollTop = scrollAreaViewportEl.scrollHeight; + } + }, [buildLogs]); + + return ( +
+
+
+

+ Starting your workspace +

+
+ Your task will be running in a few moments +
+
+ +
+ + + + + +
+
+
+ ); +}; + export class WorkspaceDoesNotHaveAITaskError extends Error { constructor(workspace: Workspace) { super( @@ -228,3 +265,7 @@ export const data = { } satisfies Task; }, }; + +const ellipsizeText = (text: string, maxLength = 80): string => { + return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`; +}; diff --git a/site/src/pages/TaskPage/TaskSidebar.tsx b/site/src/pages/TaskPage/TaskSidebar.tsx index 2309884d166b8..eb1aeb6d59375 100644 --- a/site/src/pages/TaskPage/TaskSidebar.tsx +++ b/site/src/pages/TaskPage/TaskSidebar.tsx @@ -1,24 +1,8 @@ import type { WorkspaceApp } from "api/typesGenerated"; -import { Button } from "components/Button/Button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "components/DropdownMenu/DropdownMenu"; import { Spinner } from "components/Spinner/Spinner"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "components/Tooltip/Tooltip"; -import { ArrowLeftIcon, EllipsisVerticalIcon } from "lucide-react"; import type { Task } from "modules/tasks/tasks"; import type { FC } from "react"; -import { Link as RouterLink } from "react-router"; import { TaskAppIFrame } from "./TaskAppIframe"; -import { TaskStatusLink } from "./TaskStatusLink"; type TaskSidebarProps = { task: Task; @@ -84,60 +68,6 @@ export const TaskSidebar: FC = ({ task }) => { return (
- - - ); -}; - -type ExternalAuthButtonProps = { - template: Template; - missedExternalAuth: TemplateVersionExternalAuth[]; -}; - -const ExternalAuthButtons: FC = ({ - template, - missedExternalAuth, -}) => { - const { - startPollingExternalAuth, - isPollingExternalAuth, - externalAuthPollingState, - } = useExternalAuth(template.active_version_id); - const shouldRetry = externalAuthPollingState === "abandoned"; - - return missedExternalAuth.map((auth) => { - return ( -
- - - {shouldRetry && !auth.authenticated && ( - - - - - - - Retry connecting to {auth.display_name} - - - - )} -
- ); - }); -}; - -type TasksFilterProps = { - filter: TasksFilter; - onFilterChange: (filter: TasksFilter) => void; -}; - -const TasksFilter: FC = ({ filter, onFilterChange }) => { - return ( -
-

- Filters -

- - onFilterChange({ - ...filter, - user: userOption, - }) - } - /> -
+ + )} + + + ); }; -type TasksTableProps = { - filter: TasksFilter; +type PillButtonProps = ButtonProps & { + active?: boolean; }; -const TasksTable: FC = ({ filter }) => { - const { - data: tasks, - error, - refetch, - } = useQuery({ - queryKey: ["tasks", filter], - queryFn: () => data.fetchTasks(filter), - refetchInterval: 10_000, - }); - - let body: ReactNode = null; - - if (error) { - const message = getErrorMessage(error, "Error loading tasks"); - const detail = getErrorDetail(error) ?? "Please try again"; - - body = ( - - -
-
-

- {message} -

- {detail} - -
-
-
-
- ); - } else if (tasks) { - body = - tasks.length === 0 ? ( - - -
-
-

- No tasks found -

- - Use the form above to run a task - -
-
-
-
- ) : ( - tasks.map(({ workspace, prompt }) => { - const templateDisplayName = - workspace.template_display_name ?? workspace.template_name; - - return ( - - - - - {prompt} - - - Access task - - - } - subtitle={templateDisplayName} - avatar={ - - } - /> - - - - - - - {relativeTime(new Date(workspace.created_at))} - - } - src={workspace.owner_avatar_url} - /> - - - ); - }) - ); - } else { - body = ( - - - - - - - - - - - - - - ); - } - +const PillButton: FC = ({ className, active, ...props }) => { return ( - - - - Task - Status - Created by - - - {body} -
+ +
+ + + + ); +}; + +const TasksEmpty: FC = () => { + return ( + + +
+
+

+ No tasks found +

+ + Use the form above to run a task + +
+
+
+
+ ); +}; + +type TasksProps = { tasks: Task[] }; + +const Tasks: FC = ({ tasks }) => { + return tasks.map(({ workspace, prompt }) => { + const templateDisplayName = + workspace.template_display_name ?? workspace.template_name; + + return ( + + + + + {prompt} + + + Access task + + + } + subtitle={templateDisplayName} + avatar={ + + } + /> + + + + + + + {relativeTime(new Date(workspace.created_at))} + + } + src={workspace.owner_avatar_url} + /> + + + ); + }); +}; + +const TasksSkeleton: FC = () => { + return ( + + + + + + + + + + + + + + ); +}; diff --git a/site/src/pages/TasksPage/UsersCombobox.tsx b/site/src/pages/TasksPage/UsersCombobox.tsx index 603085f28d678..e3e443754a17f 100644 --- a/site/src/pages/TasksPage/UsersCombobox.tsx +++ b/site/src/pages/TasksPage/UsersCombobox.tsx @@ -1,5 +1,6 @@ import Skeleton from "@mui/material/Skeleton"; import { users } from "api/queries/users"; +import type { User } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { Button } from "components/Button/Button"; import { @@ -15,44 +16,41 @@ import { PopoverContent, PopoverTrigger, } from "components/Popover/Popover"; +import { useAuthenticated } from "hooks"; import { useDebouncedValue } from "hooks/debounce"; import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { cn } from "utils/cn"; -export type UserOption = { +type UserOption = { label: string; - value: string; // Username + /** + * The username of the user. + */ + value: string; avatarUrl?: string; }; type UsersComboboxProps = { - selectedOption: UserOption | undefined; - onSelect: (option: UserOption | undefined) => void; + value: string; + onValueChange: (value: string) => void; }; export const UsersCombobox: FC = ({ - selectedOption, - onSelect, + value, + onValueChange, }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); - const usersQuery = useQuery({ + const { user } = useAuthenticated(); + const { data: options } = useQuery({ ...users({ q: debouncedSearch }), - select: (data) => - data.users.toSorted((a, _b) => { - return selectedOption && a.username === selectedOption.value ? -1 : 0; - }), + select: (res) => mapUsersToOptions(res.users, user, value), placeholderData: keepPreviousData, }); - - const options = usersQuery.data?.map((user) => ({ - label: user.name || user.username, - value: user.username, - avatarUrl: user.avatar_url, - })); + const selectedOption = options?.find((o) => o.value === value); return ( @@ -91,11 +89,7 @@ export const UsersCombobox: FC = ({ key={option.value} value={option.value} onSelect={() => { - onSelect( - option.value === selectedOption?.value - ? undefined - : option, - ); + onValueChange(option.value); setOpen(false); }} > @@ -131,3 +125,37 @@ const UserItem: FC = ({ option, className }) => { ); }; + +function mapUsersToOptions( + users: readonly User[], + /** + * Includes the authenticated user in the list if they are not already + * present. So the current user can always select themselves easily. + */ + authUser: User, + /** + * Username of the currently selected user. + */ + selectedValue: string, +): UserOption[] { + const includeAuthenticatedUser = (users: readonly User[]) => { + const hasAuthenticatedUser = users.some( + (u) => u.username === authUser.username, + ); + if (hasAuthenticatedUser) { + return users; + } + return [authUser, ...users]; + }; + + const sortSelectedFirst = (a: User) => + selectedValue && a.username === selectedValue ? -1 : 0; + + return includeAuthenticatedUser(users) + .toSorted(sortSelectedFirst) + .map((user) => ({ + label: user.name || user.username, + value: user.username, + avatarUrl: user.avatar_url, + })); +} diff --git a/site/src/pages/TasksPage/data.ts b/site/src/pages/TasksPage/data.ts new file mode 100644 index 0000000000000..0795dab2bb638 --- /dev/null +++ b/site/src/pages/TasksPage/data.ts @@ -0,0 +1,24 @@ +import { API } from "api/api"; +import type { Task } from "modules/tasks/tasks"; + +// TODO: This is a temporary solution while the BE does not return the Task in a +// right shape with a custom name. This should be removed once the BE is fixed. +export const data = { + async createTask( + prompt: string, + userId: string, + templateVersionId: string, + presetId: string | undefined, + ): Promise { + const workspace = await API.experimental.createTask(userId, { + template_version_id: templateVersionId, + template_version_preset_id: presetId, + prompt, + }); + + return { + workspace, + prompt, + }; + }, +}; diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 37f30ab2f2d8d..b81605dc239e9 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -17,7 +17,7 @@ import { ResourcesSidebar } from "./ResourcesSidebar"; import { resourceOptionValue, useResourcesNav } from "./useResourcesNav"; import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection"; import { - ActiveTransition, + getActiveTransitionStats, WorkspaceBuildProgress, } from "./WorkspaceBuildProgress"; import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner"; @@ -68,7 +68,9 @@ export const Workspace: FC = ({ const navigate = useNavigate(); const transitionStats = - template !== undefined ? ActiveTransition(template, workspace) : undefined; + template !== undefined + ? getActiveTransitionStats(template, workspace) + : undefined; const sidebarOption = useSearchParamsKey({ key: "sidebar" }); const setSidebarOption = (newOption: string) => { diff --git a/site/src/pages/WorkspacePage/WorkspaceBuildProgress.tsx b/site/src/pages/WorkspacePage/WorkspaceBuildProgress.tsx index bf61f8de6649c..32061f2a6590e 100644 --- a/site/src/pages/WorkspacePage/WorkspaceBuildProgress.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceBuildProgress.tsx @@ -9,9 +9,9 @@ import { type FC, useEffect, useState } from "react"; dayjs.extend(duration); -// ActiveTransition gets the build estimate for the workspace, +// getActiveTransitionStats gets the build estimate for the workspace, // if it is in a transition state. -export const ActiveTransition = ( +export const getActiveTransitionStats = ( template: Template, workspace: Workspace, ): TransitionStats | undefined => { diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 988e9a5385098..b80da553de6d6 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -305,6 +305,67 @@ describe("WorkspacesPage", () => { MockStoppedWorkspace.latest_build.template_version_id, ); }); + + it("correctly handles pagination by including pagination parameters in query key", async () => { + const totalWorkspaces = 50; + const workspacesPage1 = Array.from({ length: 25 }, (_, i) => ({ + ...MockWorkspace, + id: `page1-workspace-${i}`, + name: `page1-workspace-${i}`, + })); + const workspacesPage2 = Array.from({ length: 25 }, (_, i) => ({ + ...MockWorkspace, + id: `page2-workspace-${i}`, + name: `page2-workspace-${i}`, + })); + + const getWorkspacesSpy = jest.spyOn(API, "getWorkspaces"); + + getWorkspacesSpy.mockImplementation(({ offset }) => { + switch (offset) { + case 0: + return Promise.resolve({ + workspaces: workspacesPage1, + count: totalWorkspaces, + }); + case 25: + return Promise.resolve({ + workspaces: workspacesPage2, + count: totalWorkspaces, + }); + default: + return Promise.reject(new Error("Unexpected offset")); + } + }); + + const user = userEvent.setup(); + renderWithAuth(); + + await waitFor(() => { + expect(screen.getByText("page1-workspace-0")).toBeInTheDocument(); + }); + + expect(getWorkspacesSpy).toHaveBeenLastCalledWith({ + q: "owner:me", + offset: 0, + limit: 25, + }); + + const nextPageButton = screen.getByRole("button", { name: /next page/i }); + await user.click(nextPageButton); + + await waitFor(() => { + expect(screen.getByText("page2-workspace-0")).toBeInTheDocument(); + }); + + expect(getWorkspacesSpy).toHaveBeenLastCalledWith({ + q: "owner:me", + offset: 25, + limit: 25, + }); + + expect(screen.queryByText("page1-workspace-0")).not.toBeInTheDocument(); + }); }); const getWorkspaceCheckbox = (workspace: Workspace) => { diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 62ed7bfed7fe4..0488fc0730e5d 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -116,7 +116,8 @@ const WorkspacesPage: FC = () => { }); const workspacesQueryOptions = workspaces({ - ...pagination, + limit: pagination.limit, + offset: pagination.offset, q: filterState.filter.query, }); const { data, error, refetch } = useQuery({ diff --git a/site/src/utils/ellipsizeText.test.ts b/site/src/utils/ellipsizeText.test.ts deleted file mode 100644 index bc6e7752214e3..0000000000000 --- a/site/src/utils/ellipsizeText.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ellipsizeText } from "./ellipsizeText"; -import type { Nullable } from "./nullable"; - -describe("ellipsizeText", () => { - it.each([ - [undefined, 10, undefined], - [null, 10, undefined], - ["", 10, ""], - ["Hello World", "Hello World".length, "Hello World"], - ["Hello World", "Hello...".length, "Hello..."], - ])( - "ellipsizeText(%p, %p) returns %p", - ( - str: Nullable, - maxLength: number | undefined, - output: Nullable, - ) => { - expect(ellipsizeText(str, maxLength)).toBe(output); - }, - ); -}); diff --git a/site/src/utils/ellipsizeText.ts b/site/src/utils/ellipsizeText.ts deleted file mode 100644 index 6291ed61732dc..0000000000000 --- a/site/src/utils/ellipsizeText.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Nullable } from "./nullable"; - -/** Truncates and ellipsizes text if it's longer than maxLength */ -export const ellipsizeText = ( - text: Nullable, - maxLength = 80, -): string | undefined => { - if (typeof text !== "string") { - return; - } - return text.length <= maxLength - ? text - : `${text.substr(0, maxLength - 3)}...`; -}; diff --git a/site/src/utils/nullable.ts b/site/src/utils/nullable.ts deleted file mode 100644 index 6a9361e5034f5..0000000000000 --- a/site/src/utils/nullable.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * A Nullable may be its concrete type, `null` or `undefined` - * @remark Exact opposite of the native TS type NonNullable - */ -export type Nullable = null | undefined | T;