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: (
),