diff --git a/frontend/endpoints/destinations.go b/frontend/endpoints/destinations.go index 6eb900f2b..0be797fb8 100644 --- a/frontend/endpoints/destinations.go +++ b/frontend/endpoints/destinations.go @@ -4,9 +4,10 @@ import ( "context" "encoding/json" "fmt" + "net/http" + "github.com/odigos-io/odigos/frontend/endpoints/destination_recognition" "github.com/odigos-io/odigos/k8sutils/pkg/env" - "net/http" "github.com/gin-gonic/gin" "github.com/odigos-io/odigos/api/odigos/v1alpha1" diff --git a/frontend/graph/generated.go b/frontend/graph/generated.go index 9e2dc0ffc..024d98918 100644 --- a/frontend/graph/generated.go +++ b/frontend/graph/generated.go @@ -170,6 +170,7 @@ type ComplexityRoot struct { PersistK8sNamespace func(childComplexity int, namespace model.PersistNamespaceItemInput) int PersistK8sSources func(childComplexity int, namespace string, sources []*model.PersistNamespaceSourceInput) int TestConnectionForDestination func(childComplexity int, destination model.DestinationInput) int + UpdateDestination func(childComplexity int, id string, destination model.DestinationInput) int UpdateK8sActualSource func(childComplexity int, sourceID model.K8sSourceID, patchSourceRequest model.PatchSourceRequestInput) int } @@ -216,8 +217,6 @@ type ComputePlatformResolver interface { type DestinationResolver interface { Type(ctx context.Context, obj *model.Destination) (string, error) - Fields(ctx context.Context, obj *model.Destination) ([]string, error) - Conditions(ctx context.Context, obj *model.Destination) ([]*model.Condition, error) } type K8sActualNamespaceResolver interface { @@ -229,6 +228,7 @@ type MutationResolver interface { PersistK8sSources(ctx context.Context, namespace string, sources []*model.PersistNamespaceSourceInput) (bool, error) TestConnectionForDestination(ctx context.Context, destination model.DestinationInput) (*model.TestConnectionResponse, error) UpdateK8sActualSource(ctx context.Context, sourceID model.K8sSourceID, patchSourceRequest model.PatchSourceRequestInput) (bool, error) + UpdateDestination(ctx context.Context, id string, destination model.DestinationInput) (*model.Destination, error) } type QueryResolver interface { ComputePlatform(ctx context.Context) (*model.ComputePlatform, error) @@ -768,6 +768,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.TestConnectionForDestination(childComplexity, args["destination"].(model.DestinationInput)), true + case "Mutation.updateDestination": + if e.complexity.Mutation.UpdateDestination == nil { + break + } + + args, err := ec.field_Mutation_updateDestination_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UpdateDestination(childComplexity, args["id"].(string), args["destination"].(model.DestinationInput)), true + case "Mutation.updateK8sActualSource": if e.complexity.Mutation.UpdateK8sActualSource == nil { break @@ -1163,6 +1175,30 @@ func (ec *executionContext) field_Mutation_testConnectionForDestination_args(ctx return args, nil } +func (ec *executionContext) field_Mutation_updateDestination_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 string + if tmp, ok := rawArgs["id"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + arg0, err = ec.unmarshalNID2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["id"] = arg0 + var arg1 model.DestinationInput + if tmp, ok := rawArgs["destination"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("destination")) + arg1, err = ec.unmarshalNDestinationInput2githubᚗcomᚋodigosᚑioᚋodigosᚋfrontendᚋgraphᚋmodelᚐDestinationInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["destination"] = arg1 + return args, nil +} + func (ec *executionContext) field_Mutation_updateK8sActualSource_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2068,7 +2104,7 @@ func (ec *executionContext) _Destination_fields(ctx context.Context, field graph }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Destination().Fields(rctx, obj) + return obj.Fields, nil }) if err != nil { ec.Error(ctx, err) @@ -2080,17 +2116,17 @@ func (ec *executionContext) _Destination_fields(ctx context.Context, field graph } return graphql.Null } - res := resTmp.([]string) + res := resTmp.(string) fc.Result = res - return ec.marshalNString2ᚕstringᚄ(ctx, field.Selections, res) + return ec.marshalNString2string(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Destination_fields(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Destination", Field: field, - IsMethod: true, - IsResolver: true, + 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") }, @@ -4571,6 +4607,77 @@ func (ec *executionContext) fieldContext_Mutation_updateK8sActualSource(ctx cont return fc, nil } +func (ec *executionContext) _Mutation_updateDestination(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_updateDestination(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.Mutation().UpdateDestination(rctx, fc.Args["id"].(string), fc.Args["destination"].(model.DestinationInput)) + }) + 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.Destination) + fc.Result = res + return ec.marshalNDestination2ᚖgithubᚗcomᚋodigosᚑioᚋodigosᚋfrontendᚋgraphᚋmodelᚐDestination(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_updateDestination(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Destination_id(ctx, field) + case "name": + return ec.fieldContext_Destination_name(ctx, field) + case "type": + return ec.fieldContext_Destination_type(ctx, field) + case "exportedSignals": + return ec.fieldContext_Destination_exportedSignals(ctx, field) + case "fields": + return ec.fieldContext_Destination_fields(ctx, field) + case "destinationType": + return ec.fieldContext_Destination_destinationType(ctx, field) + case "conditions": + return ec.fieldContext_Destination_conditions(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Destination", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_updateDestination_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _ObservabilitySignalSupport_supported(ctx context.Context, field graphql.CollectedField, obj *model.ObservabilitySignalSupport) (ret graphql.Marshaler) { fc, err := ec.fieldContext_ObservabilitySignalSupport_supported(ctx, field) if err != nil { @@ -7939,41 +8046,10 @@ func (ec *executionContext) _Destination(ctx context.Context, sel ast.SelectionS atomic.AddUint32(&out.Invalids, 1) } case "fields": - 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._Destination_fields(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } - return res - } - - if field.Deferrable != nil { - dfs, ok := deferred[field.Deferrable.Label] - di := 0 - if ok { - dfs.AddField(field) - di = len(dfs.Values) - 1 - } else { - dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) - deferred[field.Deferrable.Label] = dfs - } - dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { - return innerFunc(ctx, dfs) - }) - - // don't run the out.Concurrently() call below - out.Values[i] = graphql.Null - continue + out.Values[i] = ec._Destination_fields(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) } - - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "destinationType": out.Values[i] = ec._Destination_destinationType(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -8791,6 +8867,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "updateDestination": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_updateDestination(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -10177,38 +10260,6 @@ func (ec *executionContext) marshalNString2string(ctx context.Context, sel ast.S return res } -func (ec *executionContext) unmarshalNString2ᚕstringᚄ(ctx context.Context, v interface{}) ([]string, error) { - var vSlice []interface{} - if v != nil { - vSlice = graphql.CoerceList(v) - } - var err error - res := make([]string, len(vSlice)) - for i := range vSlice { - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) - res[i], err = ec.unmarshalNString2string(ctx, vSlice[i]) - if err != nil { - return nil, err - } - } - return res, nil -} - -func (ec *executionContext) marshalNString2ᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v []string) graphql.Marshaler { - ret := make(graphql.Array, len(v)) - for i := range v { - ret[i] = ec.marshalNString2string(ctx, sel, v[i]) - } - - for _, e := range ret { - if e == graphql.Null { - return graphql.Null - } - } - - return ret -} - func (ec *executionContext) marshalNSupportedSignals2githubᚗcomᚋodigosᚑioᚋodigosᚋfrontendᚋgraphᚋmodelᚐSupportedSignals(ctx context.Context, sel ast.SelectionSet, v model.SupportedSignals) graphql.Marshaler { return ec._SupportedSignals(ctx, sel, &v) } diff --git a/frontend/graph/model/destination.go b/frontend/graph/model/destination.go index 58a8bea11..5f2890a08 100644 --- a/frontend/graph/model/destination.go +++ b/frontend/graph/model/destination.go @@ -42,7 +42,7 @@ type Destination struct { Name string `json:"name"` Type common.DestinationType `json:"type"` ExportedSignals ExportedSignals `json:"signals"` - Fields map[string]string `json:"fields"` + Fields string `json:"fields"` DestinationType DestinationTypesCategoryItem `json:"destination_type"` Conditions []metav1.Condition `json:"conditions,omitempty"` } diff --git a/frontend/graph/schema.graphqls b/frontend/graph/schema.graphqls index a153372ce..666582cb9 100644 --- a/frontend/graph/schema.graphqls +++ b/frontend/graph/schema.graphqls @@ -162,7 +162,7 @@ type Destination { name: String! type: String! exportedSignals: ExportedSignals! - fields: [String!]! + fields: String! destinationType: DestinationTypesCategoryItem! conditions: [Condition!] } @@ -260,4 +260,5 @@ type Mutation { sourceId: K8sSourceId! patchSourceRequest: PatchSourceRequestInput! ): Boolean! + updateDestination(id: ID!, destination: DestinationInput!): Destination! } diff --git a/frontend/graph/schema.resolvers.go b/frontend/graph/schema.resolvers.go index 3a8208482..9fbfff584 100644 --- a/frontend/graph/schema.resolvers.go +++ b/frontend/graph/schema.resolvers.go @@ -272,11 +272,6 @@ func (r *destinationResolver) Type(ctx context.Context, obj *model.Destination) panic(fmt.Errorf("not implemented: Type - type")) } -// Fields is the resolver for the fields field. -func (r *destinationResolver) Fields(ctx context.Context, obj *model.Destination) ([]string, error) { - panic(fmt.Errorf("not implemented: Fields - fields")) -} - // Conditions is the resolver for the conditions field. func (r *destinationResolver) Conditions(ctx context.Context, obj *model.Destination) ([]*model.Condition, error) { panic(fmt.Errorf("not implemented: Conditions - conditions")) @@ -453,6 +448,111 @@ func (r *mutationResolver) UpdateK8sActualSource(ctx context.Context, sourceID m return true, nil } +// UpdateDestination is the resolver for the updateDestination field. +func (r *mutationResolver) UpdateDestination(ctx context.Context, id string, input model.DestinationInput) (*model.Destination, error) { + odigosns := consts.DefaultOdigosNamespace + + destType := common.DestinationType(input.Type) + destName := input.Name + + // Get the destination type configuration + destTypeConfig, err := services.GetDestinationTypeConfig(destType) + if err != nil { + return nil, fmt.Errorf("destination type %s not found: %v", destType, err) + } + + // Convert fields from input to map[string]string + fields := make(map[string]string) + for _, field := range input.Fields { + fields[field.Key] = field.Value + } + + // Validate the destination data schema + validationErrors := services.VerifyDestinationDataScheme(destType, destTypeConfig, fields) + if len(validationErrors) > 0 { + var errMsg string + for _, e := range validationErrors { + errMsg += e.Error() + "; " + } + return nil, fmt.Errorf("validation errors: %s", errMsg) + } + + // Separate data fields and secret fields + dataFields, secretFields := services.TransformFieldsToDataAndSecrets(destTypeConfig, fields) + + // Retrieve the existing destination + dest, err := kube.DefaultClient.OdigosClient.Destinations(odigosns).Get(ctx, id, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get destination: %v", err) + } + + // Handle secrets + destUpdateHasSecrets := len(secretFields) > 0 + destCurrentlyHasSecrets := dest.Spec.SecretRef != nil + + if !destUpdateHasSecrets && destCurrentlyHasSecrets { + // Delete the secret if it's not needed anymore + err := kube.DefaultClient.CoreV1().Secrets(odigosns).Delete(ctx, dest.Spec.SecretRef.Name, metav1.DeleteOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to delete secret: %v", err) + } + dest.Spec.SecretRef = nil + } else if destUpdateHasSecrets && !destCurrentlyHasSecrets { + // Create the secret if it was added in this update + secretRef, err := services.CreateDestinationSecret(ctx, destType, secretFields, odigosns) + if err != nil { + return nil, fmt.Errorf("failed to create secret: %v", err) + } + dest.Spec.SecretRef = secretRef + // Add owner reference to the secret + err = services.AddDestinationOwnerReferenceToSecret(ctx, odigosns, dest) + if err != nil { + return nil, fmt.Errorf("failed to add owner reference to secret: %v", err) + } + } else if destUpdateHasSecrets && destCurrentlyHasSecrets { + // Update the secret in case it is modified + secret, err := kube.DefaultClient.CoreV1().Secrets(odigosns).Get(ctx, dest.Spec.SecretRef.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get secret: %v", err) + } + origSecret := secret.DeepCopy() + + secret.StringData = secretFields + _, err = kube.DefaultClient.CoreV1().Secrets(odigosns).Update(ctx, secret, metav1.UpdateOptions{}) + if err != nil { + // Rollback secret if needed + _, rollbackErr := kube.DefaultClient.CoreV1().Secrets(odigosns).Update(ctx, origSecret, metav1.UpdateOptions{}) + if rollbackErr != nil { + fmt.Printf("Failed to rollback secret: %v\n", rollbackErr) + } + return nil, fmt.Errorf("failed to update secret: %v", err) + } + } + + // Update the destination specification + dest.Spec.Type = destType + dest.Spec.DestinationName = destName + dest.Spec.Data = dataFields + dest.Spec.Signals = services.ExportedSignalsObjectToSlice(input.ExportedSignals) + + // Update the destination in Kubernetes + updatedDest, err := kube.DefaultClient.OdigosClient.Destinations(odigosns).Update(ctx, dest, metav1.UpdateOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to update destination: %v", err) + } + + // Get the secret fields for the updated destination + secretFields, err = services.GetDestinationSecretFields(ctx, odigosns, updatedDest) + if err != nil { + return nil, fmt.Errorf("failed to get secret fields: %v", err) + } + + // Convert the updated destination to the GraphQL model + resp := services.K8sDestinationToEndpointFormat(*updatedDest, secretFields) + + return &resp, nil +} + // ComputePlatform is the resolver for the computePlatform field. func (r *queryResolver) ComputePlatform(ctx context.Context) (*model.ComputePlatform, error) { return &model.ComputePlatform{ diff --git a/frontend/services/destinations.go b/frontend/services/destinations.go index 8a76180d4..79147d89b 100644 --- a/frontend/services/destinations.go +++ b/frontend/services/destinations.go @@ -160,6 +160,13 @@ func K8sDestinationToEndpointFormat(k8sDest v1alpha1.Destination, secretFields m mergedFields := mergeDataAndSecrets(k8sDest.Spec.Data, secretFields) destTypeConfig := DestinationTypeConfigToCategoryItem(destinations.GetDestinationByType(string(destType))) + fieldsJSON, err := json.Marshal(mergedFields) + if err != nil { + // Handle JSON encoding error + fmt.Printf("Error marshaling fields to JSON: %v\n", err) + fieldsJSON = []byte("{}") // Set to an empty JSON object in case of error + } + var conditions []metav1.Condition for _, condition := range k8sDest.Status.Conditions { conditions = append(conditions, metav1.Condition{ @@ -179,7 +186,7 @@ func K8sDestinationToEndpointFormat(k8sDest v1alpha1.Destination, secretFields m Metrics: isSignalExported(k8sDest, common.MetricsObservabilitySignal), Logs: isSignalExported(k8sDest, common.LogsObservabilitySignal), }, - Fields: mergedFields, + Fields: string(fieldsJSON), DestinationType: destTypeConfig, Conditions: conditions, } diff --git a/frontend/webapp/components/destinations/edit-destination-form/index.tsx b/frontend/webapp/components/destinations/edit-destination-form/index.tsx new file mode 100644 index 000000000..8388edb97 --- /dev/null +++ b/frontend/webapp/components/destinations/edit-destination-form/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { CheckboxList } from '@/reuseable-components'; +import { DynamicConnectDestinationFormFields } from '@/containers/main/destinations/add-destination/dynamic-form-fields'; +import { + DynamicField, + ExportedSignals, + SupportedDestinationSignals, +} from '@/types'; + +interface DestinationFormProps { + dynamicFields: DynamicField[]; + exportedSignals: ExportedSignals; + supportedSignals: SupportedDestinationSignals; + handleDynamicFieldChange: (name: string, value: any) => void; + handleSignalChange: (signal: keyof ExportedSignals, value: boolean) => void; +} + +export const EditDestinationForm: React.FC = ({ + dynamicFields, + exportedSignals, + supportedSignals, + handleSignalChange, + handleDynamicFieldChange, +}) => { + const monitors = [ + supportedSignals.logs.supported && { id: 'logs', title: 'Logs' }, + supportedSignals.metrics.supported && { id: 'metrics', title: 'Metrics' }, + supportedSignals.traces.supported && { id: 'traces', title: 'Traces' }, + ].filter(Boolean); + + return ( + <> + + + + ); +}; diff --git a/frontend/webapp/components/destinations/index.ts b/frontend/webapp/components/destinations/index.ts index e7e4b7330..e852a65cc 100644 --- a/frontend/webapp/components/destinations/index.ts +++ b/frontend/webapp/components/destinations/index.ts @@ -1,2 +1,3 @@ export * from './add-destination-button'; export * from './monitors-tap-list'; +export * from './edit-destination-form'; diff --git a/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/connection-notification.tsx b/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/connection-notification.tsx new file mode 100644 index 000000000..7cfbac089 --- /dev/null +++ b/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/connection-notification.tsx @@ -0,0 +1,30 @@ +import { NotificationNote } from '@/reuseable-components'; +import styled from 'styled-components'; + +export const ConnectionNotification = ({ + showConnectionError, + destination, +}) => ( + <> + {showConnectionError && ( + + + + )} + {destination?.fields && !showConnectionError && ( + + + + )} + +); + +const NotificationNoteWrapper = styled.div` + margin-top: 24px; +`; diff --git a/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/form-container.tsx b/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/form-container.tsx new file mode 100644 index 000000000..71bd10c1b --- /dev/null +++ b/frontend/webapp/containers/main/destinations/add-destination/connect-destination-modal-body/form-container.tsx @@ -0,0 +1,50 @@ +import styled from 'styled-components'; +import { CheckboxList, Input } from '@/reuseable-components'; +import { DynamicConnectDestinationFormFields } from '../dynamic-form-fields'; + +export const FormContainer = ({ + monitors, + dynamicFields, + exportedSignals, + destinationName, + handleDynamicFieldChange, + handleSignalChange, + setDestinationName, +}) => ( + + + setDestinationName(e.target.value)} + /> + + +); + +const StyledFormContainer = styled.div` + display: flex; + width: 100%; + max-width: 500px; + flex-direction: column; + gap: 24px; + height: 443px; + overflow-y: auto; + padding-right: 16px; + box-sizing: border-box; + overflow: overlay; + max-height: calc(100vh - 410px); + + @media (height < 768px) { + max-height: calc(100vh - 350px); + } +`; 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 118c46744..0989e7173 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 @@ -1,29 +1,26 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useAppStore } from '@/store'; -import styled from 'styled-components'; import { SideMenu } from '@/components'; import { useQuery } from '@apollo/client'; +import { FormContainer } from './form-container'; import { TestConnection } from '../test-connection'; import { GET_DESTINATION_TYPE_DETAILS } from '@/graphql'; import { Body, Container, SideMenuWrapper } from '../styled'; -import { useConnectDestinationForm, useConnectEnv } from '@/hooks'; -import { DynamicConnectDestinationFormFields } from '../dynamic-form-fields'; +import { Divider, SectionTitle } from '@/reuseable-components'; +import { ConnectionNotification } from './connection-notification'; +import { + useConnectDestinationForm, + useConnectEnv, + useDestinationFormData, + useEditDestinationFormHandlers, +} from '@/hooks'; import { StepProps, - DynamicField, - ExportedSignals, DestinationInput, DestinationTypeItem, DestinationDetailsResponse, ConfiguredDestination, } from '@/types'; -import { - CheckboxList, - Divider, - Input, - NotificationNote, - SectionTitle, -} from '@/reuseable-components'; const SIDE_MENU_DATA: StepProps[] = [ { @@ -38,28 +35,6 @@ const SIDE_MENU_DATA: StepProps[] = [ }, ]; -const FormContainer = styled.div` - display: flex; - width: 100%; - max-width: 500px; - flex-direction: column; - gap: 24px; - height: 443px; - overflow-y: auto; - padding-right: 16px; - box-sizing: border-box; - overflow: overlay; - max-height: calc(100vh - 410px); - - @media (height < 768px) { - max-height: calc(100vh - 350px); - } -`; - -const NotificationNoteWrapper = styled.div` - margin-top: 24px; -`; - interface ConnectDestinationModalBodyProps { destination: DestinationTypeItem | undefined; onSubmitRef: React.MutableRefObject<(() => void) | null>; @@ -73,15 +48,18 @@ export function ConnectDestinationModalBody({ }: ConnectDestinationModalBodyProps) { const [destinationName, setDestinationName] = useState(''); const [showConnectionError, setShowConnectionError] = useState(false); - const [dynamicFields, setDynamicFields] = useState([]); - const [exportedSignals, setExportedSignals] = useState({ - logs: false, - metrics: false, - traces: false, - }); + + const { + dynamicFields, + exportedSignals, + setExportedSignals, + setDynamicFields, + } = useDestinationFormData(); const { connectEnv } = useConnectEnv(); const { buildFormDynamicFields } = useConnectDestinationForm(); + const { handleDynamicFieldChange, handleSignalChange } = + useEditDestinationFormHandlers(setExportedSignals, setDynamicFields); const addConfiguredDestination = useAppStore( ({ addConfiguredDestination }) => addConfiguredDestination ); @@ -96,7 +74,6 @@ export function ConnectDestinationModalBody({ const monitors = useMemo(() => { if (!destination) return []; - const { logs, metrics, traces } = destination.supportedSignals; setExportedSignals({ @@ -143,20 +120,9 @@ export function ConnectDestinationModalBody({ onFormValidChange(isFormValid); }, [dynamicFields]); - function handleDynamicFieldChange(name: string, value: any) { + function onDynamicFieldChange(name: string, value: any) { setShowConnectionError(false); - setDynamicFields((prev) => { - return prev.map((field) => { - if (field.name === name) { - return { ...field, value }; - } - return field; - }); - }); - } - - function handleSignalChange(signal: string, value: boolean) { - setExportedSignals((prev) => ({ ...prev, [signal]: value })); + handleDynamicFieldChange(name, value); } function processFormFields() { @@ -259,43 +225,20 @@ export function ConnectDestinationModalBody({ ) } /> - {showConnectionError && ( - - - - )} - {destination.fields && !showConnectionError && ( - - - - )} + - - - setDestinationName(e.target.value)} - /> - - + ); diff --git a/frontend/webapp/containers/main/destinations/add-destination/dynamic-form-fields/index.tsx b/frontend/webapp/containers/main/destinations/add-destination/dynamic-form-fields/index.tsx index b935a558c..5c6ae04ca 100644 --- a/frontend/webapp/containers/main/destinations/add-destination/dynamic-form-fields/index.tsx +++ b/frontend/webapp/containers/main/destinations/add-destination/dynamic-form-fields/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; - -import { INPUT_TYPES } from '@/utils/constants/string'; +import { INPUT_TYPES } from '@/utils'; import { Dropdown, Input, diff --git a/frontend/webapp/containers/main/destinations/destination-drawer-container/index.tsx b/frontend/webapp/containers/main/destinations/destination-drawer-container/index.tsx new file mode 100644 index 000000000..fa354eeb9 --- /dev/null +++ b/frontend/webapp/containers/main/destinations/destination-drawer-container/index.tsx @@ -0,0 +1,99 @@ +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState, +} from 'react'; +import styled from 'styled-components'; +import { ExportedSignals } from '@/types'; +import { CardDetails, EditDestinationForm } from '@/components'; +import { + useDestinationFormData, + useEditDestinationFormHandlers, +} from '@/hooks'; + +export type DestinationDrawerHandle = { + getCurrentData: () => { + type: string; + exportedSignals: ExportedSignals; + fields: { key: string; value: any }[]; + }; +}; + +interface DestinationDrawerProps { + isEditing: boolean; +} + +const DestinationDrawer = forwardRef< + DestinationDrawerHandle, + DestinationDrawerProps +>(({ isEditing }, ref) => { + const [isFormDirty, setIsFormDirty] = useState(false); + const { + cardData, + dynamicFields, + exportedSignals, + supportedSignals, + destinationType, + resetFormData, + setDynamicFields, + setExportedSignals, + } = useDestinationFormData(); + + const { handleSignalChange, handleDynamicFieldChange } = + useEditDestinationFormHandlers(setExportedSignals, setDynamicFields); + + useEffect(() => { + if (!isEditing && isFormDirty) { + setIsFormDirty(false); + resetFormData(); + } + }, [isEditing]); + + const onDynamicFieldChange = (name: string, value: any) => { + handleDynamicFieldChange(name, value); + setIsFormDirty(true); + }; + + const onSignalChange = (signal: keyof ExportedSignals, value: boolean) => { + handleSignalChange(signal, value); + setIsFormDirty(true); + }; + + useImperativeHandle(ref, () => ({ + getCurrentData: () => ({ + type: destinationType, + exportedSignals, + fields: dynamicFields.map(({ name, value }) => ({ key: name, value })), + }), + })); + + return isEditing ? ( + + + + ) : ( + + ); +}); + +export { DestinationDrawer }; + +const FormContainer = styled.div` + display: flex; + width: 100%; + flex-direction: column; + gap: 24px; + height: 100%; + overflow-y: auto; + padding-right: 16px; + box-sizing: border-box; + overflow: overlay; + max-height: calc(100vh - 220px); +`; diff --git a/frontend/webapp/containers/main/destinations/index.tsx b/frontend/webapp/containers/main/destinations/index.tsx index a50795fd7..4decd49fc 100644 --- a/frontend/webapp/containers/main/destinations/index.tsx +++ b/frontend/webapp/containers/main/destinations/index.tsx @@ -1,2 +1,3 @@ export * from './managed'; export * from './add-destination'; +export * from './destination-drawer-container'; diff --git a/frontend/webapp/containers/main/overview/overview-data-flow/index.tsx b/frontend/webapp/containers/main/overview/overview-data-flow/index.tsx index 492ecb938..233bf6711 100644 --- a/frontend/webapp/containers/main/overview/overview-data-flow/index.tsx +++ b/frontend/webapp/containers/main/overview/overview-data-flow/index.tsx @@ -1,11 +1,16 @@ 'use client'; +import React, { useMemo } from 'react'; import dynamic from 'next/dynamic'; import styled from 'styled-components'; -import { useDrawerStore } from '@/store'; -import React, { useMemo, useRef, useEffect, useState } from 'react'; import { OverviewActionMenuContainer } from '../overview-actions-menu'; import { buildNodesAndEdges, NodeBaseDataFlow } from '@/reuseable-components'; -import { useActualDestination, useActualSources, useGetActions } from '@/hooks'; +import { + useGetActions, + useActualSources, + useContainerWidth, + useActualDestination, + useNodeDataFlowHandlers, +} from '@/hooks'; const OverviewDrawer = dynamic(() => import('../overview-drawer'), { ssr: false, @@ -17,37 +22,12 @@ export const OverviewDataFlowWrapper = styled.div` position: relative; `; -const TYPE_SOURCE = 'source'; - export function OverviewDataFlowContainer() { - const containerRef = useRef(null); - const [containerWidth, setContainerWidth] = useState(0); - const { actions } = useGetActions(); const { sources } = useActualSources(); const { destinations } = useActualDestination(); - const setSelectedItem = useDrawerStore( - ({ setSelectedItem }) => setSelectedItem - ); - // Get the width of the container dynamically - useEffect(() => { - if (containerRef.current) { - setContainerWidth( - containerRef.current.getBoundingClientRect().width - 64 - ); - } - - const handleResize = () => { - if (containerRef.current) { - setContainerWidth( - containerRef.current.getBoundingClientRect().width - 64 - ); - } - }; - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); + const { containerRef, containerWidth } = useContainerWidth(); + const { handleNodeClick } = useNodeDataFlowHandlers(sources, destinations); const columnWidth = 296; @@ -62,30 +42,15 @@ export function OverviewDataFlowContainer() { }); }, [sources, actions, destinations, columnWidth, containerWidth]); - function onNodeClick(_, object: any) { - if (object.data.type === TYPE_SOURCE) { - const { id } = object.data; - const selectedDrawerItem = sources.find( - ({ kind, name, namespace }) => - kind === id.kind && name === id.name && namespace === id.namespace - ); - if (!selectedDrawerItem) return; - - const { kind, name, namespace } = selectedDrawerItem; - - setSelectedItem({ - id: { kind, name, namespace }, - item: selectedDrawerItem, - type: TYPE_SOURCE, - }); - } - } - return ( - + ); } diff --git a/frontend/webapp/containers/main/overview/overview-drawer/index.tsx b/frontend/webapp/containers/main/overview/overview-drawer/index.tsx index 360787c22..64d6bbda8 100644 --- a/frontend/webapp/containers/main/overview/overview-drawer/index.tsx +++ b/frontend/webapp/containers/main/overview/overview-drawer/index.tsx @@ -1,19 +1,29 @@ import { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import { useDrawerStore } from '@/store'; -import { useActualSources } from '@/hooks'; import DrawerHeader from './drawer-header'; import DrawerFooter from './drawer-footer'; import { SourceDrawer } from '../../sources'; import { Drawer } from '@/reuseable-components'; import { DeleteEntityModal } from '@/components'; +import { useActualSources, useUpdateDestination } from '@/hooks'; +import { DestinationDrawer, DestinationDrawerHandle } from '../../destinations'; import { getMainContainerLanguageLogo } from '@/utils/constants/programming-languages'; -import { K8sActualSource, PatchSourceRequestInput, WorkloadId } from '@/types'; +import { + WorkloadId, + K8sActualSource, + ActualDestination, + isActualDestination, + OVERVIEW_ENTITY_TYPES, + PatchSourceRequestInput, +} from '@/types'; const componentMap = { source: SourceDrawer, action: () =>
Action
, - destination: () =>
Destination
, + destination: ({ isEditing }: { isEditing: boolean }) => ( + + ), }; const DRAWER_WIDTH = '560px'; @@ -27,47 +37,83 @@ const OverviewDrawer = () => { const [title, setTitle] = useState(selectedItem?.item?.name || ''); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const { updateExistingDestination } = useUpdateDestination(); const { updateActualSource, deleteSourcesForNamespace } = useActualSources(); const titleRef = useRef(null); + const destinationDrawerRef = useRef(null); useEffect(initialTitle, [selectedItem]); + //TODO: split file to separate components by type: source, destination, action + function initialTitle() { - if (selectedItem?.type === 'source' && selectedItem.item) { + if ( + selectedItem?.type === OVERVIEW_ENTITY_TYPES.SOURCE && + selectedItem.item + ) { const title = (selectedItem.item as K8sActualSource).reportedName; setTitle(title || ''); + } else if ( + selectedItem?.type === OVERVIEW_ENTITY_TYPES.DESTINATION && + selectedItem.item + ) { + const title = (selectedItem.item as ActualDestination).name; + setTitle(title || ''); } else { setTitle(''); } } const handleSave = async () => { - if (titleRef.current) { - const newTitle = titleRef.current.value; - setTitle(newTitle); - if (selectedItem?.type === 'source' && selectedItem.item) { - const sourceItem = selectedItem.item as K8sActualSource; - - const sourceId: WorkloadId = { - namespace: sourceItem.namespace, - kind: sourceItem.kind, - name: sourceItem.name, - }; - - const patchRequest: PatchSourceRequestInput = { - reportedName: newTitle, + if (selectedItem?.type === OVERVIEW_ENTITY_TYPES.DESTINATION) { + if (destinationDrawerRef.current && titleRef.current) { + const name = titleRef.current.value; + const destinationData = { + ...destinationDrawerRef.current.getCurrentData(), + name, }; - try { - await updateActualSource(sourceId, patchRequest); + await updateExistingDestination( + selectedItem.id as string, + destinationData + ); } catch (error) { - console.error('Error updating source:', error); - // Optionally show error message to user + console.error('Error updating destination:', error); } + setIsEditing(false); } } - setIsEditing(false); + + if (selectedItem?.type === OVERVIEW_ENTITY_TYPES.SOURCE) { + if (titleRef.current) { + const newTitle = titleRef.current.value; + setTitle(newTitle); + if ( + selectedItem?.type === OVERVIEW_ENTITY_TYPES.SOURCE && + selectedItem.item + ) { + const sourceItem = selectedItem.item as K8sActualSource; + + const sourceId: WorkloadId = { + namespace: sourceItem.namespace, + kind: sourceItem.kind, + name: sourceItem.name, + }; + + const patchRequest: PatchSourceRequestInput = { + reportedName: newTitle, + }; + + try { + await updateActualSource(sourceId, patchRequest); + } catch (error) { + console.error('Error updating source:', error); + } + } + } + setIsEditing(false); + } }; const handleCancel = () => { @@ -75,8 +121,17 @@ const OverviewDrawer = () => { initialTitle(); }; + const handleClose = () => { + setIsEditing(false); + setDrawerItem(null); + setIsDeleteModalOpen(false); + }; + const handleDelete = async () => { - if (selectedItem?.type === 'source' && selectedItem.item) { + if ( + selectedItem?.type === OVERVIEW_ENTITY_TYPES.SOURCE && + selectedItem.item + ) { const sourceItem = selectedItem.item as K8sActualSource; try { @@ -87,6 +142,7 @@ const OverviewDrawer = () => { selected: false, }, ]); + handleClose(); } catch (error) { console.error('Error deleting source:', error); } @@ -94,12 +150,6 @@ const OverviewDrawer = () => { setDrawerItem(null); // Close the drawer on delete }; - const handleClose = () => { - setIsEditing(false); - setDrawerItem(null); - setIsDeleteModalOpen(false); - }; - const handleCloseDeleteModal = () => { setIsDeleteModalOpen(false); }; @@ -122,16 +172,19 @@ const OverviewDrawer = () => { title={title} onClose={isEditing ? handleCancel : handleClose} imageUri={ - selectedItem?.item - ? getMainContainerLanguageLogo( - selectedItem.item as K8sActualSource - ) - : '' + selectedItem?.item ? getItemImageByType(selectedItem?.item) : '' } {...{ isEditing, setIsEditing }} /> - + {selectedItem.type === OVERVIEW_ENTITY_TYPES.DESTINATION ? ( + + ) : ( + + )} {isEditing && ( <> @@ -155,6 +208,16 @@ const OverviewDrawer = () => { ) : null; }; +function getItemImageByType(item: K8sActualSource | ActualDestination): string { + if (isActualDestination(item)) { + // item is of type ActualDestination + return item.destinationType.imageUrl; + } else { + // item is of type K8sActualSource + return getMainContainerLanguageLogo(item as K8sActualSource); + } +} + export default OverviewDrawer; const DrawerContent = styled.div` diff --git a/frontend/webapp/graphql/mutations/destination.ts b/frontend/webapp/graphql/mutations/destination.ts index 1c0f4ae01..cdce52cfa 100644 --- a/frontend/webapp/graphql/mutations/destination.ts +++ b/frontend/webapp/graphql/mutations/destination.ts @@ -19,3 +19,34 @@ export const TEST_CONNECTION_MUTATION = gql` } } `; + +export const UPDATE_DESTINATION = gql` + mutation UpdateDestination($id: ID!, $destination: DestinationInput!) { + updateDestination(id: $id, destination: $destination) { + id + name + exportedSignals { + traces + metrics + logs + } + fields + destinationType { + type + displayName + imageUrl + supportedSignals { + traces { + supported + } + metrics { + supported + } + logs { + supported + } + } + } + } + } +`; diff --git a/frontend/webapp/graphql/queries/compute-platform.ts b/frontend/webapp/graphql/queries/compute-platform.ts index 6c666b2e4..b833920f4 100644 --- a/frontend/webapp/graphql/queries/compute-platform.ts +++ b/frontend/webapp/graphql/queries/compute-platform.ts @@ -24,14 +24,27 @@ export const GET_COMPUTE_PLATFORM = gql` destinations { id name + fields exportedSignals { logs metrics traces } destinationType { + type imageUrl displayName + supportedSignals { + logs { + supported + } + metrics { + supported + } + traces { + supported + } + } } } actions { diff --git a/frontend/webapp/hooks/common/index.ts b/frontend/webapp/hooks/common/index.ts new file mode 100644 index 000000000..e536761bd --- /dev/null +++ b/frontend/webapp/hooks/common/index.ts @@ -0,0 +1 @@ +export * from './useContainerWidth'; diff --git a/frontend/webapp/hooks/common/useContainerWidth.ts b/frontend/webapp/hooks/common/useContainerWidth.ts new file mode 100644 index 000000000..9ea9c601b --- /dev/null +++ b/frontend/webapp/hooks/common/useContainerWidth.ts @@ -0,0 +1,23 @@ +import { useEffect, useState, useRef } from 'react'; + +export function useContainerWidth() { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + const updateWidth = () => { + if (containerRef.current) { + setContainerWidth( + containerRef.current.getBoundingClientRect().width - 64 + ); + } + }; + + updateWidth(); + + window.addEventListener('resize', updateWidth); + return () => window.removeEventListener('resize', updateWidth); + }, []); + + return { containerRef, containerWidth }; +} diff --git a/frontend/webapp/hooks/destinations/index.ts b/frontend/webapp/hooks/destinations/index.ts index 11b11a37a..01743f661 100644 --- a/frontend/webapp/hooks/destinations/index.ts +++ b/frontend/webapp/hooks/destinations/index.ts @@ -4,3 +4,6 @@ export * from './useConnectDestinationForm'; export * from './useCreateDestination'; export * from './usePotentialDestinations'; export * from './useActualDestinations'; +export * from './useUpdateDestination'; +export * from './useDestinationFormData'; +export * from './useEditDestinationFormHandlers'; diff --git a/frontend/webapp/hooks/destinations/useActualDestinations.ts b/frontend/webapp/hooks/destinations/useActualDestinations.ts index 5a01d89b1..0cdf41d05 100644 --- a/frontend/webapp/hooks/destinations/useActualDestinations.ts +++ b/frontend/webapp/hooks/destinations/useActualDestinations.ts @@ -1,9 +1,32 @@ import { useComputePlatform } from '../compute-platform'; +import { ActualDestination } from '@/types'; + +// Function to map raw data to the ActualDestination interface +const mapToActualDestination = (data: any): ActualDestination => ({ + id: data.id, + name: data.name, + type: data.type, + exportedSignals: data.exportedSignals, + fields: data.fields, + conditions: data.conditions, + destinationType: { + type: data.destinationType.type, + displayName: data.destinationType.displayName, + imageUrl: data.destinationType.imageUrl, + supportedSignals: data.destinationType.supportedSignals, + }, +}); export const useActualDestination = () => { const { data } = useComputePlatform(); + // Use the mapToActualDestination function to transform raw data + const destinations = + data?.computePlatform.destinations.map((destination: any) => + mapToActualDestination(destination) + ) || []; + return { - destinations: data?.computePlatform.destinations || [], + destinations, }; }; diff --git a/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts b/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts index 7f95908c5..7e8c989b8 100644 --- a/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts +++ b/frontend/webapp/hooks/destinations/useConnectDestinationForm.ts @@ -1,4 +1,4 @@ -import { safeJsonParse } from '@/utils'; +import { safeJsonParse, INPUT_TYPES } from '@/utils'; import { DestinationDetailsField, DynamicField } from '@/types'; export function useConnectDestinationForm() { @@ -18,7 +18,7 @@ export function useConnectDestinationForm() { let componentPropertiesJson; let initialValuesJson; switch (componentType) { - case 'dropdown': + case INPUT_TYPES.DROPDOWN: componentPropertiesJson = safeJsonParse<{ [key: string]: string }>( componentProperties, {} @@ -41,8 +41,8 @@ export function useConnectDestinationForm() { ...componentPropertiesJson, }; - case 'input': - case 'textarea': + case INPUT_TYPES.INPUT: + case INPUT_TYPES.TEXTAREA: componentPropertiesJson = safeJsonParse( componentProperties, [] @@ -54,7 +54,7 @@ export function useConnectDestinationForm() { ...componentPropertiesJson, }; - case 'multiInput': + case INPUT_TYPES.MULTI_INPUT: componentPropertiesJson = safeJsonParse( componentProperties, [] @@ -66,9 +66,10 @@ export function useConnectDestinationForm() { componentType, title: displayName, initialValues: initialValuesJson, + value: initialValuesJson, ...componentPropertiesJson, }; - case 'keyValuePairs': + case INPUT_TYPES.KEY_VALUE_PAIR: return { name, componentType, diff --git a/frontend/webapp/hooks/destinations/useDestinationFormData.ts b/frontend/webapp/hooks/destinations/useDestinationFormData.ts new file mode 100644 index 000000000..bba30a780 --- /dev/null +++ b/frontend/webapp/hooks/destinations/useDestinationFormData.ts @@ -0,0 +1,165 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { safeJsonParse } from '@/utils'; +import { useDrawerStore } from '@/store'; +import { useQuery } from '@apollo/client'; +import { useConnectDestinationForm } from '@/hooks'; +import { GET_DESTINATION_TYPE_DETAILS } from '@/graphql'; +import { + DynamicField, + ActualDestination, + isActualDestination, + DestinationDetailsResponse, + SupportedDestinationSignals, +} from '@/types'; + +const DEFAULT_SUPPORTED_SIGNALS: SupportedDestinationSignals = { + logs: { supported: false }, + metrics: { supported: false }, + traces: { supported: false }, +}; + +export function useDestinationFormData() { + const [dynamicFields, setDynamicFields] = useState([]); + const [exportedSignals, setExportedSignals] = useState({ + logs: false, + metrics: false, + traces: false, + }); + const [supportedSignals, setSupportedSignals] = + useState(DEFAULT_SUPPORTED_SIGNALS); + + const destination = useDrawerStore(({ selectedItem }) => selectedItem); + const shouldSkip = !isActualDestination(destination?.item); + const destinationType = isActualDestination(destination?.item) + ? destination.item.destinationType.type + : null; + + const { buildFormDynamicFields } = useConnectDestinationForm(); + + const { data: destinationFields } = useQuery( + GET_DESTINATION_TYPE_DETAILS, + { variables: { type: destinationType }, skip: shouldSkip } + ); + + // Memoize the buildFormDynamicFields to ensure it's stable across renders + const memoizedBuildFormDynamicFields = useCallback( + buildFormDynamicFields, + [] + ); + + const initialDynamicFieldsRef = useRef([]); + const initialExportedSignalsRef = useRef({ + logs: false, + metrics: false, + traces: false, + }); + const initialSupportedSignalsRef = useRef( + DEFAULT_SUPPORTED_SIGNALS + ); + + useEffect(() => { + if (destinationFields && isActualDestination(destination?.item)) { + const { fields, exportedSignals, destinationType } = destination.item; + const destinationTypeDetails = destinationFields.destinationTypeDetails; + + const parsedFields = safeJsonParse>(fields, {}); + const formFields = memoizedBuildFormDynamicFields( + destinationTypeDetails?.fields || [] + ); + + const df = formFields.map((field) => { + let fieldValue: any = parsedFields[field.name] || ''; + + // Check if fieldValue is a JSON string that needs stringifying + try { + const parsedValue = JSON.parse(fieldValue); + console.log({ parsedValue }); + if (Array.isArray(parsedValue)) { + // If it's an array, stringify it for setting the value + fieldValue = parsedValue; + } + } catch (e) { + // If parsing fails, it's not JSON, so we keep it as is + } + + return { + ...field, + value: fieldValue, + }; + }); + + setDynamicFields(df); + setExportedSignals(exportedSignals); + setSupportedSignals(destinationType.supportedSignals); + + initialDynamicFieldsRef.current = df; + initialExportedSignalsRef.current = exportedSignals; + initialSupportedSignalsRef.current = destinationType.supportedSignals; + } + }, [destinationFields, destination, memoizedBuildFormDynamicFields]); + + const cardData = useMemo(() => { + if ( + shouldSkip || + !isActualDestination(destination?.item) || + !destinationFields + ) { + return [ + { title: 'Error', value: 'No destination selected or data missing' }, + ]; + } + + const { exportedSignals, destinationType, fields } = destination.item; + const parsedFields = safeJsonParse>(fields, {}); + const destinationDetails = destinationFields.destinationTypeDetails?.fields; + const fieldsData = buildDestinationFieldData( + parsedFields, + destinationDetails + ); + + return [ + { title: 'Destination', value: destinationType.displayName || 'N/A' }, + { title: 'Monitors', value: buildMonitorsList(exportedSignals) }, + ...fieldsData, + ]; + }, [shouldSkip, destination, destinationFields]); + + // Reset function using initial values from refs + const resetFormData = useCallback(() => { + setDynamicFields(initialDynamicFieldsRef.current); + setExportedSignals(initialExportedSignalsRef.current); + setSupportedSignals(initialSupportedSignalsRef.current); + }, []); + + return { + cardData, + dynamicFields, + destinationType: destinationType || '', + exportedSignals, + supportedSignals, + setExportedSignals, + setDynamicFields, + resetFormData, + }; +} + +function buildDestinationFieldData( + parsedFields: Record, + fieldDetails?: { name: string; displayName: string }[] +) { + return Object.entries(parsedFields).map(([key, value]) => ({ + title: + fieldDetails?.find((field) => field.name === key)?.displayName || key, + value: value || 'N/A', + })); +} + +function buildMonitorsList( + exportedSignals: ActualDestination['exportedSignals'] +): string { + return ( + Object.keys(exportedSignals) + .filter((key) => exportedSignals[key] && key !== '__typename') + .join(', ') || 'None' + ); +} diff --git a/frontend/webapp/hooks/destinations/useEditDestinationFormHandlers.ts b/frontend/webapp/hooks/destinations/useEditDestinationFormHandlers.ts new file mode 100644 index 000000000..330b1390f --- /dev/null +++ b/frontend/webapp/hooks/destinations/useEditDestinationFormHandlers.ts @@ -0,0 +1,22 @@ +import { Dispatch, SetStateAction } from 'react'; +import { DynamicField, ExportedSignals } from '@/types'; + +export function useEditDestinationFormHandlers( + setExportedSignals: Dispatch>, + setDynamicFields: Dispatch> +) { + const handleSignalChange = ( + signal: keyof ExportedSignals, + value: boolean + ) => { + setExportedSignals((prev) => ({ ...prev, [signal]: value })); + }; + + const handleDynamicFieldChange = (name: string, value: any) => { + setDynamicFields((prev) => + prev.map((field) => (field.name === name ? { ...field, value } : field)) + ); + }; + + return { handleSignalChange, handleDynamicFieldChange }; +} diff --git a/frontend/webapp/hooks/destinations/useUpdateDestination.ts b/frontend/webapp/hooks/destinations/useUpdateDestination.ts new file mode 100644 index 000000000..d060dc42e --- /dev/null +++ b/frontend/webapp/hooks/destinations/useUpdateDestination.ts @@ -0,0 +1,33 @@ +// src/hooks/useUpdateDestination.ts + +import { UPDATE_DESTINATION } from '@/graphql'; +import { useDrawerStore } from '@/store'; +import { DestinationInput } from '@/types'; +import { useMutation } from '@apollo/client'; + +export function useUpdateDestination() { + const [updateDestinationMutation] = useMutation(UPDATE_DESTINATION); + + const setDrawerItem = useDrawerStore( + ({ setSelectedItem }) => setSelectedItem + ); + + async function updateExistingDestination( + id: string, + destination: DestinationInput + ) { + try { + const { data } = await updateDestinationMutation({ + variables: { id, destination }, + }); + setDrawerItem({ id, item: data?.updateDestination, type: 'destination' }); + console.log({ data }); + return data?.updateDestination?.id; + } catch (error) { + console.error('Error updating destination:', error); + throw error; + } + } + + return { updateExistingDestination }; +} diff --git a/frontend/webapp/hooks/index.tsx b/frontend/webapp/hooks/index.tsx index c2051a037..b1c09191b 100644 --- a/frontend/webapp/hooks/index.tsx +++ b/frontend/webapp/hooks/index.tsx @@ -10,3 +10,5 @@ export * from './useSSE'; export * from './new-config'; export * from './compute-platform'; export * from './useOverviewMetrics'; +export * from './overview'; +export * from './common'; diff --git a/frontend/webapp/hooks/overview/index.tsx b/frontend/webapp/hooks/overview/index.tsx new file mode 100644 index 000000000..45e0d2258 --- /dev/null +++ b/frontend/webapp/hooks/overview/index.tsx @@ -0,0 +1 @@ +export * from './useNodeDataFlowHandlers'; diff --git a/frontend/webapp/hooks/overview/useNodeDataFlowHandlers.ts b/frontend/webapp/hooks/overview/useNodeDataFlowHandlers.ts new file mode 100644 index 000000000..eb0291a82 --- /dev/null +++ b/frontend/webapp/hooks/overview/useNodeDataFlowHandlers.ts @@ -0,0 +1,55 @@ +// src/hooks/useNodeDataFlowHandlers.ts +import { useCallback } from 'react'; +import { useDrawerStore } from '@/store'; +import { K8sActualSource, ActualDestination } from '@/types'; + +const TYPE_SOURCE = 'source'; +const TYPE_DESTINATION = 'destination'; + +export function useNodeDataFlowHandlers( + sources: K8sActualSource[], + destinations: ActualDestination[] +) { + const setSelectedItem = useDrawerStore( + ({ setSelectedItem }) => setSelectedItem + ); + + const handleNodeClick = useCallback( + (_, object: any) => { + if (object.data.type === TYPE_SOURCE) { + const { id } = object.data; + const selectedDrawerItem = sources.find( + ({ kind, name, namespace }) => + kind === id.kind && name === id.name && namespace === id.namespace + ); + if (!selectedDrawerItem) return; + + const { kind, name, namespace } = selectedDrawerItem; + + setSelectedItem({ + id: { kind, name, namespace }, + item: selectedDrawerItem, + type: TYPE_SOURCE, + }); + } + + if (object.data.type === TYPE_DESTINATION) { + const { id } = object.data; + const selectedDrawerItem = destinations.find( + (destination) => destination.id === id + ); + + setSelectedItem({ + id, + item: selectedDrawerItem, + type: TYPE_DESTINATION, + }); + } + }, + [sources, destinations, setSelectedItem] + ); + + return { + handleNodeClick, + }; +} diff --git a/frontend/webapp/lib/gql/apollo-wrapper.tsx b/frontend/webapp/lib/gql/apollo-wrapper.tsx index 0bef52a51..debbe9ea4 100644 --- a/frontend/webapp/lib/gql/apollo-wrapper.tsx +++ b/frontend/webapp/lib/gql/apollo-wrapper.tsx @@ -26,7 +26,9 @@ function makeClient() { }); return new ApolloClient({ - cache: new InMemoryCache(), + cache: new InMemoryCache({ + addTypename: false, + }), devtools: { enabled: true, }, diff --git a/frontend/webapp/package.json b/frontend/webapp/package.json index c63d15009..d797d3439 100644 --- a/frontend/webapp/package.json +++ b/frontend/webapp/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@apollo/client": "^3.11.0-rc.2", - "@apollo/experimental-nextjs-app-support": "^0.11.2", + "@apollo/experimental-nextjs-app-support": "^0.11.3", "@focus-reactive/react-yaml": "^1.1.2", "@keyval-dev/design-system": "^2.3.1", "@next/font": "^13.4.7", diff --git a/frontend/webapp/reuseable-components/checkbox-list/index.tsx b/frontend/webapp/reuseable-components/checkbox-list/index.tsx index 47e8f2e17..0528fce7f 100644 --- a/frontend/webapp/reuseable-components/checkbox-list/index.tsx +++ b/frontend/webapp/reuseable-components/checkbox-list/index.tsx @@ -37,8 +37,10 @@ const CheckboxList: React.FC = ({ (value) => value ); + const trueValues = Object.values(exportedSignals).filter(Boolean); + return ( - monitors.length === 1 || + (monitors.length === 1 && trueValues.length === 1) || (selectedItems.length === 1 && exportedSignals[item.id]) ); } diff --git a/frontend/webapp/reuseable-components/input-list/index.tsx b/frontend/webapp/reuseable-components/input-list/index.tsx index 43d6fffe5..67b935d7a 100644 --- a/frontend/webapp/reuseable-components/input-list/index.tsx +++ b/frontend/webapp/reuseable-components/input-list/index.tsx @@ -11,6 +11,7 @@ interface InputListProps { title?: string; tooltip?: string; required?: boolean; + value?: string[]; onChange: (values: string[]) => void; } @@ -71,8 +72,9 @@ const InputList: React.FC = ({ tooltip, required, onChange, + value = [''], }) => { - const [inputs, setInputs] = useState(initialValues); + const [inputs, setInputs] = useState(value || initialValues); useEffect(() => { if (initialValues.length > 0) { @@ -85,7 +87,9 @@ const InputList: React.FC = ({ }; const handleDeleteInput = (index: number) => { - setInputs(inputs.filter((_, i) => i !== index)); + const newInputs = inputs.filter((_, i) => i !== index); + setInputs(newInputs); + onChange(newInputs); }; const handleInputChange = (value: string, index: number) => { @@ -101,15 +105,15 @@ const InputList: React.FC = ({ return ( {title && ( - - - {title} - {!required && ( - - (optional) - - )} - {tooltip && ( + + {title} + {!required && ( + + (optional) + + )} + {tooltip && ( + = ({ height={16} style={{ marginBottom: 4 }} /> - )} - - + + )} + )} {inputs.map((value, index) => ( diff --git a/frontend/webapp/reuseable-components/key-value-input-list/index.tsx b/frontend/webapp/reuseable-components/key-value-input-list/index.tsx index 1b25a0fbe..cbd7904ea 100644 --- a/frontend/webapp/reuseable-components/key-value-input-list/index.tsx +++ b/frontend/webapp/reuseable-components/key-value-input-list/index.tsx @@ -8,6 +8,7 @@ import { Tooltip } from '../tooltip'; interface KeyValueInputsListProps { initialKeyValuePairs?: { key: string; value: string }[]; + value?: { key: string; value: string }[]; title?: string; tooltip?: string; required?: boolean; @@ -67,13 +68,15 @@ const Title = styled(Text)` export const KeyValueInputsList: React.FC = ({ initialKeyValuePairs = [{ key: '', value: '' }], + value = [{ key: '', value: '' }], title, tooltip, required, onChange, }) => { - const [keyValuePairs, setKeyValuePairs] = - useState<{ key: string; value: string }[]>(initialKeyValuePairs); + const [keyValuePairs, setKeyValuePairs] = useState< + { key: string; value: string }[] + >(value || initialKeyValuePairs); const validPairsRef = useRef<{ key: string; value: string }[]>([]); diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts b/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts index d8282e968..7237229b9 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts +++ b/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts @@ -1,8 +1,12 @@ +import theme from '@/styles/theme'; import { Node, Edge } from 'react-flow-renderer'; import { getMainContainerLanguageLogo } from '@/utils/constants/programming-languages'; -import { ActionItem } from '@/types'; -import theme from '@/styles/theme'; -import { useDrawerStore } from '@/store'; +import { + ActionData, + ActionItem, + ActualDestination, + K8sActualSource, +} from '@/types'; // Constants const NODE_HEIGHT = 80; @@ -51,14 +55,18 @@ export const buildNodesAndEdges = ({ destinations, columnWidth, containerWidth, +}: { + sources: K8sActualSource[]; + actions: ActionData[]; + destinations: ActualDestination[]; + columnWidth: number; + containerWidth: number; }) => { // Calculate x positions for each column const leftColumnX = 0; const rightColumnX = containerWidth - columnWidth; const centerColumnX = (containerWidth - columnWidth) / 2; - const setSelectedItem = useDrawerStore((state) => state.setSelectedItem); - // Build Source Nodes const sourcesNode: Node[] = [ createNode('header-source', 'header', leftColumnX, 0, { @@ -74,7 +82,9 @@ export const buildNodesAndEdges = ({ NODE_HEIGHT * (index + 1), { type: 'source', - title: source.name, + title: + source.name + + (source.reportedName ? ` (${source.reportedName})` : ''), subTitle: source.kind, imageUri: getMainContainerLanguageLogo(source), status: 'healthy', diff --git a/frontend/webapp/store/useDrawerStore.tsx b/frontend/webapp/store/useDrawerStore.tsx index b69349fe2..863dda558 100644 --- a/frontend/webapp/store/useDrawerStore.tsx +++ b/frontend/webapp/store/useDrawerStore.tsx @@ -1,12 +1,12 @@ // drawerStore.ts import { create } from 'zustand'; -import { Destination, K8sActualSource, WorkloadId } from '@/types'; +import { ActualDestination, K8sActualSource, WorkloadId } from '@/types'; type ItemType = 'source' | 'action' | 'destination'; interface BaseItem { id: string | WorkloadId; - item?: K8sActualSource | Destination; + item?: K8sActualSource | ActualDestination; type: ItemType; // Add common properties here } diff --git a/frontend/webapp/types/common.ts b/frontend/webapp/types/common.ts index 83e80afdc..c90203c9e 100644 --- a/frontend/webapp/types/common.ts +++ b/frontend/webapp/types/common.ts @@ -35,3 +35,9 @@ export interface StepProps { state: 'finish' | 'active' | 'disabled'; stepNumber: number; } + +export enum OVERVIEW_ENTITY_TYPES { + SOURCE = 'source', + DESTINATION = 'destination', + ACTION = 'action', +} diff --git a/frontend/webapp/types/destinations.ts b/frontend/webapp/types/destinations.ts index 540995e71..0d1f3dd97 100644 --- a/frontend/webapp/types/destinations.ts +++ b/frontend/webapp/types/destinations.ts @@ -104,7 +104,7 @@ interface SupportedSignal { supported: boolean; } -interface SupportedSignals { +export interface SupportedDestinationSignals { traces: SupportedSignal; metrics: SupportedSignal; logs: SupportedSignal; @@ -114,7 +114,7 @@ export interface SelectedDestination { type: string; display_name: string; image_url: string; - supported_signals: SupportedSignals; + supported_signals: SupportedDestinationSignals; test_connection_supported: boolean; } @@ -133,17 +133,7 @@ export interface Destination { type: string; display_name: string; image_url: string; - supported_signals: { - traces: { - supported: boolean; - }; - metrics: { - supported: boolean; - }; - logs: { - supported: boolean; - }; - }; + supported_signals: SupportedDestinationSignals; }; } @@ -159,7 +149,7 @@ export interface Field { export interface DestinationConfig { type: string; name: string; - signals: SupportedSignals; + signals: SupportedDestinationSignals; fields: { [key: string]: string; }; @@ -174,11 +164,15 @@ export interface ActualDestination { metrics: boolean; logs: boolean; }; - fields: Record; + fields: string; conditions: Condition[]; destinationType: { type: string; displayName: string; imageUrl: string; + supportedSignals: SupportedDestinationSignals; }; } + +export const isActualDestination = (item: any): item is ActualDestination => + item && 'destinationType' in item; diff --git a/frontend/webapp/yarn.lock b/frontend/webapp/yarn.lock index 166a81370..fdc6854b6 100644 --- a/frontend/webapp/yarn.lock +++ b/frontend/webapp/yarn.lock @@ -10,10 +10,10 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@apollo/client-react-streaming@0.11.2": - version "0.11.2" - resolved "https://registry.yarnpkg.com/@apollo/client-react-streaming/-/client-react-streaming-0.11.2.tgz#788a5b0254469b679f8abf5391e40c420fe61965" - integrity sha512-rRA/dIA09/Y6+jtGGBnXHQfPOv6BYYVZwQP8OzQtWrWbSgDEI6uAhqULssU5f0ZhQJVzKDuslqGE9QAX0gdfRQ== +"@apollo/client-react-streaming@0.11.3": + version "0.11.3" + resolved "https://registry.yarnpkg.com/@apollo/client-react-streaming/-/client-react-streaming-0.11.3.tgz#ca14d15241b60e9e5e86f075d14e7cf72e1485ba" + integrity sha512-bAyyD7iZQ8UIvYZv2ZY3i5FTNdCgM0kfWW/0St3sqJLAs4Ji6QB9uzGUTc5434vQo6Ddb17N+Q+Ikr7fj2yTxw== dependencies: ts-invariant "^0.10.3" @@ -37,12 +37,12 @@ tslib "^2.3.0" zen-observable-ts "^1.2.5" -"@apollo/experimental-nextjs-app-support@^0.11.2": - version "0.11.2" - resolved "https://registry.yarnpkg.com/@apollo/experimental-nextjs-app-support/-/experimental-nextjs-app-support-0.11.2.tgz#3df9253229afd6ec94bc5873f649f23c487c9dfb" - integrity sha512-HRQ8/Ux/tM2pezrhZeoHsJs55+nJvJZRV1B21QwEVtWhslQXjT5gqs5nKw86KURF0xR7gX18Nyy659NzJ09Pmw== +"@apollo/experimental-nextjs-app-support@^0.11.3": + version "0.11.3" + resolved "https://registry.yarnpkg.com/@apollo/experimental-nextjs-app-support/-/experimental-nextjs-app-support-0.11.3.tgz#a0910e4d6376d6ac8293e4718e17d8df96b69de8" + integrity sha512-eMfbEtHyQE9EceBn0sTBWcHVvjhd+dkMO5dBhoEglEm0ga2n87KKiTeaNNb/XZnvOX81/6y0iyc0U7cgITpvKw== dependencies: - "@apollo/client-react-streaming" "0.11.2" + "@apollo/client-react-streaming" "0.11.3" "@babel/cli@^7.21.0": version "7.24.8"