feat: Funnel Details Page Base Structure (#7364)

* 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 <shivanshu1333@gmail.com>

* chore: remove console.og

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: revert explicitly passing funnelId to updateFunnelSteps

---------

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
Co-authored-by: ahmadshaheer <ashaheerki@gmail.com>

* chore: fix api endpoint

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* 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 <shivanshu1333@gmail.com>
Co-authored-by: Yunus M <myounis.ar@live.com>
Co-authored-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
This commit is contained in:
Shaheer Kochai 2025-05-12 09:16:26 +04:30 committed by GitHub
parent 1d379931b2
commit 6334e09a60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
89 changed files with 5665 additions and 155 deletions

View File

@ -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",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -0,0 +1 @@
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g stroke="#C0C1C3" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="m12.192 3.18-1.167 2.33-.583 1.165M7.31 12.74a.583.583 0 0 1-.835-.24L1.808 3.179"/><path d="M7 1.167c2.9 0 5.25.783 5.25 1.75 0 .966-2.35 1.75-5.25 1.75s-5.25-.784-5.25-1.75c0-.967 2.35-1.75 5.25-1.75ZM8.75 10.5h3.5M10.5 12.25v-3.5"/></g></svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)" stroke-linecap="round" stroke-linejoin="round"><path d="M8 14.666A6.667 6.667 0 1 0 8 1.333a6.667 6.667 0 0 0 0 13.333Z" fill="#C0C1C3" stroke="#C0C1C3" stroke-width="2"/><path d="M8 11.333v-4H6.333M8 4.667h.007" stroke="#121317" stroke-width="1.333"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@ -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'
),
);

View File

