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 f56df7e74746d..3bef9f97a0f17 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 @@ -105,9 +105,11 @@ type ListBotInstancesRequest struct { PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` // The page_token value returned from a previous ListBotInstances request, if // any. - PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + // A search term used to filter the results. If non-empty, it's used to match against supported fields. + FilterSearchTerm string `protobuf:"bytes,4,opt,name=filter_search_term,json=filterSearchTerm,proto3" json:"filter_search_term,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListBotInstancesRequest) Reset() { @@ -161,6 +163,13 @@ func (x *ListBotInstancesRequest) GetPageToken() string { return "" } +func (x *ListBotInstancesRequest) GetFilterSearchTerm() string { + if x != nil { + return x.FilterSearchTerm + } + return "" +} + // Response for ListBotInstances. type ListBotInstancesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -363,12 +372,13 @@ const file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = "" + "\x15GetBotInstanceRequest\x12\x19\n" + "\bbot_name\x18\x01 \x01(\tR\abotName\x12\x1f\n" + "\vinstance_id\x18\x02 \x01(\tR\n" + - "instanceId\"}\n" + + "instanceId\"\xab\x01\n" + "\x17ListBotInstancesRequest\x12&\n" + "\x0ffilter_bot_name\x18\x01 \x01(\tR\rfilterBotName\x12\x1b\n" + "\tpage_size\x18\x02 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + - "page_token\x18\x03 \x01(\tR\tpageToken\"\x8b\x01\n" + + "page_token\x18\x03 \x01(\tR\tpageToken\x12,\n" + + "\x12filter_search_term\x18\x04 \x01(\tR\x10filterSearchTerm\"\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 eff01818599b7..8f1d2fb2fefb7 100644 --- a/api/proto/teleport/machineid/v1/bot_instance_service.proto +++ b/api/proto/teleport/machineid/v1/bot_instance_service.proto @@ -43,6 +43,8 @@ message ListBotInstancesRequest { // The page_token value returned from a previous ListBotInstances request, if // any. string page_token = 3; + // A search term used to filter the results. If non-empty, it's used to match against supported fields. + string filter_search_term = 4; } // Response for ListBotInstances. diff --git a/lib/auth/machineid/machineidv1/bot_instance_service.go b/lib/auth/machineid/machineidv1/bot_instance_service.go index 2e44015aeba16..db89348713956 100644 --- a/lib/auth/machineid/machineidv1/bot_instance_service.go +++ b/lib/auth/machineid/machineidv1/bot_instance_service.go @@ -143,7 +143,7 @@ func (b *BotInstanceService) ListBotInstances(ctx context.Context, req *pb.ListB return nil, trace.Wrap(err) } - res, nextToken, err := b.backend.ListBotInstances(ctx, req.FilterBotName, int(req.PageSize), req.PageToken) + res, nextToken, err := b.backend.ListBotInstances(ctx, req.FilterBotName, int(req.PageSize), req.PageToken, req.FilterSearchTerm) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/services/bot_instance.go b/lib/services/bot_instance.go index 75932fad91843..cf6d6b9360ecf 100644 --- a/lib/services/bot_instance.go +++ b/lib/services/bot_instance.go @@ -33,7 +33,7 @@ type BotInstance interface { GetBotInstance(ctx context.Context, botName, instanceID string) (*machineidv1.BotInstance, error) // ListBotInstances - ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string) ([]*machineidv1.BotInstance, string, error) + ListBotInstances(ctx context.Context, botName string, pageSize int, lastToken string, search string) ([]*machineidv1.BotInstance, string, error) // DeleteBotInstance DeleteBotInstance(ctx context.Context, botName, instanceID string) error diff --git a/lib/services/local/bot_instance.go b/lib/services/local/bot_instance.go index 387ba28fb250e..c1a35d2816dd0 100644 --- a/lib/services/local/bot_instance.go +++ b/lib/services/local/bot_instance.go @@ -18,6 +18,8 @@ package local import ( "context" + "slices" + "strings" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" @@ -89,17 +91,45 @@ func (b *BotInstanceService) GetBotInstance(ctx context.Context, botName, instan return instance, trace.Wrap(err) } -// ListBotInstances lists all bot instances matching the given bot name filter. -// If an empty bot name is provided, all bot instances will be fetched. -func (b *BotInstanceService) ListBotInstances(ctx context.Context, botName string, pageSize int, lastKey string) ([]*machineidv1.BotInstance, string, error) { - // If botName is empty, return instances for all bots by not using a service prefix +// ListBotInstances lists all matching bot instances. A bot name and/or search terms can be optionally provided. +// If an non-empty bot name is provided, only instances for that bot will be fetched. +// If an non-empty search term is provided, only instances with a value containing the term in supported fields are fetched. +// Supported search fields include; bot name, instance id, hostname (latest), tbot version (latest), join method (latest) +func (b *BotInstanceService) ListBotInstances(ctx context.Context, botName string, pageSize int, lastKey string, search string) ([]*machineidv1.BotInstance, string, error) { + var service *generic.ServiceWrapper[*machineidv1.BotInstance] if botName == "" { - r, nextToken, err := b.service.ListResources(ctx, pageSize, lastKey) + // If botName is empty, return instances for all bots by not using a service prefix + service = b.service + } else { + service = b.service.WithPrefix(botName) + } + + if search == "" { + r, nextToken, err := service.ListResources(ctx, pageSize, lastKey) return r, nextToken, trace.Wrap(err) } - serviceWithPrefix := b.service.WithPrefix(botName) - r, nextToken, err := serviceWithPrefix.ListResources(ctx, pageSize, lastKey) + r, nextToken, err := service.ListResourcesWithFilter(ctx, pageSize, lastKey, func(item *machineidv1.BotInstance) bool { + latestHeartbeats := item.GetStatus().GetLatestHeartbeats() + heartbeat := item.Status.InitialHeartbeat // Use initial heartbeat as a fallback + if len(latestHeartbeats) > 0 { + heartbeat = latestHeartbeats[len(latestHeartbeats)-1] + } + + values := []string{ + item.Spec.BotName, + item.Spec.InstanceId, + } + + if heartbeat != nil { + values = append(values, heartbeat.Hostname, heartbeat.JoinMethod, heartbeat.Version, "v"+heartbeat.Version) + } + + return slices.ContainsFunc(values, func(val string) bool { + return strings.Contains(strings.ToLower(val), strings.ToLower(search)) + }) + }) + 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 68c3eb040c62e..dc9a451faba17 100644 --- a/lib/services/local/bot_instance_test.go +++ b/lib/services/local/bot_instance_test.go @@ -81,13 +81,73 @@ func withBotInstanceExpiry(expiry time.Time) func(*machineidv1.BotInstance) { } } +// withBotInstanceId sets the .Spec.InstanceId field of a bot instance to +// the given value. +func withBotInstanceId(value string) func(*machineidv1.BotInstance) { + return func(bi *machineidv1.BotInstance) { + if bi.Spec == nil { + bi.Spec = &machineidv1.BotInstanceSpec{} + } + + bi.Spec.InstanceId = value + } +} + +// withBotInstanceHeartbeatJoinMethod sets the .Status.InitialHeartbeat.JoinMethod +// field of a bot instance to the given value. +func withBotInstanceHeartbeatJoinMethod(value string) func(*machineidv1.BotInstance) { + return func(bi *machineidv1.BotInstance) { + if bi.Status == nil { + bi.Status = &machineidv1.BotInstanceStatus{} + } + + if bi.Status.InitialHeartbeat == nil { + bi.Status.InitialHeartbeat = &machineidv1.BotInstanceStatusHeartbeat{} + } + + bi.Status.InitialHeartbeat.JoinMethod = value + } +} + +// withBotInstanceHeartbeatVersion sets the .Status.InitialHeartbeat.Version +// field of a bot instance to the given value. +func withBotInstanceHeartbeatVersion(value string) func(*machineidv1.BotInstance) { + return func(bi *machineidv1.BotInstance) { + if bi.Status == nil { + bi.Status = &machineidv1.BotInstanceStatus{} + } + + if bi.Status.InitialHeartbeat == nil { + bi.Status.InitialHeartbeat = &machineidv1.BotInstanceStatusHeartbeat{} + } + + bi.Status.InitialHeartbeat.Version = value + } +} + +// withBotInstanceHeartbeatHostname sets the .Status.InitialHeartbeat.Hostname +// field of a bot instance to the given value. +func withBotInstanceHeartbeatHostname(value string) func(*machineidv1.BotInstance) { + return func(bi *machineidv1.BotInstance) { + if bi.Status == nil { + bi.Status = &machineidv1.BotInstanceStatus{} + } + + if bi.Status.InitialHeartbeat == nil { + bi.Status.InitialHeartbeat = &machineidv1.BotInstanceStatusHeartbeat{} + } + + bi.Status.InitialHeartbeat.Hostname = value + } +} + // createInstances creates new bot instances for the named bot with random UUIDs func createInstances(t *testing.T, ctx context.Context, service *BotInstanceService, botName string, count int) map[string]struct{} { t.Helper() ids := map[string]struct{}{} - for i := 0; i < count; i++ { + for range count { bi := newBotInstance(botName) _, err := service.CreateBotInstance(ctx, bi) require.NoError(t, err) @@ -99,7 +159,7 @@ func createInstances(t *testing.T, ctx context.Context, service *BotInstanceServ } // listInstances fetches all instances from the BotInstanceService matching the botName filter -func listInstances(t *testing.T, ctx context.Context, service *BotInstanceService, botName string) []*machineidv1.BotInstance { +func listInstances(t *testing.T, ctx context.Context, service *BotInstanceService, botName string, searchTerm string) []*machineidv1.BotInstance { t.Helper() var resources []*machineidv1.BotInstance @@ -108,7 +168,7 @@ func listInstances(t *testing.T, ctx context.Context, service *BotInstanceServic var err error for { - bis, nextKey, err = service.ListBotInstances(ctx, botName, 0, nextKey) + bis, nextKey, err = service.ListBotInstances(ctx, botName, 0, nextKey, searchTerm) require.NoError(t, err) resources = append(resources, bis...) @@ -138,7 +198,7 @@ func TestBotInstanceCreateMetadata(t *testing.T) { name: "non-nil metadata", instance: newBotInstance("foo", withBotInstanceInvalidMetadata()), assertError: require.NoError, - assertValue: func(t require.TestingT, i interface{}, _ ...interface{}) { + assertValue: func(t require.TestingT, i any, _ ...any) { bi, ok := i.(*machineidv1.BotInstance) require.True(t, ok) @@ -151,7 +211,7 @@ func TestBotInstanceCreateMetadata(t *testing.T) { name: "valid without expiry", instance: newBotInstance("foo"), assertError: require.NoError, - assertValue: func(t require.TestingT, i interface{}, _ ...interface{}) { + assertValue: func(t require.TestingT, i any, _ ...any) { bi, ok := i.(*machineidv1.BotInstance) require.True(t, ok) @@ -163,7 +223,7 @@ func TestBotInstanceCreateMetadata(t *testing.T) { name: "valid with expiry", instance: newBotInstance("foo", withBotInstanceExpiry(clock.Now().Add(time.Hour))), assertError: require.NoError, - assertValue: func(t require.TestingT, i interface{}, _ ...interface{}) { + assertValue: func(t require.TestingT, i any, _ ...any) { bi, ok := i.(*machineidv1.BotInstance) require.True(t, ok) @@ -247,7 +307,7 @@ func TestBotInstanceCRUD(t *testing.T) { require.EqualExportedValues(t, patched, bi2) require.Equal(t, bi.Metadata.Name, bi2.Metadata.Name) - resources := listInstances(t, ctx, service, "example") + resources := listInstances(t, ctx, service, "example", "") require.Len(t, resources, 1, "must list only 1 bot instance") require.EqualExportedValues(t, patched, resources[0]) @@ -273,7 +333,7 @@ func TestBotInstanceCRUD(t *testing.T) { require.Error(t, service.DeleteBotInstance(ctx, bi.Spec.BotName, bi.Spec.InstanceId)) } -// TestBotInstanceList verifies list and filtering functionality for bot +// TestBotInstanceList verifies list and filtering by bot functionality for bot // instances. func TestBotInstanceList(t *testing.T) { t.Parallel() @@ -294,14 +354,14 @@ func TestBotInstanceList(t *testing.T) { bIds := createInstances(t, ctx, service, "b", 4) // listing "a" should only return known "a" instances - aInstances := listInstances(t, ctx, service, "a") + aInstances := listInstances(t, ctx, service, "a", "") require.Len(t, aInstances, 3) for _, ins := range aInstances { require.Contains(t, aIds, ins.Spec.InstanceId) } // listing "b" should only return known "b" instances - bInstances := listInstances(t, ctx, service, "b") + bInstances := listInstances(t, ctx, service, "b", "") require.Len(t, bInstances, 4) for _, ins := range bInstances { require.Contains(t, bIds, ins.Spec.InstanceId) @@ -316,9 +376,79 @@ func TestBotInstanceList(t *testing.T) { } // Listing an empty bot name ("") should return all instances. - allInstances := listInstances(t, ctx, service, "") + allInstances := listInstances(t, ctx, service, "", "") require.Len(t, allInstances, 7) for _, ins := range allInstances { require.Contains(t, allIds, ins.Spec.InstanceId) } } + +// TestBotInstanceListWithSearchFilter verifies list and filtering wit39db3c10-870c-4544-aeec-9fc2e961eca3h search +// term functionality for bot instances. +func TestBotInstanceListWithSearchFilter(t *testing.T) { + t.Parallel() + + clock := clockwork.NewFakeClock() + + tcs := []struct { + name string + searchTerm string + instance *machineidv1.BotInstance + }{ + { + name: "match on bot name", + searchTerm: "nick", + instance: newBotInstance("this-is-nicks-test-bot"), + }, + { + name: "match on instance id", + searchTerm: "cb2c352", + instance: newBotInstance("test-bot", withBotInstanceId("cb2c3523-01f6-4258-966b-ace9f38f9862")), + }, + { + name: "match on join method", + searchTerm: "uber", + instance: newBotInstance("test-bot", withBotInstanceHeartbeatJoinMethod("kubernetes")), + }, + { + name: "match on version", + searchTerm: "1.0.0", + instance: newBotInstance("test-bot", withBotInstanceHeartbeatVersion("1.0.0-dev-a2g3hd")), + }, + { + name: "match on version (with v)", + searchTerm: "v1.0.0", + instance: newBotInstance("test-bot", withBotInstanceHeartbeatVersion("1.0.0-dev-a2g3hd")), + }, + { + name: "match on hostname", + searchTerm: "tel-123", + instance: newBotInstance("test-bot", withBotInstanceHeartbeatHostname("svr-eu-tel-123-a")), + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + 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) + + _, err = service.CreateBotInstance(ctx, tc.instance) + require.NoError(t, err) + _, err = service.CreateBotInstance(ctx, newBotInstance("bot-not-matched")) + require.NoError(t, err) + + instances := listInstances(t, ctx, service, "", tc.searchTerm) + + require.Len(t, instances, 1) + require.Equal(t, tc.instance.Spec.InstanceId, instances[0].Spec.InstanceId) + }) + } +} diff --git a/lib/utils/slices/slices.go b/lib/utils/slices/slices.go index 077911c8738cf..5e524b7f1ed9b 100644 --- a/lib/utils/slices/slices.go +++ b/lib/utils/slices/slices.go @@ -36,6 +36,15 @@ func FilterMapUnique[T any, S comparable](ts []T, fn func(T) (s S, include bool) return ss } +// Map calls the provided function on each element of a slice, and returns a slice containin the results. +func Map[T, S any](items []T, fn func(T) S) []S { + result := make([]S, len(items)) + for i, item := range items { + result[i] = fn(item) + } + return result +} + // ToPointers converts a slice of values to a slice of pointers to those values func ToPointers[T any](in []T) []*T { out := make([]*T, len(in)) diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 48d2f4e5a9ab9..84c53c0121546 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -1146,6 +1146,9 @@ func (h *Handler) bindDefaultEndpoints() { // Delete Machine ID bot h.DELETE("/webapi/sites/:site/machine-id/bot/:name", h.WithClusterAuth(h.deleteBot)) + // GET Machine ID bot instances (paged) + h.GET("/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstances)) + // GET a paginated list of notifications for a user h.GET("/webapi/sites/:site/notifications", h.WithClusterAuth(h.notificationsGet)) // Upsert the timestamp of the latest notification that the user has seen. diff --git a/lib/web/machineid.go b/lib/web/machineid.go index 7f40822ae0a50..bd49a7e8ee55f 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -18,6 +18,7 @@ package web import ( "net/http" + "strconv" "time" "github.com/gravitational/trace" @@ -29,6 +30,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/httplib" "github.com/gravitational/teleport/lib/reversetunnelclient" + tslices "github.com/gravitational/teleport/lib/utils/slices" ) const ( @@ -259,3 +261,70 @@ func (h *Handler) updateBot(w http.ResponseWriter, r *http.Request, p httprouter type updateBotRequest struct { Roles []string `json:"roles"` } + +// listBotInstances returns a list of bot instances for a given cluster site. +func (h *Handler) listBotInstances(_ http.ResponseWriter, r *http.Request, _ httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (any, error) { + clt, err := sctx.GetUserClient(r.Context(), site) + if err != nil { + return nil, trace.Wrap(err) + } + + var pageSize int64 = 20 + if r.URL.Query().Has("page_size") { + pageSize, err = strconv.ParseInt(r.URL.Query().Get("page_size"), 10, 32) + if err != nil { + return nil, trace.BadParameter("invalid page size") + } + } + + instances, err := clt.BotInstanceServiceClient().ListBotInstances(r.Context(), &machineidv1.ListBotInstancesRequest{ + FilterBotName: r.URL.Query().Get("bot_name"), + PageSize: int32(pageSize), + PageToken: r.URL.Query().Get("page_token"), + FilterSearchTerm: r.URL.Query().Get("search"), + }) + if err != nil { + return nil, trace.Wrap(err) + } + + uiInstances := tslices.Map(instances.BotInstances, func(instance *machineidv1.BotInstance) BotInstance { + latestHeartbeats := instance.GetStatus().GetLatestHeartbeats() + heartbeat := instance.Status.InitialHeartbeat // Use initial heartbeat as a fallback + if len(latestHeartbeats) > 0 { + heartbeat = latestHeartbeats[len(latestHeartbeats)-1] + } + + uiInstance := BotInstance{ + InstanceId: instance.Spec.InstanceId, + BotName: instance.Spec.BotName, + } + + if heartbeat != nil { + uiInstance.JoinMethodLatest = heartbeat.JoinMethod + uiInstance.HostNameLatest = heartbeat.Hostname + uiInstance.VersionLatest = heartbeat.Version + uiInstance.ActiveAtLatest = heartbeat.RecordedAt.AsTime().Format(time.RFC3339) + } + + return uiInstance + }) + + return ListBotInstancesResponse{ + BotInstances: uiInstances, + NextPageToken: instances.NextPageToken, + }, nil +} + +type ListBotInstancesResponse struct { + BotInstances []BotInstance `json:"bot_instances"` + NextPageToken string `json:"next_page_token,omitempty"` +} + +type BotInstance struct { + InstanceId string `json:"instance_id"` + BotName string `json:"bot_name"` + JoinMethodLatest string `json:"join_method_latest,omitempty"` + HostNameLatest string `json:"host_name_latest,omitempty"` + VersionLatest string `json:"version_latest,omitempty"` + ActiveAtLatest string `json:"active_at_latest,omitempty"` +} diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index 4a5e32eaf168c..944e92f8d42ef 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -20,15 +20,19 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" "net/url" "slices" "strconv" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" "github.com/gravitational/trace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" "github.com/gravitational/teleport/api/types" @@ -351,3 +355,370 @@ func TestEditBot(t *testing.T) { assert.Equal(t, botName, bot.Metadata.Name) assert.Equal(t, []string{"new-new-role"}, bot.Spec.Roles) } + +func TestListBotInstances(t *testing.T) { + t.Parallel() + + ctx := context.Background() + 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( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ) + + instanceId := uuid.New().String() + + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "test-bot", + InstanceId: instanceId, + }, + Status: &machineidv1.BotInstanceStatus{ + LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{ + { + RecordedAt: ×tamppb.Timestamp{ + Seconds: 2, + Nanos: 0, + }, + }, + { + RecordedAt: ×tamppb.Timestamp{ + Seconds: 1, + Nanos: 0, + }, + }, + { + RecordedAt: ×tamppb.Timestamp{ + Seconds: 3, + Nanos: 0, + }, + Version: "1.0.0", + Hostname: "test-hostname", + JoinMethod: "test-join-method", + }, + }, + }, + }) + require.NoError(t, err) + + response, err := pack.clt.Get(ctx, endpoint, url.Values{}) + 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) + require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ + BotInstances: []BotInstance{ + { + InstanceId: instanceId, + BotName: "test-bot", + JoinMethodLatest: "test-join-method", + HostNameLatest: "test-hostname", + VersionLatest: "1.0.0", + ActiveAtLatest: "1970-01-01T00:00:03Z", + }, + }, + })) +} + +func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { + t.Parallel() + + ctx := context.Background() + 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( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ) + + instanceId := uuid.New().String() + + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "test-bot", + InstanceId: instanceId, + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + RecordedAt: ×tamppb.Timestamp{ + Seconds: 3, + Nanos: 0, + }, + Version: "1.0.0", + Hostname: "test-hostname", + JoinMethod: "test-join-method", + }, + LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{}, + }, + }) + require.NoError(t, err) + + response, err := pack.clt.Get(ctx, endpoint, url.Values{}) + 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) + require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ + BotInstances: []BotInstance{ + { + InstanceId: instanceId, + BotName: "test-bot", + JoinMethodLatest: "test-join-method", + HostNameLatest: "test-hostname", + VersionLatest: "1.0.0", + ActiveAtLatest: "1970-01-01T00:00:03Z", + }, + }, + })) +} + +func TestListBotInstancesPaging(t *testing.T) { + t.Parallel() + + tcs := []struct { + name string + numInstances int + pageSize int + }{ + { + name: "zero results", + numInstances: 0, + pageSize: 1, + }, + { + name: "smaller page size", + numInstances: 5, + pageSize: 2, + }, + { + name: "larger page size", + numInstances: 2, + pageSize: 5, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + 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( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ) + + n := 0 + for n < tc.numInstances { + n += 1 + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-1", + InstanceId: uuid.New().String(), + }, + Status: &machineidv1.BotInstanceStatus{}, + }) + require.NoError(t, err) + } + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "page_token": []string{""}, // default to the start + "page_size": []string{strconv.Itoa(tc.pageSize)}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var resp ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &resp), "invalid response received") + + assert.Len(t, resp.BotInstances, int(math.Min(float64(tc.numInstances), float64(tc.pageSize)))) + }) + } +} + +func TestListBotInstancesWithBotFilter(t *testing.T) { + t.Parallel() + + ctx := context.Background() + 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( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ) + + n := 0 + for n < 5 { + n += 1 + botName := "bot-" + strconv.Itoa(n%2) + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: botName, + InstanceId: uuid.New().String(), + }, + Status: &machineidv1.BotInstanceStatus{}, + }) + require.NoError(t, err) + } + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "bot_name": []string{"bot-1"}, + }) + 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, 3) +} + +func TestListBotInstancesWithSearchTermFilter(t *testing.T) { + t.Parallel() + + tcs := []struct { + name string + searchTerm string + spec *machineidv1.BotInstanceSpec + heartbeat *machineidv1.BotInstanceStatusHeartbeat + }{ + { + name: "match on bot name", + searchTerm: "nick", + spec: &machineidv1.BotInstanceSpec{ + BotName: "this-is-nicks-test-bot", + InstanceId: "00000000-0000-0000-0000-000000000000", + }, + }, + { + name: "match on instance id", + searchTerm: "0000000", + }, + { + name: "match on join method", + searchTerm: "uber", + heartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + JoinMethod: "kubernetes", + }, + }, + { + name: "match on version", + searchTerm: "1.0.0", + heartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.0.0-dev-a2g3hd", + }, + }, + { + name: "match on version (with v)", + searchTerm: "v1.0.0", + heartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.0.0-dev-a2g3hd", + }, + }, + { + name: "match on hostname", + searchTerm: "tel-123", + heartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Hostname: "svr-eu-tel-123-a", + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + 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( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ) + + spec := tc.spec + if spec == nil { + spec = &machineidv1.BotInstanceSpec{ + BotName: "test-bot", + InstanceId: "00000000-0000-0000-0000-000000000000", + } + } + + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: spec, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: tc.heartbeat, + }, + }) + require.NoError(t, err) + + _, err = env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-gone", + InstanceId: uuid.New().String(), + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + Version: "1.1.1-prod", + Hostname: "test-hostname", + JoinMethod: "test-join-method", + }, + }, + }) + require.NoError(t, err) + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "search": []string{tc.searchTerm}, + }) + 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) + }) + } +} diff --git a/web/packages/design/src/DataTable/Table.tsx b/web/packages/design/src/DataTable/Table.tsx index d76c26e02d315..661193a45065b 100644 --- a/web/packages/design/src/DataTable/Table.tsx +++ b/web/packages/design/src/DataTable/Table.tsx @@ -104,7 +104,10 @@ export default function Table(props: TableProps) { const renderBody = (data: T[]) => { const rows: ReactNode[] = []; - if (fetching?.fetchStatus === 'loading') { + if ( + fetching?.fetchStatus === 'loading' && + !fetching.disableLoadingIndicator + ) { return ; } data.map((item, rowIdx) => { diff --git a/web/packages/design/src/DataTable/types.ts b/web/packages/design/src/DataTable/types.ts index 705bcb15d4080..f121438484094 100644 --- a/web/packages/design/src/DataTable/types.ts +++ b/web/packages/design/src/DataTable/types.ts @@ -141,6 +141,7 @@ export type FetchingConfig = { onFetchPrev?: () => void; onFetchMore?: () => void; fetchStatus: FetchStatus; + disableLoadingIndicator?: boolean; }; export type ServersideProps = { diff --git a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx new file mode 100644 index 0000000000000..c1aa62ed625c6 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx @@ -0,0 +1,280 @@ +/** + * 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 { QueryClientProvider } from '@tanstack/react-query'; +import { setupServer } from 'msw/node'; +import { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router'; + +import { darkTheme } from 'design/theme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { + fireEvent, + render, + screen, + testQueryClient, + userEvent, + waitFor, + waitForElementToBeRemoved, +} from 'design/utils/testing'; +import { InfoGuidePanelProvider } from 'shared/components/SlidingSidePanel/InfoGuide'; + +import { listBotInstances } from 'teleport/services/bot/bot'; +import { + listBotInstancesError, + listBotInstancesSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstances } from './BotInstances'; + +jest.mock('teleport/services/bot/bot', () => { + const actual = jest.requireActual('teleport/services/bot/bot'); + return { + listBotInstances: jest.fn((...all) => { + return actual.listBotInstances(...all); + }), + }; +}); + +const server = setupServer(); + +beforeEach(() => { + server.listen(); + + jest.useFakeTimers().setSystemTime(new Date('2025-05-19T08:00:00Z')); +}); + +afterEach(async () => { + server.resetHandlers(); + await testQueryClient.resetQueries(); + + jest.useRealTimers(); + jest.clearAllMocks(); +}); + +afterAll(() => server.close()); + +describe('BotInstances', () => { + it('Shows an empty state', async () => { + server.use( + listBotInstancesSuccess({ + bot_instances: [], + next_page_token: '', + }) + ); + + render(, { wrapper: Wrapper }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(screen.getByText('No active instances found')).toBeInTheDocument(); + expect( + screen.getByText( + 'Bot instances are ephemeral, and disappear once all issued credentials have expired.' + ) + ).toBeInTheDocument(); + }); + + it('Shows an error state', async () => { + server.use(listBotInstancesError(500)); + + render(, { wrapper: Wrapper }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect( + screen.getByText('Error: 500', { exact: false }) + ).toBeInTheDocument(); + }); + + it('Shows a list', async () => { + server.use( + listBotInstancesSuccess({ + bot_instances: [ + { + bot_name: 'test-bot-1', + instance_id: '5e885c66-1af3-4a36-987d-a604d8ee49d2', + 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', + }, + { + bot_name: 'test-bot-2', + instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', + }, + ], + next_page_token: '', + }) + ); + + render(, { wrapper: Wrapper }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(screen.getByText('test-bot-1')).toBeInTheDocument(); + expect(screen.getByText('5e885c6')).toBeInTheDocument(); + expect(screen.getByText('28 minutes ago')).toBeInTheDocument(); + expect(screen.getByText('test-hostname')).toBeInTheDocument(); + expect(screen.getByText('test-join-method')).toBeInTheDocument(); + expect(screen.getByText('v1.0.0-dev-a12b3c')).toBeInTheDocument(); + }); + + it('Allows paging', 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: Wrapper }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + const [nextButton] = screen.getAllByTitle('Next page'); + + expect(listBotInstances).toHaveBeenCalledTimes(1); + expect(listBotInstances).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + }); + + await waitFor(() => expect(nextButton).toBeEnabled()); + fireEvent.click(nextButton); + + expect(listBotInstances).toHaveBeenCalledTimes(2); + expect(listBotInstances).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '.next', + searchTerm: '', + }); + + await waitFor(() => expect(nextButton).toBeEnabled()); + fireEvent.click(nextButton); + + expect(listBotInstances).toHaveBeenCalledTimes(3); + expect(listBotInstances).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '.next.next', + searchTerm: '', + }); + + const [prevButton] = screen.getAllByTitle('Previous page'); + + await waitFor(() => expect(prevButton).toBeEnabled()); + fireEvent.click(prevButton); + + // This page's data will have been cached + expect(listBotInstances).toHaveBeenCalledTimes(3); + + await waitFor(() => expect(prevButton).toBeEnabled()); + fireEvent.click(prevButton); + + // This page's data will have been cached + expect(listBotInstances).toHaveBeenCalledTimes(3); + }); + + it('Allows filtering (search)', 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: Wrapper }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(listBotInstances).toHaveBeenCalledTimes(1); + expect(listBotInstances).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + }); + + const [nextButton] = screen.getAllByTitle('Next page'); + await waitFor(() => expect(nextButton).toBeEnabled()); + fireEvent.click(nextButton); + + expect(listBotInstances).toHaveBeenCalledTimes(2); + expect(listBotInstances).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '.next', + searchTerm: '', + }); + + jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + + const search = screen.getByPlaceholderText('Search...'); + await waitFor(() => expect(search).toBeEnabled()); + await userEvent.type(search, 'test-search-term'); + await userEvent.type(search, '{enter}'); + + expect(listBotInstances).toHaveBeenCalledTimes(3); + expect(listBotInstances).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', // Search should reset to the first page + searchTerm: 'test-search-term', + }); + }); +}); + +function Wrapper({ children }: PropsWithChildren) { + return ( + + + + + {children} + + + + + ); +} diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx new file mode 100644 index 0000000000000..f2c0c2601c6ca --- /dev/null +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -0,0 +1,173 @@ +/** + * 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 { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useHistory, useLocation } from 'react-router'; + +import { Alert } from 'design/Alert/Alert'; +import Box from 'design/Box/Box'; +import { Indicator } from 'design/Indicator/Indicator'; +import { Mark } from 'design/Mark/Mark'; +import { + InfoExternalTextLink, + InfoGuideButton, + InfoParagraph, + ReferenceLinks, +} from 'shared/components/SlidingSidePanel/InfoGuide/InfoGuide'; + +import { + FeatureBox, + FeatureHeader, + FeatureHeaderTitle, +} from 'teleport/components/Layout/Layout'; +import { listBotInstances } from 'teleport/services/bot/bot'; + +import { BotInstancesList } from './List/BotInstancesList'; + +export function BotInstances() { + const history = useHistory(); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const pageToken = queryParams.get('page') ?? ''; + const searchTerm = queryParams.get('search') ?? ''; + + const { isPending, isFetching, isSuccess, isError, error, data } = useQuery({ + queryKey: ['bot_instances', 'list', searchTerm, pageToken], + queryFn: () => + listBotInstances({ + pageSize: 20, + pageToken, + searchTerm, + }), + placeholderData: keepPreviousData, + staleTime: 30_000, // Cached pages are valid for 30 seconds + }); + + const hasNextPage = !!data?.next_page_token; + const hasPrevPage = !!pageToken; + + const handleFetchNext = useCallback(() => { + const search = new URLSearchParams(location.search); + search.set('page', data?.next_page_token ?? ''); + + history.push({ + pathname: `${location.pathname}`, + search: search.toString(), + }); + }, [data?.next_page_token, history, location.pathname, location.search]); + + const handleFetchPrev = useCallback(() => { + history.goBack(); + }, [history]); + + const handleSearchChange = useCallback((term: string) => { + const search = new URLSearchParams(location.search); + search.set('search', term); + search.set('page', ''); + + history.push({ + pathname: `${location.pathname}`, + search: search.toString(), + }); + }, []); + + return ( + + + Bot instances + }} /> + + + {isPending ? ( + + + + ) : undefined} + + {isError ? ( + {`Error: ${error.message}`} + ) : undefined} + + {isSuccess ? ( + + ) : undefined} + + ); +} + +const InfoGuide = () => ( + + + A{' '} + + Bot Instance + {' '} + identifies a single lineage of{' '} + + bot + {' '} + identities, even through certificate renewals and rejoins. When the{' '} + tbot client first authenticates to a cluster, a Bot Instance + is generated and its UUID is embedded in the returned client identity. + + + Bot Instances track a variety of information about tbot{' '} + instances, including regular heartbeats which include basic information + about the tbot host, like its architecture and OS version. + + + {' '} + Bot Instances have a relatively short lifespan and are set to expire after + the most recent identity issued for that instance will expire. If the{' '} + tbot client associated with a particular Bot Instance renews + or rejoins, the expiration of the bot instance is reset. This is designed + to allow users to list Bot Instances for an accurate view of the number of + active tbot clients interacting with their Teleport cluster. + + + +); + +const InfoGuideReferenceLinks = { + BotInstances: { + title: 'What are Bot instances', + href: 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bot-instances', + }, + Bots: { + title: 'What are Bots', + href: 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bots', + }, + Tctl: { + title: 'Use tctl to manage bot instances', + href: 'https://goteleport.com/docs/reference/cli/tctl/#tctl-bots-instances-add', + }, +}; diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx new file mode 100644 index 0000000000000..1e2cc43052f6d --- /dev/null +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -0,0 +1,151 @@ +/** + * 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 format from 'date-fns/format'; +import formatDistanceToNowStrict from 'date-fns/formatDistanceToNowStrict'; +import parseISO from 'date-fns/parseISO'; +import styled from 'styled-components'; + +import { Info } from 'design/Alert/Alert'; +import { Cell, LabelCell } from 'design/DataTable/Cells'; +import Table from 'design/DataTable/Table'; +import { FetchingConfig } from 'design/DataTable/types'; +import Flex from 'design/Flex'; +import Text from 'design/Text'; +import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; +import { SearchPanel } from 'shared/components/Search'; +import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton'; + +import { BotInstanceSummary } from 'teleport/services/bot/types'; + +const MonoText = styled(Text)` + font-family: ${({ theme }) => theme.fonts.mono}; +`; + +export function BotInstancesList({ + data, + fetchStatus, + onFetchNext, + onFetchPrev, + searchTerm, + onSearchChange, +}: { + data: BotInstanceSummary[]; + searchTerm: string; + onSearchChange: (term: string) => void; +} & Omit) { + const tableData = data.map(x => ({ + ...x, + hostnameDisplay: x.host_name_latest ?? '-', + instanceIdDisplay: x.instance_id.substring(0, 7), + versionDisplay: x.version_latest ? `v${x.version_latest}` : '-', + activeAtDisplay: x.active_at_latest + ? `${formatDistanceToNowStrict(parseISO(x.active_at_latest))} ago` + : '-', + activeAtLocal: x.active_at_latest + ? format(parseISO(x.active_at_latest), 'PP, p z') + : '-', + })); + + return ( + undefined, + serversideSearchPanel: ( + + ), + }} + columns={[ + { + key: 'bot_name', + headerText: 'Bot', + isSortable: false, + }, + { + key: 'instanceIdDisplay', + headerText: 'ID', + isSortable: false, + render: ({ instance_id, instanceIdDisplay }) => ( + + + {instanceIdDisplay} + + + + ), + }, + { + key: 'join_method_latest', + headerText: 'Method', + isSortable: false, + render: ({ join_method_latest }) => + join_method_latest ? ( + + ) : ( + {'-'} + ), + }, + { + key: 'hostnameDisplay', + headerText: 'Hostname', + isSortable: false, + }, + { + key: 'versionDisplay', + headerText: 'Version (tbot)', + isSortable: false, + }, + { + key: 'activeAtDisplay', + headerText: 'Last heartbeat', + isSortable: false, + render: ({ activeAtDisplay, activeAtLocal }) => ( + + + + {activeAtDisplay} + + + + ), + }, + ]} + emptyText="No active instances found" + emptyButton={ + + Bot instances are ephemeral, and disappear once all issued credentials + have expired. + + } + /> + ); +} diff --git a/web/packages/teleport/src/Bots/List/EmptyState/EmptyState.tsx b/web/packages/teleport/src/Bots/List/EmptyState/EmptyState.tsx index 0f206ef4aea27..719dc7779de8d 100644 --- a/web/packages/teleport/src/Bots/List/EmptyState/EmptyState.tsx +++ b/web/packages/teleport/src/Bots/List/EmptyState/EmptyState.tsx @@ -238,4 +238,6 @@ const PreviewBox = styled(Box)<{ includeShadow?: boolean }>` box-shadow: ${p => { return p.includeShadow ? p.theme.boxShadow[1] : 'none'; }}; + border-radius: 8px; + overflow: hidden; `; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index e28788dfc7f53..d20e3f93f87aa 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -181,6 +181,7 @@ const cfg = { desktop: '/web/cluster/:clusterId/desktops/:desktopName/:username', users: '/web/users', bots: '/web/bots', + botInstances: '/web/bots/instances', botsNew: '/web/bots/new/:type?', console: '/web/cluster/:clusterId/console', consoleNodes: '/web/cluster/:clusterId/console/nodes', @@ -448,6 +449,7 @@ const cfg = { botsPath: '/v1/webapi/sites/:clusterId/machine-id/bot/:name?', botsTokenPath: '/v1/webapi/sites/:clusterId/machine-id/token', + botInstancesPath: '/v1/webapi/sites/:clusterId/machine-id/bot-instance', gcpWorkforceConfigurePath: '/v1/webapi/scripts/integrations/configure/gcp-workforce-saml.sh?orgId=:orgId&poolName=:poolName&poolProviderName=:poolProviderName', @@ -716,6 +718,10 @@ const cfg = { return generatePath(cfg.routes.bots); }, + getBotInstancesRoute() { + return generatePath(cfg.routes.botInstances); + }, + getBotsNewRoute(type?: string) { return generatePath(cfg.routes.botsNew, { type }); }, @@ -1395,6 +1401,11 @@ const cfg = { return generatePath(cfg.api.botsPath, { clusterId, name }); }, + listBotInstancesUrl() { + const clusterId = cfg.proxyCluster; + return generatePath(cfg.api.botInstancesPath, { clusterId }); + }, + getGcpWorkforceConfigScriptUrl(p: UrlGcpWorkforceConfigParam) { return ( cfg.baseUrl + generatePath(cfg.api.gcpWorkforceConfigurePath, { ...p }) diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index c79792c469ac1..2dab14ee590e4 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -28,6 +28,7 @@ import { License, ListAddCheck, ListThin, + ListView as ListViewIcon, LockKey, PlugsConnected, Question, @@ -50,6 +51,7 @@ import { LockedAccessRequests } from './AccessRequests'; import { AccountPage } from './Account'; import { AuditContainer as Audit } from './Audit'; import { AuthConnectorsContainer as AuthConnectors } from './AuthConnectors'; +import { BotInstances } from './BotInstances/BotInstances'; import { Bots } from './Bots'; import { AddBots } from './Bots/Add'; import { Clusters } from './Clusters'; @@ -269,6 +271,40 @@ export class FeatureBots implements TeleportFeature { } } +export class FeatureBotInstances implements TeleportFeature { + category = NavigationCategory.MachineWorkloadId; + + route = { + title: 'View Bot instances', + path: cfg.routes.botInstances, + exact: true, + component: BotInstances, + }; + + hasAccess(flags: FeatureFlags) { + // if feature hiding is enabled, only show + // if the user has access + if (shouldHideFromNavigation(cfg)) { + return flags.listBotInstances; + } + return true; + } + + navigationItem = { + title: NavTitle.BotInstances, + icon: ListViewIcon, + exact: true, + getLink() { + return cfg.getBotInstancesRoute(); + }, + searchableTags: ['bots', 'bot', 'instance', 'instances'], + }; + + getRoute() { + return this.route; + } +} + export class FeatureAddBotsShortcut implements TeleportFeature { category = NavigationCategory.MachineWorkloadId; isHyperLink = true; @@ -761,6 +797,7 @@ export function getOSSFeatures(): TeleportFeature[] { // - Access new FeatureUsers(), new FeatureBots(), + new FeatureBotInstances(), new FeatureAddBotsShortcut(), new FeatureJoinTokens(), new FeatureRoles(), diff --git a/web/packages/teleport/src/mocks/contexts.ts b/web/packages/teleport/src/mocks/contexts.ts index 4dff49ec1fed2..eea39f5e93df7 100644 --- a/web/packages/teleport/src/mocks/contexts.ts +++ b/web/packages/teleport/src/mocks/contexts.ts @@ -78,6 +78,7 @@ export const allAccessAcl: Acl = { contacts: fullAccess, gitServers: fullAccess, accessGraphSettings: fullAccess, + botInstances: fullAccess, }; export function getAcl(cfg?: { noAccess: boolean }) { diff --git a/web/packages/teleport/src/services/bot/bot.ts b/web/packages/teleport/src/services/bot/bot.ts index c87b160355aaf..a8b03db9eed05 100644 --- a/web/packages/teleport/src/services/bot/bot.ts +++ b/web/packages/teleport/src/services/bot/bot.ts @@ -18,7 +18,11 @@ import cfg from 'teleport/config'; import api from 'teleport/services/api'; -import { makeBot, toApiGitHubTokenSpec } from 'teleport/services/bot/consts'; +import { + makeBot, + parseListBotInstancesResponse, + toApiGitHubTokenSpec, +} from 'teleport/services/bot/consts'; import ResourceService, { RoleResource } from 'teleport/services/resources'; import { FeatureFlags } from 'teleport/types'; @@ -103,3 +107,35 @@ export function deleteBot(flags: FeatureFlags, name: string) { return api.delete(cfg.getBotUrlWithName(name)); } + +export async function listBotInstances( + variables: { + pageToken: string; + pageSize: number; + searchTerm?: string; + botName?: string; + }, + signal?: AbortSignal +) { + const { pageToken, pageSize, searchTerm, botName } = variables; + + const path = cfg.listBotInstancesUrl(); + const qs = new URLSearchParams(); + + qs.set('page_size', pageSize.toFixed()); + qs.set('page_token', pageToken); + if (searchTerm) { + qs.set('search', searchTerm); + } + if (botName) { + qs.set('bot_name', botName); + } + + const data = await api.get(`${path}?${qs.toString()}`, signal); + + if (!parseListBotInstancesResponse(data)) { + throw new Error('failed to parse list bot instances response'); + } + + return data; +} diff --git a/web/packages/teleport/src/services/bot/consts.ts b/web/packages/teleport/src/services/bot/consts.ts index 77d2d098c34b9..091b7e3939f4b 100644 --- a/web/packages/teleport/src/services/bot/consts.ts +++ b/web/packages/teleport/src/services/bot/consts.ts @@ -22,6 +22,7 @@ import { BotUiFlow, FlatBot, GitHubRepoRule, + ListBotInstancesResponse, ProvisionTokenSpecV2GitHub, } from 'teleport/services/bot/types'; @@ -96,6 +97,24 @@ export function makeBot(bot: ApiBot): FlatBot { }; } +export function parseListBotInstancesResponse( + data: unknown +): data is ListBotInstancesResponse { + if (typeof data !== 'object' || data === null) { + return false; + } + + if (!('bot_instances' in data)) { + return false; + } + + if (!Array.isArray(data.bot_instances)) { + return false; + } + + return data.bot_instances.every(x => typeof x === 'object' || x !== null); +} + export function getBotType(labels: Map): BotType { if (!labels) { return null; diff --git a/web/packages/teleport/src/services/bot/types.ts b/web/packages/teleport/src/services/bot/types.ts index 71f193e5abe16..af161c602ffbd 100644 --- a/web/packages/teleport/src/services/bot/types.ts +++ b/web/packages/teleport/src/services/bot/types.ts @@ -53,6 +53,20 @@ export type ApiBot = { version: string; }; +export type ListBotInstancesResponse = { + bot_instances: BotInstanceSummary[]; + next_page_token?: string; +}; + +export type BotInstanceSummary = { + instance_id: string; + bot_name: string; + join_method_latest?: string; + host_name_latest?: string; + version_latest?: string; + active_at_latest?: string; +}; + export type BotList = { bots: FlatBot[]; }; diff --git a/web/packages/teleport/src/services/user/makeAcl.ts b/web/packages/teleport/src/services/user/makeAcl.ts index f1b0fba608199..03863cf1bb45d 100644 --- a/web/packages/teleport/src/services/user/makeAcl.ts +++ b/web/packages/teleport/src/services/user/makeAcl.ts @@ -84,6 +84,8 @@ export function makeAcl(json): Acl { const gitServers = json.gitServers || defaultAccess; const accessGraphSettings = json.accessGraphSettings || defaultAccess; + const botInstances = json.botInstances || defaultAccess; + return { accessList, authConnectors, @@ -125,6 +127,7 @@ export function makeAcl(json): Acl { fileTransferAccess, gitServers, accessGraphSettings, + botInstances, }; } diff --git a/web/packages/teleport/src/services/user/types.ts b/web/packages/teleport/src/services/user/types.ts index 4208e4753f879..145209cb29688 100644 --- a/web/packages/teleport/src/services/user/types.ts +++ b/web/packages/teleport/src/services/user/types.ts @@ -112,6 +112,7 @@ export interface Acl { fileTransferAccess: boolean; gitServers: Access; accessGraphSettings: Access; + botInstances: Access; } // AllTraits represent all the traits defined for a user. diff --git a/web/packages/teleport/src/services/user/user.test.ts b/web/packages/teleport/src/services/user/user.test.ts index 53b26e77b692d..50269b6975dc7 100644 --- a/web/packages/teleport/src/services/user/user.test.ts +++ b/web/packages/teleport/src/services/user/user.test.ts @@ -303,6 +303,13 @@ test('undefined values in context response gives proper default values', async ( create: false, remove: false, }, + botInstances: { + list: false, + read: false, + edit: false, + create: false, + remove: false, + }, }; expect(response).toEqual({ diff --git a/web/packages/teleport/src/stores/storeUserContext.ts b/web/packages/teleport/src/stores/storeUserContext.ts index 1b8f20dcf216d..bdaf4ce93f576 100644 --- a/web/packages/teleport/src/stores/storeUserContext.ts +++ b/web/packages/teleport/src/stores/storeUserContext.ts @@ -255,6 +255,10 @@ export default class StoreUserContext extends Store { return this.state.acl.bots; } + getBotInstancesAccess() { + return this.state.acl.botInstances; + } + getContactsAccess() { return this.state.acl.contacts; } diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx index 5d16955733ff6..403d87bea1797 100644 --- a/web/packages/teleport/src/teleportContext.tsx +++ b/web/packages/teleport/src/teleportContext.tsx @@ -220,6 +220,7 @@ class TeleportContext implements types.Context { gitServers: userContext.getGitServersAccess().list && userContext.getGitServersAccess().read, + listBotInstances: userContext.getBotInstancesAccess().list, }; } } @@ -260,6 +261,7 @@ export const disabledFeatureFlags: types.FeatureFlags = { editBots: false, removeBots: false, gitServers: false, + listBotInstances: false, }; export default TeleportContext; diff --git a/web/packages/teleport/src/test/helpers/botInstances.ts b/web/packages/teleport/src/test/helpers/botInstances.ts new file mode 100644 index 0000000000000..70fd5c278e3b8 --- /dev/null +++ b/web/packages/teleport/src/test/helpers/botInstances.ts @@ -0,0 +1,34 @@ +/** + * 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 { http, HttpResponse } from 'msw'; + +import { ListBotInstancesResponse } from 'teleport/services/bot/types'; + +const listBotInstancesPath = + '/v1/webapi/sites/:cluster_id/machine-id/bot-instance'; + +export const listBotInstancesSuccess = (mock: ListBotInstancesResponse) => + http.get(listBotInstancesPath, () => { + return HttpResponse.json(mock); + }); + +export const listBotInstancesError = (status: number) => + http.get(listBotInstancesPath, () => { + return new HttpResponse(null, { status }); + }); diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index def1ffec6f6b8..84a20017219f7 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -58,6 +58,7 @@ export enum NavTitle { // Access Management Users = 'Users', Bots = 'Bots', + BotInstances = 'Bot Instances', Roles = 'Roles', JoinTokens = 'Join Tokens', AuthConnectors = 'Auth Connectors', @@ -201,6 +202,7 @@ export interface FeatureFlags { accessGraphIntegrations: boolean; externalAuditStorage: boolean; listBots: boolean; + listBotInstances: boolean; addBots: boolean; editBots: boolean; removeBots: boolean;