From 6334e09a6059eb6cb48fdb0b33ccd3a7e41fd328 Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Mon, 12 May 2025 09:16:26 +0430 Subject: [PATCH] feat: Funnel Details Page Base Structure (#7364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: funnels list page basic UI * feat: get funnels list data from mock API, and handle data, loading and empty states * feat: implement funnel rename * chore: move useFunnels to hooks/TracesFunnels * feat: create traces funnels details basic page + funnel -> details redirection * fix: properly display created at in funnels list item + preventDefault * chore: add tab bar to trace funnel details page * chore: traces funnel details page overall skeleton * chore: traces funnel details results skeleton * fix: hide step count for add button only * feat: funnel details page steps and configuration (#7424) * chore: add a new tab for traces funnels * feat: funnels list page basic UI * feat: get funnels list data from mock API, and handle data, loading and empty states * feat: implement funnel rename * refactor: overall improvements * feat: implement sorting in traces funnels list page * feat: add sort column key and order to url params * chore: move useFunnels to hooks/TracesFunnels * feat: implement traces funnels search and refactor search and sort by extracting to custom hooks * chore: overall improvements to rename trace funnel modal * chore: make the rename input auto-focusable * feat: handle create funnel modal * feat: delete funnel modal and functionality * fix: fix the layout shift in funnel item caused by getContainer={false} * chore: overall improvements and use live api in traces funnels * feat: create traces funnels details basic page + funnel -> details redirection * fix: funnels traces light mode UI * fix: properly display created at in funnels list item + preventDefault * refactor: extract FunnelItemPopover into a separate component * chore: hide funnel tab from traces explorer * chore: add check to display trace funnels tab only in dev environment * chore: improve funnels modals light mode * chore: overall improvements * fix: properly pass funnel details link * chore: address PR review changes * chore: add tab bar to trace funnel details page * feat: funnel step UI with service, span, and where filters * feat: build radio button component * refactor: use the SignozRadioButton in funnel results -> step transitions radio buttons * feat: inter step config (i.e. latency type) UI * chore: improve steps header styles by removing divider width * feat: funnel steps title, description, popover UI + pass data from API * chore: update FilterSelect component to conditionally add url params and accept on change * fix: fix funnel step where clause and update the state variables for filters * chore: add support for isMultiple and fix the type in FilterSelect * feat: centralize the steps state management in StepsContent * fix: move steps state up + pass steps count from state * feat: implement auto save for updating the steps whenever any step changes * feat: implement auto save for validating steps if service name or span names change * feat: impelement funnel step removal * feat: implement add details modal for funnel steps * fix: fix the overflowing time range picker * feat: funnel details empty state * feat: add support for saving funnel description * chore: overall improvements * fix: fix the light mode styles * fix: fix the failing build + broken search UI * refactor: remove the reference of useLocation from traceFunnel item in TraceModulePage constant * fix: fix the issue of update steps getting triggered on initial render if we have filters * fix: fix the edge case of stale state causing filters to be re-added after removing * feat: funnel details page results (#7451) * feat: funnel metrics table component * feat: funnel metrics and steps transition metrics components UI * feat: funnel table component * feat: slowest traces and traces with error components * fix: overall light theme fixes * fix: fix the warning * chore: add empty and loading states to FunnelMetricsTable * feat: get overall funnel metrics from the API * fix: fix the empty state of funnel metrics table * feat: get data for slowest traces and traces with errors * fix: link trace id to trace details page * fix: get data for funnel step transition metrics and refactor the existing data fetching logic * refactor: add funnel context + overall refactoring and optimizations * refactor: move steps states to funnel context + handle empty and run funnel disabled states * feat: handle run funnel * fix: improve empty state * chore: rename isValidateStepsMutationLoading -> isValidateStepsLoading * chore: improve query key * fix: display loading state if funnel results are fetching * refactor: move steps validation fetching and states to the context API * fix: display loading state in funnel results while steps validation is fetching * fix: call validate steps API only on changing the service name or span name of any step * refactor: move validateStepsQuery key out of useEffect and update the dependencies * chore: centralize hasIncompleteSteps and run validate only if steps have service and spans * fix: handle all empty fields state + overall improvements * fix: handle long where query tags * feat: build the funnel result graph component * feat: build the funnel result graph component * feat: handle loading, error, empty states in funnel graph * fix: don't display change percentage if % is 0 * refactor: overall improvements * feat: get funnel steps graph data from API + move logic to custom hook * fix: improve empty and error states * fix: handle funnel graph legends width using css * fix: redirect to trace funnels list page on clicking delete from funnel details * fix: update the query cache while updating steps * fix: implement debounced search for funnel list search * fix: refetch steps graph data query on clicking run funnel / sync button * fix: improve the step footer spacing * chore: add gap between divider to inter-step-config * fix: handle loading state while fetching * feat: add span to funnel flow (from trace details page) (#7477) * chore: display add to funnel icon on hovering any span in trace details page * chore: add className to funnel item actions popover * feat: add funnels tab to trace details v2 tab bar * feat: add span to funnel flow * chore: hide actions popover button from funnel item in span -> funnel flows * chore: improve the funnel details UI in add span to funnel modal * fix: display empty state + don't redirect to funnels list on delete success + overall improvements * chore: add null check * fix: display add to funnel button based on feature flag * fix: display funnels tab in trace details based on feature flag * fix: remove maxTagCount * feat: change ms to ns * chore: address review comments * chore: remove feature flag and display trace funnels only in dev envirnoment * fix: handle restoring steps if updating funnel steps fail * refactor: update the get and delete funnel endpoints to adjust to the BE changes (#7697) * refactor: address review comments * fix: handle nested funnel response structure to fix missing funnel_id… (#7740) * fix: handle nested funnel response structure to fix missing funnel_id in updates Signed-off-by: Shivanshu Raj Shrivastava * chore: remove console.og Signed-off-by: Shivanshu Raj Shrivastava * chore: revert explicitly passing funnelId to updateFunnelSteps --------- Signed-off-by: Shivanshu Raj Shrivastava Co-authored-by: ahmadshaheer * chore: fix api endpoint Signed-off-by: Shivanshu Raj Shrivastava * refactor: incorporate the recent funnel details API changes (#7760) * chore: trace funnels feedback changes (#7772) * chore: change the copy from x traces to valid traces found / not found * chore: add open funnel button in add span to funnel modal * feat: display buttons for adding step details and funnel description + copy to clipboard * feat: highlight funnel graph column based on selected (total / error span) from the legend items * chore: trace funnel changes (#7780) * refactor: handle funnels list search on frontend * refactor: use funnel steps update API for adding / updating step title and description * feat: allow selecting user's typed option in trace funnel service and span name dropdowns * chore: properly render the -> between steps in funnel results * fix: sync funnel step name with add details modal text fields --------- Signed-off-by: Shivanshu Raj Shrivastava Co-authored-by: Yunus M Co-authored-by: Shivanshu Raj Shrivastava --- frontend/package.json | 2 +- frontend/public/Icons/empty-funnel-icon.svg | 2 + frontend/public/Icons/funnel-add.svg | 1 + frontend/public/Icons/solid-info-circle.svg | 1 + frontend/src/AppRoutes/pageComponents.ts | 3 +- frontend/src/api/traceFunnels/index.ts | 286 ++++++++++++++++-- .../CeleryOverviewConfigOptions.tsx | 86 +++++- .../ChangePercentagePill.styles.scss | 40 +++ .../ChangePercentagePill.tsx | 38 +++ .../__snapshots__/QuickFilters.test.tsx.snap | 2 + .../SignozRadioGroup.styles.scss | 55 ++++ .../SignozRadioGroup/SignozRadioGroup.tsx | 48 +++ frontend/src/constants/features.ts | 1 + frontend/src/constants/reactQueryKeys.ts | 9 +- frontend/src/container/AppLayout/index.tsx | 11 +- .../QueryBuilderSearchV2.styles.scss | 16 + .../QueryBuilderSearchV2.tsx | 16 +- .../AddSpanToFunnelModal.styles.scss | 240 +++++++++++++++ .../AddSpanToFunnelModal.tsx | 199 ++++++++++++ .../Success/Success.styles.scss | 22 ++ .../TraceWaterfallStates/Success/Success.tsx | 52 ++++ .../Success/__tests__/SpanDuration.test.tsx | 14 + .../TracesFunnels/useFunnelConfiguration.tsx | 186 ++++++++++++ .../hooks/TracesFunnels/useFunnelGraph.tsx | 263 ++++++++++++++++ .../hooks/TracesFunnels/useFunnelMetrics.ts | 69 +++++ .../src/hooks/TracesFunnels/useFunnels.tsx | 171 ++++++++++- .../useHandleTraceFunnelsSearch.tsx | 22 +- .../useHandleTraceFunnelsSort.tsx | 6 +- .../TraceDetail/__test__/TraceDetail.test.tsx | 14 + .../TraceDetailV2/TraceDetailV2.styles.scss | 3 + frontend/src/pages/TraceDetailV2/index.tsx | 19 +- .../TracesFunnelDetails.styles.scss | 28 ++ .../TracesFunnelDetails.tsx | 37 ++- .../AddFunnelDescriptionModal.styles.scss | 135 +++++++++ .../AddFunnelDescriptionModal.tsx | 109 +++++++ .../AddFunnelStepDetailsModal.styles.scss | 138 +++++++++ .../AddFunnelStepDetailsModal.tsx | 140 +++++++++ .../DeleteFunnelStep.styles.scss | 134 ++++++++ .../FunnelConfiguration/DeleteFunnelStep.tsx | 53 ++++ .../FunnelBreadcrumb.styles.scss | 38 +++ .../FunnelConfiguration/FunnelBreadcrumb.tsx | 34 +++ .../FunnelConfiguration.styles.scss | 67 ++++ .../FunnelConfiguration.tsx | 102 +++++++ .../FunnelStep.styles.scss | 225 ++++++++++++++ .../FunnelConfiguration/FunnelStep.tsx | 207 +++++++++++++ .../FunnelConfiguration/FunnelStepPopover.tsx | 136 +++++++++ .../InterStepConfig.styles.scss | 57 ++++ .../FunnelConfiguration/InterStepConfig.tsx | 43 +++ .../StepsContent.styles.scss | 156 ++++++++++ .../FunnelConfiguration/StepsContent.tsx | 107 +++++++ .../StepsFooter.styles.scss | 74 +++++ .../FunnelConfiguration/StepsFooter.tsx | 73 +++++ .../StepsHeader.styles.scss | 51 ++++ .../FunnelConfiguration/StepsHeader.tsx | 24 ++ .../EmptyFunnelResults.styles.scss | 42 +++ .../FunnelResults/EmptyFunnelResults.tsx | 33 ++ .../FunnelResults/FunnelGraph.styles.scss | 117 +++++++ .../components/FunnelResults/FunnelGraph.tsx | 126 ++++++++ .../FunnelMetricsTable.styles.scss | 122 ++++++++ .../FunnelResults/FunnelMetricsTable.tsx | 104 +++++++ .../FunnelResults/FunnelResults.styles.scss | 6 + .../FunnelResults/FunnelResults.tsx | 51 ++++ .../FunnelResults/FunnelTable.styles.scss | 148 +++++++++ .../components/FunnelResults/FunnelTable.tsx | 55 ++++ .../FunnelResults/FunnelTopTracesTable.tsx | 74 +++++ .../FunnelResults/OverallMetrics.tsx | 26 ++ .../FunnelResults/StepsTransitionMetrics.tsx | 53 ++++ .../StepsTransitionResults.styles.scss | 38 +++ .../FunnelResults/StepsTransitionResults.tsx | 66 ++++ .../FunnelResults/TopSlowestTraces.tsx | 23 ++ .../FunnelResults/TopTracesWithErrors.tsx | 23 ++ .../components/FunnelResults/utils.tsx | 27 ++ .../pages/TracesFunnelDetails/constants.ts | 49 +++ .../src/pages/TracesFunnels/FunnelContext.tsx | 244 +++++++++++++++ .../TracesFunnels/TracesFunnels.styles.scss | 3 +- .../components/CreateFunnel/CreateFunnel.tsx | 25 +- .../components/DeleteFunnel/DeleteFunnel.tsx | 18 ++ .../FunnelsEmptyState/FunnelsEmptyState.tsx | 6 +- .../FunnelsList/FunnelItemPopover.tsx | 19 +- .../FunnelsList/FunnelsList.styles.scss | 31 ++ .../components/FunnelsList/FunnelsList.tsx | 97 ++++-- .../components/RenameFunnel/RenameFunnel.tsx | 6 +- .../components/SearchBar/SearchBar.tsx | 8 +- frontend/src/pages/TracesFunnels/index.tsx | 35 ++- frontend/src/pages/TracesFunnels/utils.ts | 13 + .../TracesModulePage/TracesModulePage.tsx | 19 +- .../src/pages/TracesModulePage/constants.tsx | 12 +- frontend/src/types/api/traceFunnels/index.ts | 28 +- frontend/yarn.lock | 8 +- 89 files changed, 5665 insertions(+), 155 deletions(-) create mode 100644 frontend/public/Icons/empty-funnel-icon.svg create mode 100644 frontend/public/Icons/funnel-add.svg create mode 100644 frontend/public/Icons/solid-info-circle.svg create mode 100644 frontend/src/components/ChangePercentagePill/ChangePercentagePill.styles.scss create mode 100644 frontend/src/components/ChangePercentagePill/ChangePercentagePill.tsx create mode 100644 frontend/src/components/SignozRadioGroup/SignozRadioGroup.styles.scss create mode 100644 frontend/src/components/SignozRadioGroup/SignozRadioGroup.tsx create mode 100644 frontend/src/container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal.styles.scss create mode 100644 frontend/src/container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal.tsx create mode 100644 frontend/src/hooks/TracesFunnels/useFunnelConfiguration.tsx create mode 100644 frontend/src/hooks/TracesFunnels/useFunnelGraph.tsx create mode 100644 frontend/src/hooks/TracesFunnels/useFunnelMetrics.ts create mode 100644 frontend/src/pages/TracesFunnelDetails/TracesFunnelDetails.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/AddFunnelDescriptionModal.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/AddFunnelDescriptionModal.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/AddFunnelStepDetailsModal.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/AddFunnelStepDetailsModal.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/DeleteFunnelStep.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/DeleteFunnelStep.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelBreadcrumb.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelBreadcrumb.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelStep.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelStep.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelStepPopover.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/InterStepConfig.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/InterStepConfig.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsContent.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsContent.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsFooter.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsFooter.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsHeader.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsHeader.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/EmptyFunnelResults.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/EmptyFunnelResults.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelGraph.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelGraph.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelResults.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelResults.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTable.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTable.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTopTracesTable.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/OverallMetrics.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionMetrics.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionResults.styles.scss create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionResults.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/TopSlowestTraces.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/TopTracesWithErrors.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/components/FunnelResults/utils.tsx create mode 100644 frontend/src/pages/TracesFunnelDetails/constants.ts create mode 100644 frontend/src/pages/TracesFunnels/FunnelContext.tsx create mode 100644 frontend/src/pages/TracesFunnels/utils.ts diff --git a/frontend/package.json b/frontend/package.json index 5fb77d80da..3ddca62541 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -87,7 +87,7 @@ "less": "^4.1.2", "less-loader": "^10.2.0", "lodash-es": "^4.17.21", - "lucide-react": "0.427.0", + "lucide-react": "0.498.0", "mini-css-extract-plugin": "2.4.5", "motion": "12.4.13", "overlayscrollbars": "^2.8.1", diff --git a/frontend/public/Icons/empty-funnel-icon.svg b/frontend/public/Icons/empty-funnel-icon.svg new file mode 100644 index 0000000000..92f9c6e956 --- /dev/null +++ b/frontend/public/Icons/empty-funnel-icon.svg @@ -0,0 +1,2 @@ + + diff --git a/frontend/public/Icons/funnel-add.svg b/frontend/public/Icons/funnel-add.svg new file mode 100644 index 0000000000..f2bb65bedf --- /dev/null +++ b/frontend/public/Icons/funnel-add.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/Icons/solid-info-circle.svg b/frontend/public/Icons/solid-info-circle.svg new file mode 100644 index 0000000000..cb1e5bee93 --- /dev/null +++ b/frontend/public/Icons/solid-info-circle.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index cb650e772f..80a481ca33 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -47,9 +47,10 @@ export const TracesFunnels = Loadable( import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'), ); export const TracesFunnelDetails = Loadable( + // eslint-disable-next-line sonarjs/no-identical-functions () => import( - /* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesFunnelDetails' + /* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesModulePage' ), ); diff --git a/frontend/src/api/traceFunnels/index.ts b/frontend/src/api/traceFunnels/index.ts index 75453aca3c..6790798316 100644 --- a/frontend/src/api/traceFunnels/index.ts +++ b/frontend/src/api/traceFunnels/index.ts @@ -5,6 +5,7 @@ import { CreateFunnelPayload, CreateFunnelResponse, FunnelData, + FunnelStepData, } from 'types/api/traceFunnels'; const FUNNELS_BASE_PATH = '/trace-funnels'; @@ -13,7 +14,7 @@ export const createFunnel = async ( payload: CreateFunnelPayload, ): Promise | ErrorResponse> => { const response: AxiosResponse = await axios.post( - `${FUNNELS_BASE_PATH}/new-funnel`, + `${FUNNELS_BASE_PATH}/new`, payload, ); @@ -25,60 +26,45 @@ export const createFunnel = async ( }; }; -interface GetFunnelsListParams { - search?: string; -} - -export const getFunnelsList = async ({ - search = '', -}: GetFunnelsListParams = {}): Promise< +export const getFunnelsList = async (): Promise< SuccessResponse | ErrorResponse > => { - const params = new URLSearchParams(); - if (search.length) { - params.set('search', search); - } - - const response: AxiosResponse = await axios.get( - `${FUNNELS_BASE_PATH}/list${ - params.toString() ? `?${params.toString()}` : '' - }`, - ); + const response: AxiosResponse = await axios.get(`${FUNNELS_BASE_PATH}/list`); return { statusCode: 200, error: null, message: '', - payload: response.data, + payload: response.data.data, }; }; export const getFunnelById = async ( - funnelId: string, + funnelId?: string, ): Promise | ErrorResponse> => { const response: AxiosResponse = await axios.get( - `${FUNNELS_BASE_PATH}/get/${funnelId}`, + `${FUNNELS_BASE_PATH}/${funnelId}`, ); - return { statusCode: 200, error: null, message: '', - payload: response.data, + payload: response.data.data, }; }; -interface RenameFunnelPayload { - id: string; +export interface RenameFunnelPayload { + funnel_id: string; funnel_name: string; + timestamp: number; } export const renameFunnel = async ( payload: RenameFunnelPayload, ): Promise | ErrorResponse> => { const response: AxiosResponse = await axios.put( - `${FUNNELS_BASE_PATH}/${payload.id}/update`, - { funnel_name: payload.funnel_name }, + `${FUNNELS_BASE_PATH}/${payload.funnel_id}`, + payload, ); return { @@ -97,7 +83,7 @@ export const deleteFunnel = async ( payload: DeleteFunnelPayload, ): Promise | ErrorResponse> => { const response: AxiosResponse = await axios.delete( - `${FUNNELS_BASE_PATH}/delete/${payload.id}`, + `${FUNNELS_BASE_PATH}/${payload.id}`, ); return { @@ -107,3 +93,247 @@ export const deleteFunnel = async ( payload: response.data, }; }; + +export interface UpdateFunnelStepsPayload { + funnel_id: string; + steps: FunnelStepData[]; + timestamp: number; +} + +export const updateFunnelSteps = async ( + payload: UpdateFunnelStepsPayload, +): Promise | ErrorResponse> => { + const response: AxiosResponse = await axios.put( + `${FUNNELS_BASE_PATH}/steps/update`, + payload, + ); + + return { + statusCode: 200, + error: null, + message: 'Funnel steps updated successfully', + payload: response.data.data, + }; +}; + +export interface ValidateFunnelPayload { + start_time: number; + end_time: number; +} + +export interface ValidateFunnelResponse { + status: string; + data: Array<{ + timestamp: string; + data: { + trace_id: string; + }; + }> | null; +} + +export const validateFunnelSteps = async ( + funnelId: string, + payload: ValidateFunnelPayload, + signal?: AbortSignal, +): Promise | ErrorResponse> => { + const response = await axios.post( + `${FUNNELS_BASE_PATH}/${funnelId}/analytics/validate`, + payload, + { signal }, + ); + + return { + statusCode: 200, + error: null, + message: '', + payload: response.data, + }; +}; + +export interface UpdateFunnelStepDetailsPayload { + funnel_id: string; + steps: Array<{ + step_name: string; + description: string; + }>; + updated_at: number; +} + +interface UpdateFunnelDescriptionPayload { + funnel_id: string; + description: string; +} + +export const saveFunnelDescription = async ( + payload: UpdateFunnelDescriptionPayload, +): Promise | ErrorResponse> => { + const response: AxiosResponse = await axios.post( + `${FUNNELS_BASE_PATH}/save`, + payload, + ); + + return { + statusCode: 200, + error: null, + message: 'Funnel description updated successfully', + payload: response.data, + }; +}; + +export interface FunnelOverviewPayload { + start_time: number; + end_time: number; + step_start?: number; + step_end?: number; +} + +export interface FunnelOverviewResponse { + status: string; + data: Array<{ + timestamp: string; + data: { + avg_duration: number; + avg_rate: number; + conversion_rate: number | null; + errors: number; + p99_latency: number; + }; + }>; +} + +export const getFunnelOverview = async ( + funnelId: string, + payload: FunnelOverviewPayload, + signal?: AbortSignal, +): Promise | ErrorResponse> => { + const response = await axios.post( + `${FUNNELS_BASE_PATH}/${funnelId}/analytics/overview`, + payload, + { + signal, + }, + ); + + return { + statusCode: 200, + error: null, + message: '', + payload: response.data, + }; +}; + +export interface SlowTracesPayload { + start_time: number; + end_time: number; + step_a_order: number; + step_b_order: number; +} + +export interface SlowTraceData { + status: string; + data: Array<{ + timestamp: string; + data: { + duration_ms: string; + span_count: number; + trace_id: string; + }; + }>; +} + +export const getFunnelSlowTraces = async ( + funnelId: string, + payload: SlowTracesPayload, + signal?: AbortSignal, +): Promise | ErrorResponse> => { + const response = await axios.post( + `${FUNNELS_BASE_PATH}/${funnelId}/analytics/slow-traces`, + payload, + { + signal, + }, + ); + + return { + statusCode: 200, + error: null, + message: '', + payload: response.data, + }; +}; +export interface ErrorTracesPayload { + start_time: number; + end_time: number; + step_a_order: number; + step_b_order: number; +} + +export interface ErrorTraceData { + status: string; + data: Array<{ + timestamp: string; + data: { + duration_ms: string; + span_count: number; + trace_id: string; + }; + }>; +} + +export const getFunnelErrorTraces = async ( + funnelId: string, + payload: ErrorTracesPayload, + signal?: AbortSignal, +): Promise | ErrorResponse> => { + const response: AxiosResponse = await axios.post( + `${FUNNELS_BASE_PATH}/${funnelId}/analytics/error-traces`, + payload, + { + signal, + }, + ); + + return { + statusCode: 200, + error: null, + message: '', + payload: response.data, + }; +}; + +export interface FunnelStepsPayload { + start_time: number; + end_time: number; +} + +export interface FunnelStepGraphMetrics { + [key: `total_s${number}_spans`]: number; + [key: `total_s${number}_errored_spans`]: number; +} + +export interface FunnelStepsResponse { + status: string; + data: Array<{ + timestamp: string; + data: FunnelStepGraphMetrics; + }>; +} + +export const getFunnelSteps = async ( + funnelId: string, + payload: FunnelStepsPayload, + signal?: AbortSignal, +): Promise | ErrorResponse> => { + const response = await axios.post( + `${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps`, + payload, + { signal }, + ); + + return { + statusCode: 200, + error: null, + message: '', + payload: response.data, + }; +}; diff --git a/frontend/src/components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions.tsx b/frontend/src/components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions.tsx index ae2f1f6c1a..6cce8cd466 100644 --- a/frontend/src/components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions.tsx +++ b/frontend/src/components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions.tsx @@ -9,18 +9,27 @@ import { useCeleryFilterOptions } from 'components/CeleryTask/useCeleryFilterOpt import { SelectMaxTagPlaceholder } from 'components/MessagingQueues/MQCommon/MQCommon'; import { QueryParams } from 'constants/query'; import useUrlQuery from 'hooks/useUrlQuery'; +import { useCallback, useMemo, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; -interface SelectOptionConfig { +export interface SelectOptionConfig { placeholder: string; queryParam: QueryParams; filterType: string | string[]; + shouldSetQueryParams?: boolean; + onChange?: (value: string | string[]) => void; + values?: string | string[]; + isMultiple?: boolean; } -function FilterSelect({ +export function FilterSelect({ placeholder, queryParam, filterType, + values, + shouldSetQueryParams, + onChange, + isMultiple, }: SelectOptionConfig): JSX.Element { const { handleSearch, isFetching, options } = useCeleryFilterOptions( filterType, @@ -30,20 +39,73 @@ function FilterSelect({ const history = useHistory(); const location = useLocation(); + // Add state to track the current search input + const [searchValue, setSearchValue] = useState(''); + + // Use externally provided `values` if `shouldSetQueryParams` is false, otherwise get from URL params. + const selectValue = + !shouldSetQueryParams && !!values?.length + ? values + : getValuesFromQueryParams(queryParam, urlQuery) || []; + + // Memoize options to include the typed value if not present + const mergedOptions = useMemo(() => { + if ( + !!searchValue.trim().length && + !options.some((opt) => opt.value === searchValue) + ) { + return [{ value: searchValue, label: searchValue }, ...options]; + } + return options; + }, [options, searchValue]); + + const handleSelectChange = useCallback( + (value: string | string[]): void => { + handleSearch(''); + setSearchValue(''); // Clear search value after selection + if (shouldSetQueryParams) { + setQueryParamsFromOptions( + value as string[], + urlQuery, + history, + location, + queryParam, + ); + } + onChange?.(value); + }, + [ + handleSearch, + shouldSetQueryParams, + urlQuery, + history, + location, + queryParam, + onChange, + ], + ); + + // Update searchValue on user input + const handleSearchInput = (input: string): void => { + setSearchValue(input); + handleSearch(input); + }; + return ( + + + + ); +} +interface AddSpanToFunnelModalProps { + isOpen: boolean; + onClose: () => void; + span: Span; +} + +function AddSpanToFunnelModal({ + isOpen, + onClose, + span, +}: AddSpanToFunnelModalProps): JSX.Element { + const [activeView, setActiveView] = useState(ModalView.LIST); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedFunnelId, setSelectedFunnelId] = useState( + undefined, + ); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const handleSearch = (e: ChangeEvent): void => { + setSearchQuery(e.target.value); + }; + + const { data, isLoading, isError, isFetching } = useFunnelsList(); + + const filteredData = useMemo( + () => + filterFunnelsByQuery(data?.payload || [], searchQuery).sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ), + [data?.payload, searchQuery], + ); + + const { + data: funnelDetails, + isLoading: isFunnelDetailsLoading, + isFetching: isFunnelDetailsFetching, + } = useFunnelDetails({ + funnelId: selectedFunnelId, + }); + + const handleFunnelClick = (funnel: FunnelData): void => { + setSelectedFunnelId(funnel.funnel_id); + setActiveView(ModalView.DETAILS); + }; + + const handleBack = (): void => { + setActiveView(ModalView.LIST); + setSelectedFunnelId(undefined); + }; + + const handleCreateNewClick = (): void => { + setIsCreateModalOpen(true); + }; + + const renderListView = (): JSX.Element => ( +
+ {!!filteredData?.length && ( +
+ } + value={searchQuery} + onChange={handleSearch} + /> +
+ )} +
+ + handleFunnelClick(funnel)} + shouldRedirectToTracesListOnDeleteSuccess={false} + /> + +
+ { + if (funnelId) { + setSelectedFunnelId(funnelId); + setActiveView(ModalView.DETAILS); + } + setIsCreateModalOpen(false); + }} + redirectToDetails={false} + /> +
+ ); + + const renderDetailsView = ({ span }: { span: Span }): JSX.Element => ( +
+ + } + > +
+
+ {selectedFunnelId && funnelDetails?.payload && ( + + + + )} +
+
+
+
+ ); + + return ( + } + > + Create new funnel + + ) : null + } + > + {activeView === ModalView.LIST + ? renderListView() + : renderDetailsView({ span })} + + ); +} + +export default AddSpanToFunnelModal; diff --git a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss index 95872a882c..e2e5053ce4 100644 --- a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss +++ b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.styles.scss @@ -95,6 +95,10 @@ border-radius: 4px; background: rgba(171, 189, 255, 0.06) !important; + .div-td .span-overview .second-row .add-funnel-button { + opacity: 1; + } + .span-overview { background: unset !important; @@ -231,6 +235,24 @@ line-height: 18px; /* 128.571% */ letter-spacing: -0.07px; } + .add-funnel-button { + position: relative; + z-index: 1; + opacity: 0; + display: flex; + align-items: center; + gap: 6px; + transition: opacity 0.1s ease-in-out; + + &__separator { + color: var(--bg-vanilla-400); + } + &__button { + display: flex; + align-items: center; + justify-content: center; + } + } } } } diff --git a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx index a003dccc40..77ac514ca8 100644 --- a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx +++ b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/Success.tsx @@ -9,6 +9,7 @@ import cx from 'classnames'; import { TableV3 } from 'components/TableV3/TableV3'; import { themeColors } from 'constants/theme'; import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils'; +import AddSpanToFunnelModal from 'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal'; import SpanLineActionButtons from 'container/TraceWaterfall/SpanLineActionButtons'; import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall'; import { useSafeNavigate } from 'hooks/useSafeNavigate'; @@ -61,6 +62,7 @@ function SpanOverview({ isSpanCollapsed, handleCollapseUncollapse, setSelectedSpan, + handleAddSpanToFunnel, selectedSpan, }: { span: Span; @@ -68,6 +70,8 @@ function SpanOverview({ handleCollapseUncollapse: (id: string, collapse: boolean) => void; selectedSpan: Span | undefined; setSelectedSpan: Dispatch>; + + handleAddSpanToFunnel: (span: Span) => void; }): JSX.Element { const isRootSpan = span.level === 0; @@ -145,6 +149,30 @@ function SpanOverview({ {span.serviceName} + {!!span.serviceName && + !!span.name && + process.env.NODE_ENV === 'development' && ( +
+ · +
+ )} @@ -235,12 +263,15 @@ function getWaterfallColumns({ traceMetadata, selectedSpan, setSelectedSpan, + handleAddSpanToFunnel, }: { handleCollapseUncollapse: (id: string, collapse: boolean) => void; uncollapsedNodes: string[]; traceMetadata: ITraceMetadata; selectedSpan: Span | undefined; setSelectedSpan: Dispatch>; + + handleAddSpanToFunnel: (span: Span) => void; }): ColumnDef[] { const waterfallColumns: ColumnDef[] = [ columnDefHelper.display({ @@ -253,6 +284,7 @@ function getWaterfallColumns({ isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)} selectedSpan={selectedSpan} setSelectedSpan={setSelectedSpan} + handleAddSpanToFunnel={handleAddSpanToFunnel} /> ), size: 450, @@ -319,6 +351,17 @@ function Success(props: ISuccessProps): JSX.Element { } }; + const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] = useState( + false, + ); + const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState< + Span | undefined + >(undefined); + const handleAddSpanToFunnel = useCallback((span: Span): void => { + setIsAddSpanToFunnelModalOpen(true); + setSelectedSpanToAddToFunnel(span); + }, []); + const columns = useMemo( () => getWaterfallColumns({ @@ -327,6 +370,7 @@ function Success(props: ISuccessProps): JSX.Element { traceMetadata, selectedSpan, setSelectedSpan, + handleAddSpanToFunnel, }), [ handleCollapseUncollapse, @@ -334,6 +378,7 @@ function Success(props: ISuccessProps): JSX.Element { traceMetadata, selectedSpan, setSelectedSpan, + handleAddSpanToFunnel, ], ); @@ -405,6 +450,13 @@ function Success(props: ISuccessProps): JSX.Element { virtualiserRef={virtualizerRef} setColumnWidths={setTraceFlamegraphStatsWidth} /> + {selectedSpanToAddToFunnel && process.env.NODE_ENV === 'development' && ( + setIsAddSpanToFunnelModalOpen(false)} + /> + )} ); } diff --git a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/__tests__/SpanDuration.test.tsx b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/__tests__/SpanDuration.test.tsx index 838e8e4ebb..0ab067837a 100644 --- a/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/__tests__/SpanDuration.test.tsx +++ b/frontend/src/container/TraceWaterfall/TraceWaterfallStates/Success/__tests__/SpanDuration.test.tsx @@ -41,6 +41,20 @@ const mockTraceMetadata = { hasMissingSpans: false, }; +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock = jest.fn(() => ({ + paths, + })); + return { + paths, + default: uplotMock, + }; +}); + describe('SpanDuration', () => { const mockSetSelectedSpan = jest.fn(); const mockUrlQuerySet = jest.fn(); diff --git a/frontend/src/hooks/TracesFunnels/useFunnelConfiguration.tsx b/frontend/src/hooks/TracesFunnels/useFunnelConfiguration.tsx new file mode 100644 index 0000000000..d81200e6b6 --- /dev/null +++ b/frontend/src/hooks/TracesFunnels/useFunnelConfiguration.tsx @@ -0,0 +1,186 @@ +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import useDebounce from 'hooks/useDebounce'; +import { useNotifications } from 'hooks/useNotifications'; +import { isEqual } from 'lodash-es'; +import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQueryClient } from 'react-query'; +import { FunnelData, FunnelStepData } from 'types/api/traceFunnels'; + +import { useUpdateFunnelSteps } from './useFunnels'; + +interface UseFunnelConfiguration { + isPopoverOpen: boolean; + setIsPopoverOpen: (isPopoverOpen: boolean) => void; + steps: FunnelStepData[]; +} + +// Add this helper function +const normalizeSteps = (steps: FunnelStepData[]): FunnelStepData[] => { + if (steps.some((step) => !step.filters)) return steps; + + return steps.map((step) => ({ + ...step, + filters: { + ...step.filters, + items: step.filters.items.map((item) => ({ + id: '', + key: item.key, + value: item.value, + op: item.op, + })), + }, + })); +}; + +// eslint-disable-next-line sonarjs/cognitive-complexity +export default function useFunnelConfiguration({ + funnel, +}: { + funnel: FunnelData; +}): UseFunnelConfiguration { + const { notifications } = useNotifications(); + const { + steps, + initialSteps, + setHasIncompleteStepFields, + setHasAllEmptyStepFields, + handleRestoreSteps, + } = useFunnelContext(); + + // State management + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const debouncedSteps = useDebounce(steps, 200); + + const [lastValidatedSteps, setLastValidatedSteps] = useState( + initialSteps, + ); + + // Mutation hooks + const updateStepsMutation = useUpdateFunnelSteps( + funnel.funnel_id, + notifications, + ); + + // Derived state + const lastSavedStepsStateRef = useRef(steps); + + const hasStepsChanged = useCallback(() => { + const normalizedLastSavedSteps = normalizeSteps( + lastSavedStepsStateRef.current, + ); + const normalizedDebouncedSteps = normalizeSteps(debouncedSteps); + return !isEqual(normalizedDebouncedSteps, normalizedLastSavedSteps); + }, [debouncedSteps]); + + const hasStepServiceOrSpanNameChanged = useCallback( + (prevSteps: FunnelStepData[], nextSteps: FunnelStepData[]): boolean => { + if (prevSteps.length !== nextSteps.length) return true; + return prevSteps.some((step, index) => { + const nextStep = nextSteps[index]; + return ( + step.service_name !== nextStep.service_name || + step.span_name !== nextStep.span_name + ); + }); + }, + [], + ); + + // Mutation payload preparation + const getUpdatePayload = useCallback( + () => ({ + funnel_id: funnel.funnel_id, + steps: debouncedSteps, + timestamp: Date.now(), + }), + [funnel.funnel_id, debouncedSteps], + ); + + const queryClient = useQueryClient(); + const { selectedTime } = useFunnelContext(); + + const validateStepsQueryKey = useMemo( + () => [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnel.funnel_id, selectedTime], + [funnel.funnel_id, selectedTime], + ); + useEffect(() => { + // Check if all steps have both service_name and span_name defined + const shouldUpdate = debouncedSteps.every( + (step) => step.service_name !== '' && step.span_name !== '', + ); + + if (hasStepsChanged() && shouldUpdate) { + updateStepsMutation.mutate(getUpdatePayload(), { + onSuccess: (data) => { + const updatedFunnelSteps = data?.payload?.steps; + + if (!updatedFunnelSteps) return; + + queryClient.setQueryData( + [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnel.funnel_id], + (oldData: any) => ({ + ...oldData, + payload: { + ...oldData.payload, + steps: updatedFunnelSteps, + }, + }), + ); + + lastSavedStepsStateRef.current = updatedFunnelSteps; + + const hasIncompleteStepFields = updatedFunnelSteps.some( + (step) => step.service_name === '' || step.span_name === '', + ); + + const hasAllEmptyStepsData = updatedFunnelSteps.every( + (step) => step.service_name === '' && step.span_name === '', + ); + + setHasIncompleteStepFields(hasIncompleteStepFields); + setHasAllEmptyStepFields(hasAllEmptyStepsData); + + // Only validate if service_name or span_name changed + if ( + !hasIncompleteStepFields && + hasStepServiceOrSpanNameChanged(lastValidatedSteps, debouncedSteps) + ) { + queryClient.refetchQueries(validateStepsQueryKey); + setLastValidatedSteps(debouncedSteps); + } + }, + + onError: () => { + handleRestoreSteps(lastSavedStepsStateRef.current); + queryClient.setQueryData( + [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnel.funnel_id], + (oldData: any) => ({ + ...oldData, + payload: { + ...oldData.payload, + steps: lastSavedStepsStateRef.current, + }, + }), + ); + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + debouncedSteps, + getUpdatePayload, + hasStepServiceOrSpanNameChanged, + hasStepsChanged, + lastValidatedSteps, + queryClient, + validateStepsQueryKey, + ]); + + return { + isPopoverOpen, + setIsPopoverOpen, + steps, + }; +} diff --git a/frontend/src/hooks/TracesFunnels/useFunnelGraph.tsx b/frontend/src/hooks/TracesFunnels/useFunnelGraph.tsx new file mode 100644 index 0000000000..8ba1dca464 --- /dev/null +++ b/frontend/src/hooks/TracesFunnels/useFunnelGraph.tsx @@ -0,0 +1,263 @@ +import { Color } from '@signozhq/design-tokens'; +import { FunnelStepGraphMetrics } from 'api/traceFunnels'; +import { Chart, ChartConfiguration } from 'chart.js'; +import ChangePercentagePill from 'components/ChangePercentagePill/ChangePercentagePill'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +const CHART_CONFIG: Partial = { + type: 'bar', + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + stacked: true, + grid: { + display: false, + }, + ticks: { + font: { + family: "'Geist Mono', monospace", + }, + }, + }, + y: { + stacked: true, + beginAtZero: true, + grid: { + color: 'rgba(192, 193, 195, 0.04)', + }, + ticks: { + font: { + family: "'Geist Mono', monospace", + }, + }, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + }, + }, + }, +}; + +interface UseFunnelGraphProps { + data: FunnelStepGraphMetrics | undefined; + hoveredBar?: { index: number; type: 'total' | 'error' } | null; +} + +interface UseFunnelGraph { + successSteps: number[]; + errorSteps: number[]; + totalSteps: number; + canvasRef: React.RefObject; + renderLegendItem: ( + step: number, + successSpans: number, + errorSpans: number, + prevTotalSpans: number, + legendHoverHandlers?: { + onTotalHover: () => void; + onErrorHover: () => void; + onLegendLeave: () => void; + }, + ) => JSX.Element; +} + +function useFunnelGraph({ + data, + hoveredBar, +}: UseFunnelGraphProps): UseFunnelGraph { + const canvasRef = useRef(null); + const chartRef = useRef(null); + const [localHoveredBar, setLocalHoveredBar] = useState<{ + index: number; + type: 'total' | 'error'; + } | null>(null); + + const getPercentageChange = useCallback( + (current: number, previous: number): number => { + if (previous === 0) return 0; + return Math.abs(Math.round(((current - previous) / previous) * 100)); + }, + [], + ); + + interface StepGraphData { + successSteps: number[]; + errorSteps: number[]; + totalSteps: number; + } + const getStepGraphData = useCallback((): StepGraphData => { + const successSteps: number[] = []; + const errorSteps: number[] = []; + let stepCount = 1; + + if (!data) return { successSteps, errorSteps, totalSteps: 0 }; + + while ( + data[`total_s${stepCount}_spans`] !== undefined && + data[`total_s${stepCount}_errored_spans`] !== undefined + ) { + const totalSpans = data[`total_s${stepCount}_spans`]; + const erroredSpans = data[`total_s${stepCount}_errored_spans`]; + const successSpans = totalSpans - erroredSpans; + + successSteps.push(successSpans); + errorSteps.push(erroredSpans); + stepCount += 1; + } + + return { + successSteps, + errorSteps, + totalSteps: stepCount - 1, + }; + }, [data]); + + useEffect(() => { + if (!canvasRef.current) return; + + if (chartRef.current) { + chartRef.current.destroy(); + } + + const ctx = canvasRef.current.getContext('2d'); + if (!ctx) return; + + const { successSteps, errorSteps, totalSteps } = getStepGraphData(); + + chartRef.current = new Chart(ctx, { + ...CHART_CONFIG, + data: { + labels: Array.from({ length: totalSteps }, (_, i) => String(i + 1)), + datasets: [ + { + label: 'Success spans', + data: successSteps, + backgroundColor: successSteps.map(() => Color.BG_ROBIN_500), + stack: 'Stack 0', + borderRadius: 2, + borderSkipped: false, + }, + { + label: 'Error spans', + data: errorSteps, + backgroundColor: errorSteps.map(() => Color.BG_CHERRY_500), + stack: 'Stack 0', + borderRadius: 2, + borderSkipped: false, + borderWidth: { + top: 2, + bottom: 2, + }, + borderColor: 'rgba(0, 0, 0, 0)', + }, + ], + }, + options: CHART_CONFIG.options, + } as ChartConfiguration); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + useEffect(() => { + const chart = chartRef.current; + if (!chart) return; + + const { successSteps, errorSteps } = getStepGraphData(); + + if (chart.data.datasets && chart.data.datasets.length >= 2) { + chart.data.datasets[0].backgroundColor = successSteps.map((_, i) => + localHoveredBar && + localHoveredBar.index === i && + localHoveredBar.type === 'total' + ? '#2655ff' + : Color.BG_ROBIN_500, + ); + + chart.data.datasets[1].backgroundColor = errorSteps.map((_, i) => + localHoveredBar && + localHoveredBar.index === i && + localHoveredBar.type === 'error' + ? '#ff1018' + : Color.BG_CHERRY_500, + ); + + chart.update(); + } + }, [localHoveredBar, getStepGraphData]); + + useEffect(() => { + setLocalHoveredBar(hoveredBar ?? null); + }, [hoveredBar]); + + const renderLegendItem = useCallback( + ( + step: number, + successSpans: number, + errorSpans: number, + prevTotalSpans: number, + legendHoverHandlers?: { + onTotalHover: () => void; + onErrorHover: () => void; + onLegendLeave: () => void; + }, + ): JSX.Element => { + const totalSpans = successSpans + errorSpans; + + return ( +
+
+
+ + Total spans +
+
+ {totalSpans} + {step > 1 && ( + + )} +
+
+
+
+ + Error spans +
+
+ {errorSpans} +
+
+
+ ); + }, + [getPercentageChange], + ); + + const { successSteps, errorSteps, totalSteps } = getStepGraphData(); + + return { + successSteps, + errorSteps, + totalSteps, + canvasRef, + renderLegendItem, + }; +} + +export default useFunnelGraph; diff --git a/frontend/src/hooks/TracesFunnels/useFunnelMetrics.ts b/frontend/src/hooks/TracesFunnels/useFunnelMetrics.ts new file mode 100644 index 0000000000..53e4c95515 --- /dev/null +++ b/frontend/src/hooks/TracesFunnels/useFunnelMetrics.ts @@ -0,0 +1,69 @@ +import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig'; +import { MetricItem } from 'pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable'; +import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext'; +import { useMemo } from 'react'; + +import { useFunnelOverview } from './useFunnels'; + +interface FunnelMetricsParams { + funnelId: string; + stepStart?: number; + stepEnd?: number; +} + +export function useFunnelMetrics({ + funnelId, + stepStart, + stepEnd, +}: FunnelMetricsParams): { + isLoading: boolean; + isError: boolean; + metricsData: MetricItem[]; + conversionRate: number; +} { + const { startTime, endTime } = useFunnelContext(); + const payload = { + start_time: startTime, + end_time: endTime, + ...(stepStart !== undefined && { step_start: stepStart }), + ...(stepEnd !== undefined && { step_end: stepEnd }), + }; + + const { + data: overviewData, + isLoading, + isFetching, + isError, + } = useFunnelOverview(funnelId, payload); + + const metricsData = useMemo(() => { + const sourceData = overviewData?.payload?.data?.[0]?.data; + if (!sourceData) return []; + + return [ + { + title: 'Avg. Rate', + value: `${Number(sourceData.avg_rate.toFixed(2))} req/s`, + }, + { title: 'Errors', value: sourceData.errors }, + { + title: 'Avg. Duration', + value: getYAxisFormattedValue(sourceData.avg_duration.toString(), 'ns'), + }, + { + title: 'P99 Latency', + value: getYAxisFormattedValue(sourceData.p99_latency.toString(), 'ns'), + }, + ]; + }, [overviewData]); + + const conversionRate = + overviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0; + + return { + isLoading: isLoading || isFetching, + isError, + metricsData, + conversionRate, + }; +} diff --git a/frontend/src/hooks/TracesFunnels/useFunnels.tsx b/frontend/src/hooks/TracesFunnels/useFunnels.tsx index 92251ce06b..73a8428931 100644 --- a/frontend/src/hooks/TracesFunnels/useFunnels.tsx +++ b/frontend/src/hooks/TracesFunnels/useFunnels.tsx @@ -1,11 +1,30 @@ +import { NotificationInstance } from 'antd/es/notification/interface'; import { createFunnel, deleteFunnel, + ErrorTraceData, + ErrorTracesPayload, + FunnelOverviewPayload, + FunnelOverviewResponse, + FunnelStepsResponse, getFunnelById, + getFunnelErrorTraces, + getFunnelOverview, getFunnelsList, + getFunnelSlowTraces, + getFunnelSteps, renameFunnel, + RenameFunnelPayload, + saveFunnelDescription, + SlowTraceData, + SlowTracesPayload, + updateFunnelSteps, + UpdateFunnelStepsPayload, + ValidateFunnelResponse, + validateFunnelSteps, } from 'api/traceFunnels'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext'; import { useMutation, UseMutationResult, @@ -19,20 +38,20 @@ import { FunnelData, } from 'types/api/traceFunnels'; -export const useFunnelsList = ({ - searchQuery, -}: { - searchQuery: string; -}): UseQueryResult | ErrorResponse, unknown> => +export const useFunnelsList = (): UseQueryResult< + SuccessResponse | ErrorResponse, + unknown +> => useQuery({ - queryKey: [REACT_QUERY_KEY.GET_FUNNELS_LIST, searchQuery], - queryFn: () => getFunnelsList({ search: searchQuery }), + queryKey: [REACT_QUERY_KEY.GET_FUNNELS_LIST], + queryFn: () => getFunnelsList(), + refetchOnWindowFocus: true, }); export const useFunnelDetails = ({ funnelId, }: { - funnelId: string; + funnelId?: string; }): UseQueryResult | ErrorResponse, unknown> => useQuery({ queryKey: [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnelId], @@ -49,11 +68,6 @@ export const useCreateFunnel = (): UseMutationResult< mutationFn: createFunnel, }); -interface RenameFunnelPayload { - id: string; - funnel_name: string; -} - export const useRenameFunnel = (): UseMutationResult< SuccessResponse | ErrorResponse, Error, @@ -75,3 +89,134 @@ export const useDeleteFunnel = (): UseMutationResult< useMutation({ mutationFn: deleteFunnel, }); + +export const useUpdateFunnelSteps = ( + funnelId: string, + notification: NotificationInstance, +): UseMutationResult< + SuccessResponse | ErrorResponse, + Error, + UpdateFunnelStepsPayload +> => + useMutation({ + mutationFn: updateFunnelSteps, + mutationKey: [REACT_QUERY_KEY.UPDATE_FUNNEL_STEPS, funnelId], + + onError: (error) => { + notification.error({ + message: 'Failed to update funnel steps', + description: error.message, + }); + }, + }); + +export const useValidateFunnelSteps = ({ + funnelId, + selectedTime, + startTime, + endTime, +}: { + funnelId: string; + selectedTime: string; + startTime: number; + endTime: number; +}): UseQueryResult< + SuccessResponse | ErrorResponse, + Error +> => + useQuery({ + queryFn: ({ signal }) => + validateFunnelSteps( + funnelId, + { start_time: startTime, end_time: endTime }, + signal, + ), + queryKey: [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime], + enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime, + staleTime: 1000 * 60 * 5, + }); + +interface SaveFunnelDescriptionPayload { + funnel_id: string; + description: string; +} + +export const useSaveFunnelDescription = (): UseMutationResult< + SuccessResponse | ErrorResponse, + Error, + SaveFunnelDescriptionPayload +> => + useMutation({ + mutationFn: saveFunnelDescription, + }); + +export const useFunnelOverview = ( + funnelId: string, + payload: FunnelOverviewPayload, +): UseQueryResult< + SuccessResponse | ErrorResponse, + Error +> => { + const { selectedTime, validTracesCount } = useFunnelContext(); + return useQuery({ + queryFn: ({ signal }) => getFunnelOverview(funnelId, payload, signal), + queryKey: [ + REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW, + funnelId, + selectedTime, + payload.step_start ?? '', + payload.step_end ?? '', + ], + enabled: !!funnelId && validTracesCount > 0, + }); +}; + +export const useFunnelSlowTraces = ( + funnelId: string, + payload: SlowTracesPayload, +): UseQueryResult | ErrorResponse, Error> => { + const { selectedTime, validTracesCount } = useFunnelContext(); + return useQuery | ErrorResponse, Error>({ + queryFn: ({ signal }) => getFunnelSlowTraces(funnelId, payload, signal), + queryKey: [REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, funnelId, selectedTime], + enabled: !!funnelId && validTracesCount > 0, + }); +}; + +export const useFunnelErrorTraces = ( + funnelId: string, + payload: ErrorTracesPayload, +): UseQueryResult | ErrorResponse, Error> => { + const { selectedTime, validTracesCount } = useFunnelContext(); + return useQuery({ + queryFn: ({ signal }) => getFunnelErrorTraces(funnelId, payload, signal), + queryKey: [REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, funnelId, selectedTime], + enabled: !!funnelId && validTracesCount > 0, + }); +}; + +export function useFunnelStepsGraphData( + funnelId: string, +): UseQueryResult | ErrorResponse, Error> { + const { + startTime, + endTime, + selectedTime, + validTracesCount, + } = useFunnelContext(); + + return useQuery({ + queryFn: ({ signal }) => + getFunnelSteps( + funnelId, + { start_time: startTime, end_time: endTime }, + signal, + ), + queryKey: [ + REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA, + funnelId, + selectedTime, + ], + enabled: !!funnelId && validTracesCount > 0, + }); +} diff --git a/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSearch.tsx b/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSearch.tsx index 529f48c29c..694769ca88 100644 --- a/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSearch.tsx +++ b/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSearch.tsx @@ -1,31 +1,13 @@ -import { useSafeNavigate } from 'hooks/useSafeNavigate'; -import useUrlQuery from 'hooks/useUrlQuery'; import { ChangeEvent, useState } from 'react'; const useHandleTraceFunnelsSearch = (): { searchQuery: string; handleSearch: (e: ChangeEvent) => void; } => { - const { safeNavigate } = useSafeNavigate(); - - const urlQuery = useUrlQuery(); - const [searchQuery, setSearchQuery] = useState( - urlQuery.get('search') || '', - ); + const [searchQuery, setSearchQuery] = useState(''); const handleSearch = (e: ChangeEvent): void => { - const { value } = e.target; - setSearchQuery(value); - - const trimmedValue = value.trim(); - - if (trimmedValue) { - urlQuery.set('search', trimmedValue); - } else { - urlQuery.delete('search'); - } - - safeNavigate({ search: urlQuery.toString() }); + setSearchQuery(e.target.value); }; return { diff --git a/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSort.tsx b/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSort.tsx index 84aea69775..b2652dbc58 100644 --- a/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSort.tsx +++ b/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSort.tsx @@ -21,7 +21,7 @@ const useHandleTraceFunnelsSort = ({ const urlQuery = useUrlQuery(); const [sortOrder, setSortOrder] = useState({ - columnKey: urlQuery.get('columnKey') || 'creation_timestamp', + columnKey: urlQuery.get('columnKey') || 'created_at', order: (urlQuery.get('order') as 'ascend' | 'descend') || 'descend', }); @@ -50,8 +50,8 @@ const useHandleTraceFunnelsSort = ({ // Fallback to creation timestamp if invalid key if (typeof aValue !== 'number' || typeof bValue !== 'number') { - aValue = a.creation_timestamp; - bValue = b.creation_timestamp; + aValue = a.created_at; + bValue = b.created_at; } return order === 'ascend' ? aValue - bValue : bValue - aValue; diff --git a/frontend/src/pages/TraceDetail/__test__/TraceDetail.test.tsx b/frontend/src/pages/TraceDetail/__test__/TraceDetail.test.tsx index 3f2d419f72..ad2e050566 100644 --- a/frontend/src/pages/TraceDetail/__test__/TraceDetail.test.tsx +++ b/frontend/src/pages/TraceDetail/__test__/TraceDetail.test.tsx @@ -25,6 +25,20 @@ jest.mock('container/TraceFlameGraph/index.tsx', () => ({ default: (): JSX.Element =>
TraceFlameGraph
, })); +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + const uplotMock = jest.fn(() => ({ + paths, + })); + return { + paths, + default: uplotMock, + }; +}); + describe('TraceDetail', () => { it('should render tracedetail', async () => { const { findByText, getByText, getAllByText, getByPlaceholderText } = render( diff --git a/frontend/src/pages/TraceDetailV2/TraceDetailV2.styles.scss b/frontend/src/pages/TraceDetailV2/TraceDetailV2.styles.scss index 3d5d08a34d..1063fdea33 100644 --- a/frontend/src/pages/TraceDetailV2/TraceDetailV2.styles.scss +++ b/frontend/src/pages/TraceDetailV2/TraceDetailV2.styles.scss @@ -1,4 +1,7 @@ .traces-module-container { + .funnel-icon { + transform: rotate(180deg); + } .trace-module { .ant-tabs-tab { .tab-item { diff --git a/frontend/src/pages/TraceDetailV2/index.tsx b/frontend/src/pages/TraceDetailV2/index.tsx index cf48f69c58..7181698f6f 100644 --- a/frontend/src/pages/TraceDetailV2/index.tsx +++ b/frontend/src/pages/TraceDetailV2/index.tsx @@ -3,7 +3,7 @@ import './TraceDetailV2.styles.scss'; import { Button, Tabs } from 'antd'; import ROUTES from 'constants/routes'; import history from 'lib/history'; -import { Compass, TowerControl, Undo } from 'lucide-react'; +import { Compass, Cone, TowerControl, Undo } from 'lucide-react'; import TraceDetail from 'pages/TraceDetail'; import { useCallback, useState } from 'react'; @@ -33,6 +33,9 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element { if (activeKey === 'trace-details') { history.push(ROUTES.TRACES_EXPLORER); } + if (activeKey === 'funnels') { + history.push(ROUTES.TRACES_FUNNELS); + } }} tabBarExtraContent={ + + + + + + ); +} + +function FunnelStepPopover({ + isPopoverOpen, + setIsPopoverOpen, + stepData, + className, + onStepRemove, + stepsCount, + isAddDetailsModalOpen, + setIsAddDetailsModalOpen, +}: FunnelStepPopoverProps): JSX.Element { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const preventDefault = (e: React.MouseEvent | React.KeyboardEvent): void => { + e.preventDefault(); + e.stopPropagation(); + }; + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
+ + } + placement="bottomRight" + arrow={false} + destroyTooltipOnHide + > + + + + setIsDeleteModalOpen(false)} + onStepRemove={onStepRemove} + /> + + setIsAddDetailsModalOpen(false)} + stepData={stepData} + /> +
+ ); +} + +FunnelStepPopover.defaultProps = { + className: '', +}; + +export default FunnelStepPopover; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/InterStepConfig.styles.scss b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/InterStepConfig.styles.scss new file mode 100644 index 0000000000..fec99440c0 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/InterStepConfig.styles.scss @@ -0,0 +1,57 @@ +.inter-step-config { + display: flex; + align-items: center; + gap: 6px; + .ant-form-item { + margin-bottom: 0; + } + &::before { + content: ''; + position: absolute; + left: 4px; + bottom: 16px; + transform: translateY(-50%); + width: 12px; + height: 12px; + background-color: var(--bg-slate-400); + border-radius: 50%; + z-index: 1; + } + &__label { + color: var(--Vanilla-400, #c0c1c3); + font-size: 14px; + line-height: 20px; + letter-spacing: -0.07px; + flex-shrink: 0; + } + &__divider { + width: 100%; + .ant-divider { + margin: 0; + border-color: var(--bg-slate-400); + } + } + &__latency-options { + flex-shrink: 0; + } +} + +.lightMode { + .inter-step-config { + background-color: var(--bg-vanilla-200); + color: var(--bg-ink-400); + &::before { + background-color: var(--bg-vanilla-400); + } + + &__label { + color: var(--bg-ink-300); + } + + &__divider { + .ant-divider { + border-color: var(--bg-vanilla-400); + } + } + } +} diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/InterStepConfig.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/InterStepConfig.tsx new file mode 100644 index 0000000000..9ff0a50697 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/InterStepConfig.tsx @@ -0,0 +1,43 @@ +import './InterStepConfig.styles.scss'; + +import { Divider } from 'antd'; +import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup'; +import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext'; +import { FunnelStepData, LatencyOptions } from 'types/api/traceFunnels'; + +function InterStepConfig({ + index, + step, +}: { + index: number; + step: FunnelStepData; +}): JSX.Element { + const { handleStepChange: onStepChange } = useFunnelContext(); + const options = Object.entries(LatencyOptions).map(([key, value]) => ({ + label: key, + value, + })); + + return ( +
+
Latency type
+
+ +
+
+ + onStepChange(index, { + ...step, + latency_type: e.target.value, + }) + } + /> +
+
+ ); +} + +export default InterStepConfig; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsContent.styles.scss b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsContent.styles.scss new file mode 100644 index 0000000000..819a1f04de --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsContent.styles.scss @@ -0,0 +1,156 @@ +.steps-content { + height: calc( + 100vh - 253px + ); // 64px (footer) + 12 (steps gap) + 32 (steps header) + 16 (steps padding) + 50 (breadcrumb) + 34 (description) + 45 (steps footer) = 219px + overflow-y: auto; + .ant-btn { + box-shadow: none; + &-icon { + margin-inline-end: 0 !important; + } + } + &__description { + display: flex; + flex-direction: column; + gap: 16px; + .funnel-step-wrapper { + display: flex; + gap: 16px; + + &__replace-button { + display: flex; + height: 28px; + padding: 5px 12px; + justify-content: center; + align-items: center; + gap: 6px; + border-radius: 3px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 18px; + &:disabled { + background-color: rgba(209, 209, 209, 0.074); + color: #5f5f5f; + } + } + } + } + + &__add-btn { + border-radius: 2px; + border: 1px solid var(--bg-ink-200); + background: var(--bg-ink-200); + box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1); + padding: 6px 12px; + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; + } + + .ant-steps-item.steps-content__add-step { + .ant-steps-item-icon { + margin-left: 4px; + margin-right: 20px; + width: 12px; + height: 12px; + } + .ant-steps-icon { + display: none; + } + } + + .ant-steps-item-process .ant-steps-item-icon, + .ant-steps-item-icon { + // margin-left: 6px; + height: 20px; + width: 20px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background-color: var(--bg-slate-400) !important; + + & > .ant-steps-icon { + font-size: 13px; + font-weight: 400; + line-height: normal; + letter-spacing: -0.065px; + color: var(--bg-vanilla-400); + } + } + + .ant-steps.ant-steps-vertical + > .ant-steps-item + > .ant-steps-item-container + > .ant-steps-item-tail { + inset-inline-start: 9px; + } + .ant-steps-item-tail { + padding: 20px 0 0 !important; + + &::after { + background-color: var(--bg-slate-400) !important; + } + } + + .latency-step-marker { + &::before { + content: ''; + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + background-color: var(--bg-ink-400); + border-radius: 50%; + z-index: 1; + } + } +} + +// Light mode styles +.lightMode { + .funnel-step-wrapper__replace-button { + background: var(--bg-vanilla-300); + color: var(--bg-ink-400); + border: none; + } + .steps-content { + &__add-btn { + background: var(--bg-vanilla-300); + border: none; + color: var(--bg-ink-400); + + &:hover { + background: var(--bg-vanilla-400); + } + } + + .ant-steps-item-icon { + background-color: var(--bg-vanilla-400) !important; + + .ant-steps-icon { + color: var(--bg-ink-400); + } + } + + .ant-steps-item-tail::after { + background-color: var(--bg-vanilla-400) !important; + } + + .inter-step-config::before { + background-color: var(--bg-vanilla-400); + } + + .latency-step-marker::before { + background-color: var(--bg-vanilla-400); + } + } +} diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsContent.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsContent.tsx new file mode 100644 index 0000000000..5895a998da --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsContent.tsx @@ -0,0 +1,107 @@ +import './StepsContent.styles.scss'; + +import { Button, Steps } from 'antd'; +import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; +import { PlusIcon, Undo2 } from 'lucide-react'; +import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext'; +import { memo, useCallback } from 'react'; +import { Span } from 'types/api/trace/getTraceV2'; + +import FunnelStep from './FunnelStep'; +import InterStepConfig from './InterStepConfig'; + +const { Step } = Steps; + +function StepsContent({ + isTraceDetailsPage, + span, +}: { + isTraceDetailsPage?: boolean; + span?: Span; +}): JSX.Element { + const { steps, handleAddStep, handleReplaceStep } = useFunnelContext(); + + const handleAddForNewStep = useCallback(() => { + if (!span) return; + + const stepWasAdded = handleAddStep(); + if (stepWasAdded) { + handleReplaceStep(steps.length, span.serviceName, span.name); + } + }, [span, handleAddStep, handleReplaceStep, steps.length]); + + return ( +
+ + + {steps.map((step, index) => ( + +
+ + {isTraceDetailsPage && span && ( + + )} +
+ {/* Display InterStepConfig only between steps */} + {index < steps.length - 1 && ( + + )} +
+ } + /> + ))} + {/* For now we are only supporting 3 steps */} + {steps.length < 3 && ( + } + > + Add Funnel Step + + ) : ( + + ) + } + /> + )} + + + + ); +} + +StepsContent.defaultProps = { + isTraceDetailsPage: false, + span: undefined, +}; + +export default memo(StepsContent); diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsFooter.styles.scss b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsFooter.styles.scss new file mode 100644 index 0000000000..0191085a91 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsFooter.styles.scss @@ -0,0 +1,74 @@ +.steps-footer { + border-top: 1px solid var(--bg-slate-500); + background: var(--bg-ink-500); + padding: 16px; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + + &__left { + display: flex; + gap: 6px; + align-items: center; + font-size: 14px; + line-height: 18px; /* 128.571% */ + letter-spacing: -0.07px; + } + &__valid-traces { + &--none { + color: var(--text-amber-500); + } + } + &__right { + display: flex; + align-items: center; + gap: 8px; + } + + &__button { + border: none; + display: flex; + align-items: center; + gap: 6px; + .ant-btn-icon { + margin-inline-end: 0 !important; + } + &--save { + background-color: var(--bg-slate-400); + } + &--run { + background-color: var(--bg-robin-500); + } + } +} + +.lightMode { + .steps-footer { + border-top: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-200); + + &__left { + color: var(--bg-ink-400); + } + + &__valid-traces { + &--none { + color: var(--text-amber-600); + } + } + + &__button { + &--save { + background: var(--bg-vanilla-300); + } + &--run { + background-color: var(--bg-robin-400); + } + } + } +} diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsFooter.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsFooter.tsx new file mode 100644 index 0000000000..30b6e53e47 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsFooter.tsx @@ -0,0 +1,73 @@ +import './StepsFooter.styles.scss'; + +import { Button, Skeleton } from 'antd'; +import { Cone, Play } from 'lucide-react'; +import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext'; + +interface StepsFooterProps { + stepsCount: number; +} + +function ValidTracesCount(): JSX.Element { + const { + hasAllEmptyStepFields, + isValidateStepsLoading, + hasIncompleteStepFields, + validTracesCount, + } = useFunnelContext(); + if (isValidateStepsLoading) { + return ; + } + + if (hasAllEmptyStepFields) { + return ( + No service / span names + ); + } + + if (hasIncompleteStepFields) { + return ( + + Missing service / span names + + ); + } + + if (validTracesCount === 0) { + return ( + + No valid traces found + + ); + } + + return Valid traces found; +} + +function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element { + const { validTracesCount, handleRunFunnel } = useFunnelContext(); + + return ( +
+
+ + {stepsCount} steps + · + +
+
+ +
+
+ ); +} + +export default StepsFooter; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsHeader.styles.scss b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsHeader.styles.scss new file mode 100644 index 0000000000..9cf5247359 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsHeader.styles.scss @@ -0,0 +1,51 @@ +.steps-header { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + &__label { + color: var(--bg-slate-50); + font-size: 12px; + font-weight: 600; + line-height: 18px; + letter-spacing: 0.48px; + text-transform: uppercase; + flex-shrink: 0; + } + &__divider { + width: 100%; + .ant-divider { + margin: 0; + border-color: var(--bg-slate-400); + } + } + &__time-range { + min-width: 192px; + height: 32px; + flex-shrink: 0; + .timeSelection-input { + .ant-input-prefix > svg { + height: 12px; + } + &, + input { + background: var(--bg-ink-300); + font-size: 12px; + } + } + } +} + +.lightMode { + .steps-header { + &__label { + color: var(--bg-ink-400); + } + .timeSelection-input { + &, + input { + background: unset; + } + } + } +} diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsHeader.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsHeader.tsx new file mode 100644 index 0000000000..246735f639 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelConfiguration/StepsHeader.tsx @@ -0,0 +1,24 @@ +import './StepsHeader.styles.scss'; + +import { Divider } from 'antd'; +import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; + +function StepsHeader(): JSX.Element { + return ( +
+
FUNNEL STEPS
+
+ +
+
+ +
+
+ ); +} + +export default StepsHeader; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/EmptyFunnelResults.styles.scss b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/EmptyFunnelResults.styles.scss new file mode 100644 index 0000000000..16caf0934a --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/EmptyFunnelResults.styles.scss @@ -0,0 +1,42 @@ +.funnel-results--empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} +.empty-funnel-results { + display: flex; + flex-direction: column; + gap: 4px; + + &__title { + color: var(--bg-vanilla-100); + font-size: 14px; + font-weight: 500; + line-height: 18px; + letter-spacing: -0.07px; + } + + &__description { + color: var(--bg-vanilla-400); + font-size: 14px; + line-height: 18px; + letter-spacing: -0.07px; + } + + &__learn-more { + margin-top: 8px; + } +} + +.lightMode { + .empty-funnel-results { + &__title { + color: var(--bg-ink-400); + } + + &__description { + color: var(--bg-ink-300); + } + } +} diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/EmptyFunnelResults.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/EmptyFunnelResults.tsx new file mode 100644 index 0000000000..baaea42cbc --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/EmptyFunnelResults.tsx @@ -0,0 +1,33 @@ +import './EmptyFunnelResults.styles.scss'; + +import LearnMore from 'components/LearnMore/LearnMore'; + +function EmptyFunnelResults({ + title, + description, +}: { + title?: string; + description?: string; +}): JSX.Element { + return ( +
+
+
+ Empty funnel results +
+
{title}
+
{description}
+
+ +
+
+
+ ); +} + +EmptyFunnelResults.defaultProps = { + title: 'No spans selected yet.', + description: 'Add spans to the funnel steps to start seeing analytics here.', +}; + +export default EmptyFunnelResults; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelGraph.styles.scss b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelGraph.styles.scss new file mode 100644 index 0000000000..0c61f9778f --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelGraph.styles.scss @@ -0,0 +1,117 @@ +.funnel-graph { + width: 100%; + padding: 16px; + height: 459px; + background: var(--bg-ink-500); + border-radius: 6px; + box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1); + border: 1px solid var(--bg-slate-500); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 13px; + + &--2-columns { + .funnel-graph { + &__legend-column { + width: 45%; + } + &__legends { + padding-left: 10%; + padding-right: 5%; + } + } + } + &--3-columns { + .funnel-graph { + &__legend-column { + width: 30%; + } + &__legends { + padding-left: 6%; + padding-right: 2%; + } + } + } + + &__chart-container { + width: 100%; + height: 370px; + } + + &__legends { + display: flex; + justify-content: space-between; + padding-left: 7%; + padding-right: 2%; + width: 100%; + } + + &__legend-column { + display: flex; + flex-direction: column; + gap: 8px; + .legend-item { + display: flex; + align-items: center; + justify-content: space-between; + font-family: 'Geist Mono', monospace; + font-size: 12px; + + &__left { + display: flex; + align-items: center; + gap: 8px; + } + + &__right { + display: flex; + align-items: center; + gap: 8px; + } + + &__dot { + width: 8px; + height: 8px; + border-radius: 1px; + flex-shrink: 0; + } + + &--total { + background-color: var(--bg-robin-500); + } + + &--error { + background-color: var(--bg-cherry-500); + } + + &__label { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 12px; + } + + &__value { + color: var(--bg-vanilla-100); + font-family: 'Geist Mono'; + font-size: 12px; + } + } + } +} + +.lightMode { + .funnel-graph { + background: var(--bg-vanilla-100); + border: 1px solid var(--bg-vanilla-300); + &__legend-column { + .legend-item { + &__label, + &__value { + color: var(--bg-ink-500); + } + } + } + } +} diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelGraph.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelGraph.tsx new file mode 100644 index 0000000000..fc43641160 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelGraph.tsx @@ -0,0 +1,126 @@ +import './FunnelGraph.styles.scss'; + +import { LoadingOutlined } from '@ant-design/icons'; +import { Empty, Spin } from 'antd'; +import { + BarController, + BarElement, + CategoryScale, + Chart, + Legend, + LinearScale, + Title, +} from 'chart.js'; +import cx from 'classnames'; +import Spinner from 'components/Spinner'; +import useFunnelGraph from 'hooks/TracesFunnels/useFunnelGraph'; +import { useFunnelStepsGraphData } from 'hooks/TracesFunnels/useFunnels'; +import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext'; +import { useCallback, useMemo, useState } from 'react'; + +// Register required components +Chart.register( + BarController, + BarElement, + CategoryScale, + LinearScale, + Legend, + Title, +); + +function FunnelGraph(): JSX.Element { + const { funnelId } = useFunnelContext(); + const { + data: stepsData, + isLoading, + isFetching, + isError, + } = useFunnelStepsGraphData(funnelId); + + const data = useMemo(() => stepsData?.payload?.data?.[0]?.data, [ + stepsData?.payload?.data, + ]); + + const [hoveredBar, setHoveredBar] = useState<{ + index: number; + type: 'total' | 'error'; + } | null>(null); + + const { + successSteps, + errorSteps, + totalSteps, + canvasRef, + renderLegendItem, + } = useFunnelGraph({ + data, + hoveredBar, + }); + + const handleLegendHover = useCallback( + (index: number, type: 'total' | 'error') => { + const hover = { index, type }; + setHoveredBar(hover); + }, + [setHoveredBar], + ); + + const handleLegendLeave = useCallback(() => { + setHoveredBar(null); + }, [setHoveredBar]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!data) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ +
+ ); + } + + return ( + }> +
+
+ +
+
+ {Array.from({ length: totalSteps }, (_, index) => { + const prevTotalSpans = + index > 0 + ? successSteps[index - 1] + errorSteps[index - 1] + : successSteps[0] + errorSteps[0]; + return renderLegendItem( + index + 1, + successSteps[index], + errorSteps[index], + prevTotalSpans, + { + onTotalHover: () => handleLegendHover(index, 'total'), + onErrorHover: () => handleLegendHover(index, 'error'), + onLegendLeave: handleLegendLeave, + }, + ); + })} +
+
+
+ ); +} + +export default FunnelGraph; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable.styles.scss b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable.styles.scss new file mode 100644 index 0000000000..62fbe6fc20 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable.styles.scss @@ -0,0 +1,122 @@ +.funnel-metrics { + background: var(--bg-ink-500); + border-radius: 6px; + box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.1); + border: 1px solid var(--bg-slate-500); + &--loading-state, + &--empty-state { + padding: 16px; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + border-bottom: 1px solid var(--bg-slate-500); + } + + &__title { + color: var(--bg-vanilla-400); + font-size: 12px; + font-weight: 500; + line-height: 22px; + letter-spacing: 0.48px; + text-transform: uppercase; + } + + &__subtitle { + display: flex; + align-items: center; + gap: 8px; + + &-label { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + line-height: 22px; /* 157.143% */ + letter-spacing: -0.07px; + } + + &-value { + color: var(--bg-vanilla-100); + font-family: 'Geist Mono'; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 22px; /* 157.143% */ + letter-spacing: -0.07px; + } + } + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } + + &__item { + display: flex; + flex-direction: column; + gap: 26px; + padding: 14px 16px; + &:not(:last-child) { + border-right: 1px solid var(--bg-slate-500); + } + &-title { + color: var(--bg-vanilla-100); + font-size: 14px; + line-height: 22px; /* 157.143% */ + letter-spacing: -0.07px; + } + + &-value, + &-unit { + color: var(--bg-vanilla-400); + font-family: 'Geist Mono'; + font-size: 14px; + line-height: 22px; /* 157.143% */ + letter-spacing: -0.07px; + } + } +} + +.lightMode { + .funnel-metrics { + background: var(--bg-vanilla-100); + border: 1px solid var(--bg-vanilla-300); + box-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.05); + + &__header { + border-bottom: 1px solid var(--bg-vanilla-300); + } + + &__title { + color: var(--text-ink-300); + } + + &__subtitle { + &-label { + color: var(--text-ink-300); + } + + &-value { + color: var(--text-ink-500); + } + } + + &__item { + &:not(:last-child) { + border-right: 1px solid var(--bg-vanilla-300); + } + + &-title { + color: var(--text-ink-500); + } + + &-value, + &-unit { + color: var(--text-ink-300); + } + } + } +} diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable.tsx new file mode 100644 index 0000000000..427835fa6e --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelMetricsTable.tsx @@ -0,0 +1,104 @@ +import './FunnelMetricsTable.styles.scss'; + +import { Empty } from 'antd'; +import Spinner from 'components/Spinner'; + +export interface MetricItem { + title: string; + value: string | number; +} + +interface FunnelMetricsTableProps { + title: string; + subtitle?: { + label: string; + value: string | number; + }; + data: MetricItem[]; + isLoading?: boolean; + isError?: boolean; + emptyState?: JSX.Element; +} + +function FunnelMetricsContentRenderer({ + data, + isLoading, + isError, + emptyState, +}: { + data: MetricItem[]; + isLoading?: boolean; + isError?: boolean; + emptyState?: JSX.Element; +}): JSX.Element { + if (isLoading) + return ( +
+ +
+ ); + if (data.length === 0 && emptyState) { + return emptyState; + } + + if (isError) { + return ( + + ); + } + + return ( +
+ {data.map((metric) => ( +
+
{metric.title}
+
{metric.value}
+
+ ))} +
+ ); +} +FunnelMetricsContentRenderer.defaultProps = { + isLoading: false, + isError: false, + emptyState: , +}; + +function FunnelMetricsTable({ + title, + subtitle, + data, + isLoading, + isError, + emptyState, +}: FunnelMetricsTableProps): JSX.Element { + return ( +
+
+
{title}
+ {subtitle && ( +
+ {subtitle.label} + + {subtitle.value} +
+ )} +
+ +
+ ); +} + +FunnelMetricsTable.defaultProps = { + subtitle: undefined, + isLoading: false, + emptyState: , + isError: false, +}; + +export default FunnelMetricsTable; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelResults.styles.scss b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelResults.styles.scss new file mode 100644 index 0000000000..d951426b42 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelResults.styles.scss @@ -0,0 +1,6 @@ +.funnel-results { + padding: 16px; + display: flex; + flex-direction: column; + gap: 20px; +} diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelResults.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelResults.tsx new file mode 100644 index 0000000000..50c45fe9d2 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelResults.tsx @@ -0,0 +1,51 @@ +import './FunnelResults.styles.scss'; + +import Spinner from 'components/Spinner'; +import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext'; + +import EmptyFunnelResults from './EmptyFunnelResults'; +import FunnelGraph from './FunnelGraph'; +import OverallMetrics from './OverallMetrics'; +import StepsTransitionResults from './StepsTransitionResults'; + +function FunnelResults(): JSX.Element { + const { + validTracesCount, + isValidateStepsLoading, + hasIncompleteStepFields, + hasAllEmptyStepFields, + } = useFunnelContext(); + + if (isValidateStepsLoading) { + return ; + } + + if (hasAllEmptyStepFields) return ; + + if (hasIncompleteStepFields) + return ( + + ); + + if (validTracesCount === 0) { + return ( + + ); + } + + return ( +
+ + + +
+ ); +} + +export default FunnelResults; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTable.styles.scss b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTable.styles.scss new file mode 100644 index 0000000000..fc2840c0ab --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTable.styles.scss @@ -0,0 +1,148 @@ +.funnel-table { + border-radius: 3px; + border: 1px solid var(--bg-slate-500); + background: linear-gradient( + 0deg, + rgba(171, 189, 255, 0.01) 0%, + rgba(171, 189, 255, 0.01) 100% + ), + #0b0c0e; + + &__header { + padding: 12px 14px 12px; + padding-bottom: 24px; + color: var(--bg-vanilla-100); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + background: var(--bg-ink-400); + display: flex; + justify-content: space-between; + align-items: center; + } + + .ant-table { + .ant-table-thead > tr > th { + padding: 2px 12px; + border-bottom: none; + color: var(--text-vanilla-400); + font-family: Inter; + font-size: 11px; + font-style: normal; + font-weight: 600; + line-height: 18px; + letter-spacing: 0.44px; + text-transform: uppercase; + background: none; + .ant-table-cell:first-child { + border-radius: 0px 4px 0px 0px !important; + } + + &::before { + background-color: transparent; + } + } + + .ant-table-cell { + padding: 12px; + font-size: 13px; + line-height: 20px; + color: var(--bg-vanilla-100); + border-bottom: none; + } + + .ant-table-tbody > tr:hover > td { + background: rgba(255, 255, 255, 0.04); + } + + .ant-table-cell:first-child { + text-align: justify; + background: rgba(171, 189, 255, 0.04); + } + + .ant-table-cell:nth-child(2) { + padding-left: 16px; + padding-right: 16px; + } + + .ant-table-cell:nth-child(n + 3) { + padding-right: 24px; + } + + .column-header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + } + + .ant-table-tbody > tr > td { + border-bottom: none; + } + + .ant-table-thead + > tr + > th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before { + background-color: transparent; + } + + .ant-empty-normal { + visibility: hidden; + } + + .table-row-light { + background: none; + } + + .table-row-dark { + background: var(--bg-ink-300); + } + + .trace-id-cell { + color: var(--bg-robin-400); + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + } +} + +.lightMode { + .funnel-table { + border-color: var(--bg-vanilla-300); + .ant-table { + .ant-table-thead > tr > th { + background: var(--bg-vanilla-100); + color: var(--text-ink-300); + } + + .ant-table-cell { + background: var(--bg-vanilla-100); + color: var(--bg-ink-500); + } + + .ant-table-tbody > tr:hover > td { + background: rgba(0, 0, 0, 0.04); + } + + .table-row-light { + background: none; + color: var(--bg-ink-500); + } + + .table-row-dark { + background: none; + color: var(--bg-ink-500); + } + } + + &__header { + background: var(--bg-vanilla-100); + color: var(--text-ink-300); + } + } +} diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTable.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTable.tsx new file mode 100644 index 0000000000..c017a68140 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTable.tsx @@ -0,0 +1,55 @@ +import './FunnelTable.styles.scss'; + +import { Empty, Table, Tooltip } from 'antd'; +import { ColumnProps } from 'antd/es/table'; + +interface FunnelTableProps { + loading?: boolean; + data?: any[]; + columns: Array>; + title: string; + tooltip?: string; +} + +function FunnelTable({ + loading = false, + data = [], + columns = [], + title, + tooltip, +}: FunnelTableProps): JSX.Element { + return ( +
+
+
{title}
+
+ + info + +
+
+ , + }} + scroll={{ x: true }} + tableLayout="fixed" + rowClassName={(_, index): string => + index % 2 === 0 ? 'table-row-dark' : 'table-row-light' + } + /> + + ); +} + +FunnelTable.defaultProps = { + loading: false, + data: [], + tooltip: '', +}; + +export default FunnelTable; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTopTracesTable.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTopTracesTable.tsx new file mode 100644 index 0000000000..64973fb5f3 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/FunnelTopTracesTable.tsx @@ -0,0 +1,74 @@ +import { ErrorTraceData, SlowTraceData } from 'api/traceFunnels'; +import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext'; +import { useMemo } from 'react'; +import { UseQueryResult } from 'react-query'; +import { ErrorResponse, SuccessResponse } from 'types/api'; + +import FunnelTable from './FunnelTable'; +import { topTracesTableColumns } from './utils'; + +interface FunnelTopTracesTableProps { + funnelId: string; + stepAOrder: number; + stepBOrder: number; + title: string; + tooltip: string; + useQueryHook: ( + funnelId: string, + payload: { + start_time: number; + end_time: number; + step_a_order: number; + step_b_order: number; + }, + ) => UseQueryResult< + SuccessResponse | ErrorResponse, + Error + >; +} + +function FunnelTopTracesTable({ + funnelId, + stepAOrder, + stepBOrder, + title, + tooltip, + useQueryHook, +}: FunnelTopTracesTableProps): JSX.Element { + const { startTime, endTime } = useFunnelContext(); + const payload = useMemo( + () => ({ + start_time: startTime, + end_time: endTime, + step_a_order: stepAOrder, + step_b_order: stepBOrder, + }), + [startTime, endTime, stepAOrder, stepBOrder], + ); + + const { data: response, isLoading, isFetching } = useQueryHook( + funnelId, + payload, + ); + + const data = useMemo(() => { + if (!response?.payload?.data) return []; + return response.payload.data.map((item) => ({ + trace_id: item.data.trace_id, + duration_ms: item.data.duration_ms, + span_count: item.data.span_count, + })); + }, [response]); + + return ( + + ); +} + +export default FunnelTopTracesTable; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/OverallMetrics.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/OverallMetrics.tsx new file mode 100644 index 0000000000..bcb473776a --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/OverallMetrics.tsx @@ -0,0 +1,26 @@ +import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics'; +import { useParams } from 'react-router-dom'; + +import FunnelMetricsTable from './FunnelMetricsTable'; + +function OverallMetrics(): JSX.Element { + const { funnelId } = useParams<{ funnelId: string }>(); + const { isLoading, metricsData, conversionRate, isError } = useFunnelMetrics({ + funnelId: funnelId || '', + }); + + return ( + + ); +} + +export default OverallMetrics; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionMetrics.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionMetrics.tsx new file mode 100644 index 0000000000..6e9d9791ed --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionMetrics.tsx @@ -0,0 +1,53 @@ +import { useFunnelMetrics } from 'hooks/TracesFunnels/useFunnelMetrics'; +import { useParams } from 'react-router-dom'; + +import FunnelMetricsTable from './FunnelMetricsTable'; +import { StepTransition } from './StepsTransitionResults'; + +interface StepsTransitionMetricsProps { + selectedTransition: string; + transitions: StepTransition[]; + startStep?: number; + endStep?: number; +} + +function StepsTransitionMetrics({ + selectedTransition, + transitions, + startStep, + endStep, +}: StepsTransitionMetricsProps): JSX.Element { + const { funnelId } = useParams<{ funnelId: string }>(); + const currentTransition = transitions.find( + (transition) => transition.value === selectedTransition, + ); + + const { isLoading, metricsData, conversionRate } = useFunnelMetrics({ + funnelId: funnelId || '', + stepStart: startStep, + stepEnd: endStep, + }); + + if (!currentTransition) { + return
No transition selected
; + } + + return ( + + ); +} + +StepsTransitionMetrics.defaultProps = { + startStep: undefined, + endStep: undefined, +}; + +export default StepsTransitionMetrics; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionResults.styles.scss b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionResults.styles.scss new file mode 100644 index 0000000000..842b21963a --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionResults.styles.scss @@ -0,0 +1,38 @@ +.steps-transition-results { + display: flex; + flex-direction: column; + gap: 20px; + &__steps-selector { + display: flex; + justify-content: center; + } + + &__results { + display: flex; + flex-direction: column; + gap: 16px; + } +} + +.lightMode { + .steps-transition-results { + &__steps-selector { + .views-tabs { + .tab { + background: var(--bg-vanilla-100); + } + + .selected_view { + background: var(--bg-vanilla-300); + border: 1px solid var(--bg-slate-300); + color: var(--text-ink-400); + } + + .selected_view::before { + background: var(--bg-vanilla-300); + border-left: 1px solid var(--bg-slate-300); + } + } + } + } +} diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionResults.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionResults.tsx new file mode 100644 index 0000000000..26e549f5c1 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/StepsTransitionResults.tsx @@ -0,0 +1,66 @@ +import './StepsTransitionResults.styles.scss'; + +import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup'; +import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext'; +import { useMemo, useState } from 'react'; + +import StepsTransitionMetrics from './StepsTransitionMetrics'; +import TopSlowestTraces from './TopSlowestTraces'; +import TopTracesWithErrors from './TopTracesWithErrors'; + +export interface StepTransition { + value: string; + label: string; +} + +function generateStepTransitions(stepsCount: number): StepTransition[] { + return Array.from({ length: stepsCount - 1 }, (_, index) => ({ + value: `${index + 1}_to_${index + 2}`, + label: `Step ${index + 1} → Step ${index + 2}`, + })); +} + +function StepsTransitionResults(): JSX.Element { + const { steps, funnelId } = useFunnelContext(); + const stepTransitions = generateStepTransitions(steps.length); + const [selectedTransition, setSelectedTransition] = useState( + stepTransitions[0]?.value || '', + ); + + const [stepAOrder, stepBOrder] = useMemo(() => { + const [a, b] = selectedTransition.split('_to_'); + return [parseInt(a, 10), parseInt(b, 10)]; + }, [selectedTransition]); + + return ( +
+
+ setSelectedTransition(e.target.value)} + /> +
+
+ + + +
+
+ ); +} + +export default StepsTransitionResults; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/TopSlowestTraces.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/TopSlowestTraces.tsx new file mode 100644 index 0000000000..8067fa5f71 --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/TopSlowestTraces.tsx @@ -0,0 +1,23 @@ +import { useFunnelSlowTraces } from 'hooks/TracesFunnels/useFunnels'; + +import FunnelTopTracesTable from './FunnelTopTracesTable'; + +interface TopSlowestTracesProps { + funnelId: string; + stepAOrder: number; + stepBOrder: number; +} + +function TopSlowestTraces(props: TopSlowestTracesProps): JSX.Element { + return ( + + ); +} + +export default TopSlowestTraces; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/TopTracesWithErrors.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/TopTracesWithErrors.tsx new file mode 100644 index 0000000000..55dcf4db5d --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/TopTracesWithErrors.tsx @@ -0,0 +1,23 @@ +import { useFunnelErrorTraces } from 'hooks/TracesFunnels/useFunnels'; + +import FunnelTopTracesTable from './FunnelTopTracesTable'; + +interface TopTracesWithErrorsProps { + funnelId: string; + stepAOrder: number; + stepBOrder: number; +} + +function TopTracesWithErrors(props: TopTracesWithErrorsProps): JSX.Element { + return ( + + ); +} + +export default TopTracesWithErrors; diff --git a/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/utils.tsx b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/utils.tsx new file mode 100644 index 0000000000..8d3bee421e --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/components/FunnelResults/utils.tsx @@ -0,0 +1,27 @@ +import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig'; +import { Link } from 'react-router-dom'; + +export const topTracesTableColumns = [ + { + title: 'TRACE ID', + dataIndex: 'trace_id', + key: 'trace_id', + render: (traceId: string): JSX.Element => ( + + {traceId} + + ), + }, + { + title: 'DURATION', + dataIndex: 'duration_ms', + key: 'duration_ms', + render: (value: string): string => getYAxisFormattedValue(value, 'ms'), + }, + { + title: 'SPAN COUNT', + dataIndex: 'span_count', + key: 'span_count', + render: (value: number): string => value.toString(), + }, +]; diff --git a/frontend/src/pages/TracesFunnelDetails/constants.ts b/frontend/src/pages/TracesFunnelDetails/constants.ts new file mode 100644 index 0000000000..3c7016549c --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/constants.ts @@ -0,0 +1,49 @@ +import { FunnelStepData, LatencyOptions } from 'types/api/traceFunnels'; +import { v4 } from 'uuid'; + +export const initialStepsData: FunnelStepData[] = [ + { + id: v4(), + step_order: 1, + service_name: '', + span_name: '', + filters: { + items: [], + op: 'and', + }, + latency_pointer: 'start', + latency_type: LatencyOptions.P95, + has_errors: false, + name: '', + description: '', + }, + { + id: v4(), + step_order: 2, + service_name: '', + span_name: '', + filters: { + items: [], + op: 'and', + }, + latency_pointer: 'start', + latency_type: LatencyOptions.P95, + has_errors: false, + name: '', + description: '', + }, +]; + +export const LatencyPointers: { + value: FunnelStepData['latency_pointer']; + key: string; +}[] = [ + { + value: 'start', + key: 'Start of span', + }, + { + value: 'end', + key: 'End of span', + }, +]; diff --git a/frontend/src/pages/TracesFunnels/FunnelContext.tsx b/frontend/src/pages/TracesFunnels/FunnelContext.tsx new file mode 100644 index 0000000000..54b15b1d46 --- /dev/null +++ b/frontend/src/pages/TracesFunnels/FunnelContext.tsx @@ -0,0 +1,244 @@ +import { ValidateFunnelResponse } from 'api/traceFunnels'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import { Time } from 'container/TopNav/DateTimeSelection/config'; +import { + CustomTimeType, + Time as TimeV2, +} from 'container/TopNav/DateTimeSelectionV2/config'; +import { useValidateFunnelSteps } from 'hooks/TracesFunnels/useFunnels'; +import getStartEndRangeTime from 'lib/getStartEndRangeTime'; +import { initialStepsData } from 'pages/TracesFunnelDetails/constants'; +import { + createContext, + Dispatch, + SetStateAction, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { useQueryClient } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { FunnelData, FunnelStepData } from 'types/api/traceFunnels'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { v4 } from 'uuid'; + +interface FunnelContextType { + startTime: number; + endTime: number; + selectedTime: CustomTimeType | Time | TimeV2; + validTracesCount: number; + funnelId: string; + steps: FunnelStepData[]; + setSteps: Dispatch>; + initialSteps: FunnelStepData[]; + handleAddStep: () => boolean; + handleStepChange: (index: number, newStep: Partial) => void; + handleStepRemoval: (index: number) => void; + handleRunFunnel: () => void; + validationResponse: + | SuccessResponse + | ErrorResponse + | undefined; + isValidateStepsLoading: boolean; + hasIncompleteStepFields: boolean; + setHasIncompleteStepFields: Dispatch>; + hasAllEmptyStepFields: boolean; + setHasAllEmptyStepFields: Dispatch>; + handleReplaceStep: ( + index: number, + serviceName: string, + spanName: string, + ) => void; + handleRestoreSteps: (oldSteps: FunnelStepData[]) => void; +} + +const FunnelContext = createContext(undefined); + +export function FunnelProvider({ + children, + funnelId, +}: { + children: React.ReactNode; + funnelId: string; +}): JSX.Element { + const { selectedTime } = useSelector( + (state) => state.globalTime, + ); + const { start, end } = getStartEndRangeTime({ + type: 'GLOBAL_TIME', + interval: selectedTime, + }); + + const startTime = Math.floor(Number(start) * 1e9); + const endTime = Math.floor(Number(end) * 1e9); + + const queryClient = useQueryClient(); + const data = queryClient.getQueryData<{ payload: FunnelData }>([ + REACT_QUERY_KEY.GET_FUNNEL_DETAILS, + funnelId, + ]); + const funnel = data?.payload; + const initialSteps = funnel?.steps?.length ? funnel.steps : initialStepsData; + const [steps, setSteps] = useState(initialSteps); + const [hasIncompleteStepFields, setHasIncompleteStepFields] = useState( + steps.some((step) => step.service_name === '' || step.span_name === ''), + ); + const [hasAllEmptyStepFields, setHasAllEmptyStepFields] = useState( + steps.every((step) => step.service_name === '' && step.span_name === ''), + ); + const { + data: validationResponse, + isLoading: isValidationLoading, + isFetching: isValidationFetching, + } = useValidateFunnelSteps({ + funnelId, + selectedTime, + startTime, + endTime, + }); + + const validTracesCount = useMemo( + () => validationResponse?.payload?.data?.length || 0, + [validationResponse], + ); + + // Step modifications + const handleStepUpdate = useCallback( + (index: number, newStep: Partial) => { + setSteps((prev) => + prev.map((step, i) => (i === index ? { ...step, ...newStep } : step)), + ); + }, + [], + ); + + const addNewStep = useCallback(() => { + if (steps.length >= 3) return false; + + setSteps((prev) => [ + ...prev, + { + ...initialStepsData[0], + id: v4(), + step_order: prev.length + 1, + }, + ]); + return true; + }, [steps.length]); + + const handleStepRemoval = useCallback((index: number) => { + setSteps((prev) => + prev + // remove the step in the index + .filter((_, i) => i !== index) + // reset the step_order for the remaining steps + .map((step, newIndex) => ({ + ...step, + step_order: newIndex + 1, + })), + ); + }, []); + + const handleRestoreSteps = useCallback((oldSteps: FunnelStepData[]) => { + setSteps(oldSteps); + }, []); + + const handleReplaceStep = useCallback( + (index: number, serviceName: string, spanName: string) => { + handleStepUpdate(index, { + service_name: serviceName, + span_name: spanName, + }); + }, + [handleStepUpdate], + ); + if (!funnelId) { + throw new Error('Funnel ID is required'); + } + + const handleRunFunnel = useCallback(async (): Promise => { + if (validTracesCount === 0) return; + queryClient.refetchQueries([ + REACT_QUERY_KEY.GET_FUNNEL_OVERVIEW, + funnelId, + selectedTime, + ]); + queryClient.refetchQueries([ + REACT_QUERY_KEY.GET_FUNNEL_STEPS_GRAPH_DATA, + funnelId, + selectedTime, + ]); + queryClient.refetchQueries([ + REACT_QUERY_KEY.GET_FUNNEL_ERROR_TRACES, + funnelId, + selectedTime, + ]); + queryClient.refetchQueries([ + REACT_QUERY_KEY.GET_FUNNEL_SLOW_TRACES, + funnelId, + selectedTime, + ]); + }, [funnelId, queryClient, selectedTime, validTracesCount]); + + const value = useMemo( + () => ({ + funnelId, + startTime, + endTime, + validTracesCount, + selectedTime, + steps, + setSteps, + initialSteps, + handleStepChange: handleStepUpdate, + handleAddStep: addNewStep, + handleStepRemoval, + handleRunFunnel, + validationResponse, + isValidateStepsLoading: isValidationLoading || isValidationFetching, + hasIncompleteStepFields, + setHasIncompleteStepFields, + hasAllEmptyStepFields, + setHasAllEmptyStepFields, + handleReplaceStep, + handleRestoreSteps, + }), + [ + funnelId, + startTime, + endTime, + validTracesCount, + selectedTime, + steps, + initialSteps, + handleStepUpdate, + addNewStep, + handleStepRemoval, + handleRunFunnel, + validationResponse, + isValidationLoading, + isValidationFetching, + hasIncompleteStepFields, + setHasIncompleteStepFields, + hasAllEmptyStepFields, + setHasAllEmptyStepFields, + handleReplaceStep, + handleRestoreSteps, + ], + ); + + return ( + {children} + ); +} + +export function useFunnelContext(): FunnelContextType { + const context = useContext(FunnelContext); + if (context === undefined) { + throw new Error('useFunnelContext must be used within a FunnelProvider'); + } + return context; +} diff --git a/frontend/src/pages/TracesFunnels/TracesFunnels.styles.scss b/frontend/src/pages/TracesFunnels/TracesFunnels.styles.scss index df80118b58..d6f5ceac72 100644 --- a/frontend/src/pages/TracesFunnels/TracesFunnels.styles.scss +++ b/frontend/src/pages/TracesFunnels/TracesFunnels.styles.scss @@ -46,9 +46,8 @@ .sort-heading { color: var(--bg-vanilla-400); - font-size: var(--font-size-sm); + font-size: 14px; padding: 6px 8px; - display: block; } .sort-btns { diff --git a/frontend/src/pages/TracesFunnels/components/CreateFunnel/CreateFunnel.tsx b/frontend/src/pages/TracesFunnels/components/CreateFunnel/CreateFunnel.tsx index 16331f9dd7..fdef7b81f0 100644 --- a/frontend/src/pages/TracesFunnels/components/CreateFunnel/CreateFunnel.tsx +++ b/frontend/src/pages/TracesFunnels/components/CreateFunnel/CreateFunnel.tsx @@ -1,6 +1,7 @@ import '../RenameFunnel/RenameFunnel.styles.scss'; import { Input } from 'antd'; +import { AxiosError } from 'axios'; import SignozModal from 'components/SignozModal/SignozModal'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; @@ -14,10 +15,15 @@ import { generatePath } from 'react-router-dom'; interface CreateFunnelProps { isOpen: boolean; - onClose: () => void; + onClose: (funnelId?: string) => void; + redirectToDetails?: boolean; } -function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element { +function CreateFunnel({ + isOpen, + onClose, + redirectToDetails, +}: CreateFunnelProps): JSX.Element { const [funnelName, setFunnelName] = useState(''); const createFunnelMutation = useCreateFunnel(); const { notifications } = useNotifications(); @@ -28,7 +34,7 @@ function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element { createFunnelMutation.mutate( { funnel_name: funnelName, - creation_timestamp: new Date().getTime(), + timestamp: new Date().getTime(), }, { onSuccess: (data) => { @@ -37,8 +43,8 @@ function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element { }); setFunnelName(''); queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]); - onClose(); - if (data?.payload?.funnel_id) { + onClose(data?.payload?.funnel_id); + if (data?.payload?.funnel_id && redirectToDetails) { safeNavigate( generatePath(ROUTES.TRACES_FUNNELS_DETAIL, { funnelId: data.payload.funnel_id, @@ -46,9 +52,11 @@ function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element { ); } }, - onError: () => { + onError: (error) => { notifications.error({ - message: 'Failed to create funnel', + message: + ((error as AxiosError)?.response?.data as string) || + 'Failed to create funnel', }); }, }, @@ -100,4 +108,7 @@ function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element { ); } +CreateFunnel.defaultProps = { + redirectToDetails: true, +}; export default CreateFunnel; diff --git a/frontend/src/pages/TracesFunnels/components/DeleteFunnel/DeleteFunnel.tsx b/frontend/src/pages/TracesFunnels/components/DeleteFunnel/DeleteFunnel.tsx index e0f4825533..e599220fa4 100644 --- a/frontend/src/pages/TracesFunnels/components/DeleteFunnel/DeleteFunnel.tsx +++ b/frontend/src/pages/TracesFunnels/components/DeleteFunnel/DeleteFunnel.tsx @@ -3,26 +3,32 @@ import './DeleteFunnel.styles.scss'; import SignozModal from 'components/SignozModal/SignozModal'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import ROUTES from 'constants/routes'; import { useDeleteFunnel } from 'hooks/TracesFunnels/useFunnels'; import { useNotifications } from 'hooks/useNotifications'; import { Trash2, X } from 'lucide-react'; import { useQueryClient } from 'react-query'; +import { useHistory } from 'react-router-dom'; interface DeleteFunnelProps { isOpen: boolean; onClose: () => void; funnelId: string; + shouldRedirectToTracesListOnDeleteSuccess?: boolean; } function DeleteFunnel({ isOpen, onClose, funnelId, + shouldRedirectToTracesListOnDeleteSuccess, }: DeleteFunnelProps): JSX.Element { const deleteFunnelMutation = useDeleteFunnel(); const { notifications } = useNotifications(); const queryClient = useQueryClient(); + const history = useHistory(); + const { pathname } = history.location; const handleDelete = (): void => { deleteFunnelMutation.mutate( { @@ -34,6 +40,14 @@ function DeleteFunnel({ message: 'Funnel deleted successfully', }); onClose(); + + if ( + pathname !== ROUTES.TRACES_FUNNELS && + shouldRedirectToTracesListOnDeleteSuccess + ) { + history.push(ROUTES.TRACES_FUNNELS); + return; + } queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]); }, onError: () => { @@ -81,4 +95,8 @@ function DeleteFunnel({ ); } +DeleteFunnel.defaultProps = { + shouldRedirectToTracesListOnDeleteSuccess: true, +}; + export default DeleteFunnel; diff --git a/frontend/src/pages/TracesFunnels/components/FunnelsEmptyState/FunnelsEmptyState.tsx b/frontend/src/pages/TracesFunnels/components/FunnelsEmptyState/FunnelsEmptyState.tsx index 8307fbea53..e34e02bd1f 100644 --- a/frontend/src/pages/TracesFunnels/components/FunnelsEmptyState/FunnelsEmptyState.tsx +++ b/frontend/src/pages/TracesFunnels/components/FunnelsEmptyState/FunnelsEmptyState.tsx @@ -5,7 +5,7 @@ import LearnMore from 'components/LearnMore/LearnMore'; import { Plus } from 'lucide-react'; interface FunnelsEmptyStateProps { - onCreateFunnel: () => void; + onCreateFunnel?: () => void; } function FunnelsEmptyState({ @@ -44,4 +44,8 @@ function FunnelsEmptyState({ ); } +FunnelsEmptyState.defaultProps = { + onCreateFunnel: undefined, +}; + export default FunnelsEmptyState; diff --git a/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelItemPopover.tsx b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelItemPopover.tsx index 2016754ace..2fbc21b172 100644 --- a/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelItemPopover.tsx +++ b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelItemPopover.tsx @@ -11,6 +11,7 @@ interface FunnelItemPopoverProps { isPopoverOpen: boolean; setIsPopoverOpen: (isOpen: boolean) => void; funnel: FunnelData; + shouldRedirectToTracesListOnDeleteSuccess?: boolean; } interface FunnelItemActionsProps { @@ -56,6 +57,7 @@ function FunnelItemPopover({ isPopoverOpen, setIsPopoverOpen, funnel, + shouldRedirectToTracesListOnDeleteSuccess, }: FunnelItemPopoverProps): JSX.Element { const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -71,7 +73,12 @@ function FunnelItemPopover({ return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events -
+
setIsDeleteModalOpen(false)} - funnelId={funnel.id} + funnelId={funnel.funnel_id} />
); } +FunnelItemPopover.defaultProps = { + shouldRedirectToTracesListOnDeleteSuccess: true, +}; export default FunnelItemPopover; diff --git a/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.styles.scss b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.styles.scss index 8cba2e46ec..84f8a44f54 100644 --- a/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.styles.scss +++ b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.styles.scss @@ -47,6 +47,32 @@ opacity: 1; } + &__open-button { + display: flex; + height: 28px; + padding: 5px 12px; + justify-content: center; + align-items: center; + gap: 6px; + border-radius: 3px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 18px; + box-shadow: none; + &:disabled { + background-color: rgba(209, 209, 209, 0.074); + color: #5f5f5f; + } + .ant-btn-icon { + margin: 0 !important; + } + } + &__actions { .ant-popover-inner { width: 187px; @@ -131,6 +157,11 @@ border: unset; } } + &__open-button { + background: var(--bg-vanilla-300); + color: var(--bg-ink-400); + border: none; + } &__action-btn { color: var(--bg-ink-400); border: unset; diff --git a/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.tsx b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.tsx index 6c8a285eee..80bb4140f9 100644 --- a/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.tsx +++ b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.tsx @@ -1,9 +1,10 @@ import './FunnelsList.styles.scss'; +import { Button } from 'antd'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; import ROUTES from 'constants/routes'; import dayjs from 'dayjs'; -import { CalendarClock } from 'lucide-react'; +import { CalendarClock, DecimalsArrowRight } from 'lucide-react'; import { useState } from 'react'; import { generatePath, Link } from 'react-router-dom'; import { FunnelData } from 'types/api/traceFunnels'; @@ -12,62 +13,120 @@ import FunnelItemPopover from './FunnelItemPopover'; interface FunnelListItemProps { funnel: FunnelData; + onFunnelClick?: (funnel: FunnelData) => void; + shouldRedirectToTracesListOnDeleteSuccess?: boolean; + isSpanDetailsPage?: boolean; } -function FunnelListItem({ funnel }: FunnelListItemProps): JSX.Element { +export function FunnelListItem({ + funnel, + onFunnelClick, + shouldRedirectToTracesListOnDeleteSuccess, + isSpanDetailsPage, +}: FunnelListItemProps): JSX.Element { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const funnelDetailsLink = generatePath(ROUTES.TRACES_FUNNELS_DETAIL, { - funnelId: funnel.id, + funnelId: funnel.funnel_id, }); - return ( - + const content = ( + <>
{funnel.funnel_name}
- + + {isSpanDetailsPage ? ( + + ) : ( + + )}
- {dayjs(funnel.creation_timestamp).format( - DATE_TIME_FORMATS.FUNNELS_LIST_DATE, - )} + {dayjs(funnel.created_at).format(DATE_TIME_FORMATS.FUNNELS_LIST_DATE)}
- {funnel.user && ( + {funnel.user_email && (
- {funnel.user.substring(0, 1).toUpperCase()} + {funnel.user_email.substring(0, 1).toUpperCase()}
)} -
{funnel.user}
+
{funnel.user_email}
+ + ); + + return onFunnelClick ? ( + + ) : ( + + {content} ); } +FunnelListItem.defaultProps = { + onFunnelClick: undefined, + shouldRedirectToTracesListOnDeleteSuccess: true, + isSpanDetailsPage: false, +}; + interface FunnelsListProps { data: FunnelData[]; + onFunnelClick?: (funnel: FunnelData) => void; + shouldRedirectToTracesListOnDeleteSuccess?: boolean; } -function FunnelsList({ data }: FunnelsListProps): JSX.Element { +function FunnelsList({ + data, + onFunnelClick, + shouldRedirectToTracesListOnDeleteSuccess, +}: FunnelsListProps): JSX.Element { return (
- {data.map((funnel) => ( - + {data?.map((funnel) => ( + ))}
); } +FunnelsList.defaultProps = { + onFunnelClick: undefined, + shouldRedirectToTracesListOnDeleteSuccess: true, +}; + export default FunnelsList; diff --git a/frontend/src/pages/TracesFunnels/components/RenameFunnel/RenameFunnel.tsx b/frontend/src/pages/TracesFunnels/components/RenameFunnel/RenameFunnel.tsx index 2f24fd99d2..b069c79935 100644 --- a/frontend/src/pages/TracesFunnels/components/RenameFunnel/RenameFunnel.tsx +++ b/frontend/src/pages/TracesFunnels/components/RenameFunnel/RenameFunnel.tsx @@ -29,7 +29,11 @@ function RenameFunnel({ const handleRename = (): void => { renameFunnelMutation.mutate( - { id: funnelId, funnel_name: newFunnelName }, + { + funnel_id: funnelId, + funnel_name: newFunnelName, + timestamp: new Date().getTime(), + }, { onSuccess: () => { notifications.success({ diff --git a/frontend/src/pages/TracesFunnels/components/SearchBar/SearchBar.tsx b/frontend/src/pages/TracesFunnels/components/SearchBar/SearchBar.tsx index 1cfcd3c10c..cd4a544b1a 100644 --- a/frontend/src/pages/TracesFunnels/components/SearchBar/SearchBar.tsx +++ b/frontend/src/pages/TracesFunnels/components/SearchBar/SearchBar.tsx @@ -33,18 +33,18 @@ function SearchBar({
} diff --git a/frontend/src/pages/TracesFunnels/index.tsx b/frontend/src/pages/TracesFunnels/index.tsx index 962bad7e49..87dbd134a9 100644 --- a/frontend/src/pages/TracesFunnels/index.tsx +++ b/frontend/src/pages/TracesFunnels/index.tsx @@ -12,23 +12,28 @@ import FunnelsEmptyState from './components/FunnelsEmptyState/FunnelsEmptyState' import FunnelsList from './components/FunnelsList/FunnelsList'; import Header from './components/Header/Header'; import SearchBar from './components/SearchBar/SearchBar'; +import { filterFunnelsByQuery } from './utils'; interface TracesFunnelsContentRendererProps { isLoading: boolean; isError: boolean; data: FunnelData[]; - onCreateFunnel: () => void; + onCreateFunnel?: () => void; + onFunnelClick?: (funnel: FunnelData) => void; + shouldRedirectToTracesListOnDeleteSuccess?: boolean; } -function TracesFunnelsContentRenderer({ +export function TracesFunnelsContentRenderer({ isLoading, isError, data, onCreateFunnel, + onFunnelClick, + shouldRedirectToTracesListOnDeleteSuccess, }: TracesFunnelsContentRendererProps): JSX.Element { if (isLoading) { return (
- {Array(6) + {Array(2) .fill(0) .map((item, index) => ( Something went wrong
; } - if (data.length === 0) { + if (data.length === 0 && onCreateFunnel) { return ; } - return ; + return ( + + ); } +TracesFunnelsContentRenderer.defaultProps = { + onCreateFunnel: undefined, + onFunnelClick: undefined, + shouldRedirectToTracesListOnDeleteSuccess: true, +}; + function TracesFunnels(): JSX.Element { const { searchQuery, handleSearch } = useHandleTraceFunnelsSearch(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const { data, isLoading, isError } = useFunnelsList({ searchQuery }); + const { data, isLoading, isError } = useFunnelsList(); const { sortOrder, handleSort, sortedData } = useHandleTraceFunnelsSort({ data: data?.payload || [], }); + const filteredData = filterFunnelsByQuery(sortedData, searchQuery); + const handleCreateFunnel = (): void => { setIsCreateModalOpen(true); }; @@ -83,7 +104,7 @@ function TracesFunnels(): JSX.Element { + (funnel.funnel_name || '').toLowerCase().includes(q), + ); +} diff --git a/frontend/src/pages/TracesModulePage/TracesModulePage.tsx b/frontend/src/pages/TracesModulePage/TracesModulePage.tsx index 5e8a8ee97a..3bd4690775 100644 --- a/frontend/src/pages/TracesModulePage/TracesModulePage.tsx +++ b/frontend/src/pages/TracesModulePage/TracesModulePage.tsx @@ -2,30 +2,31 @@ import './TracesModulePage.styles.scss'; import RouteTab from 'components/RouteTab'; import { TabRoutes } from 'components/RouteTab/types'; -import { FeatureKeys } from 'constants/features'; +import ROUTES from 'constants/routes'; import history from 'lib/history'; -import { useAppContext } from 'providers/App/App'; import { useLocation } from 'react-router-dom'; import { tracesExplorer, tracesFunnel, tracesSaveView } from './constants'; function TracesModulePage(): JSX.Element { const { pathname } = useLocation(); - const { featureFlags } = useAppContext(); - - const isTraceFunnelsEnabled = - featureFlags?.find((flag) => flag.name === FeatureKeys.TRACE_FUNNELS) - ?.active || false; const routes: TabRoutes[] = [ tracesExplorer, - isTraceFunnelsEnabled ? tracesFunnel : null, + // TODO(shaheer): remove this check after everything is ready + process.env.NODE_ENV === 'development' ? tracesFunnel(pathname) : null, tracesSaveView, ].filter(Boolean) as TabRoutes[]; return (
- +
); } diff --git a/frontend/src/pages/TracesModulePage/constants.tsx b/frontend/src/pages/TracesModulePage/constants.tsx index 90ecd5fffa..566933f015 100644 --- a/frontend/src/pages/TracesModulePage/constants.tsx +++ b/frontend/src/pages/TracesModulePage/constants.tsx @@ -3,7 +3,9 @@ import ROUTES from 'constants/routes'; import { Compass, Cone, TowerControl } from 'lucide-react'; import SaveView from 'pages/SaveView'; import TracesExplorer from 'pages/TracesExplorer'; +import TracesFunnelDetails from 'pages/TracesFunnelDetails'; import TracesFunnels from 'pages/TracesFunnels'; +import { matchPath } from 'react-router-dom'; export const tracesExplorer: TabRoutes = { Component: TracesExplorer, @@ -16,8 +18,12 @@ export const tracesExplorer: TabRoutes = { key: ROUTES.TRACES_EXPLORER, }; -export const tracesFunnel: TabRoutes = { - Component: TracesFunnels, +export const tracesFunnel = (pathname: string): TabRoutes => ({ + Component: (): JSX.Element => { + const isFunnelDetails = matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL); + + return isFunnelDetails ? : ; + }, name: (
Funnels @@ -25,7 +31,7 @@ export const tracesFunnel: TabRoutes = { ), route: ROUTES.TRACES_FUNNELS, key: ROUTES.TRACES_FUNNELS, -}; +}); export const tracesSaveView: TabRoutes = { Component: SaveView, diff --git a/frontend/src/types/api/traceFunnels/index.ts b/frontend/src/types/api/traceFunnels/index.ts index 26d4b05002..2a713d6175 100644 --- a/frontend/src/types/api/traceFunnels/index.ts +++ b/frontend/src/types/api/traceFunnels/index.ts @@ -1,29 +1,39 @@ import { TagFilter } from '../queryBuilder/queryBuilderData'; -export interface FunnelStep { +export enum LatencyOptions { + P99 = 'p99', + P95 = 'p95', + P90 = 'p90', +} + +export type LatencyOptionsType = 'p99' | 'p95' | 'p90'; +export interface FunnelStepData { id: string; - funnel_order: number; + step_order: number; service_name: string; span_name: string; filters: TagFilter; latency_pointer: 'start' | 'end'; - latency_type: 'p95' | 'p99' | 'p90'; + latency_type: LatencyOptionsType; has_errors: boolean; + name?: string; + description?: string; } export interface FunnelData { - id: string; + funnel_id: string; funnel_name: string; - creation_timestamp: number; - updated_timestamp: number; - user: string; - steps?: FunnelStep[]; + created_at: number; + updated_at: number; + user_email: string; + description?: string; + steps?: FunnelStepData[]; } export interface CreateFunnelPayload { funnel_name: string; user?: string; - creation_timestamp: number; + timestamp: number; } export interface CreateFunnelResponse { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 35796e4776..37613f1a81 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -11887,10 +11887,10 @@ lru-cache@^6.0.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== -lucide-react@0.427.0: - version "0.427.0" - resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.427.0.tgz#e06974514bbd591049f9d736b3d3ae99d4ede8c9" - integrity sha512-lv9s6c5BDF/ccuA0EgTdskTxIe11qpwBDmzRZHJAKtp8LTewAvDvOM+pTES9IpbBuTqkjiMhOmGpJ/CB+mKjFw== +lucide-react@0.498.0: + version "0.498.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.498.0.tgz#3109eac93dfd0c1561db7a5cddfe4b9b20c14315" + integrity sha512-k8IKbvMNV5Dj7CHRrKyIc1wAtmGdEF0r6SCaiGAt5cZ8KnjcEao8mfdydKkWspy65l40MdlcfdK0kT3QrxpnIg== lz-string@^1.4.4: version "1.5.0"