From 65e074e4b8b48f92443600b0c9acd4b543016c5e Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Wed, 13 Aug 2025 21:43:18 +0000 Subject: [PATCH 01/11] feat: add show-offline option to provisioners list command --- cli/provisioners.go | 11 ++- cli/provisioners_test.go | 17 ++++ .../TestProvisioners_Golden/list.golden | 9 +- .../list_with_offline.golden | 5 ++ .../list_with_offline_provisioners.golden | 5 ++ .../coder_provisioner_list_--help.golden | 3 + coderd/database/querier_test.go | 86 +++++++++++++++++++ coderd/database/queries.sql.go | 10 ++- .../database/queries/provisionerdaemons.sql | 6 ++ coderd/provisionerdaemons.go | 2 + codersdk/organizations.go | 10 ++- docs/reference/cli/provisioner_list.md | 8 ++ .../coder_provisioner_list_--help.golden | 3 + site/src/api/typesGenerated.ts | 1 + 14 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 cli/testdata/TestProvisioners_Golden/list_with_offline.golden create mode 100644 cli/testdata/TestProvisioners_Golden/list_with_offline_provisioners.golden diff --git a/cli/provisioners.go b/cli/provisioners.go index 8f90a52589939..674eed3b95e4f 100644 --- a/cli/provisioners.go +++ b/cli/provisioners.go @@ -39,7 +39,8 @@ 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 ) cmd := &serpent.Command{ @@ -59,7 +60,8 @@ func (r *RootCmd) provisionerList() *serpent.Command { } daemons, err := client.OrganizationProvisionerDaemons(ctx, org.ID, &codersdk.OrganizationProvisionerDaemonsOptions{ - Limit: int(limit), + Limit: int(limit), + Offline: offline, }) if err != nil { return xerrors.Errorf("list provisioner daemons: %w", err) @@ -98,6 +100,11 @@ func (r *RootCmd) provisionerList() *serpent.Command { Default: "50", Value: serpent.Int64Of(&limit), }, + { + Flag: "show-offline", + Description: "Show offline provisioners.", + Value: serpent.BoolOf(&offline), + }, }...) orgContext.AttachOptions(cmd) diff --git a/cli/provisioners_test.go b/cli/provisioners_test.go index 30a89714ff57f..27c95ee1fc89d 100644 --- a/cli/provisioners_test.go +++ b/cli/provisioners_test.go @@ -198,6 +198,23 @@ func TestProvisioners_Golden(t *testing.T) { clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) }) + t.Run("list with offline provisioners", 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) + }) + // 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_with_offline.golden b/cli/testdata/TestProvisioners_Golden/list_with_offline.golden new file mode 100644 index 0000000000000..fd7b966d8d982 --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_with_offline.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_with_offline_provisioners.golden b/cli/testdata/TestProvisioners_Golden/list_with_offline_provisioners.golden new file mode 100644 index 0000000000000..fd7b966d8d982 --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_with_offline_provisioners.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..50df131b07098 100644 --- a/cli/testdata/coder_provisioner_list_--help.golden +++ b/cli/testdata/coder_provisioner_list_--help.golden @@ -20,5 +20,8 @@ OPTIONS: -o, --output table|json (default: table) Output format. + --show-offline bool + Show offline provisioners. + ——— Run `coder --help` for a list of global options. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 0e11886765da6..4276309d62178 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,89 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { require.Equal(t, database.ProvisionerDaemonStatusOffline, daemons[0].Status) require.Equal(t, database.ProvisionerDaemonStatusIdle, daemons[1].Status) }) + + t.Run("Excludes offline daemons", 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("Includes offline daemons", 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]) + }) } func TestGetWorkspaceAgentUsageStats(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 58874cb7ed8c8..7e15dbf3fc5cd 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8281,10 +8281,16 @@ 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)) + -- Include offline daemons only if offline is set to true + AND ( + COALESCE($5::bool, false) = true + OR + (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - ($1::bigint || ' ms')::interval)) + ) ORDER BY pd.created_at DESC LIMIT - $5::int + $6::int ` type GetProvisionerDaemonsWithStatusByOrganizationParams struct { @@ -8292,6 +8298,7 @@ type GetProvisionerDaemonsWithStatusByOrganizationParams struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` IDs []uuid.UUID `db:"ids" json:"ids"` Tags StringMap `db:"tags" json:"tags"` + Offline sql.NullBool `db:"offline" json:"offline"` Limit sql.NullInt32 `db:"limit" json:"limit"` } @@ -8319,6 +8326,7 @@ func (q *sqlQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.C arg.OrganizationID, pq.Array(arg.IDs), arg.Tags, + arg.Offline, arg.Limit, ) if err != nil { diff --git a/coderd/database/queries/provisionerdaemons.sql b/coderd/database/queries/provisionerdaemons.sql index 4f7c7a8b2200a..fae33edc7264c 100644 --- a/coderd/database/queries/provisionerdaemons.sql +++ b/coderd/database/queries/provisionerdaemons.sql @@ -110,6 +110,12 @@ 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)) + -- Include offline daemons only if offline is set to true + AND ( + COALESCE(sqlc.narg('offline')::bool, false) = true + OR + (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + ) ORDER BY pd.created_at DESC LIMIT diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index 332ae3b352e0a..a220e1963d614 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -45,6 +45,7 @@ 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") p.ErrorExcessParams(qp) if len(p.Errors) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -60,6 +61,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { OrganizationID: org.ID, StaleIntervalMS: provisionerdserver.StaleInterval.Milliseconds(), Limit: sql.NullInt32{Int32: limit, Valid: limit > 0}, + Offline: includeOffline, IDs: ids, Tags: tags, }, diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f87d0eae188ba..51e70a3e27f2f 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -344,9 +344,10 @@ 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 + IDs []uuid.UUID + Tags map[string]string } func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizationID uuid.UUID, opts *OrganizationProvisionerDaemonsOptions) ([]ProvisionerDaemon, error) { @@ -355,6 +356,9 @@ 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.IDs) > 0 { qp.Add("ids", joinSliceStringer(opts.IDs)) } diff --git a/docs/reference/cli/provisioner_list.md b/docs/reference/cli/provisioner_list.md index 128d76caf4c7e..555aaf0b20545 100644 --- a/docs/reference/cli/provisioner_list.md +++ b/docs/reference/cli/provisioner_list.md @@ -25,6 +25,14 @@ coder provisioner list [flags] Limit the number of provisioners returned. +### --show-offline + +| | | +|------|-------------------| +| Type | bool | + +Show offline provisioners. + ### -O, --org | | | diff --git a/enterprise/cli/testdata/coder_provisioner_list_--help.golden b/enterprise/cli/testdata/coder_provisioner_list_--help.golden index 7a1807bb012f5..50df131b07098 100644 --- a/enterprise/cli/testdata/coder_provisioner_list_--help.golden +++ b/enterprise/cli/testdata/coder_provisioner_list_--help.golden @@ -20,5 +20,8 @@ OPTIONS: -o, --output table|json (default: table) Output format. + --show-offline bool + Show offline provisioners. + ——— Run `coder --help` for a list of global options. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6f5ab307a2fa8..582ee3ec24b27 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1833,6 +1833,7 @@ export interface OrganizationMemberWithUserData extends OrganizationMember { // From codersdk/organizations.go export interface OrganizationProvisionerDaemonsOptions { readonly Limit: number; + readonly Offline: boolean; readonly IDs: readonly string[]; readonly Tags: Record; } From fe50c081ca25a5753f0e20315f5267bb98599749 Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Thu, 14 Aug 2025 20:54:30 +0000 Subject: [PATCH 02/11] feat: add status option to provisioners list command --- cli/provisioners.go | 11 +++ cli/provisioners_test.go | 19 +++- .../list_provisioner_daemons_by_status.golden | 4 + .../list_provisioners_by_status.golden | 4 + ...st_with_offline_provisioner_daemons.golden | 5 ++ .../coder_provisioner_list_--help.golden | 5 +- coderd/database/querier_test.go | 86 +++++++++++++++++-- coderd/database/queries.sql.go | 28 ++++-- .../database/queries/provisionerdaemons.sql | 10 +++ coderd/database/sdk2db/sdk2db.go | 16 ++++ coderd/database/sdk2db/sdk2db_test.go | 36 ++++++++ coderd/httpapi/queryparams.go | 6 ++ coderd/provisionerdaemons.go | 5 ++ codersdk/organizations.go | 4 + codersdk/provisionerdaemons.go | 8 ++ docs/reference/cli/provisioner_list.md | 16 +++- .../coder_provisioner_list_--help.golden | 5 +- site/src/api/typesGenerated.ts | 1 + 18 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden create mode 100644 cli/testdata/TestProvisioners_Golden/list_provisioners_by_status.golden create mode 100644 cli/testdata/TestProvisioners_Golden/list_with_offline_provisioner_daemons.golden create mode 100644 coderd/database/sdk2db/sdk2db.go create mode 100644 coderd/database/sdk2db/sdk2db_test.go diff --git a/cli/provisioners.go b/cli/provisioners.go index 674eed3b95e4f..b2a073001c7f0 100644 --- a/cli/provisioners.go +++ b/cli/provisioners.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -41,6 +42,7 @@ func (r *RootCmd) provisionerList() *serpent.Command { ) limit int64 offline bool + status []string ) cmd := &serpent.Command{ @@ -62,6 +64,7 @@ func (r *RootCmd) provisionerList() *serpent.Command { daemons, err := client.OrganizationProvisionerDaemons(ctx, org.ID, &codersdk.OrganizationProvisionerDaemonsOptions{ Limit: int(limit), Offline: offline, + Status: slice.StringEnums[codersdk.ProvisionerDaemonStatus](status), }) if err != nil { return xerrors.Errorf("list provisioner daemons: %w", err) @@ -102,9 +105,17 @@ func (r *RootCmd) provisionerList() *serpent.Command { }, { Flag: "show-offline", + 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())...), + }, }...) orgContext.AttachOptions(cmd) diff --git a/cli/provisioners_test.go b/cli/provisioners_test.go index 27c95ee1fc89d..008aaf658e652 100644 --- a/cli/provisioners_test.go +++ b/cli/provisioners_test.go @@ -198,7 +198,7 @@ func TestProvisioners_Golden(t *testing.T) { clitest.TestGoldenFile(t, t.Name(), got.Bytes(), replace) }) - t.Run("list with offline provisioners", func(t *testing.T) { + t.Run("list with offline provisioner daemons", func(t *testing.T) { t.Parallel() var got bytes.Buffer @@ -215,6 +215,23 @@ func TestProvisioners_Golden(t *testing.T) { 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) + }) + // 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_provisioner_daemons_by_status.golden b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden new file mode 100644 index 0000000000000..bc383a839408d --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.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_provisioners_by_status.golden b/cli/testdata/TestProvisioners_Golden/list_provisioners_by_status.golden new file mode 100644 index 0000000000000..bc383a839408d --- /dev/null +++ b/cli/testdata/TestProvisioners_Golden/list_provisioners_by_status.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 50df131b07098..49b415f77dcde 100644 --- a/cli/testdata/coder_provisioner_list_--help.golden +++ b/cli/testdata/coder_provisioner_list_--help.golden @@ -20,8 +20,11 @@ OPTIONS: -o, --output table|json (default: table) Output format. - --show-offline bool + --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/database/querier_test.go b/coderd/database/querier_test.go index 4276309d62178..dfbd9d4894bba 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -479,12 +479,12 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { require.Equal(t, database.ProvisionerDaemonStatusIdle, daemons[1].Status) }) - t.Run("Excludes offline daemons", func(t *testing.T) { + 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{ + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ Name: "offline-daemon", OrganizationID: org.ID, CreatedAt: dbtime.Now().Add(-time.Hour), @@ -514,12 +514,12 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { require.Equal(t, database.ProvisionerDaemonStatusIdle, daemons[0].Status) }) - t.Run("Includes offline daemons", func(t *testing.T) { + 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{ + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ Name: "offline-daemon", OrganizationID: org.ID, CreatedAt: dbtime.Now().Add(-time.Hour), @@ -528,14 +528,14 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { Time: dbtime.Now().Add(-time.Hour), }, }) - _ = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ Name: "foo-daemon", OrganizationID: org.ID, Tags: database.StringMap{ "foo": "bar", }, }) - _ = dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ + dbgen.ProvisionerDaemon(t, db, database.ProvisionerDaemon{ Name: "bar-daemon", OrganizationID: org.ID, CreatedAt: dbtime.Now().Add(-(30 * time.Minute)), @@ -561,6 +561,80 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { 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, + }, + { + name: "Get all - empty statuses", + statuses: []database.ProvisionerDaemonStatus{}, + expectedNum: 2, + }, + { + name: "Get all - nil statuses", + statuses: nil, + 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: 45 * time.Minute.Milliseconds(), + Offline: sql.NullBool{Bool: true, Valid: true}, + Statuses: tc.statuses, + }) + require.NoError(t, err) + require.Len(t, daemons, tc.expectedNum) + }) + } + }) } func TestGetWorkspaceAgentUsageStats(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 7e15dbf3fc5cd..f90e9a5e3317e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8281,25 +8281,36 @@ 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)) + -- Filter by status array if any status values are provided + AND (COALESCE(array_length($5::provisioner_daemon_status[], 1), 0) = 0 OR + (CASE + WHEN pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($1::bigint || ' ms')::interval) + THEN 'offline'::provisioner_daemon_status + ELSE CASE + WHEN current_job.id IS NOT NULL THEN 'busy'::provisioner_daemon_status + ELSE 'idle'::provisioner_daemon_status + END + END) = ANY($5::provisioner_daemon_status[])) -- Include offline daemons only if offline is set to true AND ( - COALESCE($5::bool, false) = true + COALESCE($6::bool, false) = true OR (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - ($1::bigint || ' ms')::interval)) ) ORDER BY pd.created_at DESC LIMIT - $6::int + $7::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"` - Offline sql.NullBool `db:"offline" json:"offline"` - Limit sql.NullInt32 `db:"limit" json:"limit"` + 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"` + Statuses []ProvisionerDaemonStatus `db:"statuses" json:"statuses"` + Offline sql.NullBool `db:"offline" json:"offline"` + Limit sql.NullInt32 `db:"limit" json:"limit"` } type GetProvisionerDaemonsWithStatusByOrganizationRow struct { @@ -8326,6 +8337,7 @@ func (q *sqlQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.C arg.OrganizationID, pq.Array(arg.IDs), arg.Tags, + pq.Array(arg.Statuses), arg.Offline, arg.Limit, ) diff --git a/coderd/database/queries/provisionerdaemons.sql b/coderd/database/queries/provisionerdaemons.sql index fae33edc7264c..c7ebbbc7af638 100644 --- a/coderd/database/queries/provisionerdaemons.sql +++ b/coderd/database/queries/provisionerdaemons.sql @@ -110,6 +110,16 @@ 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 status array if any status values are provided + AND (COALESCE(array_length(@statuses::provisioner_daemon_status[], 1), 0) = 0 OR + (CASE + WHEN pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval) + THEN 'offline'::provisioner_daemon_status + ELSE CASE + WHEN current_job.id IS NOT NULL THEN 'busy'::provisioner_daemon_status + ELSE 'idle'::provisioner_daemon_status + END + END) = ANY(@statuses::provisioner_daemon_status[])) -- Include offline daemons only if offline is set to true AND ( COALESCE(sqlc.narg('offline')::bool, false) = true 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..ccedb6d2f6c8c 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -287,6 +287,12 @@ 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 + }) +} + // 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/provisionerdaemons.go b/coderd/provisionerdaemons.go index a220e1963d614..690c570ea9c7a 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" @@ -46,6 +47,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { 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") p.ErrorExcessParams(qp) if len(p.Errors) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -55,6 +57,8 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { return } + dbStatuses := sdk2db.ProvisionerDaemonStatuses(statuses) + daemons, err := api.Database.GetProvisionerDaemonsWithStatusByOrganization( ctx, database.GetProvisionerDaemonsWithStatusByOrganizationParams{ @@ -62,6 +66,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { StaleIntervalMS: provisionerdserver.StaleInterval.Milliseconds(), Limit: sql.NullInt32{Int32: limit, Valid: limit > 0}, Offline: includeOffline, + Statuses: dbStatuses, IDs: ids, Tags: tags, }, diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 51e70a3e27f2f..078328511b148 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -346,6 +346,7 @@ func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, e type OrganizationProvisionerDaemonsOptions struct { Limit int Offline bool + Status []ProvisionerDaemonStatus IDs []uuid.UUID Tags map[string]string } @@ -359,6 +360,9 @@ func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizatio if opts.Offline { qp.Add("offline", "true") } + if len(opts.Status) > 0 { + qp.Add("status", joinSlice(opts.Status)) + } 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 555aaf0b20545..cb8fedac1dc4e 100644 --- a/docs/reference/cli/provisioner_list.md +++ b/docs/reference/cli/provisioner_list.md @@ -27,12 +27,22 @@ Limit the number of provisioners returned. ### --show-offline -| | | -|------|-------------------| -| Type | bool | +| | | +|-------------|----------------------------------------------| +| 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. + ### -O, --org | | | diff --git a/enterprise/cli/testdata/coder_provisioner_list_--help.golden b/enterprise/cli/testdata/coder_provisioner_list_--help.golden index 50df131b07098..49b415f77dcde 100644 --- a/enterprise/cli/testdata/coder_provisioner_list_--help.golden +++ b/enterprise/cli/testdata/coder_provisioner_list_--help.golden @@ -20,8 +20,11 @@ OPTIONS: -o, --output table|json (default: table) Output format. - --show-offline bool + --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/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 582ee3ec24b27..4fd661e94cbcc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1834,6 +1834,7 @@ export interface OrganizationMemberWithUserData extends OrganizationMember { export interface OrganizationProvisionerDaemonsOptions { readonly Limit: number; readonly Offline: boolean; + readonly Status: readonly ProvisionerDaemonStatus[]; readonly IDs: readonly string[]; readonly Tags: Record; } From d55026ef5c8a160b9310a21b087264d0eb85c861 Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Fri, 15 Aug 2025 17:17:35 +0000 Subject: [PATCH 03/11] Update query/tests to handle offline param or offline status --- cli/provisioners_test.go | 17 +++++ .../list_provisioner_daemons_by_status.golden | 9 +-- ...provisioner_daemons_without_offline.golden | 4 ++ coderd/database/querier_test.go | 10 +-- coderd/database/queries.sql.go | 64 +++++++++++-------- .../database/queries/provisionerdaemons.sql | 48 ++++++++------ 6 files changed, 95 insertions(+), 57 deletions(-) create mode 100644 cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_without_offline.golden diff --git a/cli/provisioners_test.go b/cli/provisioners_test.go index 008aaf658e652..49d2f8d9fa442 100644 --- a/cli/provisioners_test.go +++ b/cli/provisioners_test.go @@ -232,6 +232,23 @@ func TestProvisioners_Golden(t *testing.T) { 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) + }) + // 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_provisioner_daemons_by_status.golden b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden index bc383a839408d..fd7b966d8d982 100644 --- a/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden +++ b/cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_status.golden @@ -1,4 +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-3 v0.0.0 idle map[owner: scope:organization] +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/coderd/database/querier_test.go b/coderd/database/querier_test.go index dfbd9d4894bba..c7bc49f39fb91 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -609,15 +609,16 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { }, expectedNum: 1, }, + // Offline daemons should not be included without Offline param { - name: "Get all - empty statuses", + name: "Get idle - empty statuses", statuses: []database.ProvisionerDaemonStatus{}, - expectedNum: 2, + expectedNum: 1, }, { - name: "Get all - nil statuses", + name: "Get idle - nil statuses", statuses: nil, - expectedNum: 2, + expectedNum: 1, }, } @@ -627,7 +628,6 @@ 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}, Statuses: tc.statuses, }) require.NoError(t, err) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f90e9a5e3317e..62b3d7313a280 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8205,13 +8205,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, @@ -8278,24 +8278,32 @@ 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)) - -- Filter by status array if any status values are provided - AND (COALESCE(array_length($5::provisioner_daemon_status[], 1), 0) = 0 OR - (CASE - WHEN pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - ($1::bigint || ' ms')::interval) - THEN 'offline'::provisioner_daemon_status - ELSE CASE - WHEN current_job.id IS NOT NULL THEN 'busy'::provisioner_daemon_status - ELSE 'idle'::provisioner_daemon_status - END - END) = ANY($5::provisioner_daemon_status[])) - -- Include offline daemons only if offline is set to true + 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)) AND ( - COALESCE($6::bool, false) = true - OR - (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - ($1::bigint || ' ms')::interval)) + -- Include daemons that have been seen recently + (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - ($3::bigint || ' ms')::interval)) + -- Include offline daemons only when offline param is set OR 'offline' is in the list of statuses + 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[]) + ) + ) + ) + -- Filter daemons by their current status if statuses are provided + AND ( + 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 ( + (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)) + ) ) ORDER BY pd.created_at DESC @@ -8304,12 +8312,12 @@ LIMIT ` type GetProvisionerDaemonsWithStatusByOrganizationParams struct { + 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"` - Statuses []ProvisionerDaemonStatus `db:"statuses" json:"statuses"` - Offline sql.NullBool `db:"offline" json:"offline"` Limit sql.NullInt32 `db:"limit" json:"limit"` } @@ -8333,12 +8341,12 @@ 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, - pq.Array(arg.Statuses), - arg.Offline, arg.Limit, ) if err != nil { diff --git a/coderd/database/queries/provisionerdaemons.sql b/coderd/database/queries/provisionerdaemons.sql index c7ebbbc7af638..76aee7981db50 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,21 +110,29 @@ 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 status array if any status values are provided - AND (COALESCE(array_length(@statuses::provisioner_daemon_status[], 1), 0) = 0 OR - (CASE - WHEN pd.last_seen_at IS NULL OR pd.last_seen_at < (NOW() - (@stale_interval_ms::bigint || ' ms')::interval) - THEN 'offline'::provisioner_daemon_status - ELSE CASE - WHEN current_job.id IS NOT NULL THEN 'busy'::provisioner_daemon_status - ELSE 'idle'::provisioner_daemon_status - END - END) = ANY(@statuses::provisioner_daemon_status[])) - -- Include offline daemons only if offline is set to true AND ( - COALESCE(sqlc.narg('offline')::bool, false) = true - OR + -- Include daemons that have been seen recently (pd.last_seen_at IS NOT NULL AND pd.last_seen_at >= (NOW() - (@stale_interval_ms::bigint || ' ms')::interval)) + -- Include offline daemons only when offline param is set OR 'offline' is in the list of statuses + 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[]) + ) + ) + ) + -- Filter daemons by their current status if statuses are provided + AND ( + 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 ( + (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)) + ) ) ORDER BY pd.created_at DESC From 95713c5b1af5acc4cede69c69f258a3eb486ddbe Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Fri, 15 Aug 2025 19:49:46 +0000 Subject: [PATCH 04/11] feat: add max-age option to provisioners list command --- cli/provisioners.go | 19 ++++-- cli/provisioners_test.go | 17 +++++ ...list_provisioner_daemons_by_max_age.golden | 4 ++ .../coder_provisioner_list_--help.golden | 5 +- coderd/database/querier_test.go | 67 +++++++++++++++++++ coderd/database/queries.sql.go | 24 +++++-- .../database/queries/provisionerdaemons.sql | 20 ++++-- coderd/httpapi/queryparams.go | 17 +++++ coderd/provisionerdaemons.go | 2 + codersdk/organizations.go | 4 ++ docs/reference/cli/provisioner_list.md | 11 ++- .../coder_provisioner_list_--help.golden | 5 +- site/src/api/typesGenerated.ts | 1 + 13 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 cli/testdata/TestProvisioners_Golden/list_provisioner_daemons_by_max_age.golden diff --git a/cli/provisioners.go b/cli/provisioners.go index b2a073001c7f0..77f5e7705edd5 100644 --- a/cli/provisioners.go +++ b/cli/provisioners.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "time" "golang.org/x/xerrors" @@ -43,6 +44,7 @@ func (r *RootCmd) provisionerList() *serpent.Command { limit int64 offline bool status []string + maxAge time.Duration ) cmd := &serpent.Command{ @@ -65,6 +67,7 @@ func (r *RootCmd) provisionerList() *serpent.Command { Limit: int(limit), Offline: offline, Status: slice.StringEnums[codersdk.ProvisionerDaemonStatus](status), + MaxAge: maxAge, }) if err != nil { return xerrors.Errorf("list provisioner daemons: %w", err) @@ -104,10 +107,11 @@ func (r *RootCmd) provisionerList() *serpent.Command { Value: serpent.Int64Of(&limit), }, { - Flag: "show-offline", - Env: "CODER_PROVISIONER_SHOW_OFFLINE", - Description: "Show offline provisioners.", - Value: serpent.BoolOf(&offline), + Flag: "show-offline", + FlagShorthand: "f", + Env: "CODER_PROVISIONER_SHOW_OFFLINE", + Description: "Show offline provisioners.", + Value: serpent.BoolOf(&offline), }, { Flag: "status", @@ -116,6 +120,13 @@ func (r *RootCmd) provisionerList() *serpent.Command { 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 49d2f8d9fa442..8fa068527be5b 100644 --- a/cli/provisioners_test.go +++ b/cli/provisioners_test.go @@ -249,6 +249,23 @@ func TestProvisioners_Golden(t *testing.T) { 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_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/coder_provisioner_list_--help.golden b/cli/testdata/coder_provisioner_list_--help.golden index 49b415f77dcde..ce6d0754073a4 100644 --- a/cli/testdata/coder_provisioner_list_--help.golden +++ b/cli/testdata/coder_provisioner_list_--help.golden @@ -17,10 +17,13 @@ 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. - --show-offline bool, $CODER_PROVISIONER_SHOW_OFFLINE + -f, --show-offline bool, $CODER_PROVISIONER_SHOW_OFFLINE Show offline provisioners. -s, --status [offline|idle|busy], $CODER_PROVISIONER_LIST_STATUS diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index c7bc49f39fb91..53773842d3a52 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -635,6 +635,73 @@ func TestGetProvisionerDaemonsWithStatusByOrganization(t *testing.T) { }) } }) + + 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) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 62b3d7313a280..59f6458c712d3 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8281,10 +8281,16 @@ WHERE 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 ( - -- Include daemons that have been seen recently + $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 only when offline param is set OR 'offline' is in the list of statuses + -- 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 ( @@ -8293,22 +8299,24 @@ WHERE ) ) ) - -- Filter daemons by their current status if statuses are provided 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 ( - (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[])) + '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 OR 'offline'::provisioner_daemon_status = ANY($2::provisioner_daemon_status[])) + 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 - $7::int + $8::int ` type GetProvisionerDaemonsWithStatusByOrganizationParams struct { @@ -8318,6 +8326,7 @@ type GetProvisionerDaemonsWithStatusByOrganizationParams struct { 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"` } @@ -8347,6 +8356,7 @@ func (q *sqlQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.C 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 76aee7981db50..ad6c0948eb448 100644 --- a/coderd/database/queries/provisionerdaemons.sql +++ b/coderd/database/queries/provisionerdaemons.sql @@ -110,10 +110,16 @@ 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 ( - -- Include daemons that have been seen recently + 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 only when offline param is set OR 'offline' is in the list of statuses + -- 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 ( @@ -122,15 +128,17 @@ WHERE ) ) ) - -- Filter daemons by their current status if statuses are provided 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 ( - (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[])) + '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 OR 'offline'::provisioner_daemon_status = ANY(@statuses::provisioner_daemon_status[])) + 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)) ) ) diff --git a/coderd/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index ccedb6d2f6c8c..e1bd983ea12a3 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -293,6 +293,23 @@ func (p *QueryParamParser) ProvisionerDaemonStatuses(vals url.Values, def []code }) } +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/provisionerdaemons.go b/coderd/provisionerdaemons.go index 690c570ea9c7a..67a40b88f69e9 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -48,6 +48,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { 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{ @@ -67,6 +68,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) { 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/codersdk/organizations.go b/codersdk/organizations.go index 078328511b148..bca87c7bd4591 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -347,6 +347,7 @@ type OrganizationProvisionerDaemonsOptions struct { Limit int Offline bool Status []ProvisionerDaemonStatus + MaxAge time.Duration IDs []uuid.UUID Tags map[string]string } @@ -363,6 +364,9 @@ func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizatio 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/docs/reference/cli/provisioner_list.md b/docs/reference/cli/provisioner_list.md index cb8fedac1dc4e..aa67dcd815f67 100644 --- a/docs/reference/cli/provisioner_list.md +++ b/docs/reference/cli/provisioner_list.md @@ -25,7 +25,7 @@ coder provisioner list [flags] Limit the number of provisioners returned. -### --show-offline +### -f, --show-offline | | | |-------------|----------------------------------------------| @@ -43,6 +43,15 @@ Show offline provisioners. 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 49b415f77dcde..ce6d0754073a4 100644 --- a/enterprise/cli/testdata/coder_provisioner_list_--help.golden +++ b/enterprise/cli/testdata/coder_provisioner_list_--help.golden @@ -17,10 +17,13 @@ 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. - --show-offline bool, $CODER_PROVISIONER_SHOW_OFFLINE + -f, --show-offline bool, $CODER_PROVISIONER_SHOW_OFFLINE Show offline provisioners. -s, --status [offline|idle|busy], $CODER_PROVISIONER_LIST_STATUS diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4fd661e94cbcc..aff0c84bb944c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1835,6 +1835,7 @@ export interface OrganizationProvisionerDaemonsOptions { readonly Limit: number; readonly Offline: boolean; readonly Status: readonly ProvisionerDaemonStatus[]; + readonly MaxAge: number; readonly IDs: readonly string[]; readonly Tags: Record; } From 7d21e8590e0287bf808983972f6fbdbfcda647ff Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Fri, 15 Aug 2025 20:24:17 +0000 Subject: [PATCH 05/11] Fix tests: offline needs to be specified --- coderd/provisionerdaemons_test.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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) From 0b86c2128801aee7247fc15a83a63f954da647ed Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Fri, 15 Aug 2025 21:31:37 +0000 Subject: [PATCH 06/11] Remove unused golden files --- .../list_provisioners_by_status.golden | 4 ---- .../TestProvisioners_Golden/list_with_offline.golden | 5 ----- .../list_with_offline_provisioners.golden | 5 ----- 3 files changed, 14 deletions(-) delete mode 100644 cli/testdata/TestProvisioners_Golden/list_provisioners_by_status.golden delete mode 100644 cli/testdata/TestProvisioners_Golden/list_with_offline.golden delete mode 100644 cli/testdata/TestProvisioners_Golden/list_with_offline_provisioners.golden diff --git a/cli/testdata/TestProvisioners_Golden/list_provisioners_by_status.golden b/cli/testdata/TestProvisioners_Golden/list_provisioners_by_status.golden deleted file mode 100644 index bc383a839408d..0000000000000 --- a/cli/testdata/TestProvisioners_Golden/list_provisioners_by_status.golden +++ /dev/null @@ -1,4 +0,0 @@ -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.golden b/cli/testdata/TestProvisioners_Golden/list_with_offline.golden deleted file mode 100644 index fd7b966d8d982..0000000000000 --- a/cli/testdata/TestProvisioners_Golden/list_with_offline.golden +++ /dev/null @@ -1,5 +0,0 @@ -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_with_offline_provisioners.golden b/cli/testdata/TestProvisioners_Golden/list_with_offline_provisioners.golden deleted file mode 100644 index fd7b966d8d982..0000000000000 --- a/cli/testdata/TestProvisioners_Golden/list_with_offline_provisioners.golden +++ /dev/null @@ -1,5 +0,0 @@ -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] From 09a9f84223737ef6f8bbe5af5ba5ec27c316bd39 Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Wed, 20 Aug 2025 16:20:01 +0000 Subject: [PATCH 07/11] Add checkbox to show offline provisioners --- site/src/api/api.ts | 2 + .../OrganizationProvisionersPage.tsx | 9 +- ...ganizationProvisionersPageView.stories.tsx | 16 +++ .../OrganizationProvisionersPageView.tsx | 135 ++++++++++-------- 4 files changed, 99 insertions(+), 63 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 966c8902c3e73..5c2fb8fbb66a2 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -420,6 +420,8 @@ export type GetProvisionerDaemonsParams = { // Stringified JSON Object tags?: string; limit?: number; + // Include offline provisioner daemons? + offline?: boolean; }; /** diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx index 997621cdece10..212e8f133b1c4 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,13 @@ const OrganizationProvisionersPage: FC = () => { buildVersion={buildInfoQuery.data?.version} onRetry={provisionersQuery.refetch} filter={queryParams} - onFilterChange={setSearchParams} + onFilterChange={(filter) => { + const params: Record = { + ids: filter.ids ?? "", + offline: filter.offline ? "true" : "false", + }; + setSearchParams(params); + }} /> ); diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx index d1bcd7fbcb816..6620b7e99bb53 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..ab0453d20d5a1 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"; @@ -32,6 +33,7 @@ import { ProvisionerRow } from "./ProvisionerRow"; type ProvisionersFilter = { ids: string; + offline: boolean; }; interface OrganizationProvisionersPageViewProps { @@ -102,70 +104,79 @@ export const OrganizationProvisionersPageView: FC< documentationLink={docs("/")} /> ) : ( - - - - Name - Key - Version - Status - Tags - - - - - - - {provisioners ? ( - provisioners.length > 0 ? ( - provisioners.map((provisioner) => ( - - )) - ) : ( + <>
+ { + onFilterChange({ + ...filter, + offline: checked === true, + }); + } } /> + +
+ - - - - Create a provisioner - - - - } - /> - + Name + Key + Version + Status + Tags + + + - ) - ) : error ? ( - - - - Retry - - } - /> - - - ) : ( - - - - - - )} - -
+ + + {provisioners ? ( + provisioners.length > 0 ? ( + provisioners.map((provisioner) => ( + + )) + ) : ( + + + + + Create a provisioner + + + } /> + + + ) + ) : error ? ( + + + + Retry + } /> + + + ) : ( + + + + + + )} + + )} ); From 506d59614c4e5e13be639a0778ea8665219b9abb Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Wed, 20 Aug 2025 16:33:36 +0000 Subject: [PATCH 08/11] Formatting fixes --- ...ganizationProvisionersPageView.stories.tsx | 8 +- .../OrganizationProvisionersPageView.tsx | 117 ++++++++++-------- 2 files changed, 68 insertions(+), 57 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx index 6620b7e99bb53..8dba15b4d8856 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.stories.tsx @@ -26,7 +26,7 @@ const meta: Meta = { { ...MockUserProvisioner, status: "offline", - } + }, ], filter: { ids: "", @@ -85,6 +85,6 @@ export const FilterByOffline: Story = { filter: { ids: "", offline: false, - } - } -} + }, + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx index ab0453d20d5a1..c47f10fa86beb 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -104,7 +104,8 @@ export const OrganizationProvisionersPageView: FC< documentationLink={docs("/")} /> ) : ( - <>
+ <> +
+ }} + /> -
- - - Name - Key - Version - Status - Tags - - - - - - - {provisioners ? ( - provisioners.length > 0 ? ( - provisioners.map((provisioner) => ( - - )) - ) : ( - - - + +
+ + + Name + Key + Version + Status + Tags + + + + + + + {provisioners ? ( + provisioners.length > 0 ? ( + provisioners.map((provisioner) => ( + + )) + ) : ( + + + Create a provisioner - } /> - - - ) - ) : error ? ( - - - - Retry - } /> + + } + /> - ) : ( - - - - - - )} - -
+ ) + ) : error ? ( + + + + Retry + + } + /> + + + ) : ( + + + + + + )} + + + )} ); From ce5acac827b8d2095b5aac6623dedf9c3f3c81c1 Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Wed, 20 Aug 2025 20:08:49 +0000 Subject: [PATCH 09/11] Frontend updates based on PR feedback --- .../OrganizationProvisionersPage.tsx | 11 +++++------ .../OrganizationProvisionersPageView.tsx | 4 ++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx index 212e8f133b1c4..95db66f2c41c4 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPage.tsx @@ -67,12 +67,11 @@ const OrganizationProvisionersPage: FC = () => { buildVersion={buildInfoQuery.data?.version} onRetry={provisionersQuery.refetch} filter={queryParams} - onFilterChange={(filter) => { - const params: Record = { - ids: filter.ids ?? "", - offline: filter.offline ? "true" : "false", - }; - setSearchParams(params); + onFilterChange={({ ids, offline }) => { + setSearchParams({ + ids, + offline: offline.toString(), + }); }} /> diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx index c47f10fa86beb..c468020eef557 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -155,10 +155,10 @@ export const OrganizationProvisionersPageView: FC< description="A provisioner is required before you can create templates and workspaces. You can connect your first provisioner by following our documentation." cta={ } /> From be661268a5b2c4eede3390740f0b8ba731c65127 Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Wed, 20 Aug 2025 20:29:03 +0000 Subject: [PATCH 10/11] Remove extra icon --- .../OrganizationProvisionersPageView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx index c468020eef557..2229fb79ddfb8 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -157,7 +157,6 @@ export const OrganizationProvisionersPageView: FC< } From c9980677596c6a5c3c9fc8e6bc59e9be2453394f Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Wed, 20 Aug 2025 20:32:59 +0000 Subject: [PATCH 11/11] Linter fix --- .../OrganizationProvisionersPageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx index 2229fb79ddfb8..ac6e45aed24cf 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationProvisionersPage/OrganizationProvisionersPageView.tsx @@ -25,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"; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy