diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 495f9360588..47c1592f5de 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -870,14 +870,6 @@ components: - timestampMillis - data type: object - CloudintegrationtypesAssets: - properties: - dashboards: - items: - $ref: '#/components/schemas/CloudintegrationtypesDashboard' - nullable: true - type: array - type: object CloudintegrationtypesAzureAccountConfig: properties: deploymentRegion: @@ -1025,17 +1017,6 @@ components: - ingestionUrl - ingestionKey type: object - CloudintegrationtypesDashboard: - properties: - definition: - $ref: '#/components/schemas/DashboardtypesStorableDashboardData' - description: - type: string - id: - type: string - title: - type: string - type: object CloudintegrationtypesDataCollected: properties: logs: @@ -1209,7 +1190,7 @@ components: CloudintegrationtypesService: properties: assets: - $ref: '#/components/schemas/CloudintegrationtypesAssets' + $ref: '#/components/schemas/CloudintegrationtypesServiceAssets' cloudIntegrationService: $ref: '#/components/schemas/CloudintegrationtypesCloudIntegrationService' dataCollected: @@ -1222,8 +1203,6 @@ components: type: string supportedSignals: $ref: '#/components/schemas/CloudintegrationtypesSupportedSignals' - telemetryCollectionStrategy: - $ref: '#/components/schemas/CloudintegrationtypesTelemetryCollectionStrategy' title: type: string required: @@ -1234,9 +1213,17 @@ components: - assets - supportedSignals - dataCollected - - telemetryCollectionStrategy - cloudIntegrationService type: object + CloudintegrationtypesServiceAssets: + properties: + dashboards: + items: + $ref: '#/components/schemas/CloudintegrationtypesServiceDashboard' + type: array + required: + - dashboards + type: object CloudintegrationtypesServiceConfig: properties: aws: @@ -1244,6 +1231,18 @@ components: azure: $ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig' type: object + CloudintegrationtypesServiceDashboard: + properties: + description: + type: string + integrationDashboard: + $ref: '#/components/schemas/CloudintegrationtypesStorableIntegrationDashboard' + title: + type: string + required: + - title + - description + type: object CloudintegrationtypesServiceID: enum: - alb @@ -1278,6 +1277,30 @@ components: - icon - enabled type: object + CloudintegrationtypesStorableIntegrationDashboard: + properties: + createdAt: + format: date-time + type: string + dashboardId: + type: string + id: + type: string + provider: + type: string + slug: + type: string + updatedAt: + format: date-time + type: string + required: + - id + - dashboardId + - provider + - slug + - createdAt + - updatedAt + type: object CloudintegrationtypesSupportedSignals: properties: logs: @@ -1285,13 +1308,6 @@ components: metrics: type: boolean type: object - CloudintegrationtypesTelemetryCollectionStrategy: - properties: - aws: - $ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy' - azure: - $ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy' - type: object CloudintegrationtypesUpdatableAccount: properties: config: @@ -8087,6 +8103,64 @@ paths: summary: Update account tags: - cloudintegration + /api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services: + get: + deprecated: false + description: This endpoint lists the services metadata for the specified account + and cloud provider + operationId: ListAccountServicesMetadata + parameters: + - in: path + name: cloud_provider + required: true + schema: + type: string + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + properties: + data: + $ref: '#/components/schemas/CloudintegrationtypesGettableServicesMetadata' + status: + type: string + required: + - status + - data + type: object + description: OK + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Forbidden + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + security: + - api_key: + - ADMIN + - tokenizer: + - ADMIN + summary: List account services metadata + tags: + - cloudintegration /api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}: put: deprecated: false diff --git a/ee/modules/cloudintegration/implcloudintegration/module.go b/ee/modules/cloudintegration/implcloudintegration/module.go index a72165a8637..2ac5a98040b 100644 --- a/ee/modules/cloudintegration/implcloudintegration/module.go +++ b/ee/modules/cloudintegration/implcloudintegration/module.go @@ -355,26 +355,32 @@ func (module *module) GetService(ctx context.Context, orgID valuer.UUID, service var integrationService *cloudintegrationtypes.CloudIntegrationService - if !cloudIntegrationID.IsZero() { - storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID) - if err != nil && !errors.Ast(err, errors.TypeNotFound) { - return nil, err - } - if storedService != nil { - serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config) - if err != nil { - return nil, err - } + if cloudIntegrationID.IsZero() { + return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil + } - integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig) - } + storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + return nil, err + } + if storedService == nil { + return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil + } - if err := module.enrichDashboardIDs(ctx, orgID, provider, serviceID, serviceDefinition); err != nil { - return nil, err - } + serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config) + if err != nil { + return nil, err } - return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil + integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig) + + slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID) + integrationDashboards, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix) + if err != nil { + return nil, err + } + + return cloudintegrationtypes.NewService(provider, serviceDefinition, integrationService, integrationDashboards), nil } func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error { @@ -583,20 +589,3 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU } return nil } - -// enrichDashboardIDs replaces the raw dashboard name in each Dashboard.ID with the provisioned UUID. -// TODO: remove this hack and send idiomatic response to client. -func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error { - for i, d := range serviceDefinition.Assets.Dashboards { - slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID) - row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug) - if err != nil { - if errors.Ast(err, errors.TypeNotFound) { - continue - } - return err - } - serviceDefinition.Assets.Dashboards[i].ID = row.DashboardID - } - return nil -} diff --git a/frontend/src/api/generated/services/cloudintegration/index.ts b/frontend/src/api/generated/services/cloudintegration/index.ts index 1b5a6396fb0..7a6049e5bce 100644 --- a/frontend/src/api/generated/services/cloudintegration/index.ts +++ b/frontend/src/api/generated/services/cloudintegration/index.ts @@ -36,6 +36,8 @@ import type { GetService200, GetServiceParams, GetServicePathParameters, + ListAccountServicesMetadata200, + ListAccountServicesMetadataPathParameters, ListAccounts200, ListAccountsPathParameters, ListServicesMetadata200, @@ -631,6 +633,116 @@ export const useUpdateAccount = < > => { return useMutation(getUpdateAccountMutationOptions(options)); }; +/** + * This endpoint lists the services metadata for the specified account and cloud provider + * @summary List account services metadata + */ +export const listAccountServicesMetadata = ( + { cloudProvider, id }: ListAccountServicesMetadataPathParameters, + signal?: AbortSignal, +) => { + return GeneratedAPIInstance({ + url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`, + method: 'GET', + signal, + }); +}; + +export const getListAccountServicesMetadataQueryKey = ({ + cloudProvider, + id, +}: ListAccountServicesMetadataPathParameters) => { + return [ + `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`, + ] as const; +}; + +export const getListAccountServicesMetadataQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + { cloudProvider, id }: ListAccountServicesMetadataPathParameters, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + }, +) => { + const { query: queryOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? + getListAccountServicesMetadataQueryKey({ cloudProvider, id }); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => listAccountServicesMetadata({ cloudProvider, id }, signal); + + return { + queryKey, + queryFn, + enabled: !!(cloudProvider && id), + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListAccountServicesMetadataQueryResult = NonNullable< + Awaited> +>; +export type ListAccountServicesMetadataQueryError = + ErrorType; + +/** + * @summary List account services metadata + */ + +export function useListAccountServicesMetadata< + TData = Awaited>, + TError = ErrorType, +>( + { cloudProvider, id }: ListAccountServicesMetadataPathParameters, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListAccountServicesMetadataQueryOptions( + { cloudProvider, id }, + options, + ); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary List account services metadata + */ +export const invalidateListAccountServicesMetadata = async ( + queryClient: QueryClient, + { cloudProvider, id }: ListAccountServicesMetadataPathParameters, + options?: InvalidateOptions, +): Promise => { + await queryClient.invalidateQueries( + { queryKey: getListAccountServicesMetadataQueryKey({ cloudProvider, id }) }, + options, + ); + + return queryClient; +}; + /** * This endpoint updates a service for the specified cloud provider * @summary Update service diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index 7fed15fe0d6..23698cf1182 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -2457,33 +2457,6 @@ export interface CloudintegrationtypesAccountDTO { updatedAt?: string; } -export interface DashboardtypesStorableDashboardDataDTO { - [key: string]: unknown; -} - -export interface CloudintegrationtypesDashboardDTO { - definition?: DashboardtypesStorableDashboardDataDTO; - /** - * @type string - */ - description?: string; - /** - * @type string - */ - id?: string; - /** - * @type string - */ - title?: string; -} - -export interface CloudintegrationtypesAssetsDTO { - /** - * @type array,null - */ - dashboards?: CloudintegrationtypesDashboardDTO[] | null; -} - export interface CloudintegrationtypesAzureConnectionArtifactDTO { /** * @type string @@ -2866,6 +2839,54 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO { providerAccountId?: string; } +export interface CloudintegrationtypesStorableIntegrationDashboardDTO { + /** + * @type string + * @format date-time + */ + createdAt: string; + /** + * @type string + */ + dashboardId: string; + /** + * @type string + */ + id: string; + /** + * @type string + */ + provider: string; + /** + * @type string + */ + slug: string; + /** + * @type string + * @format date-time + */ + updatedAt: string; +} + +export interface CloudintegrationtypesServiceDashboardDTO { + /** + * @type string + */ + description: string; + integrationDashboard?: CloudintegrationtypesStorableIntegrationDashboardDTO; + /** + * @type string + */ + title: string; +} + +export interface CloudintegrationtypesServiceAssetsDTO { + /** + * @type array + */ + dashboards: CloudintegrationtypesServiceDashboardDTO[]; +} + export interface CloudintegrationtypesSupportedSignalsDTO { /** * @type boolean @@ -2877,13 +2898,8 @@ export interface CloudintegrationtypesSupportedSignalsDTO { metrics?: boolean; } -export interface CloudintegrationtypesTelemetryCollectionStrategyDTO { - aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO; - azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO; -} - export interface CloudintegrationtypesServiceDTO { - assets: CloudintegrationtypesAssetsDTO; + assets: CloudintegrationtypesServiceAssetsDTO; cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO | null; dataCollected: CloudintegrationtypesDataCollectedDTO; /** @@ -2899,7 +2915,6 @@ export interface CloudintegrationtypesServiceDTO { */ overview: string; supportedSignals: CloudintegrationtypesSupportedSignalsDTO; - telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO; /** * @type string */ @@ -3705,6 +3720,10 @@ export interface DashboardtypesCustomVariableSpecDTO { customValue: string; } +export interface DashboardtypesStorableDashboardDataDTO { + [key: string]: unknown; +} + export enum DashboardtypesSourceDTO { user = 'user', system = 'system', @@ -8676,6 +8695,18 @@ export type UpdateAccountPathParameters = { cloudProvider: string; id: string; }; +export type ListAccountServicesMetadataPathParameters = { + cloudProvider: string; + id: string; +}; +export type ListAccountServicesMetadata200 = { + data: CloudintegrationtypesGettableServicesMetadataDTO; + /** + * @type string + */ + status: string; +}; + export type UpdateServicePathParameters = { cloudProvider: string; id: string; diff --git a/frontend/src/api/trace/getTraceV3.tsx b/frontend/src/api/trace/getTraceV4.tsx similarity index 57% rename from frontend/src/api/trace/getTraceV3.tsx rename to frontend/src/api/trace/getTraceV4.tsx index 6f99a4c96ed..907856a1be1 100644 --- a/frontend/src/api/trace/getTraceV3.tsx +++ b/frontend/src/api/trace/getTraceV4.tsx @@ -1,15 +1,14 @@ -import { ApiV3Instance as axios } from 'api'; -import { omit } from 'lodash-es'; +import { getWaterfallV4 } from 'api/generated/services/tracedetail'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { - GetTraceV3PayloadProps, - GetTraceV3SuccessResponse, + GetTraceV4PayloadProps, + GetTraceV4SuccessResponse, SpanV3, } from 'types/api/trace/getTraceV3'; -const getTraceV3 = async ( - props: GetTraceV3PayloadProps, -): Promise | ErrorResponse> => { +const getTraceV4 = async ( + props: GetTraceV4PayloadProps, +): Promise | ErrorResponse> => { let uncollapsedSpans = [...props.uncollapsedSpans]; if (!props.isSelectedSpanIDUnCollapsed) { uncollapsedSpans = uncollapsedSpans.filter( @@ -19,31 +18,37 @@ const getTraceV3 = async ( props.selectedSpanId && !uncollapsedSpans.includes(props.selectedSpanId) ) { - // V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets + // Backend only uses the uncollapsedSpans list (unlike V2 which also interprets // isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span uncollapsedSpans.push(props.selectedSpanId); } - const postData: GetTraceV3PayloadProps = { - ...props, - uncollapsedSpans, - limit: 10000, - }; - const response = await axios.post( - `/traces/${props.traceId}/waterfall`, - omit(postData, 'traceId'), + const response = await getWaterfallV4( + { traceID: props.traceId }, + { + selectedSpanId: props.selectedSpanId, + uncollapsedSpans, + limit: 10000, + }, ); - // V3 API wraps response in { status, data } - const rawPayload = (response.data as any).data || response.data; + // Generated client unwraps the axios response; .data is the waterfall payload. + // Wire spans carry time_unix; SpanV3's timestamp + 'service.name' are derived below. + type WireSpan = Omit & { + time_unix: number; + }; + const rawPayload = response.data as unknown as Omit< + GetTraceV4SuccessResponse, + 'spans' + > & { spans: WireSpan[] | null }; // Derive 'service.name' from resource for convenience — only derived field - const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({ + const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({ ...span, 'service.name': span.resource?.['service.name'] || '', timestamp: span.time_unix, })); - // V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset), + // API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset), // not absolute unix millis like V2. The span timestamps are absolute unix millis. // Convert by using the first span's timestamp as the base if there's a mismatch. let { startTimestampMillis, endTimestampMillis } = rawPayload; @@ -70,4 +75,4 @@ const getTraceV3 = async ( }; }; -export default getTraceV3; +export default getTraceV4; diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index 2946f5e8688..9620bcfc5ac 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -33,7 +33,8 @@ export const REACT_QUERY_KEY = { UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE', GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3', GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL', - GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL', + GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL', + GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS', GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH', GET_POD_LIST: 'GET_POD_LIST', GET_NODE_LIST: 'GET_NODE_LIST', diff --git a/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/ServiceDetails/ServiceDetails.tsx b/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/ServiceDetails/ServiceDetails.tsx index 10e2e01328d..9cb5be99088 100644 --- a/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/ServiceDetails/ServiceDetails.tsx +++ b/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/ServiceDetails/ServiceDetails.tsx @@ -8,16 +8,16 @@ import { Tabs } from '@signozhq/ui/tabs'; import { Skeleton } from 'antd'; import logEvent from 'api/common/logEvent'; import { - getListServicesMetadataQueryKey, + getListAccountServicesMetadataQueryKey, invalidateGetService, - invalidateListServicesMetadata, + invalidateListAccountServicesMetadata, useGetService, useUpdateService, } from 'api/generated/services/cloudintegration'; import { CloudintegrationtypesServiceConfigDTO, CloudintegrationtypesServiceDTO, - ListServicesMetadata200, + ListAccountServicesMetadata200, } from 'api/generated/services/sigNoz.schemas'; import CloudServiceDataCollected from 'components/CloudIntegrations/CloudServiceDataCollected/CloudServiceDataCollected'; import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer'; @@ -240,16 +240,12 @@ function ServiceDetails({ // instead of waiting for the refetch to complete. reset(nextFormValues); - const servicesListQueryKey = getListServicesMetadataQueryKey( - { - cloudProvider: type, - }, - { - cloud_integration_id: cloudAccountId, - }, - ); + const servicesListQueryKey = getListAccountServicesMetadataQueryKey({ + cloudProvider: type, + id: cloudAccountId, + }); - queryClient.setQueryData( + queryClient.setQueryData( servicesListQueryKey, (prev) => { if (!prev?.data?.services?.length) { @@ -283,15 +279,10 @@ function ServiceDetails({ }, ); - invalidateListServicesMetadata( - queryClient, - { - cloudProvider: type, - }, - { - cloud_integration_id: cloudAccountId, - }, - ); + invalidateListAccountServicesMetadata(queryClient, { + cloudProvider: type, + id: cloudAccountId, + }); logEvent(`${type} Integration: Service settings saved`, { cloudAccountId, diff --git a/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/mockData.ts b/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/mockData.ts index 7f099becb78..3e0cf3ed4f4 100644 --- a/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/mockData.ts +++ b/frontend/src/container/Integrations/CloudIntegration/AmazonWebServices/__tests__/mockData.ts @@ -55,7 +55,6 @@ const buildServiceDetailsResponse = ( }, }, }, - telemetryCollectionStrategy: { aws: {} }, }, }); diff --git a/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/DashboardCard.tsx b/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/DashboardCard.tsx new file mode 100644 index 00000000000..970ab58d3d7 --- /dev/null +++ b/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/DashboardCard.tsx @@ -0,0 +1,68 @@ +import type { KeyboardEvent, MouseEvent } from 'react'; +import { TooltipSimple } from '@signozhq/ui/tooltip'; +import { CloudintegrationtypesServiceDashboardDTO } from 'api/generated/services/sigNoz.schemas'; +import { useSafeNavigate } from 'hooks/useSafeNavigate'; +import { openInNewTab } from 'utils/navigation'; + +const DISABLED_TOOLTIP = + 'Enable metrics collection for this service to view this dashboard.'; + +function DashboardCard({ + dashboard, + isInteractive, +}: { + dashboard: CloudintegrationtypesServiceDashboardDTO; + isInteractive: boolean; +}): JSX.Element { + const dashboardId = dashboard.integrationDashboard?.dashboardId; + const isClickable = Boolean(dashboardId) && isInteractive; + const dashboardUrl = dashboardId ? `/dashboard/${dashboardId}` : ''; + + const { safeNavigate } = useSafeNavigate(); + + const interactiveProps = isClickable + ? { + role: 'button', + tabIndex: 0, + onClick: (event: MouseEvent): void => { + if (event.metaKey || event.ctrlKey) { + openInNewTab(dashboardUrl); + return; + } + safeNavigate(dashboardUrl); + }, + onKeyDown: (event: KeyboardEvent): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + safeNavigate(dashboardUrl); + } + }, + } + : {}; + + const card = ( +
+
+
{dashboard.title}
+
+ {dashboard.description} +
+
+
+ ); + + if (!dashboardId) { + return {card}; + } + + return card; +} + +export default DashboardCard; diff --git a/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards.styles.scss b/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards.styles.scss index 6874153f812..cd2c3cfca55 100644 --- a/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards.styles.scss +++ b/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards.styles.scss @@ -53,6 +53,11 @@ } } + &.aws-service-dashboard-item-disabled { + cursor: not-allowed; + opacity: 0.6; + } + .aws-service-dashboard-item-content { display: flex; flex-direction: column; diff --git a/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards.tsx b/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards.tsx index abfeb1b0522..8248b0f714d 100644 --- a/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards.tsx +++ b/frontend/src/container/Integrations/CloudIntegration/ServiceDashboards/ServiceDashboards.tsx @@ -1,11 +1,9 @@ -/* eslint-disable sonarjs/cognitive-complexity */ import { - CloudintegrationtypesDashboardDTO, + CloudintegrationtypesServiceDashboardDTO, CloudintegrationtypesServiceDTO, } from 'api/generated/services/sigNoz.schemas'; -import { useSafeNavigate } from 'hooks/useSafeNavigate'; -import { withBasePath } from 'utils/basePath'; +import DashboardCard from './DashboardCard'; import './ServiceDashboards.styles.scss'; function ServiceDashboards({ @@ -16,7 +14,6 @@ function ServiceDashboards({ isInteractive?: boolean; }): JSX.Element { const dashboards = service?.assets?.dashboards || []; - const { safeNavigate } = useSafeNavigate(); if (!dashboards.length) { return <>; } @@ -25,68 +22,20 @@ function ServiceDashboards({
Dashboards
- {dashboards.map((dashboard: CloudintegrationtypesDashboardDTO) => { - if (!dashboard.id) { - return null; - } - - const dashboardUrl = `/dashboard/${dashboard.id}`; - - return ( -
{ - if (!isInteractive) { - return; - } - if (event.metaKey || event.ctrlKey) { - window.open( - withBasePath(dashboardUrl), - '_blank', - 'noopener,noreferrer', - ); - return; - } - safeNavigate(dashboardUrl); - }} - onAuxClick={(event): void => { - if (!isInteractive) { - return; - } - if (event.button === 1) { - window.open( - withBasePath(dashboardUrl), - '_blank', - 'noopener,noreferrer', - ); - } - }} - onKeyDown={(event): void => { - if (!isInteractive) { - return; - } - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - safeNavigate(dashboardUrl); - } - }} - > -
-
- {dashboard.title} -
-
- {dashboard.description} -
-
-
- ); - })} + {dashboards.map( + (dashboard: CloudintegrationtypesServiceDashboardDTO, index: number) => { + const key = + dashboard.integrationDashboard?.dashboardId || + `${dashboard.title}-${index}`; + return ( + + ); + }, + )}
); diff --git a/frontend/src/container/Integrations/CloudIntegration/ServicesList.tsx b/frontend/src/container/Integrations/CloudIntegration/ServicesList.tsx index 9de64b00018..966926e7e52 100644 --- a/frontend/src/container/Integrations/CloudIntegration/ServicesList.tsx +++ b/frontend/src/container/Integrations/CloudIntegration/ServicesList.tsx @@ -1,7 +1,11 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom-v5-compat'; import { Skeleton } from 'antd'; -import { useListServicesMetadata } from 'api/generated/services/cloudintegration'; +import { + useListAccounts, + useListAccountServicesMetadata, + useListServicesMetadata, +} from 'api/generated/services/cloudintegration'; import type { CloudintegrationtypesServiceMetadataDTO } from 'api/generated/services/sigNoz.schemas'; import cx from 'classnames'; import { IntegrationType } from 'container/Integrations/types'; @@ -20,17 +24,33 @@ function ServicesList({ }: ServicesListProps): JSX.Element { const urlQuery = useUrlQuery(); const navigate = useNavigate(); - const hasValidCloudAccountId = Boolean(cloudAccountId); - const serviceQueryParams = hasValidCloudAccountId - ? { cloud_integration_id: cloudAccountId } - : undefined; - - const { data: servicesMetadata, isLoading } = useListServicesMetadata( - { - cloudProvider: type, - }, - serviceQueryParams, - ); + const isAccountConnected = Boolean(cloudAccountId); + const { data: listAccountsResponse, isLoading: isAccountsLoading } = + useListAccounts({ cloudProvider: type }); + const hasConnectedAccounts = + (listAccountsResponse?.data?.accounts?.length ?? 0) > 0; + + const { data: accountServicesMetadata, isLoading: isAccountServicesLoading } = + useListAccountServicesMetadata( + { cloudProvider: type, id: cloudAccountId }, + { query: { enabled: isAccountConnected } }, + ); + + const { + data: providerServicesMetadata, + isLoading: isProviderServicesLoading, + } = useListServicesMetadata({ cloudProvider: type }, undefined, { + query: { enabled: !isAccountsLoading && !hasConnectedAccounts }, + }); + + const servicesMetadata = hasConnectedAccounts + ? accountServicesMetadata + : providerServicesMetadata; + const isLoading = + isAccountsLoading || + (hasConnectedAccounts + ? isAccountServicesLoading || !isAccountConnected + : isProviderServicesLoading); const awsServices = useMemo( () => servicesMetadata?.data?.services ?? [], diff --git a/frontend/src/hooks/trace/__tests__/useGetTraceAggregations.test.tsx b/frontend/src/hooks/trace/__tests__/useGetTraceAggregations.test.tsx new file mode 100644 index 00000000000..e56c23e09a0 --- /dev/null +++ b/frontend/src/hooks/trace/__tests__/useGetTraceAggregations.test.tsx @@ -0,0 +1,66 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { getTraceAggregations } from 'api/generated/services/tracedetail'; +import { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +import useGetTraceAggregations from '../useGetTraceAggregations'; + +jest.mock('api/generated/services/tracedetail', () => ({ + __esModule: true, + getTraceAggregations: jest + .fn() + .mockResolvedValue({ status: 'success', data: { aggregations: [] } }), +})); + +const mockApi = getTraceAggregations as jest.Mock; + +const wrapper = ({ children }: { children: ReactNode }): JSX.Element => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return {children}; +}; + +const aggregations = [ + { field: { name: 'service.name' }, aggregation: 'execution_time_percentage' }, +] as never; + +describe('useGetTraceAggregations', () => { + beforeEach(() => mockApi.mockClear()); + + it('fetches when enabled with a traceId and aggregations', async () => { + renderHook( + () => + useGetTraceAggregations({ traceId: 't1', aggregations, enabled: true }), + { wrapper }, + ); + await waitFor(() => expect(mockApi).toHaveBeenCalledTimes(1)); + expect(mockApi).toHaveBeenCalledWith({ traceID: 't1' }, { aggregations }); + }); + + it('does not fetch when disabled', () => { + renderHook( + () => + useGetTraceAggregations({ traceId: 't1', aggregations, enabled: false }), + { wrapper }, + ); + expect(mockApi).not.toHaveBeenCalled(); + }); + + it('does not fetch without a traceId', () => { + renderHook( + () => useGetTraceAggregations({ traceId: '', aggregations, enabled: true }), + { wrapper }, + ); + expect(mockApi).not.toHaveBeenCalled(); + }); + + it('does not fetch with no aggregations requested', () => { + renderHook( + () => + useGetTraceAggregations({ traceId: 't1', aggregations: [], enabled: true }), + { wrapper }, + ); + expect(mockApi).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/trace/useGetTraceAggregations.tsx b/frontend/src/hooks/trace/useGetTraceAggregations.tsx new file mode 100644 index 00000000000..d925855ee18 --- /dev/null +++ b/frontend/src/hooks/trace/useGetTraceAggregations.tsx @@ -0,0 +1,34 @@ +import { useQuery, UseQueryResult } from 'react-query'; +import { + GetTraceAggregations200, + SpantypesSpanAggregationDTO, +} from 'api/generated/services/sigNoz.schemas'; +import { getTraceAggregations } from 'api/generated/services/tracedetail'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; + +interface UseGetTraceAggregationsProps { + traceId: string; + aggregations: SpantypesSpanAggregationDTO[]; + enabled: boolean; +} + +type UseGetTraceAggregations = UseQueryResult; + +/** + * Fetches trace aggregations on demand — gate via `enabled` so the request + * fires only when the Analytics panel is open. The query key includes the + * requested fields, so changing the color-by field refetches. + */ +const useGetTraceAggregations = ({ + traceId, + aggregations, + enabled, +}: UseGetTraceAggregationsProps): UseGetTraceAggregations => + useQuery({ + queryFn: () => getTraceAggregations({ traceID: traceId }, { aggregations }), + queryKey: [REACT_QUERY_KEY.GET_TRACE_AGGREGATIONS, traceId, aggregations], + enabled: enabled && !!traceId && aggregations.length > 0, + refetchOnWindowFocus: false, + }); + +export default useGetTraceAggregations; diff --git a/frontend/src/hooks/trace/useGetTraceV3.tsx b/frontend/src/hooks/trace/useGetTraceV4.tsx similarity index 52% rename from frontend/src/hooks/trace/useGetTraceV3.tsx rename to frontend/src/hooks/trace/useGetTraceV4.tsx index fdf582ef307..07aa7b53916 100644 --- a/frontend/src/hooks/trace/useGetTraceV3.tsx +++ b/frontend/src/hooks/trace/useGetTraceV4.tsx @@ -1,30 +1,29 @@ import { useQuery, UseQueryResult } from 'react-query'; -import getTraceV3 from 'api/trace/getTraceV3'; +import getTraceV4 from 'api/trace/getTraceV4'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { - GetTraceV3PayloadProps, - GetTraceV3SuccessResponse, + GetTraceV4PayloadProps, + GetTraceV4SuccessResponse, } from 'types/api/trace/getTraceV3'; -const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 => +const useGetTraceV4 = (props: GetTraceV4PayloadProps): UseTraceV4 => useQuery({ - queryFn: () => getTraceV3(props), + queryFn: () => getTraceV4(props), queryKey: [ - REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL, + REACT_QUERY_KEY.GET_TRACE_V4_WATERFALL, props.traceId, props.selectedSpanId, props.isSelectedSpanIDUnCollapsed, - props.aggregations, ], enabled: !!props.traceId, keepPreviousData: true, refetchOnWindowFocus: false, }); -type UseTraceV3 = UseQueryResult< - SuccessResponse | ErrorResponse, +type UseTraceV4 = UseQueryResult< + SuccessResponse | ErrorResponse, unknown >; -export default useGetTraceV3; +export default useGetTraceV4; diff --git a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel.module.scss b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel.module.scss index e130fe20f06..02f368a39fd 100644 --- a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel.module.scss +++ b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel.module.scss @@ -33,6 +33,15 @@ scrollbar-width: none; } +.state { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 120px; + padding: 24px 12px; +} + .list { display: grid; grid-template-columns: auto auto 1fr; diff --git a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel.tsx b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel.tsx index fd845193ecf..4f9f3cadd42 100644 --- a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel.tsx +++ b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel.tsx @@ -1,21 +1,28 @@ import { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; import { TabsContent, TabsList, TabsRoot, TabsTrigger, } from '@signozhq/ui/tabs'; -import cx from 'classnames'; import { DetailsHeader } from 'components/DetailsPanel'; import { useIsDarkMode } from 'hooks/useDarkMode'; +import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations'; import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair'; import { FloatingPanel } from 'periscope/components/FloatingPanel'; +import { + SpantypesSpanAggregationDTO, + TelemetrytypesTelemetryFieldKeyDTO, +} from 'api/generated/services/sigNoz.schemas'; +import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3'; import { useTraceStore } from '../../stores/traceStore'; import { AGGREGATIONS, getAggregationMap as findAggregationMap, } from '../../utils/aggregations'; +import AnalyticsTabContent, { AnalyticsRow } from './AnalyticsTabContent'; import styles from './AnalyticsPanel.module.scss'; @@ -35,10 +42,31 @@ function AnalyticsPanel({ onClose, onTabChange, }: AnalyticsPanelProps): JSX.Element | null { - const aggregations = useTraceStore((s) => s.aggregations); - const colorByFieldName = useTraceStore((s) => s.colorByField.name); + const { id: traceId } = useParams(); + const colorByField = useTraceStore((s) => s.colorByField); + const colorByFieldName = colorByField.name; const isDarkMode = useIsDarkMode(); + // Fetch exec-time % + span count for the current color-by field only, and + // only while the panel is open. Changing the field refetches via the key. + const aggregationsRequest = useMemo(() => { + // v5 TelemetryFieldKey and the generated DTO are runtime-identical; only + // the literal-union vs enum nominal types differ + const field = colorByField as unknown as TelemetrytypesTelemetryFieldKeyDTO; + return [ + { field, aggregation: AGGREGATIONS.EXEC_TIME_PCT }, + { field, aggregation: AGGREGATIONS.SPAN_COUNT }, + ]; + }, [colorByField]); + + const { data, isLoading, isError } = useGetTraceAggregations({ + traceId: traceId || '', + aggregations: aggregationsRequest, + enabled: isOpen, + }); + + const aggregations = data?.data.aggregations; + const execTimePct = useMemo( () => findAggregationMap( @@ -55,38 +83,39 @@ function AnalyticsPanel({ [aggregations, colorByFieldName], ); - const execTimeRows = useMemo(() => { + const execTimeRows = useMemo(() => { if (!execTimePct) { return []; } return Object.entries(execTimePct) + .sort(([, a], [, b]) => b - a) .map(([group, percentage]) => { const pair = generateColorPair(group); return { group, - percentage, color: isDarkMode ? pair.color : pair.colorDark, + widthPct: Math.min(percentage, 100), + label: `${percentage.toFixed(2)}%`, }; - }) - .sort((a, b) => b.percentage - a.percentage); + }); }, [execTimePct, isDarkMode]); - const spanCountRows = useMemo(() => { + const spanCountRows = useMemo(() => { if (!spanCounts) { return []; } const max = Math.max(...Object.values(spanCounts), 1); return Object.entries(spanCounts) + .sort(([, a], [, b]) => b - a) .map(([group, count]) => { const pair = generateColorPair(group); return { group, - count, - max, color: isDarkMode ? pair.color : pair.colorDark, + widthPct: (count / max) * 100, + label: String(count), }; - }) - .sort((a, b) => b.count - a.count); + }); }, [spanCounts, isDarkMode]); if (!isOpen) { @@ -132,65 +161,23 @@ function AnalyticsPanel({
-
- {execTimeRows.map((row) => ( - <> -
- - {row.group} - -
-
-
-
- - {row.percentage.toFixed(2)}% - -
- - ))} -
+ -
- {spanCountRows.map((row) => ( - <> -
- - {row.group} - -
-
-
-
- - {row.count} - -
- - ))} -
+
diff --git a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsTabContent.tsx b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsTabContent.tsx new file mode 100644 index 00000000000..c9f56254473 --- /dev/null +++ b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/AnalyticsTabContent.tsx @@ -0,0 +1,84 @@ +import { Fragment } from 'react'; +import { Typography } from '@signozhq/ui/typography'; +import cx from 'classnames'; +import Spinner from 'components/Spinner'; + +import styles from './AnalyticsPanel.module.scss'; + +export interface AnalyticsRow { + group: string; + color: string; + widthPct: number; + label: string; +} + +interface AnalyticsTabContentProps { + isLoading: boolean; + isError: boolean; + fieldName: string; + rows: AnalyticsRow[]; + valueVariant: 'wide' | 'narrow'; +} + +// Loading / error / empty render in place of the rows so the tabs stay visible. +function AnalyticsTabContent({ + isLoading, + isError, + fieldName, + rows, + valueVariant, +}: AnalyticsTabContentProps): JSX.Element { + if (isLoading) { + return ( +
+ +
+ ); + } + if (isError) { + return ( +
+ Couldn't load analytics +
+ ); + } + if (rows.length === 0) { + return ( +
+ No data for {fieldName} +
+ ); + } + + return ( +
+ {rows.map((row) => ( + +
+ {row.group} +
+
+
+
+ + {row.label} + +
+ + ))} +
+ ); +} + +export default AnalyticsTabContent; diff --git a/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/__tests__/AnalyticsPanel.test.tsx b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/__tests__/AnalyticsPanel.test.tsx new file mode 100644 index 00000000000..1187eca828d --- /dev/null +++ b/frontend/src/pages/TraceDetailsV3/SpanDetailsPanel/AnalyticsPanel/__tests__/AnalyticsPanel.test.tsx @@ -0,0 +1,144 @@ +import { screen } from '@testing-library/react'; +import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations'; +import { render } from 'tests/test-utils'; + +import { DEFAULT_COLOR_BY_FIELD } from '../../../constants'; +import { useTraceStore } from '../../../stores/traceStore'; +import AnalyticsPanel from '../AnalyticsPanel'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: (): { id: string } => ({ id: 'trace-123' }), +})); + +jest.mock('hooks/trace/useGetTraceAggregations', () => ({ + __esModule: true, + default: jest.fn(), +})); + +// Isolate the panel's own logic from the floating-panel chrome. +jest.mock('periscope/components/FloatingPanel', () => ({ + __esModule: true, + FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => ( +
{children}
+ ), +})); +jest.mock('components/DetailsPanel', () => ({ + __esModule: true, + DetailsHeader: (): JSX.Element =>
, +})); +jest.mock('components/Spinner', () => ({ + __esModule: true, + default: (): JSX.Element =>
, +})); + +const mockHook = useGetTraceAggregations as jest.Mock; + +const noop = (): void => undefined; + +const renderPanel = (isOpen = true): ReturnType => + render(); + +const aggregationsResponse = { + status: 'success', + data: { + aggregations: [ + { + field: { name: 'service.name' }, + aggregation: 'execution_time_percentage', + value: { api: 80, db: 20 }, + }, + { + field: { name: 'service.name' }, + aggregation: 'span_count', + value: { api: 5, db: 2 }, + }, + ], + }, +}; + +describe('AnalyticsPanel', () => { + beforeEach(() => { + mockHook.mockReset(); + useTraceStore.setState({ colorByField: DEFAULT_COLOR_BY_FIELD }); + }); + + it('renders nothing when closed and does not enable the fetch', () => { + mockHook.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }); + const { container } = renderPanel(false); + expect(container).toBeEmptyDOMElement(); + expect(mockHook).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('requests both aggregations for the current color-by field when open', () => { + mockHook.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + renderPanel(); + expect(mockHook).toHaveBeenCalledWith( + expect.objectContaining({ + traceId: 'trace-123', + enabled: true, + aggregations: [ + { + field: DEFAULT_COLOR_BY_FIELD, + aggregation: 'execution_time_percentage', + }, + { field: DEFAULT_COLOR_BY_FIELD, aggregation: 'span_count' }, + ], + }), + ); + }); + + it('shows the loading state with the tabs still visible', () => { + mockHook.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + renderPanel(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + // tabs stay visible while loading + expect(screen.getByText('% exec time')).toBeInTheDocument(); + expect(screen.getByText('Spans')).toBeInTheDocument(); + }); + + it('shows an error state when the request fails', () => { + mockHook.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }); + renderPanel(); + expect(screen.getByText(/couldn't load analytics/i)).toBeInTheDocument(); + }); + + it('renders rows for the current field on success', () => { + mockHook.mockReturnValue({ + data: aggregationsResponse, + isLoading: false, + isError: false, + }); + renderPanel(); + expect(screen.getByText('api')).toBeInTheDocument(); + expect(screen.getByText('80.00%')).toBeInTheDocument(); + }); + + it('shows an empty state when the field has no data', () => { + mockHook.mockReturnValue({ + data: { status: 'success', data: { aggregations: [] } }, + isLoading: false, + isError: false, + }); + renderPanel(); + expect(screen.getByText(/no data for service.name/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/TraceOptionsMenu.module.scss b/frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/TraceOptionsMenu.module.scss index 6705346b2c3..e17112b4175 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/TraceOptionsMenu.module.scss +++ b/frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/TraceOptionsMenu.module.scss @@ -1,3 +1,6 @@ +// Applied to both the menu content and the submenu content (each renders in +// its own portal with a default z-index of 50) so both stack above +// FloatingPanel (z-index 999). .traceOptionsDropdown { z-index: 1100; } diff --git a/frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/TraceOptionsMenu.tsx b/frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/TraceOptionsMenu.tsx index 6ba24b468e5..880bafa22ef 100644 --- a/frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/TraceOptionsMenu.tsx +++ b/frontend/src/pages/TraceDetailsV3/TraceDetailsHeader/TraceOptionsMenu.tsx @@ -1,7 +1,16 @@ -import { useMemo } from 'react'; -import type { MenuItem } from '@signozhq/ui/dropdown-menu'; import { Button } from '@signozhq/ui/button'; -import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@signozhq/ui/dropdown-menu'; import { Settings2 } from '@signozhq/icons'; import { useTraceStore } from '../stores/traceStore'; @@ -14,6 +23,9 @@ interface TraceOptionsMenuProps { onOpenPreviewFields: () => void; } +// Composed from dropdown-menu primitives (instead of DropdownMenuSimple) +// because the simple preset offers no way to style the submenu content, +// which renders in its own portal and needs a z-index above FloatingPanel. function TraceOptionsMenu({ showTraceDetails, onToggleTraceDetails, @@ -25,78 +37,52 @@ function TraceOptionsMenu({ (s) => s.availableColorByOptions, ); - const menuItems: MenuItem[] = useMemo(() => { - const items: MenuItem[] = [ - { - key: 'toggle-trace-details', - label: showTraceDetails ? 'Hide trace details' : 'Show trace details', - onClick: onToggleTraceDetails, - }, - { - key: 'preview-fields', - label: 'Preview fields', - onClick: onOpenPreviewFields, - }, - ]; - - // Only show the "Colour by" submenu if there's an actual choice to make. - if (availableColorByOptions.length > 1) { - items.push({ - key: 'colour-by', - label: 'Colour by', - children: [ - { - type: 'group', - label: 'COLOUR BY', - children: [ - { - type: 'radio-group', - value: colorByField.name, - onChange: (name: string): void => { - const next = availableColorByOptions.find( - (o) => o.field.name === name, - ); - if (next) { - setColorByField(next.field); - } - }, - children: availableColorByOptions.map((opt) => ({ - type: 'radio', - key: opt.field.name, - label: opt.label, - value: opt.field.name, - })), - }, - ], - }, - ], - }); + const handleColorByChange = (name: string): void => { + const next = availableColorByOptions.find((o) => o.field.name === name); + if (next) { + setColorByField(next.field); } - - return items; - }, [ - showTraceDetails, - onToggleTraceDetails, - onOpenPreviewFields, - colorByField.name, - setColorByField, - availableColorByOptions, - ]); + }; return ( - -