Compare commits
3 Commits
main
...
v0.80.0-53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53b98b0baf | ||
|
|
3d3cd09e56 | ||
|
|
8a5f01692e |
@@ -1,30 +1,26 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
|
||||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SSO = "SSO"
|
const SSO = "SSO"
|
||||||
const Basic = "BASIC_PLAN"
|
const Basic = "BASIC_PLAN"
|
||||||
const Pro = "PRO_PLAN"
|
|
||||||
const Enterprise = "ENTERPRISE_PLAN"
|
const Enterprise = "ENTERPRISE_PLAN"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
PlanNameEnterprise = "ENTERPRISE"
|
PlanNameEnterprise = "ENTERPRISE"
|
||||||
PlanNameTeams = "TEAMS"
|
|
||||||
PlanNameBasic = "BASIC"
|
PlanNameBasic = "BASIC"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
MapOldPlanKeyToNewPlanName map[string]string = map[string]string{PlanNameBasic: Basic, PlanNameTeams: Pro, PlanNameEnterprise: Enterprise}
|
MapOldPlanKeyToNewPlanName map[string]string = map[string]string{PlanNameBasic: Basic, PlanNameEnterprise: Enterprise}
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
LicenseStatusInvalid = "INVALID"
|
LicenseStatusInvalid = "INVALID"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DisableUpsell = "DISABLE_UPSELL"
|
|
||||||
const Onboarding = "ONBOARDING"
|
const Onboarding = "ONBOARDING"
|
||||||
const ChatSupport = "CHAT_SUPPORT"
|
const ChatSupport = "CHAT_SUPPORT"
|
||||||
const Gateway = "GATEWAY"
|
const Gateway = "GATEWAY"
|
||||||
@@ -38,90 +34,6 @@ var BasicPlan = basemodel.FeatureSet{
|
|||||||
UsageLimit: -1,
|
UsageLimit: -1,
|
||||||
Route: "",
|
Route: "",
|
||||||
},
|
},
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.OSS,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: DisableUpsell,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.SmartTraceDetail,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.CustomMetricsFunction,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.QueryBuilderPanels,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.QueryBuilderAlerts,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelSlack,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelWebhook,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelPagerduty,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelOpsgenie,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelEmail,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelMsTeams,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
basemodel.Feature{
|
||||||
Name: basemodel.UseSpanMetrics,
|
Name: basemodel.UseSpanMetrics,
|
||||||
Active: false,
|
Active: false,
|
||||||
@@ -150,149 +62,7 @@ var BasicPlan = basemodel.FeatureSet{
|
|||||||
UsageLimit: -1,
|
UsageLimit: -1,
|
||||||
Route: "",
|
Route: "",
|
||||||
},
|
},
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.HostsInfraMonitoring,
|
|
||||||
Active: constants.EnableHostsInfraMonitoring(),
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.TraceFunnels,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var ProPlan = basemodel.FeatureSet{
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: SSO,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.OSS,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.SmartTraceDetail,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.CustomMetricsFunction,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.QueryBuilderPanels,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.QueryBuilderAlerts,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelSlack,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelWebhook,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelPagerduty,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelOpsgenie,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelEmail,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelMsTeams,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.UseSpanMetrics,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: Gateway,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: PremiumSupport,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AnomalyDetection,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.HostsInfraMonitoring,
|
|
||||||
Active: constants.EnableHostsInfraMonitoring(),
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.TraceFunnels,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var EnterprisePlan = basemodel.FeatureSet{
|
var EnterprisePlan = basemodel.FeatureSet{
|
||||||
@@ -303,83 +73,6 @@ var EnterprisePlan = basemodel.FeatureSet{
|
|||||||
UsageLimit: -1,
|
UsageLimit: -1,
|
||||||
Route: "",
|
Route: "",
|
||||||
},
|
},
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.OSS,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.SmartTraceDetail,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.CustomMetricsFunction,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.QueryBuilderPanels,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.QueryBuilderAlerts,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelSlack,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelWebhook,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelPagerduty,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelOpsgenie,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelEmail,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.AlertChannelMsTeams,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
basemodel.Feature{
|
||||||
Name: basemodel.UseSpanMetrics,
|
Name: basemodel.UseSpanMetrics,
|
||||||
Active: false,
|
Active: false,
|
||||||
@@ -422,18 +115,5 @@ var EnterprisePlan = basemodel.FeatureSet{
|
|||||||
UsageLimit: -1,
|
UsageLimit: -1,
|
||||||
Route: "",
|
Route: "",
|
||||||
},
|
},
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.HostsInfraMonitoring,
|
|
||||||
Active: constants.EnableHostsInfraMonitoring(),
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
basemodel.Feature{
|
|
||||||
Name: basemodel.TraceFunnels,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
2
frontend/public/Icons/empty-funnel-icon.svg
Normal file
2
frontend/public/Icons/empty-funnel-icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.9 KiB |
1
frontend/public/Icons/funnel-add.svg
Normal file
1
frontend/public/Icons/funnel-add.svg
Normal 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 |
1
frontend/public/Icons/solid-info-circle.svg
Normal file
1
frontend/public/Icons/solid-info-circle.svg
Normal 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 |
@@ -47,9 +47,10 @@ export const TracesFunnels = Loadable(
|
|||||||
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),
|
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),
|
||||||
);
|
);
|
||||||
export const TracesFunnelDetails = Loadable(
|
export const TracesFunnelDetails = Loadable(
|
||||||
|
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||||
() =>
|
() =>
|
||||||
import(
|
import(
|
||||||
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesFunnelDetails'
|
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesModulePage'
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
CreateFunnelPayload,
|
CreateFunnelPayload,
|
||||||
CreateFunnelResponse,
|
CreateFunnelResponse,
|
||||||
FunnelData,
|
FunnelData,
|
||||||
|
FunnelStepData,
|
||||||
} from 'types/api/traceFunnels';
|
} from 'types/api/traceFunnels';
|
||||||
|
|
||||||
const FUNNELS_BASE_PATH = '/trace-funnels';
|
const FUNNELS_BASE_PATH = '/trace-funnels';
|
||||||
@@ -54,7 +55,7 @@ export const getFunnelsList = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getFunnelById = async (
|
export const getFunnelById = async (
|
||||||
funnelId: string,
|
funnelId?: string,
|
||||||
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||||
const response: AxiosResponse = await axios.get(
|
const response: AxiosResponse = await axios.get(
|
||||||
`${FUNNELS_BASE_PATH}/get/${funnelId}`,
|
`${FUNNELS_BASE_PATH}/get/${funnelId}`,
|
||||||
@@ -107,3 +108,267 @@ export const deleteFunnel = async (
|
|||||||
payload: response.data,
|
payload: response.data,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface UpdateFunnelStepsPayload {
|
||||||
|
funnel_id: string;
|
||||||
|
steps: FunnelStepData[];
|
||||||
|
updated_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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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_timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateFunnelStepDetails = async ({
|
||||||
|
stepOrder,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
stepOrder: number;
|
||||||
|
payload: UpdateFunnelStepDetailsPayload;
|
||||||
|
}): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||||
|
const response: AxiosResponse = await axios.put(
|
||||||
|
`${FUNNELS_BASE_PATH}/steps/${stepOrder}/update`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: 'Funnel step details updated successfully',
|
||||||
|
payload: response.data,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,16 +11,24 @@ import { QueryParams } from 'constants/query';
|
|||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
interface SelectOptionConfig {
|
export interface SelectOptionConfig {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
queryParam: QueryParams;
|
queryParam: QueryParams;
|
||||||
filterType: string | string[];
|
filterType: string | string[];
|
||||||
|
shouldSetQueryParams?: boolean;
|
||||||
|
onChange?: (value: string | string[]) => void;
|
||||||
|
values?: string | string[];
|
||||||
|
isMultiple?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterSelect({
|
export function FilterSelect({
|
||||||
placeholder,
|
placeholder,
|
||||||
queryParam,
|
queryParam,
|
||||||
filterType,
|
filterType,
|
||||||
|
values,
|
||||||
|
shouldSetQueryParams,
|
||||||
|
onChange,
|
||||||
|
isMultiple,
|
||||||
}: SelectOptionConfig): JSX.Element {
|
}: SelectOptionConfig): JSX.Element {
|
||||||
const { handleSearch, isFetching, options } = useCeleryFilterOptions(
|
const { handleSearch, isFetching, options } = useCeleryFilterOptions(
|
||||||
filterType,
|
filterType,
|
||||||
@@ -35,7 +43,8 @@ function FilterSelect({
|
|||||||
key={filterType.toString()}
|
key={filterType.toString()}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
showSearch
|
showSearch
|
||||||
mode="multiple"
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
{...(isMultiple ? { mode: 'multiple' } : {})}
|
||||||
options={options}
|
options={options}
|
||||||
loading={isFetching}
|
loading={isFetching}
|
||||||
className="config-select-option"
|
className="config-select-option"
|
||||||
@@ -43,7 +52,11 @@ function FilterSelect({
|
|||||||
maxTagCount={4}
|
maxTagCount={4}
|
||||||
allowClear
|
allowClear
|
||||||
maxTagPlaceholder={SelectMaxTagPlaceholder}
|
maxTagPlaceholder={SelectMaxTagPlaceholder}
|
||||||
value={getValuesFromQueryParams(queryParam, urlQuery) || []}
|
value={
|
||||||
|
!shouldSetQueryParams && !!values?.length
|
||||||
|
? values
|
||||||
|
: getValuesFromQueryParams(queryParam, urlQuery) || []
|
||||||
|
}
|
||||||
notFoundContent={
|
notFoundContent={
|
||||||
isFetching ? (
|
isFetching ? (
|
||||||
<span>
|
<span>
|
||||||
@@ -55,12 +68,28 @@ function FilterSelect({
|
|||||||
}
|
}
|
||||||
onChange={(value): void => {
|
onChange={(value): void => {
|
||||||
handleSearch('');
|
handleSearch('');
|
||||||
setQueryParamsFromOptions(value, urlQuery, history, location, queryParam);
|
if (shouldSetQueryParams) {
|
||||||
|
setQueryParamsFromOptions(
|
||||||
|
value as string[],
|
||||||
|
urlQuery,
|
||||||
|
history,
|
||||||
|
location,
|
||||||
|
queryParam,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onChange?.(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FilterSelect.defaultProps = {
|
||||||
|
shouldSetQueryParams: true,
|
||||||
|
onChange: (): void => {},
|
||||||
|
values: [],
|
||||||
|
isMultiple: true,
|
||||||
|
};
|
||||||
|
|
||||||
function CeleryOverviewConfigOptions(): JSX.Element {
|
function CeleryOverviewConfigOptions(): JSX.Element {
|
||||||
const selectConfigs: SelectOptionConfig[] = [
|
const selectConfigs: SelectOptionConfig[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -1,30 +1,11 @@
|
|||||||
// keep this consistent with backend constants.go
|
// keep this consistent with backend constants.go
|
||||||
export enum FeatureKeys {
|
export enum FeatureKeys {
|
||||||
SSO = 'SSO',
|
SSO = 'SSO',
|
||||||
ENTERPRISE_PLAN = 'ENTERPRISE_PLAN',
|
|
||||||
BASIC_PLAN = 'BASIC_PLAN',
|
|
||||||
ALERT_CHANNEL_SLACK = 'ALERT_CHANNEL_SLACK',
|
|
||||||
ALERT_CHANNEL_WEBHOOK = 'ALERT_CHANNEL_WEBHOOK',
|
|
||||||
ALERT_CHANNEL_PAGERDUTY = 'ALERT_CHANNEL_PAGERDUTY',
|
|
||||||
ALERT_CHANNEL_OPSGENIE = 'ALERT_CHANNEL_OPSGENIE',
|
|
||||||
ALERT_CHANNEL_MSTEAMS = 'ALERT_CHANNEL_MSTEAMS',
|
|
||||||
DurationSort = 'DurationSort',
|
|
||||||
TimestampSort = 'TimestampSort',
|
|
||||||
SMART_TRACE_DETAIL = 'SMART_TRACE_DETAIL',
|
|
||||||
CUSTOM_METRICS_FUNCTION = 'CUSTOM_METRICS_FUNCTION',
|
|
||||||
QUERY_BUILDER_PANELS = 'QUERY_BUILDER_PANELS',
|
|
||||||
QUERY_BUILDER_ALERTS = 'QUERY_BUILDER_ALERTS',
|
|
||||||
DISABLE_UPSELL = 'DISABLE_UPSELL',
|
|
||||||
USE_SPAN_METRICS = 'USE_SPAN_METRICS',
|
USE_SPAN_METRICS = 'USE_SPAN_METRICS',
|
||||||
OSS = 'OSS',
|
|
||||||
ONBOARDING = 'ONBOARDING',
|
ONBOARDING = 'ONBOARDING',
|
||||||
CHAT_SUPPORT = 'CHAT_SUPPORT',
|
CHAT_SUPPORT = 'CHAT_SUPPORT',
|
||||||
GATEWAY = 'GATEWAY',
|
GATEWAY = 'GATEWAY',
|
||||||
PREMIUM_SUPPORT = 'PREMIUM_SUPPORT',
|
PREMIUM_SUPPORT = 'PREMIUM_SUPPORT',
|
||||||
QUERY_BUILDER_SEARCH_V2 = 'QUERY_BUILDER_SEARCH_V2',
|
|
||||||
ANOMALY_DETECTION = 'ANOMALY_DETECTION',
|
ANOMALY_DETECTION = 'ANOMALY_DETECTION',
|
||||||
AWS_INTEGRATION = 'AWS_INTEGRATION',
|
|
||||||
ONBOARDING_V3 = 'ONBOARDING_V3',
|
ONBOARDING_V3 = 'ONBOARDING_V3',
|
||||||
THIRD_PARTY_API = 'THIRD_PARTY_API',
|
|
||||||
TRACE_FUNNELS = 'TRACE_FUNNELS',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const REACT_QUERY_KEY = {
|
|||||||
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
|
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
|
||||||
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
|
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
|
||||||
|
|
||||||
// API Monitoring Query Keys
|
// Traces Funnels Query Keys
|
||||||
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
|
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
|
||||||
GET_ENDPOINTS_LIST_BY_DOMAIN: 'GET_ENDPOINTS_LIST_BY_DOMAIN',
|
GET_ENDPOINTS_LIST_BY_DOMAIN: 'GET_ENDPOINTS_LIST_BY_DOMAIN',
|
||||||
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
|
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
|
||||||
@@ -68,4 +68,11 @@ export const REACT_QUERY_KEY = {
|
|||||||
'GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA',
|
'GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA',
|
||||||
GET_FUNNELS_LIST: 'GET_FUNNELS_LIST',
|
GET_FUNNELS_LIST: 'GET_FUNNELS_LIST',
|
||||||
GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS',
|
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;
|
} as const;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { Helmet } from 'react-helmet-async';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQueries } from 'react-query';
|
import { useMutation, useQueries } from 'react-query';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { matchPath, useLocation } from 'react-router-dom';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import AppActions from 'types/actions';
|
import AppActions from 'types/actions';
|
||||||
import {
|
import {
|
||||||
@@ -360,6 +360,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
|||||||
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
|
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
|
||||||
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
|
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
|
||||||
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
|
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
|
||||||
|
const isTracesFunnelDetails = (): boolean =>
|
||||||
|
!!matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL);
|
||||||
|
|
||||||
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
|
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
|
||||||
|
|
||||||
const isDashboardView = (): boolean =>
|
const isDashboardView = (): boolean =>
|
||||||
@@ -665,7 +668,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
|||||||
? 0
|
? 0
|
||||||
: '0 1rem',
|
: '0 1rem',
|
||||||
|
|
||||||
...(isTraceDetailsView() || isTracesFunnels() ? { margin: 0 } : {}),
|
...(isTraceDetailsView() ||
|
||||||
|
isTracesFunnels() ||
|
||||||
|
isTracesFunnelDetails()
|
||||||
|
? { margin: 0 }
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isToDisplayLayout && !renderFullScreen && <TopNav />}
|
{isToDisplayLayout && !renderFullScreen && <TopNav />}
|
||||||
|
|||||||
@@ -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 {
|
.query-builder-search-v2 {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ interface QueryBuilderSearchV2Props {
|
|||||||
className?: string;
|
className?: string;
|
||||||
suffixIcon?: React.ReactNode;
|
suffixIcon?: React.ReactNode;
|
||||||
hardcodedAttributeKeys?: BaseAutocompleteData[];
|
hardcodedAttributeKeys?: BaseAutocompleteData[];
|
||||||
|
hasPopupContainer?: boolean;
|
||||||
|
rootClassName?: string;
|
||||||
|
maxTagCount?: number | 'responsive';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Option {
|
export interface Option {
|
||||||
@@ -121,6 +124,9 @@ function QueryBuilderSearchV2(
|
|||||||
suffixIcon,
|
suffixIcon,
|
||||||
whereClauseConfig,
|
whereClauseConfig,
|
||||||
hardcodedAttributeKeys,
|
hardcodedAttributeKeys,
|
||||||
|
hasPopupContainer,
|
||||||
|
rootClassName,
|
||||||
|
maxTagCount,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||||
@@ -928,7 +934,10 @@ function QueryBuilderSearchV2(
|
|||||||
<div className="query-builder-search-v2">
|
<div className="query-builder-search-v2">
|
||||||
<Select
|
<Select
|
||||||
ref={selectRef}
|
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('.')}
|
key={queryTags.join('.')}
|
||||||
virtual={false}
|
virtual={false}
|
||||||
showSearch
|
showSearch
|
||||||
@@ -960,7 +969,7 @@ function QueryBuilderSearchV2(
|
|||||||
: '',
|
: '',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
rootClassName="query-builder-search"
|
rootClassName={cx('query-builder-search', rootClassName)}
|
||||||
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
|
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
|
||||||
style={selectStyle}
|
style={selectStyle}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
@@ -1017,7 +1026,10 @@ QueryBuilderSearchV2.defaultProps = {
|
|||||||
className: '',
|
className: '',
|
||||||
suffixIcon: null,
|
suffixIcon: null,
|
||||||
whereClauseConfig: {},
|
whereClauseConfig: {},
|
||||||
|
hasPopupContainer: true,
|
||||||
|
rootClassName: '',
|
||||||
hardcodedAttributeKeys: undefined,
|
hardcodedAttributeKeys: undefined,
|
||||||
|
maxTagCount: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default QueryBuilderSearchV2;
|
export default QueryBuilderSearchV2;
|
||||||
|
|||||||
@@ -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 16px 12px;
|
||||||
|
&,
|
||||||
|
&: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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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 { 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}
|
||||||
|
/>
|
||||||
|
<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({
|
||||||
|
searchQuery: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredData = useMemo(
|
||||||
|
() =>
|
||||||
|
data?.payload
|
||||||
|
?.filter((funnel) =>
|
||||||
|
funnel.funnel_name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.creation_timestamp).getTime() -
|
||||||
|
new Date(a.creation_timestamp).getTime(),
|
||||||
|
),
|
||||||
|
[data?.payload, searchQuery],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: funnelDetails,
|
||||||
|
isLoading: isFunnelDetailsLoading,
|
||||||
|
isFetching: isFunnelDetailsFetching,
|
||||||
|
} = useFunnelDetails({
|
||||||
|
funnelId: selectedFunnelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFunnelClick = (funnel: FunnelData): void => {
|
||||||
|
setSelectedFunnelId(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;
|
||||||
@@ -95,6 +95,10 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(171, 189, 255, 0.06) !important;
|
background: rgba(171, 189, 255, 0.06) !important;
|
||||||
|
|
||||||
|
.div-td .span-overview .second-row .add-funnel-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.span-overview {
|
.span-overview {
|
||||||
background: unset !important;
|
background: unset !important;
|
||||||
|
|
||||||
@@ -231,6 +235,24 @@
|
|||||||
line-height: 18px; /* 128.571% */
|
line-height: 18px; /* 128.571% */
|
||||||
letter-spacing: -0.07px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import cx from 'classnames';
|
|||||||
import { TableV3 } from 'components/TableV3/TableV3';
|
import { TableV3 } from 'components/TableV3/TableV3';
|
||||||
import { themeColors } from 'constants/theme';
|
import { themeColors } from 'constants/theme';
|
||||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||||
|
import AddSpanToFunnelModal from 'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||||
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
|
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
|
||||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Span } from 'types/api/trace/getTraceV2';
|
import { Span } from 'types/api/trace/getTraceV2';
|
||||||
import { toFixed } from 'utils/toFixed';
|
import { toFixed } from 'utils/toFixed';
|
||||||
@@ -57,6 +59,7 @@ function SpanOverview({
|
|||||||
isSpanCollapsed,
|
isSpanCollapsed,
|
||||||
handleCollapseUncollapse,
|
handleCollapseUncollapse,
|
||||||
setSelectedSpan,
|
setSelectedSpan,
|
||||||
|
handleAddSpanToFunnel,
|
||||||
selectedSpan,
|
selectedSpan,
|
||||||
}: {
|
}: {
|
||||||
span: Span;
|
span: Span;
|
||||||
@@ -64,6 +67,7 @@ function SpanOverview({
|
|||||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||||
selectedSpan: Span | undefined;
|
selectedSpan: Span | undefined;
|
||||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||||
|
handleAddSpanToFunnel: (span: Span) => void;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const isRootSpan = span.level === 0;
|
const isRootSpan = span.level === 0;
|
||||||
|
|
||||||
@@ -141,6 +145,28 @@ function SpanOverview({
|
|||||||
<Typography.Text className="service-name">
|
<Typography.Text className="service-name">
|
||||||
{span.serviceName}
|
{span.serviceName}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
{!!span.serviceName && !!span.name && (
|
||||||
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,12 +236,14 @@ function getWaterfallColumns({
|
|||||||
traceMetadata,
|
traceMetadata,
|
||||||
selectedSpan,
|
selectedSpan,
|
||||||
setSelectedSpan,
|
setSelectedSpan,
|
||||||
|
handleAddSpanToFunnel,
|
||||||
}: {
|
}: {
|
||||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||||
uncollapsedNodes: string[];
|
uncollapsedNodes: string[];
|
||||||
traceMetadata: ITraceMetadata;
|
traceMetadata: ITraceMetadata;
|
||||||
selectedSpan: Span | undefined;
|
selectedSpan: Span | undefined;
|
||||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||||
|
handleAddSpanToFunnel: (span: Span) => void;
|
||||||
}): ColumnDef<Span, any>[] {
|
}): ColumnDef<Span, any>[] {
|
||||||
const waterfallColumns: ColumnDef<Span, any>[] = [
|
const waterfallColumns: ColumnDef<Span, any>[] = [
|
||||||
columnDefHelper.display({
|
columnDefHelper.display({
|
||||||
@@ -228,6 +256,7 @@ function getWaterfallColumns({
|
|||||||
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
|
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
|
||||||
selectedSpan={selectedSpan}
|
selectedSpan={selectedSpan}
|
||||||
setSelectedSpan={setSelectedSpan}
|
setSelectedSpan={setSelectedSpan}
|
||||||
|
handleAddSpanToFunnel={handleAddSpanToFunnel}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
size: 450,
|
size: 450,
|
||||||
@@ -294,6 +323,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(
|
const columns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getWaterfallColumns({
|
getWaterfallColumns({
|
||||||
@@ -302,6 +342,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
|||||||
traceMetadata,
|
traceMetadata,
|
||||||
selectedSpan,
|
selectedSpan,
|
||||||
setSelectedSpan,
|
setSelectedSpan,
|
||||||
|
handleAddSpanToFunnel,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
handleCollapseUncollapse,
|
handleCollapseUncollapse,
|
||||||
@@ -309,6 +350,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
|||||||
traceMetadata,
|
traceMetadata,
|
||||||
selectedSpan,
|
selectedSpan,
|
||||||
setSelectedSpan,
|
setSelectedSpan,
|
||||||
|
handleAddSpanToFunnel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -380,6 +422,13 @@ function Success(props: ISuccessProps): JSX.Element {
|
|||||||
virtualiserRef={virtualizerRef}
|
virtualiserRef={virtualizerRef}
|
||||||
setColumnWidths={setTraceFlamegraphStatsWidth}
|
setColumnWidths={setTraceFlamegraphStatsWidth}
|
||||||
/>
|
/>
|
||||||
|
{selectedSpanToAddToFunnel && (
|
||||||
|
<AddSpanToFunnelModal
|
||||||
|
span={selectedSpanToAddToFunnel}
|
||||||
|
isOpen={isAddSpanToFunnelModalOpen}
|
||||||
|
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
163
frontend/src/hooks/TracesFunnels/useFunnelConfiguration.tsx
Normal file
163
frontend/src/hooks/TracesFunnels/useFunnelConfiguration.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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,
|
||||||
|
} = 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.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.id,
|
||||||
|
steps: debouncedSteps,
|
||||||
|
updated_timestamp: Date.now(),
|
||||||
|
}),
|
||||||
|
[funnel.id, debouncedSteps],
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { selectedTime } = useFunnelContext();
|
||||||
|
|
||||||
|
const validateStepsQueryKey = useMemo(
|
||||||
|
() => [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnel.id, selectedTime],
|
||||||
|
[funnel.id, selectedTime],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasStepsChanged()) {
|
||||||
|
updateStepsMutation.mutate(getUpdatePayload(), {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const updatedFunnelSteps = data?.payload?.steps;
|
||||||
|
|
||||||
|
if (!updatedFunnelSteps) return;
|
||||||
|
|
||||||
|
queryClient.setQueryData(
|
||||||
|
[REACT_QUERY_KEY.GET_FUNNEL_DETAILS, 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
debouncedSteps,
|
||||||
|
getUpdatePayload,
|
||||||
|
hasStepServiceOrSpanNameChanged,
|
||||||
|
hasStepsChanged,
|
||||||
|
lastValidatedSteps,
|
||||||
|
queryClient,
|
||||||
|
validateStepsQueryKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPopoverOpen,
|
||||||
|
setIsPopoverOpen,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
207
frontend/src/hooks/TracesFunnels/useFunnelGraph.tsx
Normal file
207
frontend/src/hooks/TracesFunnels/useFunnelGraph.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
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 } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFunnelGraph {
|
||||||
|
successSteps: number[];
|
||||||
|
errorSteps: number[];
|
||||||
|
totalSteps: number;
|
||||||
|
canvasRef: React.RefObject<HTMLCanvasElement>;
|
||||||
|
renderLegendItem: (
|
||||||
|
step: number,
|
||||||
|
successSpans: number,
|
||||||
|
errorSpans: number,
|
||||||
|
prevTotalSpans: number,
|
||||||
|
) => JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFunnelGraph({ data }: UseFunnelGraphProps): UseFunnelGraph {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const chartRef = useRef<Chart | 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: Color.BG_ROBIN_500,
|
||||||
|
stack: 'Stack 0',
|
||||||
|
borderRadius: 2,
|
||||||
|
borderSkipped: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Error spans',
|
||||||
|
data: errorSteps,
|
||||||
|
backgroundColor: 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);
|
||||||
|
}, [data, getStepGraphData]);
|
||||||
|
|
||||||
|
// Log the widths when they change
|
||||||
|
|
||||||
|
const renderLegendItem = useCallback(
|
||||||
|
(
|
||||||
|
step: number,
|
||||||
|
successSpans: number,
|
||||||
|
errorSpans: number,
|
||||||
|
prevTotalSpans: number,
|
||||||
|
): JSX.Element => {
|
||||||
|
const totalSpans = successSpans + errorSpans;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step} className="funnel-graph__legend-column">
|
||||||
|
<div className="legend-item">
|
||||||
|
<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">
|
||||||
|
<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;
|
||||||
69
frontend/src/hooks/TracesFunnels/useFunnelMetrics.ts
Normal file
69
frontend/src/hooks/TracesFunnels/useFunnelMetrics.ts
Normal 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(), 'ms'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'P99 Latency',
|
||||||
|
value: getYAxisFormattedValue(sourceData.p99_latency.toString(), 'ms'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [overviewData]);
|
||||||
|
|
||||||
|
const conversionRate =
|
||||||
|
overviewData?.payload?.data?.[0]?.data?.conversion_rate ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading: isLoading || isFetching,
|
||||||
|
isError,
|
||||||
|
metricsData,
|
||||||
|
conversionRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,31 @@
|
|||||||
|
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||||
import {
|
import {
|
||||||
createFunnel,
|
createFunnel,
|
||||||
deleteFunnel,
|
deleteFunnel,
|
||||||
|
ErrorTraceData,
|
||||||
|
ErrorTracesPayload,
|
||||||
|
FunnelOverviewPayload,
|
||||||
|
FunnelOverviewResponse,
|
||||||
|
FunnelStepsResponse,
|
||||||
getFunnelById,
|
getFunnelById,
|
||||||
|
getFunnelErrorTraces,
|
||||||
|
getFunnelOverview,
|
||||||
getFunnelsList,
|
getFunnelsList,
|
||||||
|
getFunnelSlowTraces,
|
||||||
|
getFunnelSteps,
|
||||||
renameFunnel,
|
renameFunnel,
|
||||||
|
saveFunnelDescription,
|
||||||
|
SlowTraceData,
|
||||||
|
SlowTracesPayload,
|
||||||
|
updateFunnelStepDetails,
|
||||||
|
UpdateFunnelStepDetailsPayload,
|
||||||
|
updateFunnelSteps,
|
||||||
|
UpdateFunnelStepsPayload,
|
||||||
|
ValidateFunnelResponse,
|
||||||
|
validateFunnelSteps,
|
||||||
} from 'api/traceFunnels';
|
} from 'api/traceFunnels';
|
||||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
|
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||||
import {
|
import {
|
||||||
useMutation,
|
useMutation,
|
||||||
UseMutationResult,
|
UseMutationResult,
|
||||||
@@ -20,19 +40,20 @@ import {
|
|||||||
} from 'types/api/traceFunnels';
|
} from 'types/api/traceFunnels';
|
||||||
|
|
||||||
export const useFunnelsList = ({
|
export const useFunnelsList = ({
|
||||||
searchQuery,
|
searchQuery = '',
|
||||||
}: {
|
}: {
|
||||||
searchQuery: string;
|
searchQuery?: string;
|
||||||
}): UseQueryResult<SuccessResponse<FunnelData[]> | ErrorResponse, unknown> =>
|
}): UseQueryResult<SuccessResponse<FunnelData[]> | ErrorResponse, unknown> =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: [REACT_QUERY_KEY.GET_FUNNELS_LIST, searchQuery],
|
queryKey: [REACT_QUERY_KEY.GET_FUNNELS_LIST, searchQuery],
|
||||||
queryFn: () => getFunnelsList({ search: searchQuery }),
|
queryFn: () => getFunnelsList({ search: searchQuery }),
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useFunnelDetails = ({
|
export const useFunnelDetails = ({
|
||||||
funnelId,
|
funnelId,
|
||||||
}: {
|
}: {
|
||||||
funnelId: string;
|
funnelId?: string;
|
||||||
}): UseQueryResult<SuccessResponse<FunnelData> | ErrorResponse, unknown> =>
|
}): UseQueryResult<SuccessResponse<FunnelData> | ErrorResponse, unknown> =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnelId],
|
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnelId],
|
||||||
@@ -75,3 +96,148 @@ export const useDeleteFunnel = (): UseMutationResult<
|
|||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: deleteFunnel,
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useUpdateFunnelStepDetails = ({
|
||||||
|
stepOrder,
|
||||||
|
}: {
|
||||||
|
stepOrder: number;
|
||||||
|
}): UseMutationResult<
|
||||||
|
SuccessResponse<FunnelData> | ErrorResponse,
|
||||||
|
Error,
|
||||||
|
UpdateFunnelStepDetailsPayload
|
||||||
|
> =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: (payload) => updateFunnelStepDetails({ payload, stepOrder }),
|
||||||
|
mutationKey: [REACT_QUERY_KEY.UPDATE_FUNNEL_STEP_DETAILS, stepOrder],
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
import { ChangeEvent, useState } from 'react';
|
import { debounce } from 'lodash-es';
|
||||||
|
import { ChangeEvent, useCallback, useState } from 'react';
|
||||||
|
|
||||||
const useHandleTraceFunnelsSearch = (): {
|
const useHandleTraceFunnelsSearch = (): {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
handleSearch: (e: ChangeEvent<HTMLInputElement>) => void;
|
handleSearch: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
} => {
|
} => {
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
|
|
||||||
const urlQuery = useUrlQuery();
|
const urlQuery = useUrlQuery();
|
||||||
const [searchQuery, setSearchQuery] = useState<string>(
|
const [searchQuery, setSearchQuery] = useState<string>(
|
||||||
urlQuery.get('search') || '',
|
urlQuery.get('search') || '',
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const { value } = e.target;
|
const debouncedUpdateUrl = useCallback(
|
||||||
setSearchQuery(value);
|
debounce((value: string) => {
|
||||||
|
|
||||||
const trimmedValue = value.trim();
|
const trimmedValue = value.trim();
|
||||||
|
|
||||||
if (trimmedValue) {
|
if (trimmedValue) {
|
||||||
@@ -26,6 +25,14 @@ const useHandleTraceFunnelsSearch = (): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
safeNavigate({ search: urlQuery.toString() });
|
safeNavigate({ search: urlQuery.toString() });
|
||||||
|
}, 300),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const { value } = e.target;
|
||||||
|
setSearchQuery(value);
|
||||||
|
debouncedUpdateUrl(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
.traces-module-container {
|
.traces-module-container {
|
||||||
|
.funnel-icon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
.trace-module {
|
.trace-module {
|
||||||
.ant-tabs-tab {
|
.ant-tabs-tab {
|
||||||
.tab-item {
|
.tab-item {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import './TraceDetailV2.styles.scss';
|
|||||||
import { Button, Tabs } from 'antd';
|
import { Button, Tabs } from 'antd';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import history from 'lib/history';
|
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 TraceDetail from 'pages/TraceDetail';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
@@ -33,6 +33,9 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
|||||||
if (activeKey === 'trace-details') {
|
if (activeKey === 'trace-details') {
|
||||||
history.push(ROUTES.TRACES_EXPLORER);
|
history.push(ROUTES.TRACES_EXPLORER);
|
||||||
}
|
}
|
||||||
|
if (activeKey === 'funnels') {
|
||||||
|
history.push(ROUTES.TRACES_FUNNELS);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
tabBarExtraContent={
|
tabBarExtraContent={
|
||||||
<Button
|
<Button
|
||||||
@@ -61,6 +64,15 @@ export default function TraceDetailsPage(): JSX.Element {
|
|||||||
key: 'trace-details',
|
key: 'trace-details',
|
||||||
children: <TraceDetailsV2 />,
|
children: <TraceDetailsV2 />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div className="tab-item">
|
||||||
|
<Cone className="funnel-icon" size={16} /> Funnels
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
key: 'funnels',
|
||||||
|
children: <div />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<div className="tab-item">
|
<div className="tab-item">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { useFunnelDetails } from 'hooks/TracesFunnels/useFunnels';
|
||||||
|
import { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import FunnelConfiguration from './components/FunnelConfiguration/FunnelConfiguration';
|
||||||
|
import FunnelResults from './components/FunnelResults/FunnelResults';
|
||||||
|
|
||||||
function TracesFunnelDetails(): JSX.Element {
|
function TracesFunnelDetails(): JSX.Element {
|
||||||
const { funnelId } = useParams<{ funnelId: string }>();
|
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 (
|
return (
|
||||||
<div style={{ color: 'var(--bg-vanilla-400)' }}>
|
<NotFoundContainer>
|
||||||
TracesFunnelDetails, {JSON.stringify(data)}
|
<Typography>Error loading funnel details</Typography>
|
||||||
|
</NotFoundContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FunnelProvider funnelId={funnelId}>
|
||||||
|
<div className="traces-funnel-details">
|
||||||
|
<div className="traces-funnel-details__steps-config">
|
||||||
|
<FunnelConfiguration funnel={data.payload} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="traces-funnel-details__steps-results">
|
||||||
|
<FunnelResults />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FunnelProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddFunnelDescriptionModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
funnelId,
|
||||||
|
}: AddFunnelDescriptionProps): JSX.Element {
|
||||||
|
const [description, setDescription] = useState<string>('');
|
||||||
|
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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import './AddFunnelStepDetailsModal.styles.scss';
|
||||||
|
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import SignozModal from 'components/SignozModal/SignozModal';
|
||||||
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
|
import { useUpdateFunnelStepDetails } from 'hooks/TracesFunnels/useFunnels';
|
||||||
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
|
import { Check, X } from 'lucide-react';
|
||||||
|
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from 'react-query';
|
||||||
|
|
||||||
|
interface AddFunnelStepDetailsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
stepOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddFunnelStepDetailsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
stepOrder,
|
||||||
|
}: AddFunnelStepDetailsModalProps): JSX.Element {
|
||||||
|
const { funnelId } = useFunnelContext();
|
||||||
|
const [stepName, setStepName] = useState<string>('');
|
||||||
|
const [description, setDescription] = useState<string>('');
|
||||||
|
const { notifications } = useNotifications();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutate: updateFunnelStepDetails,
|
||||||
|
isLoading,
|
||||||
|
} = useUpdateFunnelStepDetails({ stepOrder });
|
||||||
|
|
||||||
|
const handleCancel = (): void => {
|
||||||
|
setStepName('');
|
||||||
|
setDescription('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = (): void => {
|
||||||
|
updateFunnelStepDetails(
|
||||||
|
{
|
||||||
|
funnel_id: funnelId,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
step_name: stepName,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updated_timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries([
|
||||||
|
REACT_QUERY_KEY.GET_FUNNEL_DETAILS,
|
||||||
|
funnelId,
|
||||||
|
]);
|
||||||
|
console.log('funnelId', funnelId);
|
||||||
|
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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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 {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb
|
||||||
|
className="funnel-breadcrumb"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
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>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FunnelBreadcrumb;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import './FunnelConfiguration.styles.scss';
|
||||||
|
|
||||||
|
import useFunnelConfiguration from 'hooks/TracesFunnels/useFunnelConfiguration';
|
||||||
|
import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/FunnelItemPopover';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Span } from 'types/api/trace/getTraceV2';
|
||||||
|
import { FunnelData } from 'types/api/traceFunnels';
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="funnel-configuration">
|
||||||
|
{!isTraceDetailsPage && (
|
||||||
|
<>
|
||||||
|
<div className="funnel-configuration__header">
|
||||||
|
<FunnelBreadcrumb funnelName={funnel.funnel_name} />
|
||||||
|
<FunnelItemPopover
|
||||||
|
isPopoverOpen={isPopoverOpen}
|
||||||
|
setIsPopoverOpen={setIsPopoverOpen}
|
||||||
|
funnel={funnel}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
funnelId={funnel.id}
|
||||||
|
stepsCount={steps.length}
|
||||||
|
funnelDescription={funnel?.description || ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FunnelConfiguration.defaultProps = {
|
||||||
|
isTraceDetailsPage: false,
|
||||||
|
span: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(FunnelConfiguration);
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import './FunnelStep.styles.scss';
|
||||||
|
|
||||||
|
import { Dropdown, Form, Space, Switch } 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 } 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 latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
|
||||||
|
(option) => ({
|
||||||
|
key: option.value,
|
||||||
|
label: option.key,
|
||||||
|
style:
|
||||||
|
option.value === stepData.latency_pointer
|
||||||
|
? { backgroundColor: 'var(--bg-slate-100)' }
|
||||||
|
: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
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.title && (
|
||||||
|
<div className="funnel-step-details__title">{stepData.title}</div>
|
||||||
|
)}
|
||||||
|
{!!stepData.description && (
|
||||||
|
<div className="funnel-step-details__description">
|
||||||
|
{stepData.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="funnel-step-actions">
|
||||||
|
<FunnelStepPopover
|
||||||
|
isPopoverOpen={isPopoverOpen}
|
||||||
|
setIsPopoverOpen={setIsPopoverOpen}
|
||||||
|
stepOrder={stepData.step_order}
|
||||||
|
onStepRemove={(): void => onStepRemove(index)}
|
||||||
|
stepsCount={stepsCount}
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
maxTagCount="responsive"
|
||||||
|
/>
|
||||||
|
</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;
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Button, Popover, Tooltip } from 'antd';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import { Ellipsis, PencilLine, Trash2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import AddFunnelStepDetailsModal from './AddFunnelStepDetailsModal';
|
||||||
|
import DeleteFunnelStep from './DeleteFunnelStep';
|
||||||
|
|
||||||
|
interface FunnelStepPopoverProps {
|
||||||
|
isPopoverOpen: boolean;
|
||||||
|
setIsPopoverOpen: (isOpen: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
stepOrder: number;
|
||||||
|
stepsCount: number;
|
||||||
|
onStepRemove: () => 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,
|
||||||
|
stepOrder,
|
||||||
|
className,
|
||||||
|
onStepRemove,
|
||||||
|
stepsCount,
|
||||||
|
}: FunnelStepPopoverProps): JSX.Element {
|
||||||
|
const [isAddDetailsModalOpen, setIsAddDetailsModalOpen] = useState<boolean>(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
stepOrder={stepOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FunnelStepPopover.defaultProps = {
|
||||||
|
className: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FunnelStepPopover;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import './StepsFooter.styles.scss';
|
||||||
|
|
||||||
|
import { SyncOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Skeleton } from 'antd';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import { Check, Cone, Play } from 'lucide-react';
|
||||||
|
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import AddFunnelDescriptionModal from './AddFunnelDescriptionModal';
|
||||||
|
|
||||||
|
interface StepsFooterProps {
|
||||||
|
stepsCount: number;
|
||||||
|
funnelId: string;
|
||||||
|
funnelDescription: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cx('steps-footer__valid-traces', {
|
||||||
|
'steps-footer__valid-traces--none': validTracesCount === 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{validTracesCount} valid traces
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepsFooter({
|
||||||
|
stepsCount,
|
||||||
|
funnelId,
|
||||||
|
funnelDescription,
|
||||||
|
}: StepsFooterProps): JSX.Element {
|
||||||
|
const { validTracesCount, handleRunFunnel } = useFunnelContext();
|
||||||
|
const [isDescriptionModalOpen, setIsDescriptionModalOpen] = useState(false);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{funnelDescription ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={validTracesCount === 0}
|
||||||
|
onClick={handleRunFunnel}
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
className="steps-footer__button steps-footer__button--save"
|
||||||
|
icon={<Check size={16} />}
|
||||||
|
onClick={(): void => setIsDescriptionModalOpen(true)}
|
||||||
|
>
|
||||||
|
Save funnel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={validTracesCount === 0}
|
||||||
|
onClick={handleRunFunnel}
|
||||||
|
type="primary"
|
||||||
|
className="steps-footer__button steps-footer__button--run"
|
||||||
|
icon={<Play size={16} />}
|
||||||
|
>
|
||||||
|
Run funnel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AddFunnelDescriptionModal
|
||||||
|
isOpen={isDescriptionModalOpen}
|
||||||
|
onClose={(): void => setIsDescriptionModalOpen(false)}
|
||||||
|
funnelId={funnelId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StepsFooter;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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 { useMemo } 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 {
|
||||||
|
successSteps,
|
||||||
|
errorSteps,
|
||||||
|
totalSteps,
|
||||||
|
canvasRef,
|
||||||
|
renderLegendItem,
|
||||||
|
} = useFunnelGraph({ data });
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FunnelGraph;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.funnel-results {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { ErrorTraceData, SlowTraceData } from 'api/traceFunnels';
|
||||||
|
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||||
|
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { UseQueryResult } from 'react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
|
|
||||||
|
import FunnelTable from './FunnelTable';
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FunnelTable
|
||||||
|
title={title}
|
||||||
|
tooltip={tooltip}
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
loading={isLoading || isFetching}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FunnelTopTracesTable;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
49
frontend/src/pages/TracesFunnelDetails/constants.ts
Normal file
49
frontend/src/pages/TracesFunnelDetails/constants.ts
Normal 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,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: v4(),
|
||||||
|
step_order: 2,
|
||||||
|
service_name: '',
|
||||||
|
span_name: '',
|
||||||
|
filters: {
|
||||||
|
items: [],
|
||||||
|
op: 'and',
|
||||||
|
},
|
||||||
|
latency_pointer: 'start',
|
||||||
|
latency_type: LatencyOptions.P95,
|
||||||
|
has_errors: false,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LatencyPointers: {
|
||||||
|
value: FunnelStepData['latency_pointer'];
|
||||||
|
key: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: 'start',
|
||||||
|
key: 'Start of span',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'end',
|
||||||
|
key: 'End of span',
|
||||||
|
},
|
||||||
|
];
|
||||||
237
frontend/src/pages/TracesFunnels/FunnelContext.tsx
Normal file
237
frontend/src/pages/TracesFunnels/FunnelContext.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
funnelId,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
validTracesCount,
|
||||||
|
selectedTime,
|
||||||
|
steps,
|
||||||
|
initialSteps,
|
||||||
|
handleStepUpdate,
|
||||||
|
addNewStep,
|
||||||
|
handleStepRemoval,
|
||||||
|
handleRunFunnel,
|
||||||
|
validationResponse,
|
||||||
|
isValidationLoading,
|
||||||
|
isValidationFetching,
|
||||||
|
hasIncompleteStepFields,
|
||||||
|
setHasIncompleteStepFields,
|
||||||
|
hasAllEmptyStepFields,
|
||||||
|
setHasAllEmptyStepFields,
|
||||||
|
handleReplaceStep,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import '../RenameFunnel/RenameFunnel.styles.scss';
|
import '../RenameFunnel/RenameFunnel.styles.scss';
|
||||||
|
|
||||||
import { Input } from 'antd';
|
import { Input } from 'antd';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
import SignozModal from 'components/SignozModal/SignozModal';
|
import SignozModal from 'components/SignozModal/SignozModal';
|
||||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
@@ -14,10 +15,15 @@ import { generatePath } from 'react-router-dom';
|
|||||||
|
|
||||||
interface CreateFunnelProps {
|
interface CreateFunnelProps {
|
||||||
isOpen: boolean;
|
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 [funnelName, setFunnelName] = useState<string>('');
|
||||||
const createFunnelMutation = useCreateFunnel();
|
const createFunnelMutation = useCreateFunnel();
|
||||||
const { notifications } = useNotifications();
|
const { notifications } = useNotifications();
|
||||||
@@ -37,8 +43,8 @@ function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element {
|
|||||||
});
|
});
|
||||||
setFunnelName('');
|
setFunnelName('');
|
||||||
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
||||||
onClose();
|
onClose(data?.payload?.funnel_id);
|
||||||
if (data?.payload?.funnel_id) {
|
if (data?.payload?.funnel_id && redirectToDetails) {
|
||||||
safeNavigate(
|
safeNavigate(
|
||||||
generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
|
generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
|
||||||
funnelId: data.payload.funnel_id,
|
funnelId: data.payload.funnel_id,
|
||||||
@@ -46,9 +52,11 @@ function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error) => {
|
||||||
notifications.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;
|
export default CreateFunnel;
|
||||||
|
|||||||
@@ -3,26 +3,32 @@ import './DeleteFunnel.styles.scss';
|
|||||||
|
|
||||||
import SignozModal from 'components/SignozModal/SignozModal';
|
import SignozModal from 'components/SignozModal/SignozModal';
|
||||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
import { useDeleteFunnel } from 'hooks/TracesFunnels/useFunnels';
|
import { useDeleteFunnel } from 'hooks/TracesFunnels/useFunnels';
|
||||||
import { useNotifications } from 'hooks/useNotifications';
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
import { Trash2, X } from 'lucide-react';
|
import { Trash2, X } from 'lucide-react';
|
||||||
import { useQueryClient } from 'react-query';
|
import { useQueryClient } from 'react-query';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
interface DeleteFunnelProps {
|
interface DeleteFunnelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
funnelId: string;
|
funnelId: string;
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeleteFunnel({
|
function DeleteFunnel({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
funnelId,
|
funnelId,
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess,
|
||||||
}: DeleteFunnelProps): JSX.Element {
|
}: DeleteFunnelProps): JSX.Element {
|
||||||
const deleteFunnelMutation = useDeleteFunnel();
|
const deleteFunnelMutation = useDeleteFunnel();
|
||||||
const { notifications } = useNotifications();
|
const { notifications } = useNotifications();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const history = useHistory();
|
||||||
|
const { pathname } = history.location;
|
||||||
const handleDelete = (): void => {
|
const handleDelete = (): void => {
|
||||||
deleteFunnelMutation.mutate(
|
deleteFunnelMutation.mutate(
|
||||||
{
|
{
|
||||||
@@ -34,6 +40,14 @@ function DeleteFunnel({
|
|||||||
message: 'Funnel deleted successfully',
|
message: 'Funnel deleted successfully',
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
|
|
||||||
|
if (
|
||||||
|
pathname !== ROUTES.TRACES_FUNNELS &&
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess
|
||||||
|
) {
|
||||||
|
history.push(ROUTES.TRACES_FUNNELS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -81,4 +95,8 @@ function DeleteFunnel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DeleteFunnel.defaultProps = {
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess: true,
|
||||||
|
};
|
||||||
|
|
||||||
export default DeleteFunnel;
|
export default DeleteFunnel;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import LearnMore from 'components/LearnMore/LearnMore';
|
|||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
interface FunnelsEmptyStateProps {
|
interface FunnelsEmptyStateProps {
|
||||||
onCreateFunnel: () => void;
|
onCreateFunnel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FunnelsEmptyState({
|
function FunnelsEmptyState({
|
||||||
@@ -44,4 +44,8 @@ function FunnelsEmptyState({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FunnelsEmptyState.defaultProps = {
|
||||||
|
onCreateFunnel: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
export default FunnelsEmptyState;
|
export default FunnelsEmptyState;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface FunnelItemPopoverProps {
|
|||||||
isPopoverOpen: boolean;
|
isPopoverOpen: boolean;
|
||||||
setIsPopoverOpen: (isOpen: boolean) => void;
|
setIsPopoverOpen: (isOpen: boolean) => void;
|
||||||
funnel: FunnelData;
|
funnel: FunnelData;
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FunnelItemActionsProps {
|
interface FunnelItemActionsProps {
|
||||||
@@ -56,6 +57,7 @@ function FunnelItemPopover({
|
|||||||
isPopoverOpen,
|
isPopoverOpen,
|
||||||
setIsPopoverOpen,
|
setIsPopoverOpen,
|
||||||
funnel,
|
funnel,
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess,
|
||||||
}: FunnelItemPopoverProps): JSX.Element {
|
}: FunnelItemPopoverProps): JSX.Element {
|
||||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState<boolean>(false);
|
const [isRenameModalOpen, setIsRenameModalOpen] = useState<boolean>(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
|
||||||
@@ -71,7 +73,12 @@ function FunnelItemPopover({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
// 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
|
<Popover
|
||||||
trigger="click"
|
trigger="click"
|
||||||
rootClassName="funnel-item__actions"
|
rootClassName="funnel-item__actions"
|
||||||
@@ -96,6 +103,9 @@ function FunnelItemPopover({
|
|||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<DeleteFunnel
|
<DeleteFunnel
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess={
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess
|
||||||
|
}
|
||||||
isOpen={isDeleteModalOpen}
|
isOpen={isDeleteModalOpen}
|
||||||
onClose={(): void => setIsDeleteModalOpen(false)}
|
onClose={(): void => setIsDeleteModalOpen(false)}
|
||||||
funnelId={funnel.id}
|
funnelId={funnel.id}
|
||||||
@@ -111,4 +121,7 @@ function FunnelItemPopover({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FunnelItemPopover.defaultProps = {
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess: true,
|
||||||
|
};
|
||||||
export default FunnelItemPopover;
|
export default FunnelItemPopover;
|
||||||
|
|||||||
@@ -12,16 +12,22 @@ import FunnelItemPopover from './FunnelItemPopover';
|
|||||||
|
|
||||||
interface FunnelListItemProps {
|
interface FunnelListItemProps {
|
||||||
funnel: FunnelData;
|
funnel: FunnelData;
|
||||||
|
onFunnelClick?: (funnel: FunnelData) => void;
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FunnelListItem({ funnel }: FunnelListItemProps): JSX.Element {
|
export function FunnelListItem({
|
||||||
|
funnel,
|
||||||
|
onFunnelClick,
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess,
|
||||||
|
}: FunnelListItemProps): JSX.Element {
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||||
const funnelDetailsLink = generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
|
const funnelDetailsLink = generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
|
||||||
funnelId: funnel.id,
|
funnelId: funnel.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<Link to={funnelDetailsLink} className="funnel-item">
|
<>
|
||||||
<div className="funnel-item__header">
|
<div className="funnel-item__header">
|
||||||
<div className="funnel-item__title">
|
<div className="funnel-item__title">
|
||||||
<div>{funnel.funnel_name}</div>
|
<div>{funnel.funnel_name}</div>
|
||||||
@@ -30,6 +36,9 @@ function FunnelListItem({ funnel }: FunnelListItemProps): JSX.Element {
|
|||||||
isPopoverOpen={isPopoverOpen}
|
isPopoverOpen={isPopoverOpen}
|
||||||
setIsPopoverOpen={setIsPopoverOpen}
|
setIsPopoverOpen={setIsPopoverOpen}
|
||||||
funnel={funnel}
|
funnel={funnel}
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess={
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,22 +61,59 @@ function FunnelListItem({ funnel }: FunnelListItemProps): JSX.Element {
|
|||||||
<div>{funnel.user}</div>
|
<div>{funnel.user}</div>
|
||||||
</div>
|
</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>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FunnelListItem.defaultProps = {
|
||||||
|
onFunnelClick: undefined,
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess: true,
|
||||||
|
};
|
||||||
|
|
||||||
interface FunnelsListProps {
|
interface FunnelsListProps {
|
||||||
data: FunnelData[];
|
data: FunnelData[];
|
||||||
|
onFunnelClick?: (funnel: FunnelData) => void;
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FunnelsList({ data }: FunnelsListProps): JSX.Element {
|
function FunnelsList({
|
||||||
|
data,
|
||||||
|
onFunnelClick,
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess,
|
||||||
|
}: FunnelsListProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="funnels-list">
|
<div className="funnels-list">
|
||||||
{data.map((funnel) => (
|
{data?.map((funnel) => (
|
||||||
<FunnelListItem key={funnel.id} funnel={funnel} />
|
<FunnelListItem
|
||||||
|
key={funnel.id}
|
||||||
|
funnel={funnel}
|
||||||
|
onFunnelClick={onFunnelClick}
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess={
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FunnelsList.defaultProps = {
|
||||||
|
onFunnelClick: undefined,
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess: true,
|
||||||
|
};
|
||||||
|
|
||||||
export default FunnelsList;
|
export default FunnelsList;
|
||||||
|
|||||||
@@ -17,18 +17,22 @@ interface TracesFunnelsContentRendererProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
data: FunnelData[];
|
data: FunnelData[];
|
||||||
onCreateFunnel: () => void;
|
onCreateFunnel?: () => void;
|
||||||
|
onFunnelClick?: (funnel: FunnelData) => void;
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess?: boolean;
|
||||||
}
|
}
|
||||||
function TracesFunnelsContentRenderer({
|
export function TracesFunnelsContentRenderer({
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
data,
|
data,
|
||||||
onCreateFunnel,
|
onCreateFunnel,
|
||||||
|
onFunnelClick,
|
||||||
|
shouldRedirectToTracesListOnDeleteSuccess,
|
||||||
}: TracesFunnelsContentRendererProps): JSX.Element {
|
}: TracesFunnelsContentRendererProps): JSX.Element {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="traces-funnels__loading">
|
<div className="traces-funnels__loading">
|
||||||
{Array(6)
|
{Array(2)
|
||||||
.fill(0)
|
.fill(0)
|
||||||
.map((item, index) => (
|
.map((item, index) => (
|
||||||
<Skeleton.Button
|
<Skeleton.Button
|
||||||
@@ -49,13 +53,27 @@ function TracesFunnelsContentRenderer({
|
|||||||
return <div>Something went wrong</div>;
|
return <div>Something went wrong</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0 && onCreateFunnel) {
|
||||||
return <FunnelsEmptyState onCreateFunnel={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 {
|
function TracesFunnels(): JSX.Element {
|
||||||
const { searchQuery, handleSearch } = useHandleTraceFunnelsSearch();
|
const { searchQuery, handleSearch } = useHandleTraceFunnelsSearch();
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
|
||||||
|
|||||||
@@ -2,24 +2,17 @@ import './TracesModulePage.styles.scss';
|
|||||||
|
|
||||||
import RouteTab from 'components/RouteTab';
|
import RouteTab from 'components/RouteTab';
|
||||||
import { TabRoutes } from 'components/RouteTab/types';
|
import { TabRoutes } from 'components/RouteTab/types';
|
||||||
import { FeatureKeys } from 'constants/features';
|
|
||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
import { useAppContext } from 'providers/App/App';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { tracesExplorer, tracesFunnel, tracesSaveView } from './constants';
|
import { tracesExplorer, tracesFunnel, tracesSaveView } from './constants';
|
||||||
|
|
||||||
function TracesModulePage(): JSX.Element {
|
function TracesModulePage(): JSX.Element {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { featureFlags } = useAppContext();
|
|
||||||
|
|
||||||
const isTraceFunnelsEnabled =
|
|
||||||
featureFlags?.find((flag) => flag.name === FeatureKeys.TRACE_FUNNELS)
|
|
||||||
?.active || false;
|
|
||||||
|
|
||||||
const routes: TabRoutes[] = [
|
const routes: TabRoutes[] = [
|
||||||
tracesExplorer,
|
tracesExplorer,
|
||||||
isTraceFunnelsEnabled ? tracesFunnel : null,
|
process.env.NODE_ENV === 'development' ? tracesFunnel : null,
|
||||||
tracesSaveView,
|
tracesSaveView,
|
||||||
].filter(Boolean) as TabRoutes[];
|
].filter(Boolean) as TabRoutes[];
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import ROUTES from 'constants/routes';
|
|||||||
import { Compass, Cone, TowerControl } from 'lucide-react';
|
import { Compass, Cone, TowerControl } from 'lucide-react';
|
||||||
import SaveView from 'pages/SaveView';
|
import SaveView from 'pages/SaveView';
|
||||||
import TracesExplorer from 'pages/TracesExplorer';
|
import TracesExplorer from 'pages/TracesExplorer';
|
||||||
|
import TracesFunnelDetails from 'pages/TracesFunnelDetails';
|
||||||
import TracesFunnels from 'pages/TracesFunnels';
|
import TracesFunnels from 'pages/TracesFunnels';
|
||||||
|
import { matchPath } from 'react-router-dom';
|
||||||
|
|
||||||
export const tracesExplorer: TabRoutes = {
|
export const tracesExplorer: TabRoutes = {
|
||||||
Component: TracesExplorer,
|
Component: TracesExplorer,
|
||||||
@@ -16,8 +18,12 @@ export const tracesExplorer: TabRoutes = {
|
|||||||
key: ROUTES.TRACES_EXPLORER,
|
key: ROUTES.TRACES_EXPLORER,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tracesFunnel: TabRoutes = {
|
export const tracesFunnel = (pathname: string): TabRoutes => ({
|
||||||
Component: TracesFunnels,
|
Component: (): JSX.Element => {
|
||||||
|
const isFunnelDetails = matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL);
|
||||||
|
|
||||||
|
return isFunnelDetails ? <TracesFunnelDetails /> : <TracesFunnels />;
|
||||||
|
},
|
||||||
name: (
|
name: (
|
||||||
<div className="tab-item">
|
<div className="tab-item">
|
||||||
<Cone className="funnel-icon" size={16} /> Funnels
|
<Cone className="funnel-icon" size={16} /> Funnels
|
||||||
@@ -25,7 +31,7 @@ export const tracesFunnel: TabRoutes = {
|
|||||||
),
|
),
|
||||||
route: ROUTES.TRACES_FUNNELS,
|
route: ROUTES.TRACES_FUNNELS,
|
||||||
key: ROUTES.TRACES_FUNNELS,
|
key: ROUTES.TRACES_FUNNELS,
|
||||||
};
|
});
|
||||||
|
|
||||||
export const tracesSaveView: TabRoutes = {
|
export const tracesSaveView: TabRoutes = {
|
||||||
Component: SaveView,
|
Component: SaveView,
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { TagFilter } from '../queryBuilder/queryBuilderData';
|
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;
|
id: string;
|
||||||
funnel_order: number;
|
step_order: number;
|
||||||
service_name: string;
|
service_name: string;
|
||||||
span_name: string;
|
span_name: string;
|
||||||
filters: TagFilter;
|
filters: TagFilter;
|
||||||
latency_pointer: 'start' | 'end';
|
latency_pointer: 'start' | 'end';
|
||||||
latency_type: 'p95' | 'p99' | 'p90';
|
latency_type: LatencyOptionsType;
|
||||||
has_errors: boolean;
|
has_errors: boolean;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FunnelData {
|
export interface FunnelData {
|
||||||
@@ -17,7 +26,8 @@ export interface FunnelData {
|
|||||||
creation_timestamp: number;
|
creation_timestamp: number;
|
||||||
updated_timestamp: number;
|
updated_timestamp: number;
|
||||||
user: string;
|
user: string;
|
||||||
steps?: FunnelStep[];
|
description?: string;
|
||||||
|
steps?: FunnelStepData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateFunnelPayload {
|
export interface CreateFunnelPayload {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,66 +9,10 @@ type Feature struct {
|
|||||||
Route string `db:"route" json:"route"`
|
Route string `db:"route" json:"route"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const SmartTraceDetail = "SMART_TRACE_DETAIL"
|
|
||||||
const CustomMetricsFunction = "CUSTOM_METRICS_FUNCTION"
|
|
||||||
const DisableUpsell = "DISABLE_UPSELL"
|
|
||||||
const OSS = "OSS"
|
|
||||||
const QueryBuilderPanels = "QUERY_BUILDER_PANELS"
|
|
||||||
const QueryBuilderAlerts = "QUERY_BUILDER_ALERTS"
|
|
||||||
const UseSpanMetrics = "USE_SPAN_METRICS"
|
const UseSpanMetrics = "USE_SPAN_METRICS"
|
||||||
const AlertChannelSlack = "ALERT_CHANNEL_SLACK"
|
|
||||||
const AlertChannelWebhook = "ALERT_CHANNEL_WEBHOOK"
|
|
||||||
const AlertChannelPagerduty = "ALERT_CHANNEL_PAGERDUTY"
|
|
||||||
const AlertChannelMsTeams = "ALERT_CHANNEL_MSTEAMS"
|
|
||||||
const AlertChannelOpsgenie = "ALERT_CHANNEL_OPSGENIE"
|
|
||||||
const AlertChannelEmail = "ALERT_CHANNEL_EMAIL"
|
|
||||||
const AnomalyDetection = "ANOMALY_DETECTION"
|
const AnomalyDetection = "ANOMALY_DETECTION"
|
||||||
const HostsInfraMonitoring = "HOSTS_INFRA_MONITORING"
|
|
||||||
const TraceFunnels = "TRACE_FUNNELS"
|
|
||||||
|
|
||||||
var BasicPlan = FeatureSet{
|
var BasicPlan = FeatureSet{
|
||||||
Feature{
|
|
||||||
Name: OSS,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
|
||||||
Name: DisableUpsell,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
|
||||||
Name: SmartTraceDetail,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
|
||||||
Name: CustomMetricsFunction,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
|
||||||
Name: QueryBuilderPanels,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
|
||||||
Name: QueryBuilderAlerts,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
Feature{
|
||||||
Name: UseSpanMetrics,
|
Name: UseSpanMetrics,
|
||||||
Active: false,
|
Active: false,
|
||||||
@@ -76,48 +20,6 @@ var BasicPlan = FeatureSet{
|
|||||||
UsageLimit: -1,
|
UsageLimit: -1,
|
||||||
Route: "",
|
Route: "",
|
||||||
},
|
},
|
||||||
Feature{
|
|
||||||
Name: AlertChannelSlack,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
|
||||||
Name: AlertChannelWebhook,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
|
||||||
Name: AlertChannelPagerduty,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
|
||||||
Name: AlertChannelOpsgenie,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
|
||||||
Name: AlertChannelEmail,
|
|
||||||
Active: true,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
|
||||||
Name: AlertChannelMsTeams,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
Feature{
|
Feature{
|
||||||
Name: AnomalyDetection,
|
Name: AnomalyDetection,
|
||||||
Active: false,
|
Active: false,
|
||||||
@@ -125,11 +27,4 @@ var BasicPlan = FeatureSet{
|
|||||||
UsageLimit: -1,
|
UsageLimit: -1,
|
||||||
Route: "",
|
Route: "",
|
||||||
},
|
},
|
||||||
Feature{
|
|
||||||
Name: TraceFunnels,
|
|
||||||
Active: false,
|
|
||||||
Usage: 0,
|
|
||||||
UsageLimit: -1,
|
|
||||||
Route: "",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user