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 48891b2f00f7b..a47dec49ef865 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"` @@ -372,71 +381,74 @@ var file_teleport_machineid_v1_bot_instance_service_proto_rawDesc = string([]byt 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x62, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, - 0x6e, 0x63, 0x65, 0x49, 0x64, 0x22, 0x7d, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x42, 0x6f, 0x74, - 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x26, 0x0a, 0x0f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x5f, 0x62, 0x6f, 0x74, 0x5f, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x66, 0x69, 0x6c, 0x74, 0x65, - 0x72, 0x42, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, - 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, - 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x8b, 0x01, 0x0a, 0x18, 0x4c, 0x69, 0x73, 0x74, 0x42, 0x6f, 0x74, - 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x47, 0x0a, 0x0d, 0x62, 0x6f, 0x74, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, - 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, - 0x2e, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x0c, 0x62, 0x6f, - 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, - 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x22, 0x56, 0x0a, 0x18, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x6f, 0x74, 0x49, - 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, - 0x0a, 0x08, 0x62, 0x6f, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x62, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x73, - 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, - 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x22, 0x69, 0x0a, 0x16, 0x53, 0x75, - 0x62, 0x6d, 0x69, 0x74, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x4f, 0x0a, 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, - 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, - 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, - 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x09, 0x68, 0x65, 0x61, 0x72, - 0x74, 0x62, 0x65, 0x61, 0x74, 0x22, 0x19, 0x0a, 0x17, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x48, - 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x32, 0xbd, 0x03, 0x0a, 0x12, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x62, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x42, 0x6f, - 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x2c, 0x2e, 0x74, 0x65, 0x6c, 0x65, - 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, - 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, - 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, - 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x73, 0x0a, 0x10, 0x4c, - 0x69, 0x73, 0x74, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12, - 0x2e, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, - 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x42, 0x6f, 0x74, 0x49, - 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x2f, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, - 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x42, 0x6f, 0x74, 0x49, + 0x6e, 0x63, 0x65, 0x49, 0x64, 0x22, 0xab, 0x01, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x42, 0x6f, + 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x5f, 0x62, 0x6f, 0x74, 0x5f, + 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x66, 0x69, 0x6c, 0x74, + 0x65, 0x72, 0x42, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, + 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, + 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x5f, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x5f, 0x74, 0x65, 0x72, 0x6d, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x10, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x54, + 0x65, 0x72, 0x6d, 0x22, 0x8b, 0x01, 0x0a, 0x18, 0x4c, 0x69, 0x73, 0x74, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x5c, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, - 0x74, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x2f, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, - 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x70, - 0x0a, 0x0f, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, - 0x74, 0x12, 0x2d, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, - 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, - 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x2e, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, + 0x12, 0x47, 0x0a, 0x0d, 0x62, 0x6f, 0x74, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, + 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, + 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x0c, 0x62, 0x6f, 0x74, + 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, + 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x22, 0x56, 0x0a, 0x18, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x6f, 0x74, 0x49, 0x6e, + 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, + 0x08, 0x62, 0x6f, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x62, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x73, 0x74, + 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, + 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x22, 0x69, 0x0a, 0x16, 0x53, 0x75, 0x62, + 0x6d, 0x69, 0x74, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x4f, 0x0a, 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x42, + 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, + 0x62, 0x65, 0x61, 0x74, 0x22, 0x19, 0x0a, 0x17, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x48, 0x65, + 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, + 0xbd, 0x03, 0x0a, 0x12, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x62, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x42, 0x6f, 0x74, + 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x2c, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, + 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, + 0x2e, 0x47, 0x65, 0x74, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x42, + 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x73, 0x0a, 0x10, 0x4c, 0x69, + 0x73, 0x74, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x2e, + 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, + 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x42, 0x6f, 0x74, 0x49, 0x6e, + 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, + 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, + 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x42, 0x6f, 0x74, 0x49, 0x6e, + 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x5c, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, + 0x61, 0x6e, 0x63, 0x65, 0x12, 0x2f, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, + 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, + 0x65, 0x74, 0x65, 0x42, 0x6f, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x70, 0x0a, + 0x0f, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, + 0x12, 0x2d, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x48, - 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x42, 0x56, 0x5a, 0x54, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, - 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, - 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, - 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2f, 0x76, 0x31, 0x3b, 0x6d, 0x61, 0x63, - 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x2e, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6d, 0x61, 0x63, 0x68, 0x69, + 0x6e, 0x65, 0x69, 0x64, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x48, 0x65, + 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, + 0x56, 0x5a, 0x54, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, + 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, 0x65, + 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x6d, + 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x69, 0x64, 0x2f, 0x76, 0x31, 0x3b, 0x6d, 0x61, 0x63, 0x68, + 0x69, 0x6e, 0x65, 0x69, 0x64, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( 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 9f60f3054c23a..1ed03495a1af2 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -1169,6 +1169,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 8cd3cd5574a07..0c39cfe7beed6 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 14f7c1344a99f..d1605741b04d1 100644 --- a/web/packages/design/src/DataTable/types.ts +++ b/web/packages/design/src/DataTable/types.ts @@ -139,6 +139,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 b1ac939dd3f0b..217cd8abcc6e9 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', @@ -455,6 +456,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', @@ -723,6 +725,10 @@ const cfg = { return generatePath(cfg.routes.bots); }, + getBotInstancesRoute() { + return generatePath(cfg.routes.botInstances); + }, + getBotsNewRoute(type?: string) { return generatePath(cfg.routes.botsNew, { type }); }, @@ -1440,6 +1446,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;