diff --git a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go index ab0dc4ee66b23..c2a4e66e81329 100644 --- a/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go +++ b/api/gen/proto/go/teleport/machineid/v1/bot_instance_service.pb.go @@ -471,7 +471,9 @@ type ListBotInstancesV2Request_Filters struct { BotName string `protobuf:"bytes,1,opt,name=bot_name,json=botName,proto3" json:"bot_name,omitempty"` // A search term used to filter the results. If non-empty, it's used to // match against supported fields. - SearchTerm string `protobuf:"bytes,2,opt,name=search_term,json=searchTerm,proto3" json:"search_term,omitempty"` + SearchTerm string `protobuf:"bytes,2,opt,name=search_term,json=searchTerm,proto3" json:"search_term,omitempty"` + // A Teleport predicate language query used to filter the results. + Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -520,6 +522,13 @@ func (x *ListBotInstancesV2Request_Filters) GetSearchTerm() string { return "" } +func (x *ListBotInstancesV2Request_Filters) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + var File_teleport_machineid_v1_bot_instance_service_proto protoreflect.FileDescriptor const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + @@ -535,7 +544,7 @@ const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + "\n" + "page_token\x18\x03 \x01(\tR\tpageToken\x12,\n" + "\x12filter_search_term\x18\x04 \x01(\tR\x10filterSearchTerm\x12!\n" + - "\x04sort\x18\x05 \x01(\v2\r.types.SortByR\x04sort\"\xac\x02\n" + + "\x04sort\x18\x05 \x01(\v2\r.types.SortByR\x04sort\"\xc2\x02\n" + "\x19ListBotInstancesV2Request\x12\x1b\n" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + @@ -543,11 +552,12 @@ const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + "\n" + "sort_field\x18\x03 \x01(\tR\tsortField\x12\x1b\n" + "\tsort_desc\x18\x04 \x01(\bR\bsortDesc\x12P\n" + - "\x06filter\x18\x05 \x01(\v28.teleport.machineid.v1.ListBotInstancesV2Request.FiltersR\x06filter\x1aE\n" + + "\x06filter\x18\x05 \x01(\v28.teleport.machineid.v1.ListBotInstancesV2Request.FiltersR\x06filter\x1a[\n" + "\aFilters\x12\x19\n" + "\bbot_name\x18\x01 \x01(\tR\abotName\x12\x1f\n" + "\vsearch_term\x18\x02 \x01(\tR\n" + - "searchTerm\"\x8b\x01\n" + + "searchTerm\x12\x14\n" + + "\x05query\x18\x03 \x01(\tR\x05query\"\x8b\x01\n" + "\x18ListBotInstancesResponse\x12G\n" + "\rbot_instances\x18\x01 \x03(\v2\".teleport.machineid.v1.BotInstanceR\fbotInstances\x12&\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"V\n" + diff --git a/api/proto/teleport/machineid/v1/bot_instance_service.proto b/api/proto/teleport/machineid/v1/bot_instance_service.proto index e60539ddf5f19..7f6b18552a1b5 100644 --- a/api/proto/teleport/machineid/v1/bot_instance_service.proto +++ b/api/proto/teleport/machineid/v1/bot_instance_service.proto @@ -78,6 +78,8 @@ message ListBotInstancesV2Request { // A search term used to filter the results. If non-empty, it's used to // match against supported fields. string search_term = 2; + // A Teleport predicate language query used to filter the results. + string query = 3; } } diff --git a/lib/auth/machineid/machineidv1/bot_instance_service.go b/lib/auth/machineid/machineidv1/bot_instance_service.go index 0b7a474efe433..7d9b27115c9dc 100644 --- a/lib/auth/machineid/machineidv1/bot_instance_service.go +++ b/lib/auth/machineid/machineidv1/bot_instance_service.go @@ -182,6 +182,7 @@ func (b *BotInstanceService) ListBotInstancesV2(ctx context.Context, req *pb.Lis SortDesc: req.GetSortDesc(), FilterBotName: req.GetFilter().GetBotName(), FilterSearchTerm: req.GetFilter().GetSearchTerm(), + FilterQuery: req.GetFilter().GetQuery(), }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/auth/machineid/machineidv1/expression/environment.go b/lib/auth/machineid/machineidv1/expression/environment.go new file mode 100644 index 0000000000000..f6a1baed0055b --- /dev/null +++ b/lib/auth/machineid/machineidv1/expression/environment.go @@ -0,0 +1,58 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package expression + +import ( + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" +) + +// Environment in which expressions will be evaluated. +type Environment struct { + Metadata *headerv1.Metadata + Spec *machineidv1.BotInstanceSpec + LatestHeartbeat *machineidv1.BotInstanceStatusHeartbeat + LatestAuthentication *machineidv1.BotInstanceStatusAuthentication +} + +func (e *Environment) GetMetadata() *headerv1.Metadata { + if e == nil { + return nil + } + return e.Metadata +} + +func (e *Environment) GetSpec() *machineidv1.BotInstanceSpec { + if e == nil { + return nil + } + return e.Spec +} + +func (e *Environment) GetLatestHeartbeat() *machineidv1.BotInstanceStatusHeartbeat { + if e == nil { + return nil + } + return e.LatestHeartbeat +} + +func (e *Environment) GetLatestAuthentication() *machineidv1.BotInstanceStatusAuthentication { + if e == nil { + return nil + } + return e.LatestAuthentication +} diff --git a/lib/auth/machineid/machineidv1/expression/expression.go b/lib/auth/machineid/machineidv1/expression/expression.go new file mode 100644 index 0000000000000..3ece9fe3c13c8 --- /dev/null +++ b/lib/auth/machineid/machineidv1/expression/expression.go @@ -0,0 +1,139 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package expression + +import ( + "github.com/coreos/go-semver/semver" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/expression" + "github.com/gravitational/teleport/lib/utils/typical" +) + +func NewBotInstanceExpressionParser() (*typical.Parser[*Environment, bool], error) { + spec := expression.DefaultParserSpec[*Environment]() + + spec.Variables = map[string]typical.Variable{ + "name": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetMetadata().GetName(), nil + }), + "metadata.name": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetMetadata().GetName(), nil + }), + "spec.bot_name": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetSpec().GetBotName(), nil + }), + "spec.instance_id": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetSpec().GetInstanceId(), nil + }), + "status.latest_heartbeat.architecture": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetLatestHeartbeat().GetArchitecture(), nil + }), + "status.latest_heartbeat.os": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetLatestHeartbeat().GetOs(), nil + }), + "status.latest_heartbeat.hostname": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetLatestHeartbeat().GetHostname(), nil + }), + "status.latest_heartbeat.one_shot": typical.DynamicVariable(func(env *Environment) (bool, error) { + return env.GetLatestHeartbeat().GetOneShot(), nil + }), + "status.latest_heartbeat.version": typical.DynamicVariable(func(env *Environment) (*semver.Version, error) { + if env.GetLatestHeartbeat().GetVersion() == "" { + return nil, nil + } + return semver.NewVersion(env.LatestHeartbeat.Version) + }), + "status.latest_authentication.join_method": typical.DynamicVariable(func(env *Environment) (string, error) { + return env.GetLatestAuthentication().GetJoinMethod(), nil + }), + } + + // e.g. `more_than(status.latest_heartbeat.version, "19.0.0")` + spec.Functions["more_than"] = typical.BinaryFunction[*Environment](semverGt) + // e.g. `less_than(status.latest_heartbeat.version, "19.0.2")` + spec.Functions["less_than"] = typical.BinaryFunction[*Environment](semverLt) + // e.g. `between(status.latest_heartbeat.version, "19.0.0", "19.0.2")` + spec.Functions["between"] = typical.TernaryFunction[*Environment](semverBetween) + // e.g. `equals(status.latest_heartbeat.version, "19.1.0")` + spec.Functions["equals"] = typical.BinaryFunction[*Environment](semverEq) + + return typical.NewParser[*Environment, bool](spec) +} + +func semverGt(a, b any) (bool, error) { + va, err := toSemver(a) + if va == nil || err != nil { + return false, err + } + vb, err := toSemver(b) + if vb == nil || err != nil { + return false, err + } + return va.Compare(*vb) > 0, nil +} + +func semverLt(a, b any) (bool, error) { + va, err := toSemver(a) + if va == nil || err != nil { + return false, err + } + vb, err := toSemver(b) + if vb == nil || err != nil { + return false, err + } + return va.Compare(*vb) < 0, nil +} + +func semverEq(a, b any) (bool, error) { + va, err := toSemver(a) + if va == nil || err != nil { + return false, err + } + vb, err := toSemver(b) + if vb == nil || err != nil { + return false, err + } + return va.Compare(*vb) == 0, nil +} + +func semverBetween(c, a, b any) (bool, error) { + gt, err := semverGt(c, a) + if err != nil { + return false, err + } + eq, err := semverEq(c, a) + if err != nil { + return false, err + } + lt, err := semverLt(c, b) + if err != nil { + return false, err + } + return (gt || eq) && lt, nil +} + +func toSemver(anyV any) (*semver.Version, error) { + switch v := anyV.(type) { + case *semver.Version: + return v, nil + case string: + return semver.NewVersion(v) + default: + return nil, trace.BadParameter("type %T cannot be parsed as semver.Version", v) + } +} diff --git a/lib/auth/machineid/machineidv1/expression/expression_test.go b/lib/auth/machineid/machineidv1/expression/expression_test.go new file mode 100644 index 0000000000000..832c2adf785ad --- /dev/null +++ b/lib/auth/machineid/machineidv1/expression/expression_test.go @@ -0,0 +1,162 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package expression + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" +) + +func TestBotInstanceExpressionParser(t *testing.T) { + parser, err := NewBotInstanceExpressionParser() + require.NoError(t, err) + + makeBaseEnv := func() Environment { + return Environment{ + Metadata: &headerv1.Metadata{ + Name: "test-bot-1/76efb07a-3077-471c-988a-54d0fa49fc71", + }, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "test-bot-1", + InstanceId: "76efb07a-3077-471c-988a-54d0fa49fc71", + }, + LatestAuthentication: &machineidv1.BotInstanceStatusAuthentication{ + JoinMethod: "kubernetes", + }, + LatestHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + IsStartup: false, + Version: "19.0.1", + OneShot: false, + Architecture: "arm64", + Os: "linux", + Hostname: "test-hostname-1", + }, + } + } + + tcs := []struct { + name string + expTrue string + expFalse string + envFns []func(*Environment) + }{ + { + name: "equals name", + expTrue: `metadata.name == "test-bot-1/76efb07a-3077-471c-988a-54d0fa49fc71"`, + expFalse: `metadata.name == "test-bot-2/bf8ad485-9a8f-483d-8bcb-9d2f8f8c48d0"`, + }, + { + name: "equals name (short)", + expTrue: `name == "test-bot-1/76efb07a-3077-471c-988a-54d0fa49fc71"`, + expFalse: `name == "test-bot-2/bf8ad485-9a8f-483d-8bcb-9d2f8f8c48d0"`, + }, + { + name: "equals bot name", + expTrue: `spec.bot_name == "test-bot-1"`, + expFalse: `spec.bot_name == "test-bot-2"`, + }, + { + name: "equals instance id", + expTrue: `spec.instance_id == "76efb07a-3077-471c-988a-54d0fa49fc71"`, + expFalse: `spec.instance_id == "80eefb93-e79c-47f1-8170-a025013da490"`, + }, + { + name: "equals architecture", + expTrue: `status.latest_heartbeat.architecture == "arm64"`, + expFalse: `status.latest_heartbeat.architecture == "amd64"`, + }, + { + name: "equals os", + expTrue: `status.latest_heartbeat.os == "linux"`, + expFalse: `status.latest_heartbeat.os == "windows"`, + }, + { + name: "equals hostname", + expTrue: `status.latest_heartbeat.hostname == "test-hostname-1"`, + expFalse: `status.latest_heartbeat.hostname == "test-hostname-2"`, + }, + { + name: "equals one shot", + expTrue: `status.latest_heartbeat.one_shot`, + expFalse: `!status.latest_heartbeat.one_shot`, + envFns: []func(*Environment){ + func(e *Environment) { + e.LatestHeartbeat = &machineidv1.BotInstanceStatusHeartbeat{ + OneShot: true, + } + }, + }, + }, + { + name: "version equals", + expTrue: `equals(status.latest_heartbeat.version, "19.0.1")`, + expFalse: `equals(status.latest_heartbeat.version, "19.0.2-rc.1+56001")`, + }, + { + name: "between versions - lower", + expTrue: `between(status.latest_heartbeat.version, "19.0.1", "19.0.2")`, + expFalse: `between(status.latest_heartbeat.version, "19.0.2", "19.0.3")`, + }, + { + name: "between versions - upper", + expTrue: `between(status.latest_heartbeat.version, "19.0.0", "19.0.2")`, + expFalse: `between(status.latest_heartbeat.version, "19.0.0", "19.0.1")`, + }, + { + name: "more than version", + expTrue: `more_than(status.latest_heartbeat.version, "19.0.0")`, + expFalse: `more_than(status.latest_heartbeat.version, "19.0.1")`, + }, + { + name: "less than version", + expTrue: `less_than(status.latest_heartbeat.version, "19.0.2")`, + expFalse: `less_than(status.latest_heartbeat.version, "19.0.1")`, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + env := makeBaseEnv() + if tc.envFns != nil { + for _, fn := range tc.envFns { + fn(&env) + } + } + + if tc.expTrue != "" { + exp, err := parser.Parse(tc.expTrue) + require.NoError(t, err) + result, err := exp.Evaluate(&env) + require.NoError(t, err) + assert.True(t, result) + } + + if tc.expFalse != "" { + exp, err := parser.Parse(tc.expFalse) + require.NoError(t, err) + result, err := exp.Evaluate(&env) + require.NoError(t, err) + assert.False(t, result) + } + }) + } +} diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index b84f3bacf9771..daca25763d73e 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -30,8 +30,10 @@ import ( machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils/clientutils" + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/expression" "github.com/gravitational/teleport/lib/itertools/stream" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils/typical" ) type botInstanceIndex string @@ -120,6 +122,18 @@ func (c *Cache) ListBotInstances(ctx context.Context, pageSize int, lastToken st return nil, "", trace.BadParameter("unsupported sort %q but expected bot_name, active_at_latest, version_latest or host_name_latest", options.GetSortField()) } + var exp typical.Expression[*expression.Environment, bool] + if options.GetFilterQuery() != "" { + parser, err := expression.NewBotInstanceExpressionParser() + if err != nil { + return nil, "", trace.Wrap(err) + } + exp, err = parser.Parse(options.GetFilterQuery()) + if err != nil { + return nil, "", trace.Wrap(err) + } + } + lister := genericLister[*machineidv1.BotInstance, botInstanceIndex]{ cache: c, collection: c.collections.botInstances, @@ -130,7 +144,7 @@ func (c *Cache) ListBotInstances(ctx context.Context, pageSize int, lastToken st return c.Config.BotInstanceService.ListBotInstances(ctx, limit, start, options) }, filter: func(b *machineidv1.BotInstance) bool { - return services.MatchBotInstance(b, options.GetFilterBotName(), options.GetFilterSearchTerm()) + return services.MatchBotInstance(b, options.GetFilterBotName(), options.GetFilterSearchTerm(), exp) }, nextToken: func(b *machineidv1.BotInstance) string { return keyFn(b) diff --git a/lib/cache/bot_instance_test.go b/lib/cache/bot_instance_test.go index e0298a9aaead6..9dfe182b00e9a 100644 --- a/lib/cache/bot_instance_test.go +++ b/lib/cache/bot_instance_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" @@ -85,7 +86,7 @@ func TestBotInstanceCache(t *testing.T) { func TestBotInstanceCachePaging(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, ForAuth) t.Cleanup(p.Close) @@ -144,7 +145,7 @@ func TestBotInstanceCachePaging(t *testing.T) { func TestBotInstanceCacheBotFilter(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, ForAuth) t.Cleanup(p.Close) @@ -181,11 +182,12 @@ func TestBotInstanceCacheBotFilter(t *testing.T) { } } -// TestBotInstanceCacheSearchFilter tests that cache items are filtered by search query. +// TestBotInstanceCacheSearchFilter tests that cache items are filtered by +// search term. func TestBotInstanceCacheSearchFilter(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, ForAuth) t.Cleanup(p.Close) @@ -226,11 +228,75 @@ func TestBotInstanceCacheSearchFilter(t *testing.T) { require.Len(t, results, 5) } +// TestBotInstanceCacheQueryFilter tests that cache items are filtered by query. +func TestBotInstanceCacheQueryFilter(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + p := newTestPack(t, ForAuth) + t.Cleanup(p.Close) + + { + _, err := p.botInstanceService.CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Metadata: &headerv1.Metadata{}, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-1", + InstanceId: "00000000-0000-0000-0000-000000000000", + }, + Status: &machineidv1.BotInstanceStatus{ + LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{ + { + Hostname: "host-1", + }, + }, + }, + }) + require.NoError(t, err) + } + + { + _, err := p.botInstanceService.CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Metadata: &headerv1.Metadata{}, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-1", + InstanceId: uuid.New().String(), + }, + Status: &machineidv1.BotInstanceStatus{ + LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{ + { + Hostname: "host-2", + }, + }, + }, + }) + require.NoError(t, err) + } + + // Let the cache catch up + require.EventuallyWithT(t, func(t *assert.CollectT) { + results, _, err := p.cache.ListBotInstances(ctx, 0, "", nil) + require.NoError(t, err) + require.Len(t, results, 2) + }, 10*time.Second, 100*time.Millisecond) + + results, _, err := p.cache.ListBotInstances(ctx, 0, "", &services.ListBotInstancesRequestOptions{ + FilterQuery: `status.latest_heartbeat.hostname == "host-1"`, + }) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, "00000000-0000-0000-0000-000000000000", results[0].Spec.InstanceId) +} + // TestBotInstanceCacheSorting tests that cache items are sorted. func TestBotInstanceCacheSorting(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, ForAuth) t.Cleanup(p.Close) diff --git a/lib/services/bot_instance.go b/lib/services/bot_instance.go index e7c5cfc6ecd2f..df9fcd3bacb1e 100644 --- a/lib/services/bot_instance.go +++ b/lib/services/bot_instance.go @@ -24,6 +24,8 @@ import ( "github.com/gravitational/trace" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/expression" + "github.com/gravitational/teleport/lib/utils/typical" ) // BotInstance is an interface for the BotInstance service. @@ -81,19 +83,27 @@ func UnmarshalBotInstance(data []byte, opts ...MarshalOption) (*machineidv1.BotI return UnmarshalProtoResource[*machineidv1.BotInstance](data, opts...) } -func MatchBotInstance(b *machineidv1.BotInstance, botName string, search string) bool { +func MatchBotInstance(b *machineidv1.BotInstance, botName string, search string, exp typical.Expression[*expression.Environment, bool]) bool { if botName != "" && b.GetSpec().GetBotName() != botName { return false } - if search == "" { - return true + heartbeat := GetBotInstanceLatestHeartbeat(b) + authentication := GetBotInstanceLatestAuthentication(b) + + if exp != nil { + if match, err := exp.Evaluate(&expression.Environment{ + Metadata: b.GetMetadata(), + Spec: b.GetSpec(), + LatestHeartbeat: heartbeat, + LatestAuthentication: authentication, + }); err != nil || !match { + return false + } } - latestHeartbeats := b.GetStatus().GetLatestHeartbeats() - heartbeat := b.Status.InitialHeartbeat // Use initial heartbeat as a fallback - if len(latestHeartbeats) > 0 { - heartbeat = latestHeartbeats[len(latestHeartbeats)-1] + if search == "" { + return true } values := []string{ @@ -111,8 +121,7 @@ func MatchBotInstance(b *machineidv1.BotInstance, botName string, search string) } // GetBotInstanceLatestHeartbeat returns the most recent heartbeat for the -// given bot instance. The initial heartbeat is returned as a fallback if no -// latest heartbeats exist. +// given bot instance. func GetBotInstanceLatestHeartbeat(botInstance *machineidv1.BotInstance) *machineidv1.BotInstanceStatusHeartbeat { heartbeat := botInstance.GetStatus().GetInitialHeartbeat() latestHeartbeats := botInstance.GetStatus().GetLatestHeartbeats() @@ -122,6 +131,17 @@ func GetBotInstanceLatestHeartbeat(botInstance *machineidv1.BotInstance) *machin return heartbeat } +// GetBotInstanceLatestAuthentication returns the most recent authentication for +// the given bot instance. +func GetBotInstanceLatestAuthentication(botInstance *machineidv1.BotInstance) *machineidv1.BotInstanceStatusAuthentication { + authentication := botInstance.GetStatus().GetInitialAuthentication() + latestAuthentications := botInstance.GetStatus().GetLatestAuthentications() + if len(latestAuthentications) > 0 { + authentication = latestAuthentications[len(latestAuthentications)-1] + } + return authentication +} + type ListBotInstancesRequestOptions struct { // The sort field to use for the results. If empty, the default sort field // is used. @@ -135,6 +155,8 @@ type ListBotInstancesRequestOptions struct { // A search term used to filter the results. If non-empty, it's used to // match against supported fields. FilterSearchTerm string + // A Teleport predicate language query used to filter the results. + FilterQuery string } func (o *ListBotInstancesRequestOptions) GetSortField() string { @@ -164,3 +186,10 @@ func (o *ListBotInstancesRequestOptions) GetFilterSearchTerm() string { } return o.FilterSearchTerm } + +func (o *ListBotInstancesRequestOptions) GetFilterQuery() string { + if o == nil { + return "" + } + return o.FilterQuery +} diff --git a/lib/services/local/bot_instance.go b/lib/services/local/bot_instance.go index 67770fcd0d382..58efc326684d3 100644 --- a/lib/services/local/bot_instance.go +++ b/lib/services/local/bot_instance.go @@ -26,9 +26,11 @@ import ( machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/expression" "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/services/local/generic" + "github.com/gravitational/teleport/lib/utils/typical" ) const ( @@ -110,13 +112,25 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, pageSize int, service = b.service.WithPrefix(options.GetFilterBotName()) } - if options.GetFilterSearchTerm() == "" { + var exp typical.Expression[*expression.Environment, bool] + if options.GetFilterQuery() != "" { + parser, err := expression.NewBotInstanceExpressionParser() + if err != nil { + return nil, "", trace.Wrap(err) + } + exp, err = parser.Parse(options.GetFilterQuery()) + if err != nil { + return nil, "", trace.Wrap(err) + } + } + + if options.GetFilterSearchTerm() == "" && exp == nil { r, nextToken, err := service.ListResources(ctx, pageSize, lastKey) return r, nextToken, trace.Wrap(err) } r, nextToken, err := service.ListResourcesWithFilter(ctx, pageSize, lastKey, func(item *machineidv1.BotInstance) bool { - return services.MatchBotInstance(item, "", options.GetFilterSearchTerm()) + return services.MatchBotInstance(item, "", options.GetFilterSearchTerm(), exp) }) return r, nextToken, trace.Wrap(err) diff --git a/lib/services/local/bot_instance_test.go b/lib/services/local/bot_instance_test.go index 080d14faacc8d..fe35c8ce5ba20 100644 --- a/lib/services/local/bot_instance_test.go +++ b/lib/services/local/bot_instance_test.go @@ -236,7 +236,7 @@ func TestBotInstanceCreateMetadata(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() mem, err := memory.New(memory.Config{ Context: ctx, @@ -259,7 +259,7 @@ func TestBotInstanceCreateMetadata(t *testing.T) { func TestBotInstanceInvalidGetters(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() clock := clockwork.NewFakeClock() mem, err := memory.New(memory.Config{ @@ -283,7 +283,7 @@ func TestBotInstanceInvalidGetters(t *testing.T) { func TestBotInstanceCRUD(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() clock := clockwork.NewFakeClock() mem, err := memory.New(memory.Config{ @@ -341,7 +341,7 @@ func TestBotInstanceCRUD(t *testing.T) { func TestBotInstanceList(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() clock := clockwork.NewFakeClock() mem, err := memory.New(memory.Config{ @@ -392,7 +392,7 @@ func TestBotInstanceList(t *testing.T) { } } -// TestBotInstanceListWithSearchFilter verifies list and filtering wit39db3c10-870c-4544-aeec-9fc2e961eca3h search +// TestBotInstanceListWithSearchFilter verifies list and filtering with search // term functionality for bot instances. func TestBotInstanceListWithSearchFilter(t *testing.T) { t.Parallel() @@ -438,7 +438,7 @@ func TestBotInstanceListWithSearchFilter(t *testing.T) { for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() mem, err := memory.New(memory.Config{ Context: ctx, @@ -464,13 +464,42 @@ func TestBotInstanceListWithSearchFilter(t *testing.T) { } } +// TestBotInstanceListWithQuery verifies list and filtering with query +// functionality for bot instances. +func TestBotInstanceListWithQuery(t *testing.T) { + t.Parallel() + + clock := clockwork.NewFakeClock() + ctx := t.Context() + mem, err := memory.New(memory.Config{ + Context: ctx, + Clock: clock, + }) + require.NoError(t, err) + service, err := NewBotInstanceService(backend.NewSanitizer(mem), clock) + require.NoError(t, err) + + instance := newBotInstance("test-bot", withBotInstanceHeartbeatHostname("svr-eu-tel-123-a")) + _, err = service.CreateBotInstance(ctx, instance) + require.NoError(t, err) + _, err = service.CreateBotInstance(ctx, newBotInstance("bot-not-matched")) + require.NoError(t, err) + + instances := listInstances(t, ctx, service, &services.ListBotInstancesRequestOptions{ + FilterQuery: `status.latest_heartbeat.hostname == "svr-eu-tel-123-a"`, + }) + + require.Len(t, instances, 1) + require.Equal(t, instance.Spec.InstanceId, instances[0].Spec.InstanceId) +} + // TestBotInstanceListWithSort verifies sorting returns a not-implemented error. func TestBotInstanceListWithSort(t *testing.T) { t.Parallel() clock := clockwork.NewFakeClock() - ctx := context.Background() + ctx := t.Context() mem, err := memory.New(memory.Config{ Context: ctx, diff --git a/lib/web/machineid.go b/lib/web/machineid.go index f3652cae760ec..9d644f4dd83c5 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -463,6 +463,7 @@ func (h *Handler) listBotInstancesV2(_ http.ResponseWriter, r *http.Request, _ h Filter: &machineidv1.ListBotInstancesV2Request_Filters{ BotName: r.URL.Query().Get("bot_name"), SearchTerm: r.URL.Query().Get("search"), + Query: r.URL.Query().Get("query"), }, } @@ -486,18 +487,22 @@ func (h *Handler) listBotInstancesV2(_ http.ResponseWriter, r *http.Request, _ h uiInstances := tslices.Map(instances.BotInstances, func(instance *machineidv1.BotInstance) BotInstance { heartbeat := services.GetBotInstanceLatestHeartbeat(instance) + authentication := services.GetBotInstanceLatestAuthentication(instance) uiInstance := BotInstance{ - InstanceId: instance.Spec.InstanceId, - BotName: instance.Spec.BotName, + InstanceId: instance.GetSpec().GetInstanceId(), + BotName: instance.GetSpec().GetBotName(), + } + + if authentication != nil { + uiInstance.JoinMethodLatest = authentication.GetJoinMethod() } if heartbeat != nil { - uiInstance.JoinMethodLatest = heartbeat.JoinMethod - uiInstance.HostNameLatest = heartbeat.Hostname - uiInstance.VersionLatest = heartbeat.Version - uiInstance.ActiveAtLatest = heartbeat.RecordedAt.AsTime().Format(time.RFC3339) - uiInstance.OSLatest = heartbeat.Os + uiInstance.HostNameLatest = heartbeat.GetHostname() + uiInstance.VersionLatest = heartbeat.GetVersion() + uiInstance.ActiveAtLatest = heartbeat.GetRecordedAt().AsTime().Format(time.RFC3339) + uiInstance.OSLatest = heartbeat.GetOs() } return uiInstance diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index ce3a99ff17d53..1f449f1fc2976 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -17,7 +17,6 @@ package web import ( - "context" "encoding/json" "fmt" "math" @@ -43,7 +42,7 @@ import ( ) func TestListBots(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -82,7 +81,7 @@ func TestListBots(t *testing.T) { } func TestListBots_UnauthenticatedError(t *testing.T) { - ctx := context.Background() + ctx := t.Context() s := newWebSuite(t) env := newWebPack(t, 1) proxy := env.proxies[0] @@ -121,7 +120,7 @@ func TestCreateBot(t *testing.T) { "bot", ) - ctx := context.Background() + ctx := t.Context() resp, err := pack.clt.PostJSON(ctx, endpoint, CreateBotRequest{ BotName: "test-bot", @@ -179,7 +178,7 @@ func TestCreateBot(t *testing.T) { } func TestCreateBotJoinToken(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -231,7 +230,7 @@ func TestCreateBotJoinToken(t *testing.T) { } func TestDeleteBot_UnauthenticatedError(t *testing.T) { - ctx := context.Background() + ctx := t.Context() s := newWebSuite(t) env := newWebPack(t, 1) proxy := env.proxies[0] @@ -255,7 +254,7 @@ func TestDeleteBot_UnauthenticatedError(t *testing.T) { func TestDeleteBot(t *testing.T) { botName := "bot-bravo" - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -290,7 +289,7 @@ func TestDeleteBot(t *testing.T) { } func TestGetBotByName(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -325,7 +324,7 @@ func TestGetBotByName(t *testing.T) { } func TestEditBot(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -359,7 +358,7 @@ func TestEditBot(t *testing.T) { } func TestEditBotRoles(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -415,7 +414,7 @@ func TestEditBotRoles(t *testing.T) { } func TestEditBotTraits(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -484,7 +483,7 @@ func TestEditBotTraits(t *testing.T) { } func TestEditBotMaxSessionTTL(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -549,7 +548,7 @@ func TestEditBotMaxSessionTTL(t *testing.T) { func TestListBotInstances(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -605,6 +604,27 @@ func TestListBotInstances(t *testing.T) { Os: "linux", }, }, + LatestAuthentications: []*machineidv1.BotInstanceStatusAuthentication{ + { + AuthenticatedAt: ×tamppb.Timestamp{ + Seconds: 2, + Nanos: 0, + }, + }, + { + AuthenticatedAt: ×tamppb.Timestamp{ + Seconds: 1, + Nanos: 0, + }, + }, + { + AuthenticatedAt: ×tamppb.Timestamp{ + Seconds: 2, + Nanos: 0, + }, + JoinMethod: "test-join-method", + }, + }, }, }) require.NoError(t, err) @@ -639,7 +659,7 @@ func TestListBotInstances(t *testing.T) { func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -682,6 +702,9 @@ func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { JoinMethod: "test-join-method", }, LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{}, + InitialAuthentication: &machineidv1.BotInstanceStatusAuthentication{ + JoinMethod: "test-join-method", + }, }, }) require.NoError(t, err) @@ -715,7 +738,7 @@ func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { func TestListBotInstancesPaging(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -858,7 +881,7 @@ func TestListBotInstancesSorting(t *testing.T) { func TestListBotInstancesWithBotFilter(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -916,7 +939,7 @@ func TestListBotInstancesWithBotFilter(t *testing.T) { func TestListBotInstancesWithSearchTermFilter(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -1047,8 +1070,68 @@ func TestListBotInstancesWithSearchTermFilter(t *testing.T) { } } +func TestListBotInstancesWithQueryFilter(t *testing.T) { + t.Parallel() + + ctx := t.Context() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + endpoint := pack.clt.Endpoint( + "v2", + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ) + + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "test-bot-1", + InstanceId: "00000000-0000-0000-0000-000000000000", + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Hostname: "svr-eu-tel-123-a", + }, + }, + }) + require.NoError(t, err) + + _, err = env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "test-bot-2", + InstanceId: uuid.New().String(), + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Hostname: "test-hostname", + }, + }, + }) + require.NoError(t, err) + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "query": []string{`status.latest_heartbeat.hostname == "svr-eu-tel-123-a"`}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var instances ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + + assert.Len(t, instances.BotInstances, 1) + assert.Equal(t, "00000000-0000-0000-0000-000000000000", instances.BotInstances[0].InstanceId) +} + func TestGetBotInstance(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) diff --git a/web/packages/design/src/DataTable/InputSearch/InputSearch.test.tsx b/web/packages/design/src/DataTable/InputSearch/InputSearch.test.tsx new file mode 100644 index 0000000000000..cadbe626dcb5c --- /dev/null +++ b/web/packages/design/src/DataTable/InputSearch/InputSearch.test.tsx @@ -0,0 +1,67 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { ComponentProps, PropsWithChildren } from 'react'; + +import { Providers, render, screen, userEvent } from 'design/utils/testing'; + +import InputSearch from './InputSearch'; + +describe('InputSearch', () => { + test('renders', async () => { + renderComponent({ + searchValue: '', + setSearchValue: jest.fn(), + }); + + expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); + }); + + test('submits a search', async () => { + const setSearchValue = jest.fn(); + + const { user } = renderComponent({ + searchValue: '', + setSearchValue, + }); + + const input = screen.getByPlaceholderText('Search...'); + await user.click(input); + await user.paste('Lorem ipsum delor sit amet.'); + await user.type(input, '{enter}'); + + expect(setSearchValue).toHaveBeenCalledTimes(1); + expect(setSearchValue).toHaveBeenLastCalledWith( + 'Lorem ipsum delor sit amet.' + ); + }); +}); + +function renderComponent(props: ComponentProps) { + const user = userEvent.setup(); + return { + ...render(, { wrapper: makeWrapper() }), + user, + }; +} + +function makeWrapper() { + return (props: PropsWithChildren) => { + return {props.children}; + }; +} diff --git a/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx b/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx index 4708f19cc9969..bd69ad0673945 100644 --- a/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx +++ b/web/packages/design/src/DataTable/InputSearch/InputSearch.tsx @@ -61,6 +61,8 @@ export default function InputSearch({ {children} + {/* Required to submit a form with multiple inputs using keyboard [ENTER] */} + ); diff --git a/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx b/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx index 70a2ff22a6899..f20d09ab9d8f9 100644 --- a/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx +++ b/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx @@ -38,8 +38,11 @@ export function AdvancedSearchToggle(props: { px={props.px} className={props.className} > - - Advanced + + + Advanced + + diff --git a/web/packages/shared/components/Search/SearchPanel.test.tsx b/web/packages/shared/components/Search/SearchPanel.test.tsx new file mode 100644 index 0000000000000..40e9e11e3e936 --- /dev/null +++ b/web/packages/shared/components/Search/SearchPanel.test.tsx @@ -0,0 +1,105 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { ComponentProps, PropsWithChildren } from 'react'; + +import { Providers, render, screen, userEvent } from 'design/utils/testing'; + +import { SearchPanel } from './SearchPanel'; + +describe('SearchPanel', () => { + test('renders', async () => { + renderComponent({ + filter: { + search: '', + }, + updateSearch: jest.fn(), + updateQuery: jest.fn(), + disableSearch: false, + }); + + expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument(); + }); + + test('submits a search', async () => { + const updateSearch = jest.fn(); + const updateQuery = jest.fn(); + + const { user } = renderComponent({ + filter: { + search: '', + }, + updateSearch, + updateQuery, + hideAdvancedSearch: true, + disableSearch: false, + }); + + const input = screen.getByPlaceholderText('Search...'); + await user.click(input); + await user.paste('Lorem ipsum delor sit amet.'); + await user.type(input, '{enter}'); + + expect(updateSearch).toHaveBeenCalledTimes(1); + expect(updateSearch).toHaveBeenLastCalledWith( + 'Lorem ipsum delor sit amet.' + ); + expect(updateQuery).not.toHaveBeenCalled(); + }); + + test('submits a query (advanced)', async () => { + const updateSearch = jest.fn(); + const updateQuery = jest.fn(); + + const { user } = renderComponent({ + filter: { + query: '', + }, + updateSearch, + updateQuery, + hideAdvancedSearch: false, + disableSearch: false, + }); + + // Toggle advanced mode on + await userEvent.click(screen.getByLabelText('Advanced')); + + const input = screen.getByPlaceholderText('Search...'); + await user.click(input); + await user.paste('Lorem ipsum delor sit amet.'); + await user.type(input, '{enter}'); + + expect(updateQuery).toHaveBeenCalledTimes(1); + expect(updateQuery).toHaveBeenLastCalledWith('Lorem ipsum delor sit amet.'); + expect(updateSearch).not.toHaveBeenCalled(); + }); +}); + +function renderComponent(props: ComponentProps) { + const user = userEvent.setup(); + return { + ...render(, { wrapper: makeWrapper() }), + user, + }; +} + +function makeWrapper() { + return (props: PropsWithChildren) => { + return {props.children}; + }; +} diff --git a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx index bda06ee65d455..b073a9ca58f57 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx @@ -223,7 +223,7 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(1); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 20, + pageSize: 30, pageToken: '', searchTerm: '', query: '', @@ -239,7 +239,7 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(2); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 20, + pageSize: 30, pageToken: '.next', searchTerm: '', query: '', @@ -255,7 +255,7 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(3); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 20, + pageSize: 30, pageToken: '.next.next', searchTerm: '', query: '', @@ -309,7 +309,7 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(1); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 20, + pageSize: 30, pageToken: '', searchTerm: '', query: '', @@ -326,7 +326,7 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(2); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 20, + pageSize: 30, pageToken: '.next', searchTerm: '', query: '', @@ -336,17 +336,18 @@ describe('BotInstances', () => { expect.anything() ); - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + jest.useRealTimers(); // Required as user.type() uses setTimeout internally const search = screen.getByPlaceholderText('Search...'); await waitFor(() => expect(search).toBeEnabled()); - await userEvent.type(search, 'test-search-term'); + await userEvent.click(search); + await userEvent.paste('test-search-term'); await userEvent.type(search, '{enter}'); expect(listBotInstances).toHaveBeenCalledTimes(3); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 20, + pageSize: 30, pageToken: '', // Search should reset to the first page searchTerm: 'test-search-term', query: '', @@ -357,6 +358,85 @@ describe('BotInstances', () => { ); }); + it('Allows filtering (query)', async () => { + jest.mocked(listBotInstances).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + bot_instances: [ + { + bot_name: `test-bot`, + instance_id: `00000000-0000-4000-0000-000000000000`, + active_at_latest: `2025-05-19T07:32:00Z`, + host_name_latest: 'test-hostname', + join_method_latest: 'test-join-method', + version_latest: `1.0.0-dev-a12b3c`, + }, + ], + next_page_token: pageToken + '.next', + }); + }) + ); + + expect(listBotInstances).toHaveBeenCalledTimes(0); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(listBotInstances).toHaveBeenCalledTimes(1); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 30, + pageToken: '', + searchTerm: '', + query: '', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + + const [nextButton] = screen.getAllByTitle('Next page'); + await waitFor(() => expect(nextButton).toBeEnabled()); + fireEvent.click(nextButton); + + expect(listBotInstances).toHaveBeenCalledTimes(2); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 30, + pageToken: '.next', + searchTerm: '', + query: '', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + + jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + + const search = screen.getByPlaceholderText('Search...'); + await waitFor(() => expect(search).toBeEnabled()); + await userEvent.click(screen.getByLabelText('Advanced')); + await userEvent.click(search); + await userEvent.paste(`status.latest_heartbeat.hostname == "host-1"`); + await userEvent.type(search, '{enter}'); + + expect(listBotInstances).toHaveBeenCalledTimes(3); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 30, + pageToken: '', // Search should reset to the first page + searchTerm: '', + query: `status.latest_heartbeat.hostname == "host-1"`, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + }); + it('Allows sorting', async () => { jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => @@ -388,7 +468,7 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(1); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 20, + pageSize: 30, pageToken: '', searchTerm: '', query: '', @@ -403,7 +483,7 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(2); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 20, + pageSize: 30, pageToken: '', searchTerm: '', query: '', @@ -419,7 +499,7 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(3); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 20, + pageSize: 30, pageToken: '', searchTerm: '', query: '', diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index df4cf75c2c112..232204cba3f76 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -69,11 +69,12 @@ export function BotInstances() { pageToken, sortField, sortDir, + query, ], queryFn: ({ signal }) => listBotInstances( { - pageSize: 20, + pageSize: 30, pageToken, searchTerm, query, @@ -134,9 +135,25 @@ export function BotInstances() { (term: string) => { const search = new URLSearchParams(location.search); search.set('search', term); - search.set('page', ''); + search.delete('query'); + search.delete('page'); - history.replace({ + history.push({ + pathname: `${location.pathname}`, + search: search.toString(), + }); + }, + [history, location.pathname, location.search] + ); + + const handleQueryChange = useCallback( + (exp: string) => { + const search = new URLSearchParams(location.search); + search.set('query', exp); + search.delete('search'); + search.delete('page'); + + history.push({ pathname: `${location.pathname}`, search: search.toString(), }); @@ -166,7 +183,7 @@ export function BotInstances() { const search = new URLSearchParams(location.search); search.set('sort_field', sortType.fieldName); search.set('sort_dir', sortType.dir); - search.set('page', ''); + search.delete('page'); history.replace({ pathname: location.pathname, @@ -228,7 +245,9 @@ export function BotInstances() { onFetchNext={hasNextPage ? handleFetchNext : undefined} onFetchPrev={hasPrevPage ? handleFetchPrev : undefined} onSearchChange={handleSearchChange} + onQueryChange={handleQueryChange} searchTerm={searchTerm} + query={query} onItemSelected={onItemSelected} sortType={sortType} onSortChanged={handleSortChanged} diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index 081937383ebbf..3b50734882d61 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -43,14 +43,18 @@ export function BotInstancesList({ onFetchNext, onFetchPrev, searchTerm, + query, onSearchChange, + onQueryChange, onItemSelected, sortType, onSortChanged, }: { data: BotInstanceSummary[]; searchTerm: string; + query: string; onSearchChange: (term: string) => void; + onQueryChange: (term: string) => void; onItemSelected: (item: BotInstanceSummary) => void; sortType: SortType; onSortChanged: (sortType: SortType) => void; @@ -83,8 +87,9 @@ export function BotInstancesList({ serversideSearchPanel: ( ),