diff --git a/.vscode/launch.json b/.vscode/launch.json index 1932951d2..a3ba143a6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -28,6 +28,14 @@ "cwd": "${workspaceFolder}/frontend", "args": ["--port", "8085", "--address", "0.0.0.0"] }, + { + "name": "gql-playground", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/frontend/main.go", + "cwd": "${workspaceFolder}/frontend" + }, { "name": "autoscaler local", "type": "go", diff --git a/frontend/graph/generated.go b/frontend/graph/generated.go index 23247a13c..ec686160a 100644 --- a/frontend/graph/generated.go +++ b/frontend/graph/generated.go @@ -77,6 +77,12 @@ type ComplexityRoot struct { Type func(childComplexity int) int } + DestinationDetails struct { + Fields func(childComplexity int) int + Type func(childComplexity int) int + URLString func(childComplexity int) int + } + DestinationTypesCategoryItem struct { DisplayName func(childComplexity int) int ImageUrl func(childComplexity int) int @@ -156,6 +162,7 @@ type ComplexityRoot struct { Config func(childComplexity int) int DestinationTypeDetails func(childComplexity int, typeArg string) int DestinationTypes func(childComplexity int) int + PotentialDestinations func(childComplexity int) int } SourceContainerRuntimeDetails struct { @@ -204,6 +211,7 @@ type QueryResolver interface { Config(ctx context.Context) (*model.GetConfigResponse, error) DestinationTypes(ctx context.Context) (*model.GetDestinationTypesResponse, error) DestinationTypeDetails(ctx context.Context, typeArg string) (*model.GetDestinationDetailsResponse, error) + PotentialDestinations(ctx context.Context) ([]*model.DestinationDetails, error) } type executableSchema struct { @@ -361,6 +369,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Destination.Type(childComplexity), true + case "DestinationDetails.fields": + if e.complexity.DestinationDetails.Fields == nil { + break + } + + return e.complexity.DestinationDetails.Fields(childComplexity), true + + case "DestinationDetails.type": + if e.complexity.DestinationDetails.Type == nil { + break + } + + return e.complexity.DestinationDetails.Type(childComplexity), true + + case "DestinationDetails.urlString": + if e.complexity.DestinationDetails.URLString == nil { + break + } + + return e.complexity.DestinationDetails.URLString(childComplexity), true + case "DestinationTypesCategoryItem.displayName": if e.complexity.DestinationTypesCategoryItem.DisplayName == nil { break @@ -685,6 +714,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.DestinationTypes(childComplexity), true + case "Query.potentialDestinations": + if e.complexity.Query.PotentialDestinations == nil { + break + } + + return e.complexity.Query.PotentialDestinations(childComplexity), true + case "SourceContainerRuntimeDetails.containerName": if e.complexity.SourceContainerRuntimeDetails.ContainerName == nil { break @@ -1965,6 +2001,138 @@ func (ec *executionContext) fieldContext_Destination_conditions(_ context.Contex return fc, nil } +func (ec *executionContext) _DestinationDetails_type(ctx context.Context, field graphql.CollectedField, obj *model.DestinationDetails) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_DestinationDetails_type(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Type, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_DestinationDetails_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DestinationDetails", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DestinationDetails_urlString(ctx context.Context, field graphql.CollectedField, obj *model.DestinationDetails) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_DestinationDetails_urlString(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.URLString, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_DestinationDetails_urlString(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DestinationDetails", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _DestinationDetails_fields(ctx context.Context, field graphql.CollectedField, obj *model.DestinationDetails) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_DestinationDetails_fields(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Fields, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_DestinationDetails_fields(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DestinationDetails", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _DestinationTypesCategoryItem_type(ctx context.Context, field graphql.CollectedField, obj *model.DestinationTypesCategoryItem) (ret graphql.Marshaler) { fc, err := ec.fieldContext_DestinationTypesCategoryItem_type(ctx, field) if err != nil { @@ -3978,6 +4146,58 @@ func (ec *executionContext) fieldContext_Query_destinationTypeDetails(ctx contex return fc, nil } +func (ec *executionContext) _Query_potentialDestinations(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_potentialDestinations(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().PotentialDestinations(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*model.DestinationDetails) + fc.Result = res + return ec.marshalNDestinationDetails2ᚕᚖgithubᚗcomᚋodigosᚑioᚋodigosᚋfrontendᚋgraphᚋmodelᚐDestinationDetailsᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_potentialDestinations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "type": + return ec.fieldContext_DestinationDetails_type(ctx, field) + case "urlString": + return ec.fieldContext_DestinationDetails_urlString(ctx, field) + case "fields": + return ec.fieldContext_DestinationDetails_fields(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DestinationDetails", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query___type(ctx, field) if err != nil { @@ -6984,6 +7204,55 @@ func (ec *executionContext) _Destination(ctx context.Context, sel ast.SelectionS return out } +var destinationDetailsImplementors = []string{"DestinationDetails"} + +func (ec *executionContext) _DestinationDetails(ctx context.Context, sel ast.SelectionSet, obj *model.DestinationDetails) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, destinationDetailsImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DestinationDetails") + case "type": + out.Values[i] = ec._DestinationDetails_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "urlString": + out.Values[i] = ec._DestinationDetails_urlString(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "fields": + out.Values[i] = ec._DestinationDetails_fields(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var destinationTypesCategoryItemImplementors = []string{"DestinationTypesCategoryItem"} func (ec *executionContext) _DestinationTypesCategoryItem(ctx context.Context, sel ast.SelectionSet, obj *model.DestinationTypesCategoryItem) graphql.Marshaler { @@ -7696,6 +7965,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "potentialDestinations": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_potentialDestinations(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "__type": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { @@ -8256,6 +8547,60 @@ func (ec *executionContext) marshalNDestination2ᚖgithubᚗcomᚋodigosᚑioᚋ return ec._Destination(ctx, sel, v) } +func (ec *executionContext) marshalNDestinationDetails2ᚕᚖgithubᚗcomᚋodigosᚑioᚋodigosᚋfrontendᚋgraphᚋmodelᚐDestinationDetailsᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.DestinationDetails) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNDestinationDetails2ᚖgithubᚗcomᚋodigosᚑioᚋodigosᚋfrontendᚋgraphᚋmodelᚐDestinationDetails(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNDestinationDetails2ᚖgithubᚗcomᚋodigosᚑioᚋodigosᚋfrontendᚋgraphᚋmodelᚐDestinationDetails(ctx context.Context, sel ast.SelectionSet, v *model.DestinationDetails) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._DestinationDetails(ctx, sel, v) +} + func (ec *executionContext) unmarshalNDestinationInput2githubᚗcomᚋodigosᚑioᚋodigosᚋfrontendᚋgraphᚋmodelᚐDestinationInput(ctx context.Context, v interface{}) (model.DestinationInput, error) { res, err := ec.unmarshalInputDestinationInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/frontend/graph/model/models_gen.go b/frontend/graph/model/models_gen.go index e71638825..52b137a14 100644 --- a/frontend/graph/model/models_gen.go +++ b/frontend/graph/model/models_gen.go @@ -25,6 +25,12 @@ type Condition struct { Message *string `json:"message,omitempty"` } +type DestinationDetails struct { + Type string `json:"type"` + URLString string `json:"urlString"` + Fields string `json:"fields"` +} + type DestinationInput struct { Name string `json:"name"` Type string `json:"type"` diff --git a/frontend/graph/schema.graphqls b/frontend/graph/schema.graphqls index 9cfed5381..ac7d0f7e1 100644 --- a/frontend/graph/schema.graphqls +++ b/frontend/graph/schema.graphqls @@ -209,11 +209,18 @@ type TestConnectionResponse { reason: String } +type DestinationDetails { + type: String! + urlString: String! + fields: String! +} + type Query { computePlatform: ComputePlatform config: GetConfigResponse destinationTypes: GetDestinationTypesResponse destinationTypeDetails(type: String!): GetDestinationDetailsResponse + potentialDestinations: [DestinationDetails!]! } type Mutation { diff --git a/frontend/graph/schema.resolvers.go b/frontend/graph/schema.resolvers.go index 983b6a218..b141a6dbf 100644 --- a/frontend/graph/schema.resolvers.go +++ b/frontend/graph/schema.resolvers.go @@ -196,8 +196,8 @@ func (r *mutationResolver) PersistK8sSources(ctx context.Context, namespace stri } // TestConnectionForDestination is the resolver for the testConnectionForDestination field. -func (r *mutationResolver) TestConnectionForDestination(ctx context.Context, input model.DestinationInput) (*model.TestConnectionResponse, error) { - destType := common.DestinationType(input.Type) +func (r *mutationResolver) TestConnectionForDestination(ctx context.Context, destination model.DestinationInput) (*model.TestConnectionResponse, error) { + destType := common.DestinationType(destination.Type) destConfig, err := services.GetDestinationTypeConfig(destType) if err != nil { @@ -205,10 +205,10 @@ func (r *mutationResolver) TestConnectionForDestination(ctx context.Context, inp } if !destConfig.Spec.TestConnectionSupported { - return nil, fmt.Errorf("destination type %s does not support test connection", input.Type) + return nil, fmt.Errorf("destination type %s does not support test connection", destination.Type) } - configurer, err := testconnection.ConvertDestinationToConfigurer(input) + configurer, err := testconnection.ConvertDestinationToConfigurer(destination) if err != nil { return nil, err } @@ -303,6 +303,31 @@ func (r *queryResolver) DestinationTypeDetails(ctx context.Context, typeArg stri return &resp, nil } +// PotentialDestinations is the resolver for the potentialDestinations field. +func (r *queryResolver) PotentialDestinations(ctx context.Context) ([]*model.DestinationDetails, error) { + potentialDestinations := services.PotentialDestinations(ctx) + if potentialDestinations == nil { + return nil, fmt.Errorf("failed to fetch potential destinations") + } + + // Convert []destination_recognition.DestinationDetails to []*DestinationDetails + var result []*model.DestinationDetails + for _, dest := range potentialDestinations { + + fieldsString, err := json.Marshal(dest.Fields) + if err != nil { + return nil, fmt.Errorf("error marshalling fields: %v", err) + } + + result = append(result, &model.DestinationDetails{ + Type: string(dest.Type), + Fields: string(fieldsString), + }) + } + + return result, nil +} + // ComputePlatform returns ComputePlatformResolver implementation. func (r *Resolver) ComputePlatform() ComputePlatformResolver { return &computePlatformResolver{r} } diff --git a/frontend/services/destination_recognition/destination_finder.go b/frontend/services/destination_recognition/destination_finder.go new file mode 100644 index 000000000..20258eff7 --- /dev/null +++ b/frontend/services/destination_recognition/destination_finder.go @@ -0,0 +1,79 @@ +package destination_recognition + +import ( + "context" + + odigosv1 "github.com/odigos-io/odigos/api/odigos/v1alpha1" + "github.com/odigos-io/odigos/common" + "github.com/odigos-io/odigos/frontend/kube" + "github.com/odigos-io/odigos/k8sutils/pkg/client" + k8s "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var SupportedDestinationType = []common.DestinationType{common.JaegerDestinationType, common.ElasticsearchDestinationType} + +type DestinationDetails struct { + Type common.DestinationType `json:"type"` + Fields map[string]string `json:"fields"` +} + +type IDestinationFinder interface { + isPotentialService(k8s.Service) bool + fetchDestinationDetails(k8s.Service) DestinationDetails + getServiceURL() string +} + +func GetAllPotentialDestinationDetails(ctx context.Context, namespaces []k8s.Namespace, dests *odigosv1.DestinationList) ([]DestinationDetails, error) { + var destinationFinder IDestinationFinder + var destinationDetails []DestinationDetails + var err error + + for _, ns := range namespaces { + err = client.ListWithPages(client.DefaultPageSize, kube.DefaultClient.CoreV1().Services(ns.Name).List, + ctx, metav1.ListOptions{}, func(services *k8s.ServiceList) error { + for _, service := range services.Items { + for _, destinationType := range SupportedDestinationType { + destinationFinder = getDestinationFinder(destinationType) + + if destinationFinder.isPotentialService(service) { + potentialDestination := destinationFinder.fetchDestinationDetails(service) + + if !destinationExist(dests, potentialDestination, destinationFinder) { + destinationDetails = append(destinationDetails, potentialDestination) + } + break + } + } + } + return nil + }) + } + + if err != nil { + return nil, err + } + + return destinationDetails, nil +} + +func getDestinationFinder(destinationType common.DestinationType) IDestinationFinder { + switch destinationType { + case common.JaegerDestinationType: + return &JaegerDestinationFinder{} + case common.ElasticsearchDestinationType: + return &ElasticSearchDestinationFinder{} + } + + return nil +} + +func destinationExist(dests *odigosv1.DestinationList, potentialDestination DestinationDetails, destinationFinder IDestinationFinder) bool { + for _, dest := range dests.Items { + if dest.Spec.Type == potentialDestination.Type && dest.GetConfig()[destinationFinder.getServiceURL()] == potentialDestination.Fields[destinationFinder.getServiceURL()] { + return true + } + } + + return false +} diff --git a/frontend/services/destination_recognition/elasticsearch.go b/frontend/services/destination_recognition/elasticsearch.go new file mode 100644 index 000000000..38c3e7093 --- /dev/null +++ b/frontend/services/destination_recognition/elasticsearch.go @@ -0,0 +1,45 @@ +package destination_recognition + +import ( + "fmt" + "strings" + + "github.com/odigos-io/odigos/common" + "github.com/odigos-io/odigos/common/config" + k8s "k8s.io/api/core/v1" +) + +type ElasticSearchDestinationFinder struct{} + +const ElasticSearchHttpPort int32 = 9200 +const ElasticSearchHttpUrlFormat = "https://%s.%s:%d" + +func (j *ElasticSearchDestinationFinder) isPotentialService(service k8s.Service) bool { + for _, port := range service.Spec.Ports { + if isElasticSearchService(port.Port, service.Name) { + return true + } + } + + return false +} + +func isElasticSearchService(portNumber int32, name string) bool { + return portNumber == ElasticSearchHttpPort && strings.Contains(name, string(common.ElasticsearchDestinationType)) +} + +func (j *ElasticSearchDestinationFinder) fetchDestinationDetails(service k8s.Service) DestinationDetails { + urlString := fmt.Sprintf(ElasticSearchHttpUrlFormat, service.Name, service.Namespace, ElasticSearchHttpPort) + elasticServiceURL := j.getServiceURL() + fields := make(map[string]string) + fields[elasticServiceURL] = urlString + + return DestinationDetails{ + Type: common.ElasticsearchDestinationType, + Fields: fields, + } +} + +func (j *ElasticSearchDestinationFinder) getServiceURL() string { + return config.ElasticsearchUrlKey +} diff --git a/frontend/services/destination_recognition/jaeger.go b/frontend/services/destination_recognition/jaeger.go new file mode 100644 index 000000000..f66fd9bdd --- /dev/null +++ b/frontend/services/destination_recognition/jaeger.go @@ -0,0 +1,46 @@ +package destination_recognition + +import ( + "fmt" + "strings" + + "github.com/odigos-io/odigos/common" + "github.com/odigos-io/odigos/common/config" + k8s "k8s.io/api/core/v1" +) + +type JaegerDestinationFinder struct{} + +const JaegerGrpcOtlpPort int32 = 4317 +const JaegerGrpcUrlFormat = "%s.%s:%d" + +func (j *JaegerDestinationFinder) isPotentialService(service k8s.Service) bool { + for _, port := range service.Spec.Ports { + if isJaegerService(port.Port, service.Name) { + return true + } + } + + return false +} + +func isJaegerService(portNumber int32, name string) bool { + return portNumber == JaegerGrpcOtlpPort && strings.Contains(name, string(common.JaegerDestinationType)) +} + +func (j *JaegerDestinationFinder) fetchDestinationDetails(service k8s.Service) DestinationDetails { + urlString := fmt.Sprintf(JaegerGrpcUrlFormat, service.Name, service.Namespace, JaegerGrpcOtlpPort) + + jaegerServiceURL := j.getServiceURL() + fields := make(map[string]string) + fields[jaegerServiceURL] = urlString + + return DestinationDetails{ + Type: common.JaegerDestinationType, + Fields: fields, + } +} + +func (j *JaegerDestinationFinder) getServiceURL() string { + return config.JaegerUrlKey +} diff --git a/frontend/services/destinations.go b/frontend/services/destinations.go index 30c1bc77a..8a76180d4 100644 --- a/frontend/services/destinations.go +++ b/frontend/services/destinations.go @@ -7,9 +7,12 @@ import ( "github.com/odigos-io/odigos/api/odigos/v1alpha1" "github.com/odigos-io/odigos/common" + "github.com/odigos-io/odigos/common/consts" "github.com/odigos-io/odigos/destinations" "github.com/odigos-io/odigos/frontend/graph/model" "github.com/odigos-io/odigos/frontend/kube" + "github.com/odigos-io/odigos/frontend/services/destination_recognition" + "github.com/odigos-io/odigos/k8sutils/pkg/env" k8s "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -267,3 +270,24 @@ func AddDestinationOwnerReferenceToSecret(ctx context.Context, odigosns string, } return nil } + +func PotentialDestinations(ctx context.Context) []destination_recognition.DestinationDetails { + odigosns := consts.DefaultOdigosNamespace + relevantNamespaces, err := getRelevantNameSpaces(ctx, env.GetCurrentNamespace()) + if err != nil { + return nil + } + + // Existing Destinations + existingDestination, err := kube.DefaultClient.OdigosClient.Destinations(odigosns).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil + } + + destinationDetails, err := destination_recognition.GetAllPotentialDestinationDetails(ctx, relevantNamespaces, existingDestination) + if err != nil { + return nil + } + + return destinationDetails +} diff --git a/frontend/services/utils.go b/frontend/services/utils.go index 4464bb016..a85f6b8f3 100644 --- a/frontend/services/utils.go +++ b/frontend/services/utils.go @@ -3,7 +3,9 @@ package services import ( "context" "errors" + "fmt" "path" + "strings" "github.com/odigos-io/odigos/frontend/kube" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -33,3 +35,16 @@ func setWorkloadInstrumentationLabel(ctx context.Context, nsName string, workloa return errors.New("unsupported workload kind " + string(workloadKind)) } } + +func ConvertFieldsToString(fields map[string]string) string { + if len(fields) == 0 { + return "" + } + + var parts []string + for key, value := range fields { + parts = append(parts, fmt.Sprintf("%s: %s", key, value)) + } + + return strings.Join(parts, ", ") +} diff --git a/frontend/webapp/containers/main/destinations/add-destination/add-destination-modal/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/add-destination-modal/index.tsx index 29b8b3b51..afadab7f3 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/add-destination-modal/index.tsx +++ b/frontend/webapp/containers/main/destinations/add-destination/add-destination-modal/index.tsx @@ -1,21 +1,14 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { useQuery } from '@apollo/client'; +import React, { useState, useRef } from 'react'; import { DestinationTypeItem } from '@/types'; -import { GET_DESTINATION_TYPE } from '@/graphql'; import { Modal, NavigationButtons } from '@/reuseable-components'; -import { ConnectDestinationModalBody } from '../connect-destination-modal-body'; import { ChooseDestinationModalBody } from '../choose-destination-modal-body'; +import { ConnectDestinationModalBody } from '../connect-destination-modal-body'; interface AddDestinationModalProps { isModalOpen: boolean; handleCloseModal: () => void; } -interface DestinationCategory { - name: string; - items: DestinationTypeItem[]; -} - function ModalActionComponent({ onNext, onBack, @@ -38,7 +31,7 @@ function ModalActionComponent({ }, { label: 'DONE', - onClick: onNext, // This will trigger handleSubmit + onClick: onNext, variant: 'primary', }, ] @@ -52,37 +45,8 @@ export function AddDestinationModal({ isModalOpen, handleCloseModal, }: AddDestinationModalProps) { - const { data } = useQuery(GET_DESTINATION_TYPE); - const [selectedItem, setSelectedItem] = useState(); - const [destinationTypeList, setDestinationTypeList] = useState< - DestinationTypeItem[] - >([]); - const submitRef = useRef<() => void | null>(null); - - useEffect(() => { - data && buildDestinationTypeList(); - }, [data]); - - function buildDestinationTypeList() { - console.log({ data }); - const destinationTypes = data?.destinationTypes?.categories || []; - const destinationTypeList: DestinationTypeItem[] = destinationTypes.reduce( - (acc: DestinationTypeItem[], category: DestinationCategory) => { - const items = category.items.map((item: DestinationTypeItem) => ({ - category: category.name, - displayName: item.displayName, - imageUrl: item.imageUrl, - supportedSignals: item.supportedSignals, - testConnectionSupported: item.testConnectionSupported, - type: item.type, - })); - return [...acc, ...items]; - }, - [] - ); - setDestinationTypeList(destinationTypeList); - } + const [selectedItem, setSelectedItem] = useState(); function handleNextStep(item: DestinationTypeItem) { setSelectedItem(item); @@ -95,16 +59,14 @@ export function AddDestinationModal({ onSubmitRef={submitRef} /> ) : ( - + ); } function handleNext() { if (submitRef.current) { submitRef.current(); + setSelectedItem(undefined); handleCloseModal(); } } @@ -120,7 +82,10 @@ export function AddDestinationModal({ /> } header={{ title: 'Add destination' }} - onClose={handleCloseModal} + onClose={() => { + setSelectedItem(undefined); + handleCloseModal(); + }} > {renderModalBody()} diff --git a/frontend/webapp/containers/main/destinations/add-destination/choose-destination-modal-body/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/choose-destination-modal-body/index.tsx index 5f4fdd619..160e9b77e 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/choose-destination-modal-body/index.tsx +++ b/frontend/webapp/containers/main/destinations/add-destination/choose-destination-modal-body/index.tsx @@ -1,14 +1,21 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { SideMenu } from '@/components'; +import { useQuery } from '@apollo/client'; +import { GET_DESTINATION_TYPE } from '@/graphql'; import { DestinationsList } from '../destinations-list'; import { Body, Container, SideMenuWrapper } from '../styled'; import { Divider, SectionTitle } from '@/reuseable-components'; import { DestinationFilterComponent } from '../choose-destination-menu'; -import { DestinationTypeItem, DropdownOption, StepProps } from '@/types'; +import { + StepProps, + DropdownOption, + DestinationTypeItem, + DestinationsCategory, + GetDestinationTypesResponse, +} from '@/types'; interface ChooseDestinationModalBodyProps { - data: DestinationTypeItem[]; onSelect: (item: DestinationTypeItem) => void; } @@ -25,50 +32,75 @@ const SIDE_MENU_DATA: StepProps[] = [ }, ]; +const DEFAULT_MONITORS = ['logs', 'metrics', 'traces']; +const DEFAULT_DROPDOWN_VALUE = { id: 'all', value: 'All types' }; +const CATEGORIES_DESCRIPTION = { + managed: 'Effortless Monitoring with Scalable Performance Management', + 'self hosted': + 'Full Control and Customization for Advanced Application Monitoring', +}; + +export interface IDestinationListItem extends DestinationsCategory { + description: string; +} + export function ChooseDestinationModalBody({ - data, onSelect, }: ChooseDestinationModalBodyProps) { const [searchValue, setSearchValue] = useState(''); - const [selectedMonitors, setSelectedMonitors] = useState([ - 'logs', - 'metrics', - 'traces', - ]); - const [dropdownValue, setDropdownValue] = useState({ - id: 'all', - value: 'All types', - }); + const [destinations, setDestinations] = useState([]); + const [selectedMonitors, setSelectedMonitors] = + useState(DEFAULT_MONITORS); + const [dropdownValue, setDropdownValue] = useState( + DEFAULT_DROPDOWN_VALUE + ); + + const { data } = useQuery(GET_DESTINATION_TYPE); + useEffect(() => { + if (data) { + const destinationsCategories = data.destinationTypes.categories.map( + (category) => { + return { + name: category.name, + description: CATEGORIES_DESCRIPTION[category.name], + items: category.items, + }; + } + ); + setDestinations(destinationsCategories); + } + }, [data]); function handleTagSelect(option: DropdownOption) { setDropdownValue(option); } - function filterData() { - let filteredData = data; + const filteredDestinations = useMemo(() => { + return destinations + .map((category) => { + const filteredItems = category.items.filter((item) => { + const matchesSearch = searchValue + ? item.displayName.toLowerCase().includes(searchValue.toLowerCase()) + : true; - if (searchValue) { - filteredData = filteredData.filter((item) => - item.displayName.toLowerCase().includes(searchValue.toLowerCase()) - ); - } + const matchesDropdown = + dropdownValue.id !== 'all' + ? category.name === dropdownValue.id + : true; - if (dropdownValue.id !== 'all') { - filteredData = filteredData.filter( - (item) => item.category === dropdownValue.id - ); - } + const matchesMonitor = selectedMonitors.length + ? selectedMonitors.some( + (monitor) => item.supportedSignals[monitor]?.supported + ) + : true; - if (selectedMonitors.length) { - filteredData = filteredData.filter((item) => - selectedMonitors.some( - (monitor) => item.supportedSignals[monitor].supported - ) - ); - } + return matchesSearch && matchesDropdown && matchesMonitor; + }); - return filteredData; - } + return { ...category, items: filteredItems }; + }) + .filter((category) => category.items.length > 0); // Filter out empty categories + }, [destinations, searchValue, dropdownValue, selectedMonitors]); function onMonitorSelect(monitor: string) { if (selectedMonitors.includes(monitor)) { @@ -96,7 +128,10 @@ export function ChooseDestinationModalBody({ onMonitorSelect={onMonitorSelect} /> - + ); diff --git a/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/index.tsx index b9a325a09..3f59b33a1 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/index.tsx +++ b/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/index.tsx @@ -111,11 +111,21 @@ export function ConnectDestinationModalBody({ }, [destination]); useEffect(() => { - if (data) { + if (data && destination) { const df = buildFormDynamicFields(data.destinationTypeDetails.fields); - setDynamicFields(df); + + const newDynamicFields = df.map((field) => { + if (destination.fields && field?.name in destination.fields) { + return { + ...field, + initialValue: destination.fields[field.name], + }; + } + return field; + }); + setDynamicFields(newDynamicFields); } - }, [data]); + }, [data, destination]); useEffect(() => { // Assign handleSubmit to the onSubmitRef so it can be triggered externally @@ -152,7 +162,7 @@ export function ConnectDestinationModalBody({ destinationTypeDetails, type: destination?.type || '', imageUrl: destination?.imageUrl || '', - category: destination?.category || '', + category: '', displayName: destination?.displayName || '', }; @@ -210,6 +220,14 @@ export function ConnectDestinationModalBody({ /> )} + {destination.fields && !showConnectionError && ( + + + + )} theme.font_family.secondary}; + text-transform: uppercase; + margin-right: 16px; +`; + +interface DestinationListItemProps { + item: DestinationTypeItem; + onSelect: (item: DestinationTypeItem) => void; +} + +const DestinationListItem: React.FC = ({ + item, + onSelect, +}) => { + const renderSupportedSignals = () => { + const signals = Object.keys(item.supportedSignals).filter( + (signal) => item.supportedSignals[signal].supported + ); + + return signals.map((signal, index) => ( + + {signal} + {index < signals.length - 1 && ·} + + )); + }; + + return ( + onSelect(item)}> + + + destination + + + {item.displayName} + {renderSupportedSignals()} + + + + {'Select'} + + + ); +}; + +export { DestinationListItem }; diff --git a/frontend/webapp/containers/main/destinations/add-destination/destinations-list/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/destinations-list/index.tsx index 05968c978..1e2c67109 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/destinations-list/index.tsx +++ b/frontend/webapp/containers/main/destinations/add-destination/destinations-list/index.tsx @@ -1,14 +1,15 @@ import React from 'react'; -import Image from 'next/image'; import styled from 'styled-components'; import { DestinationTypeItem } from '@/types'; -import { NoDataFound, Text } from '@/reuseable-components'; +import { capitalizeFirstLetter } from '@/utils'; +import { DestinationListItem } from './destination-list-item'; +import { Counter, NoDataFound, SectionTitle } from '@/reuseable-components'; +import { IDestinationListItem } from '../choose-destination-modal-body'; +import { PotentialDestinationsList } from './potential-destinations-list'; const Container = styled.div` display: flex; flex-direction: column; - align-items: center; - gap: 12px; align-self: stretch; max-height: calc(100vh - 424px); overflow-y: auto; @@ -18,70 +19,18 @@ const Container = styled.div` } `; -const ListItem = styled.div<{}>` - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 16px 0px; - transition: background 0.3s; - border-radius: 16px; - - cursor: pointer; - background: rgba(249, 249, 249, 0.04); - - &:hover { - background: rgba(249, 249, 249, 0.08); - } - &:last-child { - margin-bottom: 32px; - } -`; - -const ListItemContent = styled.div` - margin-left: 16px; +const ListsWrapper = styled.div` display: flex; + flex-direction: column; gap: 12px; `; -const DestinationIconWrapper = styled.div` - display: flex; - width: 36px; - height: 36px; - justify-content: center; - align-items: center; - gap: 8px; - border-radius: 8px; - background: linear-gradient( - 180deg, - rgba(249, 249, 249, 0.06) 0%, - rgba(249, 249, 249, 0.02) 100% - ); -`; - -const SignalsWrapper = styled.div` - display: flex; - gap: 4px; -`; - -const SignalText = styled(Text)` - color: rgba(249, 249, 249, 0.8); - font-size: 10px; - text-transform: capitalize; -`; - -const TextWrapper = styled.div` - display: flex; - flex-direction: column; - height: 36px; - justify-content: space-between; -`; const NoDataFoundWrapper = styled(Container)` margin-top: 80px; `; interface DestinationsListProps { - items: DestinationTypeItem[]; + items: IDestinationListItem[]; setSelectedItems: (item: DestinationTypeItem) => void; } @@ -89,50 +38,38 @@ const DestinationsList: React.FC = ({ items, setSelectedItems, }) => { - console.log({ items }); - function renderSupportedSignals(item: DestinationTypeItem) { - const supportedSignals = item.supportedSignals; - const signals = Object.keys(supportedSignals); - const supportedSignalsList = signals.filter( - (signal) => supportedSignals[signal].supported - ); - - return supportedSignalsList.map((signal, index) => ( - - {signal} - {index < supportedSignalsList.length - 1 && ·} - - )); - } - - if (!items.length) { - return ( - - - - ); + function renderCategoriesList() { + if (!items.length) { + return ( + + + + ); + } + + return items.map((item) => { + return ( + + + {item.items.map((categoryItem) => ( + + ))} + + ); + }); } return ( - {items.map((item) => ( - setSelectedItems(item)}> - - - destination - - - {item.displayName} - {renderSupportedSignals(item)} - - - - ))} + + {renderCategoriesList()} ); }; diff --git a/frontend/webapp/containers/main/destinations/add-destination/destinations-list/potential-destinations-list/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/destinations-list/potential-destinations-list/index.tsx new file mode 100644 index 000000000..8470f2e81 --- /dev/null +++ b/frontend/webapp/containers/main/destinations/add-destination/destinations-list/potential-destinations-list/index.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import styled from 'styled-components'; +import { DestinationTypeItem } from '@/types'; +import { usePotentialDestinations } from '@/hooks'; +import { DestinationListItem } from '../destination-list-item'; +import { SectionTitle, SkeletonLoader } from '@/reuseable-components'; + +const ListsWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +interface PotentialDestinationsListProps { + setSelectedItems: (item: DestinationTypeItem) => void; +} + +const PotentialDestinationsList: React.FC = ({ + setSelectedItems, +}) => { + const { loading, data } = usePotentialDestinations(); + + if (!data.length) { + return null; + } + + return ( + + + {loading ? ( + + ) : ( + data.map((item) => ( + + )) + )} + + ); +}; + +export { PotentialDestinationsList }; diff --git a/frontend/webapp/graphql/queries/destination.ts b/frontend/webapp/graphql/queries/destination.ts index 993f302c4..1bc8a05d9 100644 --- a/frontend/webapp/graphql/queries/destination.ts +++ b/frontend/webapp/graphql/queries/destination.ts @@ -40,3 +40,12 @@ export const GET_DESTINATION_TYPE_DETAILS = gql` } } `; + +export const GET_POTENTIAL_DESTINATIONS = gql` + query GetPotentialDestinations { + potentialDestinations { + type + fields + } + } +`; diff --git a/frontend/webapp/hooks/destinations/index.ts b/frontend/webapp/hooks/destinations/index.ts index ccb9e9a9d..ee99a9db8 100644 --- a/frontend/webapp/hooks/destinations/index.ts +++ b/frontend/webapp/hooks/destinations/index.ts @@ -2,3 +2,4 @@ export * from './useDestinations'; export * from './useTestConnection'; export * from './useConnectDestinationForm'; export * from './useCreateDestination'; +export * from './usePotentialDestinations'; diff --git a/frontend/webapp/hooks/destinations/usePotentialDestinations.ts b/frontend/webapp/hooks/destinations/usePotentialDestinations.ts new file mode 100644 index 000000000..d4ab2d837 --- /dev/null +++ b/frontend/webapp/hooks/destinations/usePotentialDestinations.ts @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; +import { safeJsonParse } from '@/utils'; +import { useQuery } from '@apollo/client'; +import { GetDestinationTypesResponse } from '@/types'; +import { GET_DESTINATION_TYPE, GET_POTENTIAL_DESTINATIONS } from '@/graphql'; + +interface DestinationDetails { + type: string; + fields: string; +} + +interface GetPotentialDestinationsData { + potentialDestinations: DestinationDetails[]; +} + +export const usePotentialDestinations = () => { + const { data: destinationTypesData } = + useQuery(GET_DESTINATION_TYPE); + const { loading, error, data } = useQuery( + GET_POTENTIAL_DESTINATIONS + ); + + const mappedPotentialDestinations = useMemo(() => { + if (!destinationTypesData || !data) return []; + + // Create a deep copy of destination types to manipulate + const destinationTypesCopy = JSON.parse( + JSON.stringify(destinationTypesData.destinationTypes.categories) + ); + + // Map over the potential destinations + return data.potentialDestinations.map((destination) => { + for (const category of destinationTypesCopy) { + const index = category.items.findIndex( + (item) => item.type === destination.type + ); + if (index !== -1) { + // Spread the matched destination type data into the potential destination + const matchedType = category.items[index]; + category.items.splice(index, 1); // Remove the matched item from destination types + return { + ...destination, + ...matchedType, + fields: safeJsonParse<{ [key: string]: string }>( + destination.fields, + {} + ), + }; + } + } + return destination; + }); + }, [destinationTypesData, data]); + + return { + loading, + error, + data: mappedPotentialDestinations, + }; +}; diff --git a/frontend/webapp/public/brand/odigos-icon.svg b/frontend/webapp/public/brand/odigos-icon.svg index 9247584f3..7690eb2ee 100644 --- a/frontend/webapp/public/brand/odigos-icon.svg +++ b/frontend/webapp/public/brand/odigos-icon.svg @@ -1,6 +1,5 @@ - - - - - - + + + + + \ No newline at end of file diff --git a/frontend/webapp/reuseable-components/index.ts b/frontend/webapp/reuseable-components/index.ts index ada40c8e4..c772ffb8c 100644 --- a/frontend/webapp/reuseable-components/index.ts +++ b/frontend/webapp/reuseable-components/index.ts @@ -18,3 +18,4 @@ export * from './textarea'; export * from './input-list'; export * from './key-value-input-list'; export * from './no-data-found'; +export * from './skeleton-loader'; diff --git a/frontend/webapp/reuseable-components/input/index.tsx b/frontend/webapp/reuseable-components/input/index.tsx index 20a16d51b..082c88623 100644 --- a/frontend/webapp/reuseable-components/input/index.tsx +++ b/frontend/webapp/reuseable-components/input/index.tsx @@ -1,5 +1,5 @@ import Image from 'next/image'; -import React from 'react'; +import React, { useState } from 'react'; import { Text } from '../text'; import styled, { css } from 'styled-components'; import { Tooltip } from '../tooltip'; @@ -12,6 +12,7 @@ interface InputProps extends React.InputHTMLAttributes { title?: string; tooltip?: string; required?: boolean; + initialValue?: string; } const Container = styled.div` @@ -148,8 +149,19 @@ const Input: React.FC = ({ title, tooltip, required, + initialValue, + onChange, ...props }) => { + const [value, setValue] = useState(initialValue || ''); + + const handleInputChange = (e: React.ChangeEvent) => { + setValue(e.target.value); + if (onChange) { + onChange(e); + } + }; + return ( {title && ( @@ -184,7 +196,12 @@ const Input: React.FC = ({ )} - + {buttonLabel && onButtonClick && (