@ -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<SuccessResponse<CreateFunnelResponse> | 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<FunnelData[]> | 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<SuccessResponse<FunnelData> | 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<SuccessResponse<FunnelData> | 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<SuccessResponse<FunnelData> | 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<SuccessResponse<FunnelData> | 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<SuccessResponse<ValidateFunnelResponse> | 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<SuccessResponse<FunnelData> | 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<SuccessResponse<FunnelOverviewResponse> | 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<SuccessResponse<SlowTraceData> | 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<SuccessResponse<ErrorTraceData> | 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<SuccessResponse<FunnelStepsResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps`,
payload,
{ signal },
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};

View File

@ -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<string>('');
// 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 (
<Select
key={filterType.toString()}
placeholder={placeholder}
showSearch
mode="multiple"
options={options}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(isMultiple ? { mode: 'multiple' } : {})}
options={mergedOptions}
loading={isFetching}
className="config-select-option"
onSearch={handleSearch}
onSearch={handleSearchInput}
maxTagCount={4}
allowClear
maxTagPlaceholder={SelectMaxTagPlaceholder}
value={getValuesFromQueryParams(queryParam, urlQuery) || []}
value={selectValue}
notFoundContent={
isFetching ? (
<span>
@ -53,14 +115,18 @@ function FilterSelect({
<span>No {placeholder} found</span>
)
}
onChange={(value): void => {
handleSearch('');
setQueryParamsFromOptions(value, urlQuery, history, location, queryParam);
}}
onChange={handleSelectChange}
/>
);
}
FilterSelect.defaultProps = {
shouldSetQueryParams: true,
onChange: (): void => {},
values: [],
isMultiple: true,
};
function CeleryOverviewConfigOptions(): JSX.Element {
const selectConfigs: SelectOptionConfig[] = [
{

View File

@ -0,0 +1,40 @@
.change-percentage-pill {
display: flex;
align-items: center;
gap: 2px;
padding: 1px 4px;
border-radius: 50px;
&__icon {
display: flex;
align-items: center;
justify-content: center;
}
&__label {
font-family: 'Geist Mono';
font-size: 12px;
line-height: normal;
}
&--positive {
.change-percentage-pill {
&__icon {
color: var(--bg-forest-500);
}
&__label {
color: var(--bg-forest-500);
}
}
}
&--negative {
background: rgba(229, 72, 77, 0.1);
.change-percentage-pill {
&__icon {
color: var(--bg-cherry-500);
}
&__label {
color: var(--bg-cherry-500);
}
}
}
}

View File

@ -0,0 +1,38 @@
import './ChangePercentagePill.styles.scss';
import { Color } from '@signozhq/design-tokens';
import cx from 'classnames';
import { ArrowDown, ArrowUp } from 'lucide-react';
interface ChangePercentagePillProps {
percentage: number;
direction: number;
}
function ChangePercentagePill({
percentage,
direction,
}: ChangePercentagePillProps): JSX.Element | null {
if (direction === 0 || percentage === 0) {
return null;
}
const isPositive = direction > 0;
return (
<div
className={cx('change-percentage-pill', {
'change-percentage-pill--positive': isPositive,
'change-percentage-pill--negative': !isPositive,
})}
>
<div className="change-percentage-pill__icon">
{isPositive ? (
<ArrowUp size={12} color={Color.BG_FOREST_500} />
) : (
<ArrowDown size={12} color={Color.BG_CHERRY_500} />
)}
</div>
<div className="change-percentage-pill__label">{percentage}%</div>
</div>
);
}
export default ChangePercentagePill;

View File

@ -110,6 +110,7 @@ exports[`Quick Filters renders correctly with default props 1`] = `
class="left-action"
>
<svg
aria-hidden="true"
class="lucide lucide-chevron-down"
cursor="pointer"
fill="none"
@ -347,6 +348,7 @@ exports[`Quick Filters renders correctly with default props 1`] = `
class="left-action"
>
<svg
aria-hidden="true"
class="lucide lucide-chevron-right"
cursor="pointer"
fill="none"

View File

@ -0,0 +1,55 @@
.signoz-radio-group.ant-radio-group {
color: var(--text-vanilla-400);
.view-title {
display: flex;
gap: var(--margin-2);
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-style: normal;
font-weight: var(--font-weight-normal);
}
.tab {
border: 1px solid var(--bg-slate-400);
&:hover {
color: var(--text-vanilla-100);
}
&::before {
background: var(--bg-slate-400);
}
}
.selected_view {
&,
&:hover {
background: var(--bg-slate-300);
color: var(--text-vanilla-100);
border: 1px solid var(--bg-slate-400);
}
&::before {
background: var(--bg-slate-400);
}
}
}
// Light mode styles
.lightMode {
.signoz-radio-group {
.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);
}
}
}

View File

@ -0,0 +1,48 @@
import './SignozRadioGroup.styles.scss';
import { Radio } from 'antd';
import { RadioChangeEvent } from 'antd/es/radio';
interface Option {
value: string;
label: string;
}
interface SignozRadioGroupProps {
value: string;
options: Option[];
onChange: (e: RadioChangeEvent) => void;
className?: string;
}
function SignozRadioGroup({
value,
options,
onChange,
className = '',
}: SignozRadioGroupProps): JSX.Element {
return (
<Radio.Group
value={value}
buttonStyle="solid"
className={`signoz-radio-group ${className}`}
onChange={onChange}
>
{options.map((option) => (
<Radio.Button
key={option.value}
value={option.value}
className={value === option.value ? 'selected_view tab' : 'tab'}
>
{option.label}
</Radio.Button>
))}
</Radio.Group>
);
}
SignozRadioGroup.defaultProps = {
className: '',
};
export default SignozRadioGroup;

View File

@ -8,5 +8,6 @@ export enum FeatureKeys {
PREMIUM_SUPPORT = 'PREMIUM_SUPPORT',
ANOMALY_DETECTION = 'ANOMALY_DETECTION',
ONBOARDING_V3 = 'ONBOARDING_V3',
THIRD_PARTY_API = 'THIRD_PARTY_API',
TRACE_FUNNELS = 'TRACE_FUNNELS',
}

View File

@ -53,7 +53,7 @@ export const REACT_QUERY_KEY = {
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
GET_INSPECT_METRICS_DETAILS: 'GET_INSPECT_METRICS_DETAILS',
// API Monitoring Query Keys
// Traces Funnels Query Keys
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
GET_DOMAIN_METRICS_DATA: 'GET_DOMAIN_METRICS_DATA',
GET_ENDPOINTS_LIST_BY_DOMAIN: 'GET_ENDPOINTS_LIST_BY_DOMAIN',
@ -71,4 +71,11 @@ export const REACT_QUERY_KEY = {
'GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA',
GET_FUNNELS_LIST: 'GET_FUNNELS_LIST',
GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS',
UPDATE_FUNNEL_STEPS: 'UPDATE_FUNNEL_STEPS',
VALIDATE_FUNNEL_STEPS: 'VALIDATE_FUNNEL_STEPS',
UPDATE_FUNNEL_STEP_DETAILS: 'UPDATE_FUNNEL_STEP_DETAILS',
GET_FUNNEL_OVERVIEW: 'GET_FUNNEL_OVERVIEW',
GET_FUNNEL_SLOW_TRACES: 'GET_FUNNEL_SLOW_TRACES',
GET_FUNNEL_ERROR_TRACES: 'GET_FUNNEL_ERROR_TRACES',
GET_FUNNEL_STEPS_GRAPH_DATA: 'GET_FUNNEL_STEPS_GRAPH_DATA',
} as const;

View File

@ -42,7 +42,7 @@ import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueries } from 'react-query';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { matchPath, useLocation } from 'react-router-dom';
import { Dispatch } from 'redux';
import AppActions from 'types/actions';
import {
@ -362,6 +362,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
const isTracesFunnelDetails = (): boolean =>
!!matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL);
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
const isDashboardView = (): boolean =>
@ -672,7 +675,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
? 0
: '0 1rem',
...(isTraceDetailsView() || isTracesFunnels() ? { margin: 0 } : {}),
...(isTraceDetailsView() ||
isTracesFunnels() ||
isTracesFunnelDetails()
? { margin: 0 }
: {}),
}}
>
{isToDisplayLayout && !renderFullScreen && <TopNav />}

View File

@ -1,3 +1,19 @@
.query-builder-search {
.content {
.suggested-filters {
color: var(--bg-slate-50);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 12px 0px 8px 14px;
}
}
}
.query-builder-search-v2 {
display: flex;
gap: 4px;

View File

@ -91,6 +91,9 @@ interface QueryBuilderSearchV2Props {
className?: string;
suffixIcon?: React.ReactNode;
hardcodedAttributeKeys?: BaseAutocompleteData[];
hasPopupContainer?: boolean;
rootClassName?: string;
maxTagCount?: number | 'responsive';
operatorConfigKey?: OperatorConfigKeys;
hideSpanScopeSelector?: boolean;
// Determines whether to call onChange when a tag is closed
@ -128,6 +131,9 @@ function QueryBuilderSearchV2(
suffixIcon,
whereClauseConfig,
hardcodedAttributeKeys,
hasPopupContainer,
rootClassName,
maxTagCount,
operatorConfigKey,
hideSpanScopeSelector,
triggerOnChangeOnClose,
@ -948,7 +954,10 @@ function QueryBuilderSearchV2(
<div className="query-builder-search-v2">
<Select
ref={selectRef}
getPopupContainer={popupContainer}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(maxTagCount ? { maxTagCount } : {})}
key={queryTags.join('.')}
virtual={false}
showSearch
@ -980,7 +989,7 @@ function QueryBuilderSearchV2(
: '',
className,
)}
rootClassName="query-builder-search"
rootClassName={cx('query-builder-search', rootClassName)}
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
style={selectStyle}
onSearch={handleSearch}
@ -1038,7 +1047,10 @@ QueryBuilderSearchV2.defaultProps = {
className: '',
suffixIcon: null,
whereClauseConfig: {},
hasPopupContainer: true,
rootClassName: '',
hardcodedAttributeKeys: undefined,
maxTagCount: undefined,
operatorConfigKey: undefined,
hideSpanScopeSelector: true,
triggerOnChangeOnClose: false,

View File

@ -0,0 +1,240 @@
// Modal base styles
.add-span-to-funnel-modal-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-ink-500);
}
&-header {
border-bottom: none;
.ant-modal-title {
color: var(--bg-vanilla-100);
}
}
&-body {
padding: 14px 16px !important;
}
}
&--details {
.ant-modal-content {
height: 710px;
}
}
}
// Main modal styles
.add-span-to-funnel-modal {
// Common button styles
%button-base {
display: flex;
align-items: center;
font-family: Inter;
}
// Details view styles
&--details {
.traces-funnel-details {
height: unset;
&__steps-config {
width: unset;
border: none;
}
.funnel-step-wrapper {
gap: 15px;
}
.steps-content {
height: 500px;
}
}
}
// Search section
&__search {
display: flex;
gap: 12px;
margin-bottom: 14px;
align-items: center;
&-input {
flex: 1;
padding: 6px 8px;
background: var(--bg-ink-300);
.ant-input-prefix {
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
}
&,
input {
font-family: Inter;
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
font-weight: 400;
background: var(--bg-ink-300);
}
input::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.4;
}
}
}
// Create button
&__create-button {
@extend %button-base;
width: 153px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
flex-shrink: 0;
border-radius: 2px;
background: var(--bg-slate-500);
border: none;
box-shadow: none;
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 500;
line-height: 24px;
}
.funnel-item {
padding: 8px 8px 12px 16px;
&,
&:first-child {
border-radius: 6px;
}
&__header {
line-height: 20px;
}
&__details {
line-height: 18px;
}
}
// List section
&__list {
max-height: 400px;
overflow-y: scroll;
.funnels-empty {
&__content {
padding: 0;
}
}
.funnels-list {
gap: 8px;
.funnel-item {
padding: 8px 16px 12px;
&__details {
margin-top: 8px;
}
}
}
}
&__spinner {
height: 400px;
}
// Back button
&__back-button {
@extend %button-base;
gap: 6px;
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
margin-bottom: 14px;
}
// Details section
&__details {
display: flex;
flex-direction: column;
gap: 24px;
.funnel-configuration__steps {
padding: 0;
.funnel-step {
&__content .filters__service-and-span .ant-select {
width: 170px;
}
&__footer .error {
width: 25%;
}
}
.inter-step-config {
width: calc(100% - 104px);
}
}
.funnel-item__actions-popover {
display: none;
}
}
}
// Light mode styles
.lightMode {
.add-span-to-funnel-modal-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-vanilla-100);
}
&-title {
color: var(--bg-ink-500);
}
}
}
.add-span-to-funnel-modal {
&__search-input {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-500);
input {
color: var(--bg-ink-500);
background: var(--bg-vanilla-100);
&::placeholder {
color: var(--bg-ink-400);
}
}
}
&__create-button {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
&__back-button {
color: var(--bg-ink-500);
&:hover {
color: var(--bg-ink-400);
}
}
&__details h3 {
color: var(--bg-ink-500);
}
}
}

View File

@ -0,0 +1,199 @@
import './AddSpanToFunnelModal.styles.scss';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SignozModal from 'components/SignozModal/SignozModal';
import {
useFunnelDetails,
useFunnelsList,
} from 'hooks/TracesFunnels/useFunnels';
import { ArrowLeft, Plus, Search } from 'lucide-react';
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
import { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
import { ChangeEvent, useMemo, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
enum ModalView {
LIST = 'list',
DETAILS = 'details',
}
function FunnelDetailsView({
funnel,
span,
}: {
funnel: FunnelData;
span: Span;
}): JSX.Element {
return (
<div className="add-span-to-funnel-modal__details">
<FunnelListItem
funnel={funnel}
shouldRedirectToTracesListOnDeleteSuccess={false}
isSpanDetailsPage
/>
<FunnelConfiguration funnel={funnel} isTraceDetailsPage span={span} />
</div>
);
}
interface AddSpanToFunnelModalProps {
isOpen: boolean;
onClose: () => void;
span: Span;
}
function AddSpanToFunnelModal({
isOpen,
onClose,
span,
}: AddSpanToFunnelModalProps): JSX.Element {
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
undefined,
);
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): 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 => (
<div className="add-span-to-funnel-modal">
{!!filteredData?.length && (
<div className="add-span-to-funnel-modal__search">
<Input
className="add-span-to-funnel-modal__search-input"
placeholder="Search by name, description, or tags..."
prefix={<Search size={12} />}
value={searchQuery}
onChange={handleSearch}
/>
</div>
)}
<div className="add-span-to-funnel-modal__list">
<OverlayScrollbar>
<TracesFunnelsContentRenderer
isError={isError}
isLoading={isLoading || isFetching}
data={filteredData || []}
onCreateFunnel={handleCreateNewClick}
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
shouldRedirectToTracesListOnDeleteSuccess={false}
/>
</OverlayScrollbar>
</div>
<CreateFunnel
isOpen={isCreateModalOpen}
onClose={(funnelId): void => {
if (funnelId) {
setSelectedFunnelId(funnelId);
setActiveView(ModalView.DETAILS);
}
setIsCreateModalOpen(false);
}}
redirectToDetails={false}
/>
</div>
);
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
<Button
type="text"
className="add-span-to-funnel-modal__back-button"
onClick={handleBack}
>
<ArrowLeft size={14} />
All funnels
</Button>
<Spin
style={{ height: 400 }}
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<LoadingOutlined spin />}
>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider funnelId={selectedFunnelId}>
<FunnelDetailsView funnel={funnelDetails.payload} span={span} />
</FunnelProvider>
)}
</div>
</div>
</Spin>
</div>
);
return (
<SignozModal
open={isOpen}
onCancel={onClose}
width={570}
title="Add span to funnel"
className={cx('add-span-to-funnel-modal-container', {
'add-span-to-funnel-modal-container--details':
activeView === ModalView.DETAILS,
})}
okText="Save Funnel"
footer={
activeView === ModalView.LIST && !!filteredData?.length ? (
<Button
type="default"
className="add-span-to-funnel-modal__create-button"
onClick={handleCreateNewClick}
icon={<Plus size={14} />}
>
Create new funnel
</Button>
) : null
}
>
{activeView === ModalView.LIST
? renderListView()
: renderDetailsView({ span })}
</SignozModal>
);
}
export default AddSpanToFunnelModal;

View File

@ -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;
}
}
}
}
}

View File

@ -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<SetStateAction<Span | undefined>>;
handleAddSpanToFunnel: (span: Span) => void;
}): JSX.Element {
const isRootSpan = span.level === 0;
@ -145,6 +149,30 @@ function SpanOverview({
<Typography.Text className="service-name">
{span.serviceName}
</Typography.Text>
{!!span.serviceName &&
!!span.name &&
process.env.NODE_ENV === 'development' && (
<div className="add-funnel-button">
<span className="add-funnel-button__separator">·</span>
<Button
type="text"
size="small"
className="add-funnel-button__button"
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
handleAddSpanToFunnel(span);
}}
icon={
<img
className="add-funnel-button__icon"
src="/Icons/funnel-add.svg"
alt="funnel-icon"
/>
}
/>
</div>
)}
</section>
</div>
</div>
@ -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<SetStateAction<Span | undefined>>;
handleAddSpanToFunnel: (span: Span) => void;
}): ColumnDef<Span, any>[] {
const waterfallColumns: ColumnDef<Span, any>[] = [
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' && (
<AddSpanToFunnelModal
span={selectedSpanToAddToFunnel}
isOpen={isAddSpanToFunnelModalOpen}
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
/>
)}
</div>
);
}

View File

@ -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();

View File

@ -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<FunnelStepData[]>(
initialSteps,
);
// Mutation hooks
const updateStepsMutation = useUpdateFunnelSteps(
funnel.funnel_id,
notifications,
);
// Derived state
const lastSavedStepsStateRef = useRef<FunnelStepData[]>(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,
};
}

View File

@ -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<ChartConfiguration> = {
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<HTMLCanvasElement>;
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<HTMLCanvasElement>(null);
const chartRef = useRef<Chart | null>(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 (
<div key={step} className="funnel-graph__legend-column">
<div
className="legend-item"
onMouseEnter={legendHoverHandlers?.onTotalHover}
onMouseLeave={legendHoverHandlers?.onLegendLeave}
>
<div className="legend-item__left">
<span className="legend-item__dot legend-item--total" />
<span className="legend-item__label">Total spans</span>
</div>
<div className="legend-item__right">
<span className="legend-item__value">{totalSpans}</span>
{step > 1 && (
<ChangePercentagePill
direction={totalSpans < prevTotalSpans ? -1 : 1}
percentage={getPercentageChange(totalSpans, prevTotalSpans)}
/>
)}
</div>
</div>
<div
className="legend-item"
onMouseEnter={legendHoverHandlers?.onErrorHover}
onMouseLeave={legendHoverHandlers?.onLegendLeave}
>
<div className="legend-item__left">
<span className="legend-item__dot legend-item--error" />
<span className="legend-item__label">Error spans</span>
</div>
<div className="legend-item__right">
<span className="legend-item__value">{errorSpans}</span>
</div>
</div>
</div>
);
},
[getPercentageChange],
);
const { successSteps, errorSteps, totalSteps } = getStepGraphData();
return {
successSteps,
errorSteps,
totalSteps,
canvasRef,
renderLegendItem,
};
}
export default useFunnelGraph;

View File

@ -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,
};
}

View File

@ -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<SuccessResponse<FunnelData[]> | ErrorResponse, unknown> =>
export const useFunnelsList = (): UseQueryResult<
SuccessResponse<FunnelData[]> | 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<SuccessResponse<FunnelData> | 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<FunnelData> | ErrorResponse,
Error,
@ -75,3 +89,134 @@ export const useDeleteFunnel = (): UseMutationResult<
useMutation({
mutationFn: deleteFunnel,
});
export const useUpdateFunnelSteps = (
funnelId: string,
notification: NotificationInstance,
): UseMutationResult<
SuccessResponse<FunnelData> | 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<ValidateFunnelResponse> | 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<FunnelData> | ErrorResponse,
Error,
SaveFunnelDescriptionPayload
> =>
useMutation({
mutationFn: saveFunnelDescription,
});
export const useFunnelOverview = (
funnelId: string,
payload: FunnelOverviewPayload,
): UseQueryResult<
SuccessResponse<FunnelOverviewResponse> | 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<SuccessResponse<SlowTraceData> | ErrorResponse, Error> => {
const { selectedTime, validTracesCount } = useFunnelContext();
return useQuery<SuccessResponse<SlowTraceData> | 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<SuccessResponse<ErrorTraceData> | 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<SuccessResponse<FunnelStepsResponse> | 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,
});
}

View File

@ -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<HTMLInputElement>) => void;
} => {
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const [searchQuery, setSearchQuery] = useState<string>(
urlQuery.get('search') || '',
);
const [searchQuery, setSearchQuery] = useState<string>('');
const handleSearch = (e: ChangeEvent<HTMLInputElement>): 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 {

View File

@ -21,7 +21,7 @@ const useHandleTraceFunnelsSort = ({
const urlQuery = useUrlQuery();
const [sortOrder, setSortOrder] = useState<SortOrder>({
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;

View File

@ -25,6 +25,20 @@ jest.mock('container/TraceFlameGraph/index.tsx', () => ({
default: (): JSX.Element => <div>TraceFlameGraph</div>,
}));
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(

View File

@ -1,4 +1,7 @@
.traces-module-container {
.funnel-icon {
transform: rotate(180deg);
}
.trace-module {
.ant-tabs-tab {
.tab-item {

View File

@ -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={
<Button
@ -51,6 +54,7 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
export default function TraceDetailsPage(): JSX.Element {
const [showOldTraceDetails, setShowOldTraceDetails] = useState<boolean>(false);
const items = [
{
label: (
@ -61,6 +65,19 @@ export default function TraceDetailsPage(): JSX.Element {
key: 'trace-details',
children: <TraceDetailsV2 />,
},
...(process.env.NODE_ENV === 'development'
? [
{
label: (
<div className="tab-item">
<Cone className="funnel-icon" size={16} /> Funnels
</div>
),
key: 'funnels',
children: <div />,
},
]
: []),
{
label: (
<div className="tab-item">

View File

@ -0,0 +1,28 @@
.traces-funnel-details {
display: flex;
// 45px -> height of the tab bar
height: calc(100vh - 45px);
&__steps-config {
flex-shrink: 0;
width: 600px;
border-right: 1px solid var(--bg-slate-400);
position: relative;
}
&__steps-results {
width: 100%;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}
.lightMode {
.traces-funnel-details {
&__steps-config {
border-color: var(--bg-vanilla-300);
}
}
}

View File

@ -1,13 +1,42 @@
import './TracesFunnelDetails.styles.scss';
import { Typography } from 'antd';
import Spinner from 'components/Spinner';
import { NotFoundContainer } from 'container/GridCardLayout/GridCard/FullView/styles';
import { useFunnelDetails } from 'hooks/TracesFunnels/useFunnels';
import { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
import { useParams } from 'react-router-dom';
import FunnelConfiguration from './components/FunnelConfiguration/FunnelConfiguration';
import FunnelResults from './components/FunnelResults/FunnelResults';
function TracesFunnelDetails(): JSX.Element {
const { funnelId } = useParams<{ funnelId: string }>();
const { data } = useFunnelDetails({ funnelId });
const { data, isLoading, isError } = useFunnelDetails({ funnelId });
if (isLoading || !data?.payload) {
return <Spinner size="large" tip="Loading..." />;
}
if (isError) {
return (
<NotFoundContainer>
<Typography>Error loading funnel details</Typography>
</NotFoundContainer>
);
}
return (
<div style={{ color: 'var(--bg-vanilla-400)' }}>
TracesFunnelDetails, {JSON.stringify(data)}
</div>
<FunnelProvider funnelId={funnelId}>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
<FunnelConfiguration funnel={data.payload} />
</div>
<div className="traces-funnel-details__steps-results">
<FunnelResults />
</div>
</div>
</FunnelProvider>
);
}

View File

@ -0,0 +1,135 @@
.funnel-step-modal {
.ant-modal-content {
background: var(--bg-ink-400);
.ant-modal-header {
background: var(--bg-ink-400);
.ant-modal-title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
}
.ant-modal-body {
padding-bottom: 20px;
}
}
&__ok-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-robin-500);
border: none;
&[disabled] {
background: var(--bg-slate-400);
opacity: 1;
}
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
&__cancel-btn {
display: flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
}
.funnel-step-modal-content {
display: flex;
flex-direction: column;
gap: 28px;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
&__input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
}
}
}
}
// Light mode styles
.lightMode {
.funnel-step-modal {
.ant-modal-content {
.ant-modal-header {
background: var(--bg-vanilla-100);
.ant-modal-title {
color: var(--bg-ink-400);
}
}
}
&__cancel-btn {
color: var(--bg-ink-400);
}
}
.funnel-step-modal-content {
&__label {
color: var(--bg-ink-400);
}
&__input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
}
}
}
}
}

View File

@ -0,0 +1,109 @@
import './AddFunnelDescriptionModal.styles.scss';
import { Input } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useSaveFunnelDescription } from 'hooks/TracesFunnels/useFunnels';
import { useNotifications } from 'hooks/useNotifications';
import { Check, X } from 'lucide-react';
import { useState } from 'react';
import { useQueryClient } from 'react-query';
interface AddFunnelDescriptionProps {
isOpen: boolean;
onClose: () => void;
funnelId: string;
funnelDescription: string;
}
function AddFunnelDescriptionModal({
isOpen,
onClose,
funnelId,
funnelDescription,
}: AddFunnelDescriptionProps): JSX.Element {
const [description, setDescription] = useState<string>(funnelDescription);
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const {
mutate: saveFunnelDescription,
isLoading,
} = useSaveFunnelDescription();
const handleCancel = (): void => {
setDescription('');
onClose();
};
const handleSave = (): void => {
saveFunnelDescription(
{
funnel_id: funnelId,
description,
},
{
onSuccess: () => {
queryClient.invalidateQueries([
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
funnelId,
]);
notifications.success({
message: 'Success',
description: 'Funnel description saved successfully',
});
handleCancel();
},
onError: (error) => {
notifications.error({
message: 'Failed to save funnel description',
description: error.message,
});
},
},
);
};
return (
<SignozModal
open={isOpen}
title="Add funnel description"
width={384}
onCancel={handleCancel}
rootClassName="funnel-step-modal funnel-modal signoz-modal"
cancelText="Cancel"
okText="Save changes"
okButtonProps={{
icon: <Check size={14} />,
type: 'primary',
className: 'funnel-step-modal__ok-btn',
onClick: handleSave,
loading: isLoading,
}}
cancelButtonProps={{
icon: <X size={14} />,
type: 'text',
className: 'funnel-step-modal__cancel-btn',
onClick: handleCancel,
disabled: isLoading,
}}
destroyOnClose
>
<div className="funnel-step-modal-content">
<div className="funnel-step-modal-content__field">
<span className="funnel-step-modal-content__label">Description</span>
<Input.TextArea
className="funnel-step-modal-content__input"
placeholder="(Optional) Eg. checkout dropoff funnel"
value={description}
onChange={(e): void => setDescription(e.target.value)}
autoSize={{ minRows: 3, maxRows: 5 }}
disabled={isLoading}
/>
</div>
</div>
</SignozModal>
);
}
export default AddFunnelDescriptionModal;

View File

@ -0,0 +1,138 @@
.funnel-step-modal {
.ant-modal-content {
background: var(--bg-ink-400);
.ant-modal-header {
background: var(--bg-ink-400);
.ant-modal-title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
}
.ant-modal-body {
padding-bottom: 20px;
}
}
&__ok-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-robin-500);
border: none;
&[disabled] {
background: var(--bg-slate-400);
opacity: 1;
}
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
&__cancel-btn {
display: flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
}
.funnel-step-modal-content {
display: flex;
flex-direction: column;
gap: 28px;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
&__input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
}
}
}
}
// Light mode styles
.lightMode {
.funnel-step-modal {
.ant-modal-content {
.ant-modal-header {
.ant-modal-title {
color: var(--bg-ink-400);
}
}
}
&__ok-btn {
background: var(--bg-robin-500) !important;
}
&__cancel-btn {
color: var(--bg-ink-400);
}
}
.funnel-step-modal-content {
&__label {
color: var(--bg-ink-400);
}
&__input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
}
}
}
}
}

View File

@ -0,0 +1,140 @@
import './AddFunnelStepDetailsModal.styles.scss';
import { Input } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { useUpdateFunnelSteps } from 'hooks/TracesFunnels/useFunnels';
import { useNotifications } from 'hooks/useNotifications';
import { Check, X } from 'lucide-react';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useEffect, useState } from 'react';
import { FunnelStepPopoverProps } from './FunnelStepPopover';
interface AddFunnelStepDetailsModalProps {
isOpen: boolean;
onClose: () => void;
stepData: FunnelStepPopoverProps['stepData'];
}
function AddFunnelStepDetailsModal({
isOpen,
onClose,
stepData,
}: AddFunnelStepDetailsModalProps): JSX.Element {
const { funnelId, steps, setSteps } = useFunnelContext();
const [stepName, setStepName] = useState<string>(stepData?.name || '');
const [description, setDescription] = useState<string>(
stepData?.description || '',
);
const { notifications } = useNotifications();
const { mutate: updateFunnelStepDetails, isLoading } = useUpdateFunnelSteps(
funnelId,
notifications,
);
useEffect(() => {
if (isOpen) {
setStepName(stepData?.name || '');
setDescription(stepData?.description || '');
}
}, [isOpen, stepData]);
const handleCancel = (): void => {
setStepName('');
setDescription('');
onClose();
};
const handleSave = (): void => {
updateFunnelStepDetails(
{
funnel_id: funnelId,
steps: steps.map((step) => ({
...step,
...(step.step_order === stepData.step_order
? {
name: stepName || '',
description: description || '',
}
: {}),
})),
timestamp: Date.now(),
},
{
onSuccess: (data) => {
if (data.payload?.steps) {
setSteps(data.payload.steps);
}
notifications.success({
message: 'Success',
description: 'Funnel step details updated successfully',
});
handleCancel();
},
onError: (error) => {
notifications.error({
message: 'Failed to update funnel step details',
description: error.message,
});
},
},
);
};
return (
<SignozModal
open={isOpen}
title="Add funnel step details"
width={384}
onCancel={handleCancel}
rootClassName="funnel-step-modal funnel-modal signoz-modal"
cancelText="Cancel"
okText="Save changes"
okButtonProps={{
icon: <Check size={14} />,
type: 'primary',
className: 'funnel-step-modal__ok-btn',
onClick: handleSave,
disabled: !stepName.trim(),
loading: isLoading,
}}
cancelButtonProps={{
icon: <X size={14} />,
type: 'text',
className: 'funnel-step-modal__cancel-btn',
onClick: handleCancel,
disabled: isLoading,
}}
destroyOnClose
>
<div className="funnel-step-modal-content">
<div className="funnel-step-modal-content__field">
<span className="funnel-step-modal-content__label">Step name</span>
<Input
className="funnel-step-modal-content__input"
placeholder="Eg. checkout-dropoff-funnel-step1"
value={stepName}
onChange={(e): void => setStepName(e.target.value)}
autoFocus
disabled={isLoading}
/>
</div>
<div className="funnel-step-modal-content__field">
<span className="funnel-step-modal-content__label">Description</span>
<Input.TextArea
className="funnel-step-modal-content__input"
placeholder="Eg. checkout dropoff funnel"
value={description}
onChange={(e): void => setDescription(e.target.value)}
autoSize={{ minRows: 3, maxRows: 5 }}
disabled={isLoading}
/>
</div>
</div>
</SignozModal>
);
}
export default AddFunnelStepDetailsModal;

View File

@ -0,0 +1,134 @@
.funnel-step-modal {
.ant-modal-content {
background: var(--bg-ink-400);
.ant-modal-header {
background: var(--bg-ink-400);
.ant-modal-title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
line-height: 20px;
}
}
.ant-modal-body {
padding-bottom: 20px;
}
}
&__ok-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-robin-500);
border: none;
&[disabled] {
background: var(--bg-slate-400);
opacity: 1;
}
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
&__cancel-btn {
display: flex;
align-items: center;
gap: 6px;
color: var(--bg-vanilla-400);
.ant-btn-icon {
margin-inline-end: 0 !important;
}
}
}
.funnel-step-modal-content {
display: flex;
flex-direction: column;
gap: 28px;
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
&__input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
color: var(--bg-vanilla-400);
&::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.6;
}
}
}
}
}
// Light mode styles
.lightMode {
.funnel-step-modal {
.ant-modal-content {
.ant-modal-header {
.ant-modal-title {
color: var(--bg-ink-400);
}
}
}
&__cancel-btn {
color: var(--bg-ink-400);
}
}
.funnel-step-modal-content {
&__label {
color: var(--bg-ink-400);
}
&__input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
&.ant-input-textarea {
.ant-input {
background: var(--bg-vanilla-400);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-100);
}
}
}
}
}
}

View File

@ -0,0 +1,53 @@
import './DeleteFunnelStep.styles.scss';
import SignozModal from 'components/SignozModal/SignozModal';
import { Trash2, X } from 'lucide-react';
interface DeleteFunnelStepProps {
isOpen: boolean;
onClose: () => void;
onStepRemove: () => void;
}
function DeleteFunnelStep({
isOpen,
onClose,
onStepRemove,
}: DeleteFunnelStepProps): JSX.Element {
const handleStepRemoval = (): void => {
onStepRemove();
onClose();
};
return (
<SignozModal
open={isOpen}
title="Delete this step"
width={390}
onCancel={onClose}
rootClassName="funnel-modal delete-funnel-modal"
cancelText="Cancel"
okText="Delete Funnel"
okButtonProps={{
icon: <Trash2 size={14} />,
type: 'primary',
className: 'funnel-modal__ok-btn',
onClick: handleStepRemoval,
}}
cancelButtonProps={{
icon: <X size={14} />,
type: 'text',
className: 'funnel-modal__cancel-btn',
onClick: onClose,
}}
destroyOnClose
>
<div className="delete-funnel-modal-content">
Deleting this step would stop further analytics using this step of the
funnel.
</div>
</SignozModal>
);
}
export default DeleteFunnelStep;

View File

@ -0,0 +1,38 @@
.funnel-breadcrumb {
height: 20px;
&__link {
display: flex;
align-items: center;
}
li:first-of-type {
.funnel-breadcrumb__title {
color: var(--bg-vanilla-400);
}
}
.ant-breadcrumb-separator {
color: var(--bg-vanilla-100);
}
& > ol {
gap: 6px;
}
&__title {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
}
.lightMode {
.funnel-breadcrumb__title,
.ant-breadcrumb-separator {
color: var(--bg-ink-400);
}
li:first-of-type {
.funnel-breadcrumb__title {
color: var(--bg-ink-400);
}
}
}

View File

@ -0,0 +1,34 @@
import './FunnelBreadcrumb.styles.scss';
import { Breadcrumb } from 'antd';
import ROUTES from 'constants/routes';
import { Link } from 'react-router-dom';
interface FunnelBreadcrumbProps {
funnelName: string;
}
function FunnelBreadcrumb({ funnelName }: FunnelBreadcrumbProps): JSX.Element {
const breadcrumbItems = [
{
title: (
<Link to={ROUTES.TRACES_FUNNELS}>
<span className="funnel-breadcrumb__link">
<span className="funnel-breadcrumb__title">All funnels</span>
</span>
</Link>
),
},
{
title: <div className="funnel-breadcrumb__title">{funnelName}</div>,
},
];
return (
<div>
<Breadcrumb className="funnel-breadcrumb" items={breadcrumbItems} />
</div>
);
}
export default FunnelBreadcrumb;

View File

@ -0,0 +1,67 @@
.funnel-configuration {
&__steps-wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--bg-slate-400);
&-right {
&,
& > div {
display: flex;
align-items: center;
}
gap: 12px;
.ant-divider-vertical {
margin: 0;
}
.funnel-configuration__rename-btn {
padding: 4px;
width: 24px;
height: 24px;
justify-content: center;
}
.copy-to-clipboard {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
width: 90px;
}
}
}
&__description {
padding: 16px 16px 0 16px;
color: var(--bg-vanilla-400);
font-size: 12px;
line-height: 18px; /* 150% */
letter-spacing: -0.06px;
}
.funnel-item__action-icon {
opacity: 1;
}
&__steps {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
}
.lightMode {
.funnel-configuration {
&__header {
border-color: var(--bg-vanilla-300);
}
}
}

View File

@ -0,0 +1,102 @@
import './FunnelConfiguration.styles.scss';
import { Button, Divider, Tooltip } from 'antd';
import useFunnelConfiguration from 'hooks/TracesFunnels/useFunnelConfiguration';
import { PencilLine } from 'lucide-react';
import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/FunnelItemPopover';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { memo, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
import AddFunnelDescriptionModal from './AddFunnelDescriptionModal';
import FunnelBreadcrumb from './FunnelBreadcrumb';
import StepsContent from './StepsContent';
import StepsFooter from './StepsFooter';
import StepsHeader from './StepsHeader';
interface FunnelConfigurationProps {
funnel: FunnelData;
isTraceDetailsPage?: boolean;
span?: Span;
}
function FunnelConfiguration({
funnel,
isTraceDetailsPage,
span,
}: FunnelConfigurationProps): JSX.Element {
const { isPopoverOpen, setIsPopoverOpen, steps } = useFunnelConfiguration({
funnel,
});
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState<boolean>(
false,
);
const handleDescriptionModalClose = (): void => {
setIsDescriptionModalOpen(false);
};
return (
<div className="funnel-configuration">
{!isTraceDetailsPage && (
<>
<div className="funnel-configuration__header">
<div className="funnel-configuration__header-left">
<FunnelBreadcrumb funnelName={funnel.funnel_name} />
</div>
<div className="funnel-configuration__header-right">
<Tooltip
title={
funnel?.description
? 'Edit funnel description'
: 'Add funnel description'
}
>
<Button
type="text"
className="funnel-item__action-btn funnel-configuration__rename-btn"
icon={<PencilLine size={14} />}
onClick={(): void => setIsDescriptionModalOpen(true)}
aria-label="Edit Funnel Description"
/>
</Tooltip>
<CopyToClipboard textToCopy={window.location.href} />
<Divider type="vertical" />
<FunnelItemPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
funnel={funnel}
/>
</div>
</div>
<div className="funnel-configuration__description">
{funnel?.description}
</div>
</>
)}
<div className="funnel-configuration__steps-wrapper">
<div className="funnel-configuration__steps">
{!isTraceDetailsPage && <StepsHeader />}
<StepsContent isTraceDetailsPage={isTraceDetailsPage} span={span} />
</div>
{!isTraceDetailsPage && <StepsFooter stepsCount={steps.length} />}
</div>
{!isTraceDetailsPage && (
<AddFunnelDescriptionModal
isOpen={isDescriptionModalOpen}
onClose={handleDescriptionModalClose}
funnelId={funnel.funnel_id}
funnelDescription={funnel?.description || ''}
/>
)}
</div>
);
}
FunnelConfiguration.defaultProps = {
isTraceDetailsPage: false,
span: undefined,
};
export default memo(FunnelConfiguration);

View File

@ -0,0 +1,225 @@
.traces-funnel-where-filter {
.keyboard-shortcuts {
display: none !important;
}
}
.funnel-step {
background: var(--bg-ink-400);
color: var(--bg-vanilla-400);
border: 1px solid var(--bg-slate-500);
border-radius: 6px;
.step-popover {
opacity: 0;
width: 22px;
height: 22px;
padding: 4px;
background: var(--bg-ink-100);
border-radius: 2px;
position: absolute;
right: -11px;
top: -11px;
}
&:hover .step-popover {
opacity: 1;
}
&__header {
display: flex;
justify-content: space-between;
align-items: start;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-500);
.funnel-step-details {
display: flex;
flex-direction: column;
gap: 4px;
&__title {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
&__description {
color: var(--bg-vanilla-400);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
}
}
.funnel-step-actions {
&,
& > div {
display: flex;
align-items: center;
}
.ant-divider-vertical {
margin: 0 12px;
}
.funnel-item__action-btn {
border: none;
padding: 4px;
width: 24px;
height: 24px;
justify-content: center;
}
}
}
&__content {
display: flex;
align-items: baseline;
gap: 6px;
padding: 16px;
padding-left: 6px;
.ant-form-item {
margin: 0;
width: 100%;
}
.drag-icon {
cursor: grab;
}
.filters {
display: flex;
flex-direction: column;
gap: 10px;
.ant-select-selector {
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-500);
.ant-select-selection-placeholder {
font-size: 12px;
line-height: 16px;
}
}
&__service-and-span {
display: flex;
align-items: center;
gap: 12px;
.ant-select-selection-placeholder {
color: var(--bg-vanilla-400);
}
.ant-select {
width: 239px;
}
}
&__where-filter {
display: flex;
align-items: center;
gap: 8px;
.label {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.query-builder-search-v2 {
width: 100%;
}
}
}
.ant-steps.ant-steps-vertical > .ant-steps-item .ant-steps-item-description {
padding-bottom: 16px;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--bg-slate-500);
.error {
display: flex;
align-items: center;
padding: 10.5px 12px 10.5px 16px;
gap: 20px;
border-right: 1px solid var(--bg-slate-500);
width: 50%;
}
.error__label,
.latency-pointer__label {
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.latency-pointer {
padding: 10.5px 16px 10.5px 12px;
width: 55%;
display: flex;
align-items: center;
justify-content: space-between;
.ant-space {
display: flex;
align-items: center;
cursor: pointer;
&-item {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
&:last-child {
height: 14px;
}
}
}
}
}
}
.lightMode {
.funnel-step {
background: var(--bg-vanilla-100);
color: var(--bg-ink-400);
border-color: var(--bg-vanilla-300);
.step-popover {
background: var(--bg-vanilla-100);
}
&__header {
border-color: var(--bg-vanilla-300);
.funnel-step-details {
&__title {
color: var(--bg-ink-400);
}
&__description {
color: var(--bg-ink-400);
}
}
}
&__content {
.filters {
.ant-select-selector {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
&__service-and-span {
.ant-select-selection-placeholder {
color: var(--bg-ink-400);
}
}
&__where-filter {
.label {
color: var(--bg-ink-400);
}
}
}
}
&__footer {
&,
.error {
border-color: var(--bg-vanilla-300);
}
.error__label,
.latency-pointer__label {
color: var(--bg-ink-400);
}
.latency-pointer {
.ant-space-item {
color: var(--bg-ink-400);
}
}
}
}
}

View File

@ -0,0 +1,207 @@
import './FunnelStep.styles.scss';
import { Button, Divider, Dropdown, Form, Space, Switch, Tooltip } from 'antd';
import { MenuProps } from 'antd/lib';
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
import { QueryParams } from 'constants/query';
import { initialQueriesMap } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { ChevronDown, GripVertical, HardHat, PencilLine } from 'lucide-react';
import { LatencyPointers } from 'pages/TracesFunnelDetails/constants';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useMemo, useState } from 'react';
import { FunnelStepData } from 'types/api/traceFunnels';
import { DataSource } from 'types/common/queryBuilder';
import FunnelStepPopover from './FunnelStepPopover';
interface FunnelStepProps {
stepData: FunnelStepData;
index: number;
stepsCount: number;
}
function FunnelStep({
stepData,
index,
stepsCount,
}: FunnelStepProps): JSX.Element {
const {
handleStepChange: onStepChange,
handleStepRemoval: onStepRemove,
} = useFunnelContext();
const [form] = Form.useForm();
const currentQuery = initialQueriesMap[DataSource.TRACES];
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [isAddDetailsModalOpen, setIsAddDetailsModalOpen] = useState<boolean>(
false,
);
const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
(option) => ({
key: option.value,
label: option.key,
style:
option.value === stepData.latency_pointer
? { backgroundColor: 'var(--bg-slate-100)' }
: {},
}),
);
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters: stepData.filters ?? {
op: 'AND',
items: [],
},
},
],
},
}),
[stepData.filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
return (
<div className="funnel-step">
<Form form={form}>
<div className="funnel-step__header">
<div className="funnel-step-details">
{stepData.name ? (
<div className="funnel-step-details__title">{stepData.name}</div>
) : (
<div className="funnel-step-details__title">Step {index + 1}</div>
)}
{!!stepData.description && (
<div className="funnel-step-details__description">
{stepData.description}
</div>
)}
</div>
<div className="funnel-step-actions">
<Tooltip title="Add details to step">
<Button
type="text"
className="funnel-item__action-btn"
icon={<PencilLine size={14} />}
onClick={(): void => setIsAddDetailsModalOpen(true)}
/>
</Tooltip>
<Divider type="vertical" />
<FunnelStepPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
onStepRemove={(): void => onStepRemove(index)}
stepsCount={stepsCount}
isAddDetailsModalOpen={isAddDetailsModalOpen}
setIsAddDetailsModalOpen={setIsAddDetailsModalOpen}
stepData={{
step_order: stepData.step_order,
name: stepData.name,
description: stepData.description,
}}
/>
</div>
</div>
<div className="funnel-step__content">
<div className="drag-icon">
<GripVertical size={14} color="var(--bg-slate-200)" />
</div>
<div className="filters">
<div className="filters__service-and-span">
<div className="service">
<Form.Item name={['steps', stepData.id, 'service_name']}>
<FilterSelect
placeholder="Select Service"
queryParam={QueryParams.service}
filterType="serviceName"
shouldSetQueryParams={false}
values={stepData.service_name}
isMultiple={false}
onChange={(v): void => {
onStepChange(index, { service_name: (v ?? '') as string });
}}
/>
</Form.Item>
</div>
<div className="span">
<Form.Item name={['steps', stepData.id, 'span_name']}>
<FilterSelect
placeholder="Select Span name"
queryParam={QueryParams.spanName}
filterType="name"
shouldSetQueryParams={false}
values={stepData.span_name}
isMultiple={false}
onChange={(v): void =>
onStepChange(index, { span_name: (v ?? '') as string })
}
/>
</Form.Item>
</div>
</div>
<div className="filters__where-filter">
<div className="label">Where</div>
<Form.Item name={['steps', stepData.id, 'filters']}>
<QueryBuilderSearchV2
query={query}
onChange={(query): void => onStepChange(index, { filters: query })}
hasPopupContainer={false}
placeholder="Search for filters..."
suffixIcon={<HardHat size={12} color="var(--bg-vanilla-400)" />}
rootClassName="traces-funnel-where-filter"
/>
</Form.Item>
</div>
</div>
</div>
<div className="funnel-step__footer">
<div className="error">
<Switch
className="error__switch"
size="small"
checked={stepData.has_errors}
onChange={(): void =>
onStepChange(index, { has_errors: !stepData.has_errors })
}
/>
<div className="error__label">Errors</div>
</div>
<div className="latency-pointer">
<div className="latency-pointer__label">Latency pointer</div>
<Dropdown
menu={{
items: latencyPointerItems,
onClick: ({ key }): void =>
onStepChange(index, {
latency_pointer: key as FunnelStepData['latency_pointer'],
}),
}}
trigger={['click']}
>
<Space>
{
LatencyPointers.find(
(option) => option.value === stepData.latency_pointer,
)?.key
}
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
</Space>
</Dropdown>
</div>
</div>
</Form>
</div>
);
}
export default FunnelStep;

View File

@ -0,0 +1,136 @@
import { Button, Popover, Tooltip } from 'antd';
import cx from 'classnames';
import { Ellipsis, PencilLine, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { FunnelStepData } from 'types/api/traceFunnels';
import AddFunnelStepDetailsModal from './AddFunnelStepDetailsModal';
import DeleteFunnelStep from './DeleteFunnelStep';
export interface FunnelStepPopoverProps {
isPopoverOpen: boolean;
setIsPopoverOpen: (isOpen: boolean) => void;
className?: string;
stepData: {
step_order: FunnelStepData['step_order'];
name?: FunnelStepData['name'];
description?: FunnelStepData['description'];
};
stepsCount: number;
onStepRemove: () => void;
isAddDetailsModalOpen: boolean;
setIsAddDetailsModalOpen: (isOpen: boolean) => void;
}
interface FunnelStepActionsProps {
setIsPopoverOpen: (isOpen: boolean) => void;
setIsAddDetailsModalOpen: (isOpen: boolean) => void;
setIsDeleteModalOpen: (isOpen: boolean) => void;
stepsCount: number;
}
function FunnelStepActions({
setIsPopoverOpen,
setIsAddDetailsModalOpen,
setIsDeleteModalOpen,
stepsCount,
}: FunnelStepActionsProps): JSX.Element {
return (
<div className="funnel-item__actions">
<Button
type="text"
className="funnel-item__action-btn"
icon={<PencilLine size={14} />}
onClick={(): void => {
setIsPopoverOpen(false);
setIsAddDetailsModalOpen(true);
}}
>
Add details
</Button>
<Tooltip title={stepsCount <= 2 ? 'Minimum 2 steps required' : 'Delete'}>
<Button
type="text"
className="funnel-item__action-btn funnel-item__action-btn--delete"
icon={<Trash2 size={14} />}
disabled={stepsCount <= 2}
onClick={(): void => {
if (stepsCount > 2) {
setIsPopoverOpen(false);
setIsDeleteModalOpen(true);
}
}}
>
Delete
</Button>
</Tooltip>
</div>
);
}
function FunnelStepPopover({
isPopoverOpen,
setIsPopoverOpen,
stepData,
className,
onStepRemove,
stepsCount,
isAddDetailsModalOpen,
setIsAddDetailsModalOpen,
}: FunnelStepPopoverProps): JSX.Element {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(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
<div onClick={preventDefault} role="button" tabIndex={0}>
<Popover
trigger="click"
rootClassName="funnel-item__actions"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
content={
<FunnelStepActions
setIsDeleteModalOpen={setIsDeleteModalOpen}
setIsPopoverOpen={setIsPopoverOpen}
setIsAddDetailsModalOpen={setIsAddDetailsModalOpen}
stepsCount={stepsCount}
/>
}
placement="bottomRight"
arrow={false}
destroyTooltipOnHide
>
<Ellipsis
className={cx('funnel-item__action-icon', className, {
'funnel-item__action-icon--active': isPopoverOpen,
})}
size={14}
/>
</Popover>
<DeleteFunnelStep
isOpen={isDeleteModalOpen}
onClose={(): void => setIsDeleteModalOpen(false)}
onStepRemove={onStepRemove}
/>
<AddFunnelStepDetailsModal
isOpen={isAddDetailsModalOpen}
onClose={(): void => setIsAddDetailsModalOpen(false)}
stepData={stepData}
/>
</div>
);
}
FunnelStepPopover.defaultProps = {
className: '',
};
export default FunnelStepPopover;

View File

@ -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);
}
}
}
}

View File

@ -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 (
<div className="inter-step-config">
<div className="inter-step-config__label">Latency type</div>
<div className="inter-step-config__divider">
<Divider dashed />
</div>
<div className="inter-step-config__latency-options">
<SignozRadioGroup
value={step.latency_type}
options={options}
onChange={(e): void =>
onStepChange(index, {
...step,
latency_type: e.target.value,
})
}
/>
</div>
</div>
);
}
export default InterStepConfig;

View File

@ -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);
}
}
}

View File

@ -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 (
<div className="steps-content">
<OverlayScrollbar>
<Steps direction="vertical">
{steps.map((step, index) => (
<Step
key={`step-${index + 1}`}
description={
<div className="steps-content__description">
<div className="funnel-step-wrapper">
<FunnelStep stepData={step} index={index} stepsCount={steps.length} />
{isTraceDetailsPage && span && (
<Button
type="default"
className="funnel-step-wrapper__replace-button"
icon={<Undo2 size={12} />}
disabled={
step.service_name === span.serviceName &&
step.span_name === span.name
}
onClick={(): void =>
handleReplaceStep(index, span.serviceName, span.name)
}
>
Replace
</Button>
)}
</div>
{/* Display InterStepConfig only between steps */}
{index < steps.length - 1 && (
<InterStepConfig index={index} step={step} />
)}
</div>
}
/>
))}
{/* For now we are only supporting 3 steps */}
{steps.length < 3 && (
<Step
className="steps-content__add-step"
description={
!isTraceDetailsPage ? (
<Button
type="default"
className="steps-content__add-btn"
onClick={handleAddStep}
icon={<PlusIcon size={14} />}
>
Add Funnel Step
</Button>
) : (
<Button
type="default"
className="steps-content__add-btn"
onClick={handleAddForNewStep}
icon={<PlusIcon size={14} />}
>
Add for new Step
</Button>
)
}
/>
)}
</Steps>
</OverlayScrollbar>
</div>
);
}
StepsContent.defaultProps = {
isTraceDetailsPage: false,
span: undefined,
};
export default memo(StepsContent);

View File

@ -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);
}
}
}
}

View File

@ -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 <Skeleton.Button size="small" />;
}
if (hasAllEmptyStepFields) {
return (
<span className="steps-footer__valid-traces">No service / span names</span>
);
}
if (hasIncompleteStepFields) {
return (
<span className="steps-footer__valid-traces">
Missing service / span names
</span>
);
}
if (validTracesCount === 0) {
return (
<span className="steps-footer__valid-traces steps-footer__valid-traces--none">
No valid traces found
</span>
);
}
return <span className="steps-footer__valid-traces">Valid traces found</span>;
}
function StepsFooter({ stepsCount }: StepsFooterProps): JSX.Element {
const { validTracesCount, handleRunFunnel } = useFunnelContext();
return (
<div className="steps-footer">
<div className="steps-footer__left">
<Cone className="funnel-icon" size={14} />
<span>{stepsCount} steps</span>
<span>·</span>
<ValidTracesCount />
</div>
<div className="steps-footer__right">
<Button
disabled={validTracesCount === 0}
onClick={handleRunFunnel}
type="primary"
className="steps-footer__button steps-footer__button--run"
icon={<Play size={16} />}
>
Run funnel
</Button>
</div>
</div>
);
}
export default StepsFooter;

View File

@ -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;
}
}
}
}

View File

@ -0,0 +1,24 @@
import './StepsHeader.styles.scss';
import { Divider } from 'antd';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
function StepsHeader(): JSX.Element {
return (
<div className="steps-header">
<div className="steps-header__label">FUNNEL STEPS</div>
<div className="steps-header__divider">
<Divider dashed />
</div>
<div className="steps-header__time-range">
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
/>
</div>
</div>
);
}
export default StepsHeader;

View File

@ -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);
}
}
}

View File

@ -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 (
<div className="funnel-results funnel-results--empty">
<div className="empty-funnel-results">
<div className="empty-funnel-results__icon">
<img src="/Icons/empty-funnel-icon.svg" alt="Empty funnel results" />
</div>
<div className="empty-funnel-results__title">{title}</div>
<div className="empty-funnel-results__description">{description}</div>
<div className="empty-funnel-results__learn-more">
<LearnMore />
</div>
</div>
</div>
);
}
EmptyFunnelResults.defaultProps = {
title: 'No spans selected yet.',
description: 'Add spans to the funnel steps to start seeing analytics here.',
};
export default EmptyFunnelResults;

View File

@ -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);
}
}
}
}
}

View File

@ -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 (
<div className="funnel-graph">
<Spinner size="default" />
</div>
);
}
if (!data) {
return (
<div className="funnel-graph">
<Empty description="No data" />
</div>
);
}
if (isError) {
return (
<div className="funnel-graph">
<Empty description="Error fetching data. If the problem persists, please contact support." />
</div>
);
}
return (
<Spin spinning={isFetching} indicator={<LoadingOutlined spin />}>
<div className={cx('funnel-graph', `funnel-graph--${totalSteps}-columns`)}>
<div className="funnel-graph__chart-container">
<canvas ref={canvasRef} />
</div>
<div className="funnel-graph__legends">
{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,
},
);
})}
</div>
</div>
</Spin>
);
}
export default FunnelGraph;

View File

@ -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);
}
}
}
}

View File

@ -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 (
<div className="funnel-metrics--loading-state">
<Spinner size="small" height="100%" />
</div>
);
if (data.length === 0 && emptyState) {
return emptyState;
}
if (isError) {
return (
<Empty description="Error fetching data. If the problem persists, please contact support." />
);
}
return (
<div className="funnel-metrics__grid">
{data.map((metric) => (
<div key={metric.title} className="funnel-metrics__item">
<div className="funnel-metrics__item-title">{metric.title}</div>
<div className="funnel-metrics__item-value">{metric.value}</div>
</div>
))}
</div>
);
}
FunnelMetricsContentRenderer.defaultProps = {
isLoading: false,
isError: false,
emptyState: <Empty className="funnel-metrics--empty-state" />,
};
function FunnelMetricsTable({
title,
subtitle,
data,
isLoading,
isError,
emptyState,
}: FunnelMetricsTableProps): JSX.Element {
return (
<div className="funnel-metrics">
<div className="funnel-metrics__header">
<div className="funnel-metrics__title">{title}</div>
{subtitle && (
<div className="funnel-metrics__subtitle">
<span className="funnel-metrics__subtitle-label">{subtitle.label}</span>
<span className="funnel-metrics__subtitle-separator"></span>
<span className="funnel-metrics__subtitle-value">{subtitle.value}</span>
</div>
)}
</div>
<FunnelMetricsContentRenderer
data={data}
isLoading={isLoading}
emptyState={emptyState}
isError={isError}
/>
</div>
);
}
FunnelMetricsTable.defaultProps = {
subtitle: undefined,
isLoading: false,
emptyState: <Empty className="funnel-metrics--empty-state" />,
isError: false,
};
export default FunnelMetricsTable;

View File

@ -0,0 +1,6 @@
.funnel-results {
padding: 16px;
display: flex;
flex-direction: column;
gap: 20px;
}

View File

@ -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 <Spinner size="large" />;
}
if (hasAllEmptyStepFields) return <EmptyFunnelResults />;
if (hasIncompleteStepFields)
return (
<EmptyFunnelResults
title="Missing service / span names"
description="Fill in the service and span names for all the steps"
/>
);
if (validTracesCount === 0) {
return (
<EmptyFunnelResults
title="There are no traces that match the funnel steps."
description="Check the service / span names in the funnel steps and try again to start seeing analytics here"
/>
);
}
return (
<div className="funnel-results">
<OverallMetrics />
<FunnelGraph />
<StepsTransitionResults />
</div>
);
}
export default FunnelResults;

View File

@ -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);
}
}
}

View File

@ -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<ColumnProps<any>>;
title: string;
tooltip?: string;
}
function FunnelTable({
loading = false,
data = [],
columns = [],
title,
tooltip,
}: FunnelTableProps): JSX.Element {
return (
<div className="funnel-table">
<div className="funnel-table__header">
<div className="funnel-table__title">{title}</div>
<div className="funnel-table__actions">
<Tooltip title={tooltip ?? null}>
<img src="/Icons/solid-info-circle.svg" alt="info" />
</Tooltip>
</div>
</div>
<Table
columns={columns}
dataSource={data}
loading={loading}
pagination={false}
locale={{
emptyText: loading ? null : <Empty />,
}}
scroll={{ x: true }}
tableLayout="fixed"
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
</div>
);
}
FunnelTable.defaultProps = {
loading: false,
data: [],
tooltip: '',
};
export default FunnelTable;

View File

@ -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<SlowTraceData | ErrorTraceData> | 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 (
<FunnelTable
title={title}
tooltip={tooltip}
columns={topTracesTableColumns}
data={data}
loading={isLoading || isFetching}
/>
);
}
export default FunnelTopTracesTable;

View File

@ -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 (
<FunnelMetricsTable
title="Overall Funnel Metrics"
subtitle={{
label: 'Conversion rate',
value: `${conversionRate.toFixed(2)}%`,
}}
isLoading={isLoading}
isError={isError}
data={metricsData}
/>
);
}
export default OverallMetrics;

View File

@ -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 <div>No transition selected</div>;
}
return (
<FunnelMetricsTable
title={currentTransition.label}
subtitle={{
label: 'Conversion rate',
value: `${conversionRate.toFixed(2)}%`,
}}
isLoading={isLoading}
data={metricsData}
/>
);
}
StepsTransitionMetrics.defaultProps = {
startStep: undefined,
endStep: undefined,
};
export default StepsTransitionMetrics;

View File

@ -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);
}
}
}
}
}

View File

@ -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<string>(
stepTransitions[0]?.value || '',
);
const [stepAOrder, stepBOrder] = useMemo(() => {
const [a, b] = selectedTransition.split('_to_');
return [parseInt(a, 10), parseInt(b, 10)];
}, [selectedTransition]);
return (
<div className="steps-transition-results">
<div className="steps-transition-results__steps-selector">
<SignozRadioGroup
value={selectedTransition}
options={stepTransitions}
onChange={(e): void => setSelectedTransition(e.target.value)}
/>
</div>
<div className="steps-transition-results__results">
<StepsTransitionMetrics
selectedTransition={selectedTransition}
transitions={stepTransitions}
startStep={stepAOrder}
endStep={stepBOrder}
/>
<TopSlowestTraces
funnelId={funnelId}
stepAOrder={stepAOrder}
stepBOrder={stepBOrder}
/>
<TopTracesWithErrors
funnelId={funnelId}
stepAOrder={stepAOrder}
stepBOrder={stepBOrder}
/>
</div>
</div>
);
}
export default StepsTransitionResults;

View File

@ -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 (
<FunnelTopTracesTable
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
title="Slowest 5 traces"
tooltip="A list of the slowest traces in the funnel"
useQueryHook={useFunnelSlowTraces}
/>
);
}
export default TopSlowestTraces;

View File

@ -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 (
<FunnelTopTracesTable
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
title="Traces with errors"
tooltip="A list of the traces with errors in the funnel"
useQueryHook={useFunnelErrorTraces}
/>
);
}
export default TopTracesWithErrors;

View File

@ -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 => (
<Link to={`/trace/${traceId}`} className="trace-id-cell">
{traceId}
</Link>
),
},
{
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(),
},
];

View File

@ -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',
},
];

View File

@ -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<SetStateAction<FunnelStepData[]>>;
initialSteps: FunnelStepData[];
handleAddStep: () => boolean;
handleStepChange: (index: number, newStep: Partial<FunnelStepData>) => void;
handleStepRemoval: (index: number) => void;
handleRunFunnel: () => void;
validationResponse:
| SuccessResponse<ValidateFunnelResponse>
| ErrorResponse
| undefined;
isValidateStepsLoading: boolean;
hasIncompleteStepFields: boolean;
setHasIncompleteStepFields: Dispatch<SetStateAction<boolean>>;
hasAllEmptyStepFields: boolean;
setHasAllEmptyStepFields: Dispatch<SetStateAction<boolean>>;
handleReplaceStep: (
index: number,
serviceName: string,
spanName: string,
) => void;
handleRestoreSteps: (oldSteps: FunnelStepData[]) => void;
}
const FunnelContext = createContext<FunnelContextType | undefined>(undefined);
export function FunnelProvider({
children,
funnelId,
}: {
children: React.ReactNode;
funnelId: string;
}): JSX.Element {
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(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<FunnelStepData[]>(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<FunnelStepData>) => {
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<void> => {
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<FunnelContextType>(
() => ({
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 (
<FunnelContext.Provider value={value}>{children}</FunnelContext.Provider>
);
}
export function useFunnelContext(): FunnelContextType {
const context = useContext(FunnelContext);
if (context === undefined) {
throw new Error('useFunnelContext must be used within a FunnelProvider');
}
return context;
}

View File

@ -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 {

View File

@ -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<string>('');
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;

View File

@ -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;

View File

@ -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;

View File

@ -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<boolean>(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
@ -71,7 +73,12 @@ function FunnelItemPopover({
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div onClick={preventDefault} role="button" tabIndex={0}>
<div
onClick={preventDefault}
role="button"
tabIndex={0}
className="funnel-item__actions-popover"
>
<Popover
trigger="click"
rootClassName="funnel-item__actions"
@ -96,19 +103,25 @@ function FunnelItemPopover({
</Popover>
<DeleteFunnel
shouldRedirectToTracesListOnDeleteSuccess={
shouldRedirectToTracesListOnDeleteSuccess
}
isOpen={isDeleteModalOpen}
onClose={(): void => setIsDeleteModalOpen(false)}
funnelId={funnel.id}
funnelId={funnel.funnel_id}
/>
<RenameFunnel
isOpen={isRenameModalOpen}
onClose={handleRenameCancel}
funnelId={funnel.id}
funnelId={funnel.funnel_id}
initialName={funnel.funnel_name}
/>
</div>
);
}
FunnelItemPopover.defaultProps = {
shouldRedirectToTracesListOnDeleteSuccess: true,
};
export default FunnelItemPopover;

View File

@ -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;

View File

@ -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<boolean>(false);
const funnelDetailsLink = generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
funnelId: funnel.id,
funnelId: funnel.funnel_id,
});
return (
<Link to={funnelDetailsLink} className="funnel-item">
const content = (
<>
<div className="funnel-item__header">
<div className="funnel-item__title">
<div>{funnel.funnel_name}</div>
</div>
<FunnelItemPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
funnel={funnel}
/>
{isSpanDetailsPage ? (
<Button
type="default"
className="funnel-item__open-button"
icon={<DecimalsArrowRight size={12} />}
>
Open funnel
</Button>
) : (
<FunnelItemPopover
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
funnel={funnel}
shouldRedirectToTracesListOnDeleteSuccess={
shouldRedirectToTracesListOnDeleteSuccess
}
/>
)}
</div>
<div className="funnel-item__details">
<div className="funnel-item__created-at">
<CalendarClock size={14} />
<div>
{dayjs(funnel.creation_timestamp).format(
DATE_TIME_FORMATS.FUNNELS_LIST_DATE,
)}
{dayjs(funnel.created_at).format(DATE_TIME_FORMATS.FUNNELS_LIST_DATE)}
</div>
</div>
<div className="funnel-item__user">
{funnel.user && (
{funnel.user_email && (
<div className="funnel-item__user-avatar">
{funnel.user.substring(0, 1).toUpperCase()}
{funnel.user_email.substring(0, 1).toUpperCase()}
</div>
)}
<div>{funnel.user}</div>
<div>{funnel.user_email}</div>
</div>
</div>
</>
);
return onFunnelClick ? (
<button
type="button"
className="funnel-item"
onClick={(): void => onFunnelClick(funnel)}
>
{content}
</button>
) : (
<Link to={funnelDetailsLink} className="funnel-item">
{content}
</Link>
);
}
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 (
<div className="funnels-list">
{data.map((funnel) => (
<FunnelListItem key={funnel.id} funnel={funnel} />
{data?.map((funnel) => (
<FunnelListItem
key={funnel.funnel_id}
funnel={funnel}
onFunnelClick={onFunnelClick}
shouldRedirectToTracesListOnDeleteSuccess={
shouldRedirectToTracesListOnDeleteSuccess
}
/>
))}
</div>
);
}
FunnelsList.defaultProps = {
onFunnelClick: undefined,
shouldRedirectToTracesListOnDeleteSuccess: true,
};
export default FunnelsList;

View File

@ -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({

View File

@ -33,18 +33,18 @@ function SearchBar({
<Button
type="text"
className="sort-popover-content__button"
onClick={(): void => onSort('creation_timestamp')}
onClick={(): void => onSort('created_at')}
>
Last created
{sortOrder.columnKey === 'creation_timestamp' && <Check size={14} />}
{sortOrder.columnKey === 'created_at' && <Check size={14} />}
</Button>
<Button
type="text"
className="sort-popover-content__button"
onClick={(): void => onSort('updated_timestamp')}
onClick={(): void => onSort('updated_at')}
>
Last updated
{sortOrder.columnKey === 'updated_timestamp' && <Check size={14} />}
{sortOrder.columnKey === 'updated_at' && <Check size={14} />}
</Button>
</div>
}

View File

@ -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 (
<div className="traces-funnels__loading">
{Array(6)
{Array(2)
.fill(0)
.map((item, index) => (
<Skeleton.Button
@ -49,22 +54,38 @@ function TracesFunnelsContentRenderer({
return <div>Something went wrong</div>;
}
if (data.length === 0) {
if (data.length === 0 && onCreateFunnel) {
return <FunnelsEmptyState onCreateFunnel={onCreateFunnel} />;
}
return <FunnelsList data={data} />;
return (
<FunnelsList
data={data}
onFunnelClick={onFunnelClick}
shouldRedirectToTracesListOnDeleteSuccess={
shouldRedirectToTracesListOnDeleteSuccess
}
/>
);
}
TracesFunnelsContentRenderer.defaultProps = {
onCreateFunnel: undefined,
onFunnelClick: undefined,
shouldRedirectToTracesListOnDeleteSuccess: true,
};
function TracesFunnels(): JSX.Element {
const { searchQuery, handleSearch } = useHandleTraceFunnelsSearch();
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(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 {
<TracesFunnelsContentRenderer
isError={isError}
isLoading={isLoading}
data={sortedData}
data={filteredData}
onCreateFunnel={handleCreateFunnel}
/>
<CreateFunnel

View File

@ -0,0 +1,13 @@
import { FunnelData } from 'types/api/traceFunnels';
export function filterFunnelsByQuery(
funnels: FunnelData[],
query: string,
): FunnelData[] {
const q = query.trim().toLowerCase();
if (!q) return funnels;
return funnels.filter((funnel) =>
(funnel.funnel_name || '').toLowerCase().includes(q),
);
}

View File

@ -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 (
<div className="traces-module-container">
<RouteTab routes={routes} activeKey={pathname} history={history} />
<RouteTab
routes={routes}
activeKey={
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
}
history={history}
/>
</div>
);
}

View File

@ -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 ? <TracesFunnelDetails /> : <TracesFunnels />;
},
name: (
<div className="tab-item">
<Cone className="funnel-icon" size={16} /> Funnels
@ -25,7 +31,7 @@ export const tracesFunnel: TabRoutes = {
),
route: ROUTES.TRACES_FUNNELS,
key: ROUTES.TRACES_FUNNELS,
};
});
export const tracesSaveView: TabRoutes = {
Component: SaveView,

View File

@ -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 {

View File

@ -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"