mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-29 17:24:16 +00:00
Compare commits
26 Commits
multiselec
...
v0.80.0-53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53b98b0baf | ||
|
|
3d3cd09e56 | ||
|
|
8a5f01692e | ||
|
|
71d1dfe9bd | ||
|
|
459712d25c | ||
|
|
61de2d414d | ||
|
|
0b7cd4c1a7 | ||
|
|
62c033ccf8 | ||
|
|
e637487984 | ||
|
|
8fc43a00f8 | ||
|
|
031d62ca44 | ||
|
|
8c4c357351 | ||
|
|
d8d8191a32 | ||
|
|
a876c0a744 | ||
|
|
c36f913a90 | ||
|
|
ed597f00c0 | ||
|
|
4957d3ae93 | ||
|
|
8835e3493d | ||
|
|
027a1631ef | ||
|
|
d7a6607a25 | ||
|
|
7a58bc58c9 | ||
|
|
88be23c3e3 | ||
|
|
8f095dfbc9 | ||
|
|
72207691a3 | ||
|
|
8998ca652e | ||
|
|
f4ae5f19ff |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -54,6 +54,7 @@ ee/query-service/tests/test-deploy/data/
|
||||
bin/
|
||||
.local/
|
||||
*/query-service/queries.active
|
||||
ee/query-service/db
|
||||
|
||||
# e2e
|
||||
|
||||
|
||||
@@ -77,4 +77,4 @@ Need assistance? Join our Slack community:
|
||||
## Where do I go from here?
|
||||
|
||||
- Set up your [development environment](docs/contributing/development.md)
|
||||
- Deploy and observe [SigNoz in action with OpenTelemetry Demo Application](docs/otel-demo/otel-demo-docs.md)
|
||||
- Deploy and observe [SigNoz in action with OpenTelemetry Demo Application](docs/otel-demo-docs.md)
|
||||
|
||||
@@ -313,6 +313,9 @@ func (p *BaseSeasonalProvider) getScore(
|
||||
series, prevSeries, weekSeries, weekPrevSeries, past2SeasonSeries, past3SeasonSeries *v3.Series, value float64, idx int,
|
||||
) float64 {
|
||||
expectedValue := p.getExpectedValue(series, prevSeries, weekSeries, weekPrevSeries, past2SeasonSeries, past3SeasonSeries, idx)
|
||||
if expectedValue < 0 {
|
||||
expectedValue = p.getMovingAvg(prevSeries, movingAvgWindowSize, idx)
|
||||
}
|
||||
return (value - expectedValue) / p.getStdDev(weekSeries)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/app"
|
||||
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/config"
|
||||
"github.com/SigNoz/signoz/pkg/config/envprovider"
|
||||
"github.com/SigNoz/signoz/pkg/config/fileprovider"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/auth"
|
||||
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
|
||||
@@ -94,12 +96,17 @@ func main() {
|
||||
|
||||
version.Info.PrettyPrint(config.Version)
|
||||
|
||||
sqlStoreFactories := signoz.NewSQLStoreProviderFactories()
|
||||
if err := sqlStoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil {
|
||||
zap.L().Fatal("Failed to add postgressqlstore factory", zap.Error(err))
|
||||
}
|
||||
|
||||
signoz, err := signoz.New(
|
||||
context.Background(),
|
||||
config,
|
||||
signoz.NewCacheProviderFactories(),
|
||||
signoz.NewWebProviderFactories(),
|
||||
signoz.NewSQLStoreProviderFactories(),
|
||||
sqlStoreFactories,
|
||||
signoz.NewTelemetryStoreProviderFactories(),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
)
|
||||
|
||||
const SSO = "SSO"
|
||||
const Basic = "BASIC_PLAN"
|
||||
const Pro = "PRO_PLAN"
|
||||
const Enterprise = "ENTERPRISE_PLAN"
|
||||
|
||||
var (
|
||||
PlanNameEnterprise = "ENTERPRISE"
|
||||
PlanNameTeams = "TEAMS"
|
||||
PlanNameBasic = "BASIC"
|
||||
)
|
||||
|
||||
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 (
|
||||
LicenseStatusInvalid = "INVALID"
|
||||
)
|
||||
|
||||
const DisableUpsell = "DISABLE_UPSELL"
|
||||
const Onboarding = "ONBOARDING"
|
||||
const ChatSupport = "CHAT_SUPPORT"
|
||||
const Gateway = "GATEWAY"
|
||||
@@ -38,90 +34,6 @@ var BasicPlan = basemodel.FeatureSet{
|
||||
UsageLimit: -1,
|
||||
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{
|
||||
Name: basemodel.UseSpanMetrics,
|
||||
Active: false,
|
||||
@@ -150,135 +62,7 @@ var BasicPlan = basemodel.FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: basemodel.HostsInfraMonitoring,
|
||||
Active: constants.EnableHostsInfraMonitoring(),
|
||||
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: "",
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
var EnterprisePlan = basemodel.FeatureSet{
|
||||
@@ -289,83 +73,6 @@ var EnterprisePlan = basemodel.FeatureSet{
|
||||
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,
|
||||
@@ -408,11 +115,5 @@ var EnterprisePlan = basemodel.FeatureSet{
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
},
|
||||
basemodel.Feature{
|
||||
Name: basemodel.HostsInfraMonitoring,
|
||||
Active: constants.EnableHostsInfraMonitoring(),
|
||||
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 |
@@ -60,10 +60,14 @@
|
||||
"INTEGRATIONS": "SigNoz | Integrations",
|
||||
"ALERT_HISTORY": "SigNoz | Alert Rule History",
|
||||
"ALERT_OVERVIEW": "SigNoz | Alert Rule Overview",
|
||||
"MESSAGING_QUEUES": "SigNoz | Messaging Queues",
|
||||
"MESSAGING_QUEUES_OVERVIEW": "SigNoz | Messaging Queues",
|
||||
"MESSAGING_QUEUES_KAFKA": "SigNoz | Messaging Queues | Kafka",
|
||||
"MESSAGING_QUEUES_KAFKA_DETAIL": "SigNoz | Messaging Queues | Kafka",
|
||||
"MESSAGING_QUEUES_CELERY_TASK": "SigNoz | Messaging Queues | Celery",
|
||||
"INFRASTRUCTURE_MONITORING_HOSTS": "SigNoz | Infra Monitoring",
|
||||
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
|
||||
"METRICS_EXPLORER": "SigNoz | Metrics Explorer",
|
||||
"METRICS_EXPLORER_EXPLORER": "SigNoz | Metrics Explorer",
|
||||
"METRICS_EXPLORER_VIEWS": "SigNoz | Metrics Explorer"
|
||||
"METRICS_EXPLORER_VIEWS": "SigNoz | Metrics Explorer",
|
||||
"API_MONITORING": "SigNoz | API Monitoring"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Route, Router, Switch } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { extractDomain } from 'utils/app';
|
||||
|
||||
import { Home } from './pageComponents';
|
||||
import PrivateRoute from './Private';
|
||||
import defaultRoutes, {
|
||||
AppRoutes,
|
||||
@@ -310,6 +311,7 @@ function App(): JSX.Element {
|
||||
/>
|
||||
))}
|
||||
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
|
||||
@@ -47,9 +47,10 @@ export const TracesFunnels = Loadable(
|
||||
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),
|
||||
);
|
||||
export const TracesFunnelDetails = Loadable(
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesFunnelDetails'
|
||||
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesModulePage'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -207,7 +208,7 @@ export const PasswordReset = Loadable(
|
||||
export const SomethingWentWrong = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "SomethingWentWrong" */ 'pages/SomethingWentWrong'
|
||||
/* webpackChunkName: "ErrorBoundaryFallback" */ 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -299,10 +300,3 @@ export const MetricsExplorer = Loadable(
|
||||
export const ApiMonitoring = Loadable(
|
||||
() => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'),
|
||||
);
|
||||
|
||||
export const DynamicVariableTest = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "DynamicVariableTest" */ 'pages/DynamicVariableTest'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
CustomDomainSettings,
|
||||
DashboardPage,
|
||||
DashboardWidget,
|
||||
DynamicVariableTest,
|
||||
EditAlertChannelsAlerts,
|
||||
EditRulesPage,
|
||||
ErrorDetails,
|
||||
@@ -506,13 +505,6 @@ const routes: AppRoutes[] = [
|
||||
key: 'API_MONITORING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.DYNAMIC_VARIABLE_TEST,
|
||||
exact: true,
|
||||
component: DynamicVariableTest,
|
||||
key: 'DYNAMIC_VARIABLE_TEST',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CreateFunnelPayload,
|
||||
CreateFunnelResponse,
|
||||
FunnelData,
|
||||
FunnelStepData,
|
||||
} from 'types/api/traceFunnels';
|
||||
|
||||
const FUNNELS_BASE_PATH = '/trace-funnels';
|
||||
@@ -54,7 +55,7 @@ export const getFunnelsList = async ({
|
||||
};
|
||||
|
||||
export const getFunnelById = async (
|
||||
funnelId: string,
|
||||
funnelId?: string,
|
||||
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||
const response: AxiosResponse = await axios.get(
|
||||
`${FUNNELS_BASE_PATH}/get/${funnelId}`,
|
||||
@@ -107,3 +108,267 @@ export const deleteFunnel = async (
|
||||
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 { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
interface SelectOptionConfig {
|
||||
export interface SelectOptionConfig {
|
||||
placeholder: string;
|
||||
queryParam: QueryParams;
|
||||
filterType: string | string[];
|
||||
shouldSetQueryParams?: boolean;
|
||||
onChange?: (value: string | string[]) => void;
|
||||
values?: string | string[];
|
||||
isMultiple?: boolean;
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
export function FilterSelect({
|
||||
placeholder,
|
||||
queryParam,
|
||||
filterType,
|
||||
values,
|
||||
shouldSetQueryParams,
|
||||
onChange,
|
||||
isMultiple,
|
||||
}: SelectOptionConfig): JSX.Element {
|
||||
const { handleSearch, isFetching, options } = useCeleryFilterOptions(
|
||||
filterType,
|
||||
@@ -35,7 +43,8 @@ function FilterSelect({
|
||||
key={filterType.toString()}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
mode="multiple"
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...(isMultiple ? { mode: 'multiple' } : {})}
|
||||
options={options}
|
||||
loading={isFetching}
|
||||
className="config-select-option"
|
||||
@@ -43,7 +52,11 @@ function FilterSelect({
|
||||
maxTagCount={4}
|
||||
allowClear
|
||||
maxTagPlaceholder={SelectMaxTagPlaceholder}
|
||||
value={getValuesFromQueryParams(queryParam, urlQuery) || []}
|
||||
value={
|
||||
!shouldSetQueryParams && !!values?.length
|
||||
? values
|
||||
: getValuesFromQueryParams(queryParam, urlQuery) || []
|
||||
}
|
||||
notFoundContent={
|
||||
isFetching ? (
|
||||
<span>
|
||||
@@ -55,12 +68,28 @@ function FilterSelect({
|
||||
}
|
||||
onChange={(value): void => {
|
||||
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 {
|
||||
const selectConfigs: SelectOptionConfig[] = [
|
||||
{
|
||||
|
||||
@@ -521,7 +521,7 @@ export default function CeleryOverviewTable({
|
||||
locale={{
|
||||
emptyText: isLoading ? null : <Typography.Text>No data</Typography.Text>,
|
||||
}}
|
||||
scroll={{ x: true }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
showSorterTooltip
|
||||
onDragColumn={handleDragColumn}
|
||||
onRow={(record): { onClick: () => void; className: string } => ({
|
||||
|
||||
@@ -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;
|
||||
@@ -18,6 +18,7 @@ function CopyClipboardHOC({
|
||||
|
||||
notifications.success({
|
||||
message: notificationMessage,
|
||||
key: notificationMessage,
|
||||
});
|
||||
}
|
||||
}, [value, notifications, entityKey]);
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
.multi-select-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.multi-select-label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.multi-select-input {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: text;
|
||||
background-color: #fff;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&.multi-select-input-focused {
|
||||
border-color: #40a9ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.multi-select-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.multi-select-chip {
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
|
||||
.multi-select-chip-remove {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multi-select-search {
|
||||
flex: 1;
|
||||
min-width: 50px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// Override Ant Design's styles
|
||||
.ant-input {
|
||||
background: transparent;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multi-select-clear-all {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
.multi-select-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
margin-top: 4px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1050;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.multi-select-option {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.multi-select-divider {
|
||||
height: 1px;
|
||||
background-color: #f0f0f0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.multi-select-section-label {
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.multi-select-loading {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.multi-select-no-results {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.multi-select-options-container,
|
||||
.multi-select-section-content {
|
||||
overflow-y: auto;
|
||||
|
||||
/* For WebKit browsers */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #aaa;
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #ccc #f0f0f0;
|
||||
}
|
||||
|
||||
.multi-select-error {
|
||||
border-color: #ff4d4f;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.multi-select-input-focused {
|
||||
border-color: #ff7875;
|
||||
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.multi-select-error-text {
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.multi-select-disabled {
|
||||
.multi-select-input {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
border-color: #d9d9d9;
|
||||
|
||||
&:hover {
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
}
|
||||
|
||||
.multi-select-chip {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
background-color: #eee;
|
||||
}
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import './MultiSelect.styles.scss';
|
||||
|
||||
import { CloseOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { Checkbox, Input, Spin } from 'antd';
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import { InputRef } from 'antd/lib/input';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface MultiSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface MultiSelectSection {
|
||||
title: string;
|
||||
options: MultiSelectOption[];
|
||||
}
|
||||
|
||||
export interface MultiSelectProps {
|
||||
/** Array of options to display in the dropdown */
|
||||
options: MultiSelectOption[];
|
||||
/** Callback when selected values change */
|
||||
onChange: (selectedValues: string[]) => void;
|
||||
/** Currently selected values */
|
||||
value?: string[];
|
||||
/** Placeholder text for the search input */
|
||||
placeholder?: string;
|
||||
/** Whether the component is in loading state */
|
||||
loading?: boolean;
|
||||
/** Allow users to add custom values */
|
||||
allowCustomValues?: boolean;
|
||||
/** Callback when search text changes - can be used for server filtering */
|
||||
onSearch?: (searchText: string) => void;
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
/** Additional sections to display (e.g., "Related Values") */
|
||||
additionalSections?: MultiSelectSection[];
|
||||
/** Show "Select All" option */
|
||||
showSelectAll?: boolean;
|
||||
/** Maximum height of dropdown in pixels */
|
||||
dropdownMaxHeight?: number;
|
||||
/** Maximum width of dropdown in pixels (defaults to matching input width) */
|
||||
dropdownMaxWidth?: number;
|
||||
/** Disable the component */
|
||||
disabled?: boolean;
|
||||
/** Error message to display */
|
||||
error?: string;
|
||||
/** Label text */
|
||||
label?: string;
|
||||
/** Allow users to clear all selections */
|
||||
allowClear?: boolean;
|
||||
/** Maximum height of a section */
|
||||
sectionMaxHeight?: number;
|
||||
}
|
||||
|
||||
function MultiSelect({
|
||||
options,
|
||||
onChange,
|
||||
value = [],
|
||||
placeholder = 'Search...',
|
||||
loading = false,
|
||||
allowCustomValues = true,
|
||||
onSearch,
|
||||
className = '',
|
||||
additionalSections = [],
|
||||
showSelectAll = true,
|
||||
dropdownMaxHeight = 400,
|
||||
dropdownMaxWidth,
|
||||
disabled = false,
|
||||
error,
|
||||
label,
|
||||
allowClear = true,
|
||||
sectionMaxHeight = 150,
|
||||
}: MultiSelectProps): JSX.Element {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>(value);
|
||||
const [displayOptions, setDisplayOptions] = useState<MultiSelectOption[]>(
|
||||
options,
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [focusedChipIndex, setFocusedChipIndex] = useState<number>(-1);
|
||||
const chipRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Handle save action - memoize with useCallback
|
||||
const handleSave = useCallback((): void => {
|
||||
setIsDropdownOpen(false);
|
||||
setSearchText('');
|
||||
onChange(selectedValues);
|
||||
}, [onChange, selectedValues]);
|
||||
|
||||
// Synchronize value prop with internal state
|
||||
useEffect(() => {
|
||||
setSelectedValues(value);
|
||||
}, [value]);
|
||||
|
||||
// Filter and sort options based on search text
|
||||
useEffect(() => {
|
||||
// Filter options based on search text
|
||||
const filteredOptions = options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
// Add custom value option if no matches found and allowCustomValues is true
|
||||
if (
|
||||
allowCustomValues &&
|
||||
searchText &&
|
||||
!filteredOptions.some(
|
||||
(option) => option.label.toLowerCase() === searchText.toLowerCase(),
|
||||
) &&
|
||||
!filteredOptions.some(
|
||||
(option) => option.value.toLowerCase() === searchText.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
filteredOptions.unshift({
|
||||
label: `Add "${searchText}"`,
|
||||
value: searchText,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort options: selected first, then matching search term
|
||||
const sortedOptions = [...filteredOptions].sort((a, b) => {
|
||||
// First by selection status
|
||||
if (selectedValues.includes(a.value) && !selectedValues.includes(b.value))
|
||||
return -1;
|
||||
if (!selectedValues.includes(a.value) && selectedValues.includes(b.value))
|
||||
return 1;
|
||||
|
||||
// Then by match position (exact matches or starts with come first)
|
||||
const aLower = a.label.toLowerCase();
|
||||
const bLower = b.label.toLowerCase();
|
||||
const searchLower = searchText.toLowerCase();
|
||||
|
||||
if (aLower === searchLower && bLower !== searchLower) return -1;
|
||||
if (aLower !== searchLower && bLower === searchLower) return 1;
|
||||
if (aLower.startsWith(searchLower) && !bLower.startsWith(searchLower))
|
||||
return -1;
|
||||
if (!aLower.startsWith(searchLower) && bLower.startsWith(searchLower))
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
setDisplayOptions(sortedOptions);
|
||||
}, [options, searchText, selectedValues, allowCustomValues]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return (): void => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [selectedValues, handleSave]);
|
||||
|
||||
// Adjust dropdown position if needed
|
||||
useEffect(() => {
|
||||
if (isDropdownOpen && dropdownRef.current && containerRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const dropdownHeight = dropdownRef.current.offsetHeight;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Check if dropdown extends beyond viewport bottom
|
||||
if (
|
||||
containerRect.bottom + dropdownHeight > viewportHeight &&
|
||||
containerRect.top > dropdownHeight
|
||||
) {
|
||||
dropdownRef.current.style.top = 'auto';
|
||||
dropdownRef.current.style.bottom = '100%';
|
||||
dropdownRef.current.style.marginTop = '0';
|
||||
dropdownRef.current.style.marginBottom = '4px';
|
||||
}
|
||||
}
|
||||
}, [isDropdownOpen, displayOptions]);
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const text = e.target.value;
|
||||
setSearchText(text);
|
||||
if (onSearch) {
|
||||
onSearch(text);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle selection change
|
||||
const handleSelectionChange = (
|
||||
option: MultiSelectOption,
|
||||
e: CheckboxChangeEvent,
|
||||
): void => {
|
||||
const { checked } = e.target;
|
||||
let newSelectedValues: string[];
|
||||
|
||||
if (checked) {
|
||||
newSelectedValues = [...selectedValues, option.value];
|
||||
} else {
|
||||
newSelectedValues = selectedValues.filter((val) => val !== option.value);
|
||||
}
|
||||
|
||||
setSelectedValues(newSelectedValues);
|
||||
};
|
||||
|
||||
// Handle "All" checkbox change
|
||||
const handleSelectAll = (e: CheckboxChangeEvent): void => {
|
||||
if (e.target.checked) {
|
||||
const allValues = options
|
||||
.filter((option) => !option.disabled)
|
||||
.map((option) => option.value);
|
||||
setSelectedValues(allValues);
|
||||
} else {
|
||||
setSelectedValues([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a selected item
|
||||
const handleRemoveItem = useCallback(
|
||||
(value: string): void => {
|
||||
const newSelectedValues = selectedValues.filter((val) => val !== value);
|
||||
setSelectedValues(newSelectedValues);
|
||||
},
|
||||
[selectedValues],
|
||||
);
|
||||
|
||||
// Handle clicking the input area
|
||||
const handleInputClick = (): void => {
|
||||
if (!disabled) {
|
||||
setIsDropdownOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clear all selections
|
||||
const handleClearAll = (): void => {
|
||||
setSelectedValues([]);
|
||||
setSearchText('');
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// Get display value of a selection (chips)
|
||||
const getSelectedOptions = (): MultiSelectOption[] =>
|
||||
selectedValues.map((value) => {
|
||||
const option = options.find((opt) => opt.value === value);
|
||||
return {
|
||||
label: option?.label || value,
|
||||
value,
|
||||
};
|
||||
});
|
||||
|
||||
const selectedOptions = getSelectedOptions();
|
||||
const allSelectableOptions = options.filter((option) => !option.disabled);
|
||||
const allSelected =
|
||||
allSelectableOptions.length > 0 &&
|
||||
selectedValues.length === allSelectableOptions.length;
|
||||
|
||||
const containerClasses = [
|
||||
'multi-select-container',
|
||||
className,
|
||||
disabled ? 'multi-select-disabled' : '',
|
||||
error ? 'multi-select-error' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const inputClasses = [
|
||||
'multi-select-input',
|
||||
isDropdownOpen ? 'multi-select-input-focused' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
// Reset chip refs array when selected options change
|
||||
useEffect(() => {
|
||||
chipRefs.current = Array(selectedOptions.length).fill(null);
|
||||
}, [selectedOptions.length]);
|
||||
|
||||
// Handle chip keyboard navigation
|
||||
const handleChipKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, index: number) => {
|
||||
e.stopPropagation(); // Prevent bubbling to container
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
// Move focus to previous chip
|
||||
if (index > 0) {
|
||||
setFocusedChipIndex(index - 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
// Move focus to next chip or input
|
||||
if (index < selectedOptions.length - 1) {
|
||||
setFocusedChipIndex(index + 1);
|
||||
} else {
|
||||
// Focus the input when at the last chip
|
||||
setFocusedChipIndex(-1);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
e.preventDefault();
|
||||
// Remove current chip
|
||||
handleRemoveItem(selectedOptions[index].value);
|
||||
|
||||
// Adjust focus after deletion
|
||||
if (selectedOptions.length > 1) {
|
||||
// Focus previous chip if not at beginning
|
||||
const newIndex = Math.min(index, selectedOptions.length - 2);
|
||||
setFocusedChipIndex(newIndex);
|
||||
} else {
|
||||
// If this was the last chip, focus input
|
||||
setFocusedChipIndex(-1);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
// Return focus to input
|
||||
setFocusedChipIndex(-1);
|
||||
inputRef.current?.focus();
|
||||
break;
|
||||
default:
|
||||
// No-op for unhandled keys
|
||||
break;
|
||||
}
|
||||
},
|
||||
[selectedOptions, handleRemoveItem],
|
||||
);
|
||||
|
||||
// Handle key events in the input
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Add custom value on Enter if it doesn't exist
|
||||
if (
|
||||
allowCustomValues &&
|
||||
searchText &&
|
||||
!options.some(
|
||||
(option) => option.value.toLowerCase() === searchText.toLowerCase(),
|
||||
) &&
|
||||
!options.some(
|
||||
(option) => option.label.toLowerCase() === searchText.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
const newSelectedValues = [...selectedValues, searchText];
|
||||
setSelectedValues(newSelectedValues);
|
||||
setSearchText('');
|
||||
} else if (isDropdownOpen) {
|
||||
handleSave();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
handleSave();
|
||||
} else if (
|
||||
e.key === 'Backspace' &&
|
||||
!searchText &&
|
||||
selectedValues.length > 0
|
||||
) {
|
||||
// Remove the last selected item when pressing backspace in an empty input
|
||||
const newSelectedValues = [...selectedValues];
|
||||
newSelectedValues.pop();
|
||||
setSelectedValues(newSelectedValues);
|
||||
} else if (e.key === 'Tab' && isDropdownOpen) {
|
||||
// Close dropdown but keep focus within component
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
|
||||
// Add navigation TO chips when in input field
|
||||
if (e.key === 'ArrowLeft' && !searchText && selectedOptions.length > 0) {
|
||||
e.preventDefault();
|
||||
setFocusedChipIndex(selectedOptions.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Focus the appropriate chip when focusedChipIndex changes
|
||||
useEffect(() => {
|
||||
if (focusedChipIndex >= 0 && chipRefs.current[focusedChipIndex]) {
|
||||
chipRefs.current[focusedChipIndex]?.focus();
|
||||
}
|
||||
}, [focusedChipIndex]);
|
||||
|
||||
return (
|
||||
<div className={containerClasses} ref={containerRef}>
|
||||
{label && <div className="multi-select-label">{label}</div>}
|
||||
<div
|
||||
className={inputClasses}
|
||||
onClick={handleInputClick}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleInputClick();
|
||||
}
|
||||
}}
|
||||
role="combobox"
|
||||
aria-expanded={isDropdownOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls="multi-select-dropdown"
|
||||
aria-owns="multi-select-dropdown"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
<div className="multi-select-chips">
|
||||
{selectedOptions.map((option, index) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`multi-select-chip ${
|
||||
focusedChipIndex === index ? 'multi-select-chip-focused' : ''
|
||||
}`}
|
||||
ref={(el): void => {
|
||||
chipRefs.current[index] = el;
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => handleChipKeyDown(e, index)}
|
||||
onFocus={(): void => setFocusedChipIndex(index)}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
aria-label={`Selected option: ${option.label}`}
|
||||
>
|
||||
{option.label}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="multi-select-chip-remove"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleRemoveItem(option.value);
|
||||
}}
|
||||
aria-label={`Remove ${option.label}`}
|
||||
tabIndex={-1} // Don't make the inner button tabbable
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="multi-select-search"
|
||||
placeholder={selectedOptions.length === 0 ? placeholder : ''}
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={(): void => setIsDropdownOpen(true)}
|
||||
suffix={<SearchOutlined />}
|
||||
bordered={false}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{allowClear && selectedValues.length > 0 && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="multi-select-clear-all"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleClearAll();
|
||||
}}
|
||||
aria-label="Clear all selections"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="multi-select-error-text">{error}</div>}
|
||||
|
||||
{isDropdownOpen && !disabled && (
|
||||
<div
|
||||
className="multi-select-dropdown"
|
||||
ref={dropdownRef}
|
||||
style={{
|
||||
maxHeight: `${dropdownMaxHeight}px`,
|
||||
maxWidth: dropdownMaxWidth ? `${dropdownMaxWidth}px` : undefined,
|
||||
}}
|
||||
id="multi-select-dropdown"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="multi-select-loading">
|
||||
<Spin size="small" />
|
||||
<span>We are updating the values ...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showSelectAll && (
|
||||
<>
|
||||
<div className="multi-select-option">
|
||||
<Checkbox checked={allSelected} onChange={handleSelectAll}>
|
||||
ALL
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="multi-select-divider" />
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="multi-select-options-container"
|
||||
style={{ maxHeight: `${sectionMaxHeight}px` }}
|
||||
>
|
||||
{displayOptions.length > 0 ? (
|
||||
displayOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="multi-select-option"
|
||||
role="option"
|
||||
aria-selected={selectedValues.includes(option.value)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onChange={(e): void => handleSelectionChange(option, e)}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="multi-select-no-results">
|
||||
{allowCustomValues && searchText
|
||||
? `Add "${searchText}"`
|
||||
: 'No results found'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{additionalSections.map(
|
||||
(section) =>
|
||||
section.options.length > 0 && (
|
||||
<div key={`section-${section.title}`}>
|
||||
<div className="multi-select-divider" />
|
||||
<div className="multi-select-section-label">{section.title}</div>
|
||||
<div
|
||||
className="multi-select-section-content"
|
||||
style={{ maxHeight: `${sectionMaxHeight}px` }}
|
||||
>
|
||||
{section.options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="multi-select-option"
|
||||
role="option"
|
||||
aria-selected={selectedValues.includes(option.value)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onChange={(e): void => handleSelectionChange(option, e)}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Define defaultProps to fix linter warnings
|
||||
MultiSelect.defaultProps = {
|
||||
value: [],
|
||||
placeholder: 'Search...',
|
||||
loading: false,
|
||||
allowCustomValues: true,
|
||||
onSearch: undefined,
|
||||
className: '',
|
||||
additionalSections: [],
|
||||
showSelectAll: true,
|
||||
dropdownMaxHeight: 400,
|
||||
dropdownMaxWidth: undefined,
|
||||
disabled: false,
|
||||
error: undefined,
|
||||
label: undefined,
|
||||
allowClear: true,
|
||||
sectionMaxHeight: 150,
|
||||
};
|
||||
|
||||
export default MultiSelect;
|
||||
@@ -1,8 +0,0 @@
|
||||
import MultiSelect from './MultiSelect';
|
||||
|
||||
export type {
|
||||
MultiSelectOption,
|
||||
MultiSelectProps,
|
||||
MultiSelectSection,
|
||||
} from './MultiSelect';
|
||||
export default MultiSelect;
|
||||
@@ -1,3 +1,5 @@
|
||||
import './ResizeTable.styles.scss';
|
||||
|
||||
import { SyntheticEvent, useMemo } from 'react';
|
||||
import { Resizable, ResizeCallbackData } from 'react-resizable';
|
||||
|
||||
@@ -10,8 +12,8 @@ function ResizableHeader(props: ResizableHeaderProps): JSX.Element {
|
||||
const handle = useMemo(
|
||||
() => (
|
||||
<SpanStyle
|
||||
className="react-resizable-handle"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
className="resize-handle"
|
||||
/>
|
||||
),
|
||||
[],
|
||||
@@ -19,7 +21,7 @@ function ResizableHeader(props: ResizableHeaderProps): JSX.Element {
|
||||
|
||||
if (!width) {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return <th {...restProps} />;
|
||||
return <th {...restProps} className="resizable-header" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -29,9 +31,10 @@ function ResizableHeader(props: ResizableHeaderProps): JSX.Element {
|
||||
handle={handle}
|
||||
onResize={onResize}
|
||||
draggableOpts={enableUserSelectHack}
|
||||
minConstraints={[150, 0]}
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<th {...restProps} />
|
||||
<th {...restProps} className="resizable-header" />
|
||||
</Resizable>
|
||||
);
|
||||
}
|
||||
|
||||
53
frontend/src/components/ResizeTable/ResizeTable.styles.scss
Normal file
53
frontend/src/components/ResizeTable/ResizeTable.styles.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
.resizable-header {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
position: relative;
|
||||
|
||||
.ant-table-column-title {
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.resize-main-table {
|
||||
.ant-table-body {
|
||||
.ant-table-tbody {
|
||||
.ant-table-row {
|
||||
.ant-table-cell {
|
||||
.ant-typography {
|
||||
white-space: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-table,
|
||||
.traces-table {
|
||||
.resize-table {
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-end: -5px;
|
||||
width: 10px;
|
||||
cursor: col-resize;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1px;
|
||||
height: 1.6em;
|
||||
background-color: var(--bg-slate-200);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,35 +2,63 @@
|
||||
|
||||
import { Table } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import cx from 'classnames';
|
||||
import { dragColumnParams } from 'hooks/useDragColumns/configs';
|
||||
import { set } from 'lodash-es';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { debounce, set } from 'lodash-es';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import {
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import ReactDragListView from 'react-drag-listview';
|
||||
import { ResizeCallbackData } from 'react-resizable';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
import ResizableHeader from './ResizableHeader';
|
||||
import { DragSpanStyle } from './styles';
|
||||
import { ResizeTableProps } from './types';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function ResizeTable({
|
||||
columns,
|
||||
onDragColumn,
|
||||
pagination,
|
||||
widgetId,
|
||||
shouldPersistColumnWidths = false,
|
||||
...restProps
|
||||
}: ResizeTableProps): JSX.Element {
|
||||
const [columnsData, setColumns] = useState<ColumnsType>([]);
|
||||
const { setColumnWidths, selectedDashboard } = useDashboard();
|
||||
|
||||
const columnWidths = shouldPersistColumnWidths
|
||||
? (selectedDashboard?.data?.widgets?.find(
|
||||
(widget) => widget.id === widgetId,
|
||||
) as Widgets)?.columnWidths
|
||||
: undefined;
|
||||
|
||||
const updateAllColumnWidths = useRef(
|
||||
debounce((widthsConfig: Record<string, number>) => {
|
||||
if (!widgetId || !shouldPersistColumnWidths) return;
|
||||
setColumnWidths?.((prev) => ({
|
||||
...prev,
|
||||
[widgetId]: widthsConfig,
|
||||
}));
|
||||
}, 1000),
|
||||
).current;
|
||||
|
||||
const handleResize = useCallback(
|
||||
(index: number) => (
|
||||
_e: SyntheticEvent<Element>,
|
||||
e: SyntheticEvent<Element>,
|
||||
{ size }: ResizeCallbackData,
|
||||
): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const newColumns = [...columnsData];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
@@ -65,6 +93,7 @@ function ResizeTable({
|
||||
...restProps,
|
||||
components: { header: { cell: ResizableHeader } },
|
||||
columns: mergedColumns,
|
||||
className: cx('resize-main-table', restProps.className),
|
||||
};
|
||||
|
||||
set(
|
||||
@@ -78,9 +107,39 @@ function ResizeTable({
|
||||
|
||||
useEffect(() => {
|
||||
if (columns) {
|
||||
setColumns(columns);
|
||||
// Apply stored column widths from widget configuration
|
||||
const columnsWithStoredWidths = columns.map((col) => {
|
||||
const dataIndex = (col as RowData).dataIndex as string;
|
||||
if (dataIndex && columnWidths && columnWidths[dataIndex]) {
|
||||
return {
|
||||
...col,
|
||||
width: columnWidths[dataIndex], // Apply stored width
|
||||
};
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
setColumns(columnsWithStoredWidths);
|
||||
}
|
||||
}, [columns]);
|
||||
}, [columns, columnWidths]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldPersistColumnWidths) return;
|
||||
// Collect all column widths in a single object
|
||||
const newColumnWidths: Record<string, number> = {};
|
||||
|
||||
mergedColumns.forEach((col) => {
|
||||
if (col.width && (col as RowData).dataIndex) {
|
||||
const dataIndex = (col as RowData).dataIndex as string;
|
||||
newColumnWidths[dataIndex] = col.width as number;
|
||||
}
|
||||
});
|
||||
|
||||
// Only update if there are actual widths to set
|
||||
if (Object.keys(newColumnWidths).length > 0) {
|
||||
updateAllColumnWidths(newColumnWidths);
|
||||
}
|
||||
}, [mergedColumns, updateAllColumnWidths, shouldPersistColumnWidths]);
|
||||
|
||||
return onDragColumn ? (
|
||||
<ReactDragListView.DragColumn {...dragColumnParams} onDragEnd={onDragColumn}>
|
||||
|
||||
@@ -8,6 +8,8 @@ export const SpanStyle = styled.span`
|
||||
width: 0.625rem;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
export const DragSpanStyle = styled.span`
|
||||
|
||||
@@ -9,6 +9,8 @@ import { TableDataSource } from './contants';
|
||||
|
||||
export interface ResizeTableProps extends TableProps<any> {
|
||||
onDragColumn?: (fromIndex: number, toIndex: number) => void;
|
||||
widgetId?: string;
|
||||
shouldPersistColumnWidths?: boolean;
|
||||
}
|
||||
export interface DynamicColumnTableProps extends TableProps<any> {
|
||||
tablesource: typeof TableDataSource[keyof typeof TableDataSource];
|
||||
|
||||
@@ -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,28 +1,11 @@
|
||||
// keep this consistent with backend constants.go
|
||||
export enum FeatureKeys {
|
||||
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',
|
||||
OSS = 'OSS',
|
||||
ONBOARDING = 'ONBOARDING',
|
||||
CHAT_SUPPORT = 'CHAT_SUPPORT',
|
||||
GATEWAY = 'GATEWAY',
|
||||
PREMIUM_SUPPORT = 'PREMIUM_SUPPORT',
|
||||
QUERY_BUILDER_SEARCH_V2 = 'QUERY_BUILDER_SEARCH_V2',
|
||||
ANOMALY_DETECTION = 'ANOMALY_DETECTION',
|
||||
AWS_INTEGRATION = 'AWS_INTEGRATION',
|
||||
ONBOARDING_V3 = 'ONBOARDING_V3',
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const REACT_QUERY_KEY = {
|
||||
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
|
||||
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
|
||||
|
||||
// API Monitoring Query Keys
|
||||
// Traces Funnels Query Keys
|
||||
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
|
||||
GET_ENDPOINTS_LIST_BY_DOMAIN: 'GET_ENDPOINTS_LIST_BY_DOMAIN',
|
||||
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_FUNNELS_LIST: 'GET_FUNNELS_LIST',
|
||||
GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS',
|
||||
UPDATE_FUNNEL_STEPS: 'UPDATE_FUNNEL_STEPS',
|
||||
VALIDATE_FUNNEL_STEPS: 'VALIDATE_FUNNEL_STEPS',
|
||||
UPDATE_FUNNEL_STEP_DETAILS: 'UPDATE_FUNNEL_STEP_DETAILS',
|
||||
GET_FUNNEL_OVERVIEW: 'GET_FUNNEL_OVERVIEW',
|
||||
GET_FUNNEL_SLOW_TRACES: 'GET_FUNNEL_SLOW_TRACES',
|
||||
GET_FUNNEL_ERROR_TRACES: 'GET_FUNNEL_ERROR_TRACES',
|
||||
GET_FUNNEL_STEPS_GRAPH_DATA: 'GET_FUNNEL_STEPS_GRAPH_DATA',
|
||||
} as const;
|
||||
|
||||
@@ -75,7 +75,6 @@ const ROUTES = {
|
||||
METRICS_EXPLORER_BASE: '/metrics-explorer',
|
||||
WORKSPACE_ACCESS_RESTRICTED: '/workspace-access-restricted',
|
||||
HOME_PAGE: '/',
|
||||
DYNAMIC_VARIABLE_TEST: '/dynamic-variable-test',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertChannels from 'container/AllAlertChannels';
|
||||
import { allAlertChannels } from 'mocks-server/__mockdata__/alerts';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
@@ -20,6 +21,13 @@ jest.mock('hooks/useNotifications', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Alert Channels Settings List page', () => {
|
||||
beforeEach(() => {
|
||||
render(<AlertChannels />);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertChannels from 'container/AllAlertChannels';
|
||||
import { allAlertChannels } from 'mocks-server/__mockdata__/alerts';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
@@ -25,6 +26,13 @@ jest.mock('hooks/useComponentPermission', () => ({
|
||||
default: jest.fn().mockImplementation(() => [false]),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALL_CHANNELS}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Alert Channels Settings List page (Normal User)', () => {
|
||||
beforeEach(() => {
|
||||
render(<AlertChannels />);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Select, Spin, Table, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
@@ -151,6 +152,7 @@ function AllEndPoints({
|
||||
if (groupBy.length === 0) {
|
||||
setSelectedEndPointName(record.endpointName); // this will open up the endpoint details tab
|
||||
setSelectedView(VIEW_TYPES.ENDPOINT_DETAILS);
|
||||
logEvent('API Monitoring: Endpoint name row clicked', {});
|
||||
} else {
|
||||
handleGroupByRowClick(record); // this will prepare the nested query payload
|
||||
}
|
||||
|
||||
@@ -392,6 +392,39 @@
|
||||
gap: 20px;
|
||||
padding-top: 20px;
|
||||
|
||||
.endpoint-meta-data {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.endpoint-meta-data-pill {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
width: fit-content;
|
||||
.endpoint-meta-data-label {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-right: 1px solid var(--bg-slate-300);
|
||||
color: var(--text-vanilla-100);
|
||||
background: var(--bg-slate-500);
|
||||
height: calc(100% - 12px);
|
||||
}
|
||||
|
||||
.endpoint-meta-data-value {
|
||||
display: flex;
|
||||
padding: 6px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-vanilla-400);
|
||||
background: var(--bg-slate-400);
|
||||
height: calc(100% - 12px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.endpoint-details-filters-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -405,6 +438,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-item,
|
||||
.ant-select-item-option-content {
|
||||
flex: auto;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.status-code-table-container {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
@@ -809,6 +849,13 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-item,
|
||||
.ant-select-item-option-content {
|
||||
flex: auto;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
@@ -917,6 +964,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.endpoint-meta-data {
|
||||
.endpoint-meta-data-pill {
|
||||
.endpoint-meta-data-label {
|
||||
color: var(--text-ink-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.endpoint-meta-data-value {
|
||||
color: var(--text-ink-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-code-table-container {
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
|
||||
@@ -19,12 +19,14 @@ function DomainDetails({
|
||||
selectedDomainIndex,
|
||||
setSelectedDomainIndex,
|
||||
domainListLength,
|
||||
domainListFilters,
|
||||
}: {
|
||||
domainData: any;
|
||||
handleClose: () => void;
|
||||
selectedDomainIndex: number;
|
||||
setSelectedDomainIndex: (index: number) => void;
|
||||
domainListLength: number;
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
}): JSX.Element {
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(VIEWS.ALL_ENDPOINTS);
|
||||
const [selectedEndPointName, setSelectedEndPointName] = useState<string>('');
|
||||
@@ -132,6 +134,7 @@ function DomainDetails({
|
||||
domainName={domainData.domainName}
|
||||
endPointName={selectedEndPointName}
|
||||
setSelectedEndPointName={setSelectedEndPointName}
|
||||
domainListFilters={domainListFilters}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,10 @@ import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
extractPortAndEndpoint,
|
||||
getEndPointDetailsQueryPayload,
|
||||
getLatencyOverTimeWidgetData,
|
||||
getRateOverTimeWidgetData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
@@ -27,10 +30,12 @@ function EndPointDetails({
|
||||
domainName,
|
||||
endPointName,
|
||||
setSelectedEndPointName,
|
||||
domainListFilters,
|
||||
}: {
|
||||
domainName: string;
|
||||
endPointName: string;
|
||||
setSelectedEndPointName: (value: string) => void;
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
}): JSX.Element {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
@@ -101,8 +106,6 @@ function EndPointDetails({
|
||||
const [
|
||||
endPointMetricsDataQuery,
|
||||
endPointStatusCodeDataQuery,
|
||||
endPointRateOverTimeDataQuery,
|
||||
endPointLatencyOverTimeDataQuery,
|
||||
endPointDropDownDataQuery,
|
||||
endPointDependentServicesDataQuery,
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
@@ -115,12 +118,29 @@ function EndPointDetails({
|
||||
endPointDetailsDataQueries[3],
|
||||
endPointDetailsDataQueries[4],
|
||||
endPointDetailsDataQueries[5],
|
||||
endPointDetailsDataQueries[6],
|
||||
endPointDetailsDataQueries[7],
|
||||
],
|
||||
[endPointDetailsDataQueries],
|
||||
);
|
||||
|
||||
const { endpoint, port } = useMemo(
|
||||
() => extractPortAndEndpoint(endPointName),
|
||||
[endPointName],
|
||||
);
|
||||
|
||||
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
|
||||
() => [
|
||||
getRateOverTimeWidgetData(domainName, endPointName, {
|
||||
items: [...domainListFilters.items, ...filters.items],
|
||||
op: filters.op,
|
||||
}),
|
||||
getLatencyOverTimeWidgetData(domainName, endPointName, {
|
||||
items: [...domainListFilters.items, ...filters.items],
|
||||
op: filters.op,
|
||||
}),
|
||||
],
|
||||
[domainName, endPointName, filters, domainListFilters],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="endpoint-details-container">
|
||||
<div className="endpoint-details-filters-container">
|
||||
@@ -129,6 +149,8 @@ function EndPointDetails({
|
||||
selectedEndPointName={endPointName}
|
||||
setSelectedEndPointName={setSelectedEndPointName}
|
||||
endPointDropDownDataQuery={endPointDropDownDataQuery}
|
||||
parentContainerDiv=".endpoint-details-filters-container"
|
||||
dropdownStyle={{ width: 'calc(100% - 36px)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="endpoint-details-filters-container-search">
|
||||
@@ -141,6 +163,16 @@ function EndPointDetails({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="endpoint-meta-data">
|
||||
<div className="endpoint-meta-data-pill">
|
||||
<div className="endpoint-meta-data-label">Endpoint</div>
|
||||
<div className="endpoint-meta-data-value">{endpoint || '-'}</div>
|
||||
</div>
|
||||
<div className="endpoint-meta-data-pill">
|
||||
<div className="endpoint-meta-data-label">Port</div>
|
||||
<div className="endpoint-meta-data-value">{port || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<EndPointMetrics endPointMetricsDataQuery={endPointMetricsDataQuery} />
|
||||
{!isServicesFilterApplied && (
|
||||
<DependentServices
|
||||
@@ -152,18 +184,14 @@ function EndPointDetails({
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={
|
||||
endPointStatusCodeLatencyBarChartsDataQuery
|
||||
}
|
||||
domainName={domainName}
|
||||
endPointName={endPointName}
|
||||
domainListFilters={domainListFilters}
|
||||
filters={filters}
|
||||
/>
|
||||
<StatusCodeTable endPointStatusCodeDataQuery={endPointStatusCodeDataQuery} />
|
||||
<MetricOverTimeGraph
|
||||
metricOverTimeDataQuery={endPointRateOverTimeDataQuery}
|
||||
widgetInfoIndex={0}
|
||||
endPointName={endPointName}
|
||||
/>
|
||||
<MetricOverTimeGraph
|
||||
metricOverTimeDataQuery={endPointLatencyOverTimeDataQuery}
|
||||
widgetInfoIndex={1}
|
||||
endPointName={endPointName}
|
||||
/>
|
||||
<MetricOverTimeGraph widget={rateOverTimeWidget} />
|
||||
<MetricOverTimeGraph widget={latencyOverTimeWidget} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import EndPointDetailsZeroState from './components/EndPointDetailsZeroState';
|
||||
@@ -17,10 +18,12 @@ function EndPointDetailsWrapper({
|
||||
domainName,
|
||||
endPointName,
|
||||
setSelectedEndPointName,
|
||||
domainListFilters,
|
||||
}: {
|
||||
domainName: string;
|
||||
endPointName: string;
|
||||
setSelectedEndPointName: (value: string) => void;
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
}): JSX.Element {
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
@@ -69,6 +72,7 @@ function EndPointDetailsWrapper({
|
||||
domainName={domainName}
|
||||
endPointName={endPointName}
|
||||
setSelectedEndPointName={setSelectedEndPointName}
|
||||
domainListFilters={domainListFilters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ function EndPointDetailsZeroState({
|
||||
<EndPointsDropDown
|
||||
setSelectedEndPointName={setSelectedEndPointName}
|
||||
endPointDropDownDataQuery={endPointDropDownDataQuery}
|
||||
parentContainerDiv=".end-point-details-zero-state-wrapper"
|
||||
dropdownStyle={{ width: '60%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ function EndPointMetrics({
|
||||
<Skeleton.Button active size="small" />
|
||||
) : (
|
||||
<Tooltip title={metricsData?.rate}>
|
||||
<span className="round-metric-tag">{metricsData?.rate}/sec</span>
|
||||
<span className="round-metric-tag">{metricsData?.rate} ops/sec</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -8,16 +8,22 @@ interface EndPointsDropDownProps {
|
||||
selectedEndPointName?: string;
|
||||
setSelectedEndPointName: (value: string) => void;
|
||||
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
|
||||
parentContainerDiv?: string;
|
||||
dropdownStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
selectedEndPointName: '',
|
||||
parentContainerDiv: '',
|
||||
dropdownStyle: {},
|
||||
};
|
||||
|
||||
function EndPointsDropDown({
|
||||
selectedEndPointName,
|
||||
setSelectedEndPointName,
|
||||
endPointDropDownDataQuery,
|
||||
parentContainerDiv,
|
||||
dropdownStyle,
|
||||
}: EndPointsDropDownProps): JSX.Element {
|
||||
const { data, isLoading, isFetching } = endPointDropDownDataQuery;
|
||||
|
||||
@@ -39,6 +45,13 @@ function EndPointsDropDown({
|
||||
style={{ width: '100%' }}
|
||||
onChange={handleChange}
|
||||
options={formattedData}
|
||||
getPopupContainer={
|
||||
parentContainerDiv
|
||||
? (): HTMLElement =>
|
||||
document.querySelector(parentContainerDiv) as HTMLElement
|
||||
: (triggerNode): HTMLElement => triggerNode.parentNode as HTMLElement
|
||||
}
|
||||
dropdownStyle={dropdownStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Table } from 'antd';
|
||||
import { ColumnType } from 'antd/lib/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
@@ -114,6 +115,7 @@ function ExpandedRow({
|
||||
onClick: (): void => {
|
||||
setSelectedEndPointName(record.endpointName);
|
||||
setSelectedView(VIEW_TYPES.ENDPOINT_DETAILS);
|
||||
logEvent('API Monitoring: Endpoint name row clicked', {});
|
||||
},
|
||||
className: 'expanded-clickable-row',
|
||||
})}
|
||||
|
||||
@@ -1,110 +1,18 @@
|
||||
import { Card, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
apiWidgetInfo,
|
||||
extractPortAndEndpoint,
|
||||
getFormattedChartData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { Options } from 'uplot';
|
||||
|
||||
import ErrorState from './ErrorState';
|
||||
|
||||
function MetricOverTimeGraph({
|
||||
metricOverTimeDataQuery,
|
||||
widgetInfoIndex,
|
||||
endPointName,
|
||||
}: {
|
||||
metricOverTimeDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
|
||||
widgetInfoIndex: number;
|
||||
endPointName: string;
|
||||
}): JSX.Element {
|
||||
const { data } = metricOverTimeDataQuery;
|
||||
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
|
||||
const { endpoint } = extractPortAndEndpoint(endPointName);
|
||||
|
||||
const formattedChartData = useMemo(
|
||||
() => getFormattedChartData(data?.payload, [endpoint]),
|
||||
[data?.payload, endpoint],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => getUPlotChartData(formattedChartData), [
|
||||
formattedChartData,
|
||||
]);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
apiResponse: formattedChartData,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
yAxisUnit: apiWidgetInfo[widgetInfoIndex].yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
minTimeScale: Math.floor(minTime / 1e9),
|
||||
maxTimeScale: Math.floor(maxTime / 1e9),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
[
|
||||
formattedChartData,
|
||||
minTime,
|
||||
maxTime,
|
||||
widgetInfoIndex,
|
||||
dimensions,
|
||||
isDarkMode,
|
||||
],
|
||||
);
|
||||
|
||||
const renderCardContent = useCallback(
|
||||
(query: UseQueryResult<SuccessResponse<any>, unknown>): JSX.Element => {
|
||||
if (query.isLoading) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
if (query.error) {
|
||||
return <ErrorState refetch={query.refetch} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('chart-container', {
|
||||
'no-data-container':
|
||||
!query.isLoading && !query?.data?.payload?.data?.result?.length,
|
||||
})}
|
||||
>
|
||||
<Uplot options={options as Options} data={chartData} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[options, chartData],
|
||||
);
|
||||
import { Card } from 'antd';
|
||||
import GridCard from 'container/GridCardLayout/GridCard';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
function MetricOverTimeGraph({ widget }: { widget: Widgets }): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Card bordered className="endpoint-details-card">
|
||||
<Typography.Text>{apiWidgetInfo[widgetInfoIndex].title}</Typography.Text>
|
||||
<div className="graph-container" ref={graphRef}>
|
||||
{renderCardContent(metricOverTimeDataQuery)}
|
||||
<div className="graph-container">
|
||||
<GridCard
|
||||
widget={widget}
|
||||
isQueryEnabled
|
||||
onDragSelect={(): void => {}}
|
||||
customOnDragSelect={(): void => {}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Card, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { useGetGraphCustomSeries } from 'components/CeleryTask/useGetGraphCustomSeries';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
getCustomFiltersForBarChart,
|
||||
getFormattedEndPointStatusCodeChartData,
|
||||
getStatusCodeBarChartWidgetData,
|
||||
statusCodeWidgetInfo,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
|
||||
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
@@ -15,6 +24,8 @@ import { UseQueryResult } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { Options } from 'uplot';
|
||||
|
||||
@@ -23,6 +34,10 @@ import ErrorState from './ErrorState';
|
||||
function StatusCodeBarCharts({
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
endPointStatusCodeLatencyBarChartsDataQuery,
|
||||
domainName,
|
||||
endPointName,
|
||||
domainListFilters,
|
||||
filters,
|
||||
}: {
|
||||
endPointStatusCodeBarChartsDataQuery: UseQueryResult<
|
||||
SuccessResponse<any>,
|
||||
@@ -32,6 +47,10 @@ function StatusCodeBarCharts({
|
||||
SuccessResponse<any>,
|
||||
unknown
|
||||
>;
|
||||
domainName: string;
|
||||
endPointName: string;
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
filters: IBuilderQuery['filters'];
|
||||
}): JSX.Element {
|
||||
// 0 : Status Code Count
|
||||
// 1 : Status Code Latency
|
||||
@@ -85,6 +104,72 @@ function StatusCodeBarCharts({
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const graphClick = useGraphClickToShowButton({
|
||||
graphRef,
|
||||
isButtonEnabled: true,
|
||||
buttonClassName: 'view-onclick-show-button',
|
||||
});
|
||||
|
||||
const navigateToExplorer = useNavigateToExplorer();
|
||||
|
||||
const navigateToExplorerPages = useNavigateToExplorerPages();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { getCustomSeries } = useGetGraphCustomSeries({
|
||||
isDarkMode,
|
||||
drawStyle: 'bars',
|
||||
colorMapping: {
|
||||
'200-299': Color.BG_FOREST_500,
|
||||
'300-399': Color.BG_AMBER_400,
|
||||
'400-499': Color.BG_CHERRY_500,
|
||||
'500-599': Color.BG_ROBIN_500,
|
||||
Other: Color.BG_SIENNA_500,
|
||||
},
|
||||
});
|
||||
|
||||
const widget = useMemo<Widgets>(
|
||||
() =>
|
||||
getStatusCodeBarChartWidgetData(domainName, endPointName, {
|
||||
items: [...domainListFilters.items, ...filters.items],
|
||||
op: filters.op,
|
||||
}),
|
||||
[domainName, endPointName, domainListFilters, filters],
|
||||
);
|
||||
|
||||
const graphClickHandler = useCallback(
|
||||
(
|
||||
xValue: number,
|
||||
yValue: number,
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
metric?: { [key: string]: string },
|
||||
queryData?: { queryName: string; inFocusOrNot: boolean },
|
||||
): void => {
|
||||
const customFilters = getCustomFiltersForBarChart(metric);
|
||||
handleGraphClick({
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
metric,
|
||||
queryData,
|
||||
widget,
|
||||
navigateToExplorerPages,
|
||||
navigateToExplorer,
|
||||
notifications,
|
||||
graphClick,
|
||||
customFilters,
|
||||
});
|
||||
},
|
||||
[
|
||||
widget,
|
||||
navigateToExplorerPages,
|
||||
navigateToExplorer,
|
||||
notifications,
|
||||
graphClick,
|
||||
],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
@@ -100,6 +185,8 @@ function StatusCodeBarCharts({
|
||||
minTimeScale: Math.floor(minTime / 1e9),
|
||||
maxTimeScale: Math.floor(maxTime / 1e9),
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
onClickHandler: graphClickHandler,
|
||||
customSeries: getCustomSeries,
|
||||
}),
|
||||
[
|
||||
minTime,
|
||||
@@ -109,6 +196,8 @@ function StatusCodeBarCharts({
|
||||
formattedEndPointStatusCodeBarChartsDataPayload,
|
||||
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
|
||||
isDarkMode,
|
||||
graphClickHandler,
|
||||
getCustomSeries,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import '../Explorer.styles.scss';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Table, Typography } from 'antd';
|
||||
import axios from 'api';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
@@ -130,6 +131,7 @@ function DomainList({
|
||||
(item) => item.key === record.key,
|
||||
);
|
||||
setSelectedDomainIndex(dataIndex);
|
||||
logEvent('API Monitoring: Domain name row clicked', {});
|
||||
}
|
||||
},
|
||||
className: 'expanded-clickable-row',
|
||||
@@ -147,6 +149,7 @@ function DomainList({
|
||||
handleClose={(): void => {
|
||||
setSelectedDomainIndex(-1);
|
||||
}}
|
||||
domainListFilters={query?.filters}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -3,13 +3,14 @@ import './Explorer.styles.scss';
|
||||
import { FilterOutlined } from '@ant-design/icons';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Switch, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -21,6 +22,10 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('API Monitoring: Landing page visited', {});
|
||||
}, []);
|
||||
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index: 0,
|
||||
query: currentQuery.builder.queryData[0],
|
||||
@@ -64,7 +69,12 @@ function Explorer(): JSX.Element {
|
||||
style={{ marginLeft: 'auto' }}
|
||||
checked={showIP}
|
||||
onClick={(): void => {
|
||||
setShowIP((showIP) => !showIP);
|
||||
setShowIP((showIP): boolean => {
|
||||
logEvent('API Monitoring: Show IP addresses clicked', {
|
||||
showIP: !showIP,
|
||||
});
|
||||
return !showIP;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,16 +8,23 @@ import {
|
||||
} from 'components/QuickFilters/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { GraphClickMetaData } from 'container/GridCardLayout/useNavigateToExplorerPages';
|
||||
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
|
||||
import dayjs from 'dayjs';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { ArrowUpDown, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { getWidgetQuery } from 'pages/MessagingQueues/MQDetails/MetricPage/MetricPageUtil';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -128,12 +135,15 @@ export const columnsConfig: ColumnType<APIDomainsRowData>[] = [
|
||||
sorter: false,
|
||||
align: 'right',
|
||||
className: `column`,
|
||||
render: (lastUsed: number): string => getLastUsedRelativeTime(lastUsed),
|
||||
render: (lastUsed: number | string): string =>
|
||||
lastUsed === 'n/a' || lastUsed === '-'
|
||||
? '-'
|
||||
: getLastUsedRelativeTime(lastUsed as number),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<div>
|
||||
Rate <span className="round-metric-tag">/s</span>
|
||||
Rate <span className="round-metric-tag">ops/s</span>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'rate',
|
||||
@@ -155,21 +165,26 @@ export const columnsConfig: ColumnType<APIDomainsRowData>[] = [
|
||||
sorter: false,
|
||||
align: 'right',
|
||||
className: `column`,
|
||||
render: (errorRate: number): React.ReactNode => (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number((errorRate * 100).toFixed(1))}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number((errorRate * 100).toFixed(1));
|
||||
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
|
||||
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
className="progress-bar error-rate"
|
||||
/>
|
||||
),
|
||||
render: (errorRate: number | string): React.ReactNode => {
|
||||
if (errorRate === 'n/a' || errorRate === '-') {
|
||||
return '-';
|
||||
}
|
||||
return (
|
||||
<Progress
|
||||
status="active"
|
||||
percent={Number(((errorRate as number) * 100).toFixed(1))}
|
||||
strokeLinecap="butt"
|
||||
size="small"
|
||||
strokeColor={((): string => {
|
||||
const errorRatePercent = Number(((errorRate as number) * 100).toFixed(1));
|
||||
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
|
||||
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
className="progress-bar error-rate"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (
|
||||
@@ -217,9 +232,9 @@ interface APIMonitoringResponseRow {
|
||||
data: {
|
||||
endpoints: number;
|
||||
error_rate: number;
|
||||
lastseen: number;
|
||||
lastseen: number | string;
|
||||
[domainNameKey]: string;
|
||||
p99: number;
|
||||
p99: number | string;
|
||||
rps: number;
|
||||
};
|
||||
}
|
||||
@@ -232,12 +247,12 @@ interface EndPointsResponseRow {
|
||||
|
||||
export interface APIDomainsRowData {
|
||||
key: string;
|
||||
domainName: React.ReactNode;
|
||||
endpointCount: React.ReactNode;
|
||||
rate: React.ReactNode;
|
||||
errorRate: React.ReactNode;
|
||||
latency: React.ReactNode;
|
||||
lastUsed: React.ReactNode;
|
||||
domainName: string;
|
||||
endpointCount: number | string;
|
||||
rate: number | string;
|
||||
errorRate: number | string;
|
||||
latency: number | string;
|
||||
lastUsed: string;
|
||||
}
|
||||
|
||||
// Rename this to a proper name
|
||||
@@ -246,12 +261,20 @@ export const formatDataForTable = (
|
||||
): APIDomainsRowData[] =>
|
||||
data?.map((domain) => ({
|
||||
key: v4(),
|
||||
domainName: domain.data[domainNameKey] || '',
|
||||
endpointCount: domain.data.endpoints,
|
||||
rate: domain.data.rps,
|
||||
errorRate: domain.data.error_rate,
|
||||
latency: Math.round(domain.data.p99 / 1000000), // Convert from nanoseconds to milliseconds
|
||||
lastUsed: new Date(Math.floor(domain.data.lastseen / 1000000)).toISOString(), // Convert from nanoseconds to milliseconds
|
||||
domainName: domain?.data[domainNameKey] || '-',
|
||||
endpointCount: domain?.data?.endpoints || '-',
|
||||
rate: domain.data.rps || '-',
|
||||
errorRate: domain.data.error_rate || '-',
|
||||
latency:
|
||||
domain.data.p99 === 'n/a'
|
||||
? '-'
|
||||
: Math.round(Number(domain.data.p99) / 1000000), // Convert from nanoseconds to milliseconds
|
||||
lastUsed:
|
||||
domain.data.lastseen === 'n/a'
|
||||
? '-'
|
||||
: new Date(
|
||||
Math.floor(Number(domain.data.lastseen) / 1000000),
|
||||
).toISOString(), // Convert from nanoseconds to milliseconds
|
||||
}));
|
||||
|
||||
// Rename this to a proper name
|
||||
@@ -468,7 +491,6 @@ export const extractPortAndEndpoint = (
|
||||
}
|
||||
};
|
||||
|
||||
// Add icons in the below column headers
|
||||
export const getEndPointsColumnsConfig = (
|
||||
isGroupedByAttribute: boolean,
|
||||
expandedRowKeys: React.Key[],
|
||||
@@ -576,7 +598,7 @@ export const formatEndPointsDataForTable = (
|
||||
);
|
||||
return {
|
||||
key: v4(),
|
||||
endpointName: (endpoint.data['http.url'] as string) || '',
|
||||
endpointName: (endpoint.data['http.url'] as string) || '-',
|
||||
port,
|
||||
callCount: endpoint.data.A || '-',
|
||||
latency:
|
||||
@@ -593,7 +615,6 @@ export const formatEndPointsDataForTable = (
|
||||
|
||||
const groupedByAttributeData = groupBy.map((attribute) => attribute.key);
|
||||
|
||||
// TODO: Use tags to show the concatenated attribute values
|
||||
return data?.map((endpoint) => {
|
||||
const newEndpointName = groupedByAttributeData
|
||||
.map((attribute) => endpoint.data[attribute])
|
||||
@@ -639,7 +660,7 @@ export const createFiltersForSelectedRowData = (
|
||||
type: null,
|
||||
},
|
||||
op: '=',
|
||||
value: groupedByMeta[key],
|
||||
value: groupedByMeta[key] || '',
|
||||
id: key,
|
||||
})),
|
||||
);
|
||||
@@ -649,12 +670,10 @@ export const createFiltersForSelectedRowData = (
|
||||
|
||||
// First query payload for endpoint metrics
|
||||
// Second query payload for endpoint status code
|
||||
// Third query payload for endpoint rate over time graph
|
||||
// Fourth query payload for endpoint latency over time graph
|
||||
// Fifth query payload for endpoint dropdown selection
|
||||
// Sixth query payload for endpoint dependant services
|
||||
// Seventh query payload for endpoint response status count bar chart
|
||||
// Eighth query payload for endpoint response status code latency bar chart
|
||||
// Third query payload for endpoint dropdown selection
|
||||
// Fourth query payload for endpoint dependant services
|
||||
// Fifth query payload for endpoint response status count bar chart
|
||||
// Sixth query payload for endpoint response status code latency bar chart
|
||||
export const getEndPointDetailsQueryPayload = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
@@ -1101,205 +1120,6 @@ export const getEndPointDetailsQueryPayload = (
|
||||
end,
|
||||
step: 60,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.String,
|
||||
id: '------false',
|
||||
isColumn: false,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
aggregateOperator: 'rate',
|
||||
dataSource: DataSource.TRACES,
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: '3c76fe0b',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'net.peer.name--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'net.peer.name',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
id: '30710f04',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'http.url--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'http.url',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: endPointName,
|
||||
},
|
||||
...filters.items,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'http.url--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'http.url',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'B',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'rate',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: false,
|
||||
start,
|
||||
end,
|
||||
step: 60,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'duration_nano--float64----true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
key: 'duration_nano',
|
||||
type: '',
|
||||
},
|
||||
aggregateOperator: 'p99',
|
||||
dataSource: DataSource.TRACES,
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: '63adb3ff',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'net.peer.name--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'net.peer.name',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
id: '50142500',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'http.url--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'http.url',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: endPointName,
|
||||
},
|
||||
...filters.items,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'http.url--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'http.url',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'B',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'p99',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: false,
|
||||
start,
|
||||
end,
|
||||
step: 60,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
@@ -1801,7 +1621,7 @@ interface EndPointMetricsData {
|
||||
interface EndPointStatusCodeData {
|
||||
key: string;
|
||||
statusCode: string;
|
||||
count: number;
|
||||
count: number | string;
|
||||
p99Latency: number | string;
|
||||
}
|
||||
|
||||
@@ -1824,8 +1644,8 @@ export const getFormattedEndPointStatusCodeData = (
|
||||
): EndPointStatusCodeData[] =>
|
||||
data?.map((row) => ({
|
||||
key: v4(),
|
||||
statusCode: row.data.response_status_code,
|
||||
count: row.data.A,
|
||||
statusCode: row.data.response_status_code || '-',
|
||||
count: row.data.A || '-',
|
||||
p99Latency:
|
||||
row.data.B === 'n/a' ? '-' : Math.round(Number(row.data.B) / 1000000), // Convert from nanoseconds to milliseconds,
|
||||
}));
|
||||
@@ -1857,11 +1677,6 @@ export const endPointStatusCodeColumns: ColumnType<EndPointStatusCodeData>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const apiWidgetInfo = [
|
||||
{ title: 'Rate over time', yAxisUnit: 'ops/s' },
|
||||
{ title: 'Latency over time', yAxisUnit: 'ns' },
|
||||
];
|
||||
|
||||
export const statusCodeWidgetInfo = [
|
||||
{ yAxisUnit: 'calls' },
|
||||
{ yAxisUnit: 'ns' },
|
||||
@@ -1885,8 +1700,8 @@ export const getFormattedEndPointDropDownData = (
|
||||
): EndPointDropDownData[] =>
|
||||
data?.map((row) => ({
|
||||
key: v4(),
|
||||
label: row.data['http.url'],
|
||||
value: row.data['http.url'],
|
||||
label: row.data['http.url'] || '-',
|
||||
value: row.data['http.url'] || '-',
|
||||
}));
|
||||
|
||||
interface DependentServicesResponseRow {
|
||||
@@ -1903,6 +1718,7 @@ interface DependentServicesData {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
// Discuss once about type safety of this function
|
||||
export const getFormattedDependentServicesData = (
|
||||
data: DependentServicesResponseRow[],
|
||||
): DependentServicesData[] => {
|
||||
@@ -1983,7 +1799,7 @@ export const groupStatusCodes = (
|
||||
|
||||
// Track all timestamps
|
||||
series.values.forEach((value) => {
|
||||
allTimestamps.add(value[0]);
|
||||
allTimestamps.add(Number(value[0]));
|
||||
});
|
||||
|
||||
// Initialize or update the grouped series
|
||||
@@ -2049,8 +1865,114 @@ export const groupStatusCodes = (
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(groupedSeries);
|
||||
// Define the order of status code ranges
|
||||
const statusCodeOrder = ['200-299', '300-399', '400-499', '500-599', 'Other'];
|
||||
|
||||
// Return the grouped series in the specified order
|
||||
return statusCodeOrder
|
||||
.filter((code) => groupedSeries[code]) // Only include codes that exist in the data
|
||||
.map((code) => groupedSeries[code]);
|
||||
};
|
||||
|
||||
export const getStatusCodeBarChartWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): Widgets => ({
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.String,
|
||||
id: '------false',
|
||||
isColumn: false,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
aggregateOperator: 'count',
|
||||
dataSource: DataSource.TRACES,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'c6724407',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'net.peer.name--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'net.peer.name',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
id: '8b1be6f0',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'http.url--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'http.url',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: endPointName,
|
||||
},
|
||||
...filters.items,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'rate',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
description: '',
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
isStacked: false,
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
title: '',
|
||||
opacity: '',
|
||||
nullZeroValues: '',
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
softMin: null,
|
||||
softMax: null,
|
||||
selectedLogFields: null,
|
||||
selectedTracesFields: null,
|
||||
});
|
||||
interface EndPointStatusCodePayloadData {
|
||||
data: {
|
||||
result: QueryData[];
|
||||
@@ -2085,3 +2007,277 @@ export const END_POINT_DETAILS_QUERY_KEYS_ARRAY = [
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
|
||||
];
|
||||
|
||||
export const getRateOverTimeWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): Widgets => {
|
||||
const { endpoint, port } = extractPortAndEndpoint(endPointName);
|
||||
const legend = `${
|
||||
port !== '-' && port !== 'n/a' ? `${port}:` : ''
|
||||
}${endpoint}`;
|
||||
return getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
title: 'Rate Over Time',
|
||||
description: 'Rate over time.',
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.String,
|
||||
id: '------false',
|
||||
isColumn: false,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
aggregateOperator: 'rate',
|
||||
dataSource: DataSource.TRACES,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: '3c76fe0b',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'net.peer.name--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'net.peer.name',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
id: '30710f04',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'http.url--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'http.url',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: endPointName,
|
||||
},
|
||||
...filters.items,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'http.url--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'http.url',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [],
|
||||
legend,
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'rate',
|
||||
},
|
||||
],
|
||||
yAxisUnit: 'ops/s',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const getLatencyOverTimeWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): Widgets => {
|
||||
const { endpoint, port } = extractPortAndEndpoint(endPointName);
|
||||
const legend = `${port}:${endpoint}`;
|
||||
return getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
title: 'Latency Over Time',
|
||||
description: 'Latency over time.',
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'duration_nano--float64----true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
key: 'duration_nano',
|
||||
type: '',
|
||||
},
|
||||
aggregateOperator: 'p99',
|
||||
dataSource: DataSource.TRACES,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: '63adb3ff',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'net.peer.name--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'net.peer.name',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
id: '50142500',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'http.url--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'http.url',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: endPointName,
|
||||
},
|
||||
...filters.items,
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'http.url--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'http.url',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [],
|
||||
legend,
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'p99',
|
||||
},
|
||||
],
|
||||
yAxisUnit: 'ns',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to get the start and end status codes from a status code range string
|
||||
* @param value Status code range string (e.g. '200-299') or boolean
|
||||
* @returns Tuple of [startStatusCode, endStatusCode] as strings
|
||||
*/
|
||||
const getStartAndEndStatusCode = (
|
||||
value: string | boolean,
|
||||
): [string, string] => {
|
||||
if (!value) {
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
case '100-199':
|
||||
return ['100', '199'];
|
||||
case '200-299':
|
||||
return ['200', '299'];
|
||||
case '300-399':
|
||||
return ['300', '399'];
|
||||
case '400-499':
|
||||
return ['400', '499'];
|
||||
case '500-599':
|
||||
return ['500', '599'];
|
||||
default:
|
||||
return ['', ''];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates filter items for bar chart based on group by fields and request data
|
||||
* Used specifically for filtering status code ranges in bar charts
|
||||
* @param groupBy Array of group by fields to create filters for
|
||||
* @param requestData Data from graph click containing values to filter on
|
||||
* @returns Array of TagFilterItems with >= and < operators for status code ranges
|
||||
*/
|
||||
export const createGroupByFiltersForBarChart = (
|
||||
groupBy: BaseAutocompleteData[],
|
||||
requestData: GraphClickMetaData,
|
||||
): TagFilterItem[] =>
|
||||
groupBy
|
||||
.map((gb) => {
|
||||
const value = requestData[gb.key];
|
||||
const [startStatusCode, endStatusCode] = getStartAndEndStatusCode(value);
|
||||
return value
|
||||
? [
|
||||
{
|
||||
id: v4(),
|
||||
key: gb,
|
||||
op: '>=',
|
||||
value: startStatusCode,
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
key: gb,
|
||||
op: '<=',
|
||||
value: endStatusCode,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
.flat();
|
||||
|
||||
export const getCustomFiltersForBarChart = (
|
||||
metric:
|
||||
| {
|
||||
[key: string]: string;
|
||||
}
|
||||
| undefined,
|
||||
): TagFilterItem[] => {
|
||||
if (!metric?.response_status_code) {
|
||||
return [];
|
||||
}
|
||||
const [startStatusCode, endStatusCode] = getStartAndEndStatusCode(
|
||||
metric.response_status_code,
|
||||
);
|
||||
return [
|
||||
{
|
||||
id: v4(),
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'response_status_code--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'response_status_code',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '>=',
|
||||
value: startStatusCode,
|
||||
},
|
||||
{
|
||||
id: v4(),
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'response_status_code--string--tag--false',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
key: 'response_status_code',
|
||||
type: 'tag',
|
||||
},
|
||||
op: '<=',
|
||||
value: endStatusCode,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueries } from 'react-query';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Dispatch } from 'redux';
|
||||
import AppActions from 'types/actions';
|
||||
import {
|
||||
@@ -360,6 +360,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
|
||||
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
|
||||
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
|
||||
const isTracesFunnelDetails = (): boolean =>
|
||||
!!matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL);
|
||||
|
||||
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
|
||||
|
||||
const isDashboardView = (): boolean =>
|
||||
@@ -665,7 +668,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
? 0
|
||||
: '0 1rem',
|
||||
|
||||
...(isTraceDetailsView() || isTracesFunnels() ? { margin: 0 } : {}),
|
||||
...(isTraceDetailsView() ||
|
||||
isTracesFunnels() ||
|
||||
isTracesFunnelDetails()
|
||||
? { margin: 0 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{isToDisplayLayout && !renderFullScreen && <TopNav />}
|
||||
|
||||
@@ -49,6 +49,7 @@ function FullView({
|
||||
isDependedDataLoaded = false,
|
||||
onToggleModelHandler,
|
||||
onClickHandler,
|
||||
customOnDragSelect,
|
||||
setCurrentGraphRef,
|
||||
}: FullViewProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -252,7 +253,7 @@ function FullView({
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphVisibility={setGraphsVisibilityStates}
|
||||
graphVisibility={graphsVisibilityStates}
|
||||
onDragSelect={onDragSelect}
|
||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
searchTerm={searchTerm}
|
||||
onClickHandler={onClickHandler}
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface FullViewProps {
|
||||
widget: Widgets;
|
||||
fullViewOptions?: boolean;
|
||||
onClickHandler?: OnClickPluginOpts['onClick'];
|
||||
customOnDragSelect?: (start: number, end: number) => void;
|
||||
name: string;
|
||||
tableProcessedDataRef: MutableRefObject<RowData[]>;
|
||||
version?: string;
|
||||
|
||||
@@ -50,6 +50,7 @@ function WidgetGraphComponent({
|
||||
setRequestData,
|
||||
onClickHandler,
|
||||
onDragSelect,
|
||||
customOnDragSelect,
|
||||
customTooltipElement,
|
||||
openTracesButton,
|
||||
onOpenTraceBtnClick,
|
||||
@@ -327,6 +328,7 @@ function WidgetGraphComponent({
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
onClickHandler={onClickHandler ?? graphClickHandler}
|
||||
customOnDragSelect={customOnDragSelect}
|
||||
setCurrentGraphRef={setCurrentGraphRef}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -36,6 +36,7 @@ function GridCardGraph({
|
||||
version,
|
||||
onClickHandler,
|
||||
onDragSelect,
|
||||
customOnDragSelect,
|
||||
customTooltipElement,
|
||||
dataAvailable,
|
||||
getGraphData,
|
||||
@@ -272,6 +273,7 @@ function GridCardGraph({
|
||||
setRequestData={setRequestData}
|
||||
onClickHandler={onClickHandler}
|
||||
onDragSelect={onDragSelect}
|
||||
customOnDragSelect={customOnDragSelect}
|
||||
customTooltipElement={customTooltipElement}
|
||||
openTracesButton={openTracesButton}
|
||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface WidgetGraphComponentProps {
|
||||
setRequestData?: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||
onClickHandler?: OnClickPluginOpts['onClick'];
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
customOnDragSelect?: (start: number, end: number) => void;
|
||||
customTooltipElement?: HTMLDivElement;
|
||||
openTracesButton?: boolean;
|
||||
onOpenTraceBtnClick?: (record: RowData) => void;
|
||||
@@ -49,6 +50,7 @@ export interface GridCardGraphProps {
|
||||
variables?: Dashboard['data']['variables'];
|
||||
version?: string;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
customOnDragSelect?: (start: number, end: number) => void;
|
||||
customTooltipElement?: HTMLDivElement;
|
||||
dataAvailable?: (isDataAvailable: boolean) => void;
|
||||
getGraphData?: (graphData?: MetricRangePayloadProps['data']) => void;
|
||||
|
||||
@@ -178,6 +178,7 @@ interface HandleGraphClickParams {
|
||||
navigateToExplorer: (props: NavigateToExplorerProps) => void;
|
||||
notifications: NotificationInstance;
|
||||
graphClick: (props: GraphClickProps) => void;
|
||||
customFilters?: TagFilterItem[];
|
||||
}
|
||||
|
||||
export const handleGraphClick = async ({
|
||||
@@ -192,6 +193,7 @@ export const handleGraphClick = async ({
|
||||
navigateToExplorer,
|
||||
notifications,
|
||||
graphClick,
|
||||
customFilters,
|
||||
}: HandleGraphClickParams): Promise<void> => {
|
||||
const { stepInterval } = widget?.query?.builder?.queryData?.[0] ?? {};
|
||||
|
||||
@@ -221,7 +223,7 @@ export const handleGraphClick = async ({
|
||||
}: ${key}`,
|
||||
onClick: (): void =>
|
||||
navigateToExplorer({
|
||||
filters: result[key].filters,
|
||||
filters: [...result[key].filters, ...(customFilters || [])],
|
||||
dataSource: result[key].dataSource as DataSource,
|
||||
startTime: xValue,
|
||||
endTime: xValue + (stepInterval ?? 60),
|
||||
|
||||
@@ -44,7 +44,10 @@ import { EditMenuAction, ViewMenuAction } from './config';
|
||||
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
|
||||
import GridCard from './GridCard';
|
||||
import { Card, CardContainer, ReactGridLayout } from './styles';
|
||||
import { removeUndefinedValuesFromLayout } from './utils';
|
||||
import {
|
||||
hasColumnWidthsChanged,
|
||||
removeUndefinedValuesFromLayout,
|
||||
} from './utils';
|
||||
import { MenuItemKeys } from './WidgetHeader/contants';
|
||||
import { WidgetRowHeader } from './WidgetRow';
|
||||
|
||||
@@ -68,6 +71,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
setDashboardQueryRangeCalled,
|
||||
setSelectedRowWidgetId,
|
||||
isDashboardFetching,
|
||||
columnWidths,
|
||||
} = useDashboard();
|
||||
const { data } = selectedDashboard || {};
|
||||
const { pathname } = useLocation();
|
||||
@@ -162,6 +166,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const onSaveHandler = (): void => {
|
||||
if (!selectedDashboard) return;
|
||||
|
||||
@@ -171,6 +176,15 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
...selectedDashboard.data,
|
||||
panelMap: { ...currentPanelMap },
|
||||
layout: dashboardLayout.filter((e) => e.i !== PANEL_TYPES.EMPTY_WIDGET),
|
||||
widgets: selectedDashboard?.data?.widgets?.map((widget) => {
|
||||
if (columnWidths?.[widget.id]) {
|
||||
return {
|
||||
...widget,
|
||||
columnWidths: columnWidths[widget.id],
|
||||
};
|
||||
}
|
||||
return widget;
|
||||
}),
|
||||
},
|
||||
uuid: selectedDashboard.uuid,
|
||||
};
|
||||
@@ -227,20 +241,31 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isDashboardLocked ||
|
||||
!saveLayoutPermission ||
|
||||
updateDashboardMutation.isLoading ||
|
||||
isDashboardFetching
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSaveLayout =
|
||||
dashboardLayout &&
|
||||
Array.isArray(dashboardLayout) &&
|
||||
dashboardLayout.length > 0 &&
|
||||
!isEqual(layouts, dashboardLayout) &&
|
||||
!isDashboardLocked &&
|
||||
saveLayoutPermission &&
|
||||
!updateDashboardMutation.isLoading &&
|
||||
!isDashboardFetching
|
||||
) {
|
||||
!isEqual(layouts, dashboardLayout);
|
||||
|
||||
const shouldSaveColumnWidths =
|
||||
dashboardLayout &&
|
||||
Array.isArray(dashboardLayout) &&
|
||||
dashboardLayout.length > 0 &&
|
||||
hasColumnWidthsChanged(columnWidths, selectedDashboard);
|
||||
|
||||
if (shouldSaveLayout || shouldSaveColumnWidths) {
|
||||
onSaveHandler();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardLayout]);
|
||||
}, [dashboardLayout, columnWidths]);
|
||||
|
||||
const onSettingsModalSubmit = (): void => {
|
||||
const newTitle = form.getFieldValue('title');
|
||||
|
||||
@@ -12,7 +12,7 @@ import { v4 } from 'uuid';
|
||||
|
||||
import { extractQueryNamesFromExpression } from './utils';
|
||||
|
||||
type GraphClickMetaData = {
|
||||
export type GraphClickMetaData = {
|
||||
[key: string]: string | boolean;
|
||||
queryName: string;
|
||||
inFocusOrNot: boolean;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FORMULA_REGEXP } from 'constants/regExp';
|
||||
import { isEmpty, isEqual } from 'lodash-es';
|
||||
import { Layout } from 'react-grid-layout';
|
||||
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
export const removeUndefinedValuesFromLayout = (layout: Layout[]): Layout[] =>
|
||||
layout.map((obj) =>
|
||||
@@ -25,3 +27,27 @@ export function extractQueryNamesFromExpression(expression: string): string[] {
|
||||
// Extract matches and deduplicate
|
||||
return [...new Set(expression.match(queryNameRegex) || [])];
|
||||
}
|
||||
|
||||
export const hasColumnWidthsChanged = (
|
||||
columnWidths: Record<string, Record<string, number>>,
|
||||
selectedDashboard?: Dashboard,
|
||||
): boolean => {
|
||||
// If no column widths stored, no changes
|
||||
if (isEmpty(columnWidths) || !selectedDashboard) return false;
|
||||
|
||||
// Check each widget's column widths
|
||||
return Object.keys(columnWidths).some((widgetId) => {
|
||||
const dashboardWidget = selectedDashboard?.data?.widgets?.find(
|
||||
(widget) => widget.id === widgetId,
|
||||
) as Widgets;
|
||||
|
||||
const newWidths = columnWidths[widgetId];
|
||||
const existingWidths = dashboardWidget?.columnWidths;
|
||||
|
||||
// If both are empty/undefined, no change
|
||||
if (isEmpty(newWidths) || isEmpty(existingWidths)) return false;
|
||||
|
||||
// Compare stored column widths with dashboard widget's column widths
|
||||
return !isEqual(newWidths, existingWidths);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ function GridTableComponent({
|
||||
sticky,
|
||||
openTracesButton,
|
||||
onOpenTraceBtnClick,
|
||||
widgetId,
|
||||
...props
|
||||
}: GridTableComponentProps): JSX.Element {
|
||||
const { t } = useTranslation(['valueGraph']);
|
||||
@@ -229,6 +230,7 @@ function GridTableComponent({
|
||||
columns={openTracesButton ? columnDataWithOpenTracesButton : newColumnData}
|
||||
dataSource={dataSource}
|
||||
sticky={sticky}
|
||||
widgetId={widgetId}
|
||||
onRow={
|
||||
openTracesButton
|
||||
? (record): React.HTMLAttributes<HTMLElement> => ({
|
||||
|
||||
@@ -17,6 +17,7 @@ export type GridTableComponentProps = {
|
||||
searchTerm?: string;
|
||||
openTracesButton?: boolean;
|
||||
onOpenTraceBtnClick?: (record: RowData) => void;
|
||||
widgetId?: string;
|
||||
} & Pick<LogsExplorerTableProps, 'data'> &
|
||||
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import './LogsPanelComponent.styles.scss';
|
||||
|
||||
import { Table } from 'antd';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import Controls from 'container/Controls';
|
||||
@@ -79,9 +79,14 @@ function LogsPanelComponent({
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns = getLogPanelColumnsList(
|
||||
widget.selectedLogFields,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getLogPanelColumnsList(
|
||||
widget.selectedLogFields,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[widget.selectedLogFields],
|
||||
);
|
||||
|
||||
const dataLength =
|
||||
@@ -216,16 +221,18 @@ function LogsPanelComponent({
|
||||
<div className="logs-table">
|
||||
<div className="resize-table">
|
||||
<OverlayScrollbar>
|
||||
<Table
|
||||
<ResizeTable
|
||||
pagination={false}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: `calc(50vw - 10px)` }}
|
||||
scroll={{ x: `max-content` }}
|
||||
sticky
|
||||
loading={queryResponse.isFetching}
|
||||
style={tableStyles}
|
||||
dataSource={flattenLogData}
|
||||
columns={columns}
|
||||
onRow={handleRow}
|
||||
widgetId={widget.id}
|
||||
shouldPersistColumnWidths
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
|
||||
@@ -74,6 +74,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
setToScrollWidgetId,
|
||||
selectedRowWidgetId,
|
||||
setSelectedRowWidgetId,
|
||||
columnWidths,
|
||||
} = useDashboard();
|
||||
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
@@ -238,8 +239,10 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
selectedLogFields,
|
||||
selectedTracesFields,
|
||||
isLogScale,
|
||||
columnWidths: columnWidths?.[selectedWidget?.id],
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
columnUnits,
|
||||
currentQuery,
|
||||
@@ -260,6 +263,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
combineHistogram,
|
||||
stackedBarChart,
|
||||
isLogScale,
|
||||
columnWidths,
|
||||
]);
|
||||
|
||||
const closeModal = (): void => {
|
||||
|
||||
@@ -26,6 +26,7 @@ function TablePanelWrapper({
|
||||
searchTerm={searchTerm}
|
||||
openTracesButton={openTracesButton}
|
||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||
widgetId={widget.id}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...GRID_TABLE_CONFIG}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,8 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
width: 0.625rem;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
@@ -54,7 +56,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
class="query-table"
|
||||
>
|
||||
<div
|
||||
class="ant-table-wrapper css-dev-only-do-not-override-2i2tap"
|
||||
class="ant-table-wrapper resize-main-table css-dev-only-do-not-override-2i2tap"
|
||||
>
|
||||
<div
|
||||
class="ant-spin-nested-loading css-dev-only-do-not-override-2i2tap"
|
||||
@@ -82,7 +84,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<tr>
|
||||
<th
|
||||
aria-label="service_name"
|
||||
class="ant-table-cell ant-table-column-has-sorters react-resizable"
|
||||
class="resizable-header react-resizable"
|
||||
scope="col"
|
||||
tabindex="0"
|
||||
>
|
||||
@@ -143,12 +145,12 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="c1 react-resizable-handle"
|
||||
class="c1 resize-handle"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
aria-label="latency-per-service"
|
||||
class="ant-table-cell ant-table-column-has-sorters react-resizable"
|
||||
class="resizable-header react-resizable"
|
||||
scope="col"
|
||||
tabindex="0"
|
||||
>
|
||||
@@ -209,7 +211,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="c1 react-resizable-handle"
|
||||
class="c1 resize-handle"
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -221,7 +223,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
style="overflow-x: auto; overflow-y: hidden;"
|
||||
>
|
||||
<table
|
||||
style="width: auto; min-width: 100%; table-layout: fixed;"
|
||||
style="min-width: 100%; table-layout: fixed;"
|
||||
>
|
||||
<colgroup>
|
||||
<col
|
||||
|
||||
@@ -453,7 +453,7 @@ export const Query = memo(function Query({
|
||||
</Col>
|
||||
)}
|
||||
<Col flex="1" className="qb-search-container">
|
||||
{query.dataSource === DataSource.LOGS ? (
|
||||
{[DataSource.LOGS, DataSource.TRACES].includes(query.dataSource) ? (
|
||||
<QueryBuilderSearchV2
|
||||
query={query}
|
||||
onChange={handleChangeTagFilters}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import './QueryBuilderSearchV2.styles.scss';
|
||||
|
||||
import { Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
@@ -25,6 +26,7 @@ interface ICustomDropdownProps {
|
||||
exampleQueries: TagFilter[];
|
||||
onChange: (value: TagFilter) => void;
|
||||
currentFilterItem?: ITag;
|
||||
isLogsDataSource: boolean;
|
||||
}
|
||||
|
||||
export default function QueryBuilderSearchDropdown(
|
||||
@@ -38,11 +40,14 @@ export default function QueryBuilderSearchDropdown(
|
||||
exampleQueries,
|
||||
options,
|
||||
onChange,
|
||||
isLogsDataSource,
|
||||
} = props;
|
||||
const userOs = getUserOperatingSystem();
|
||||
return (
|
||||
<>
|
||||
<div className="content">
|
||||
<div
|
||||
className={cx('content', { 'non-logs-data-source': !isLogsDataSource })}
|
||||
>
|
||||
{!currentFilterItem?.key ? (
|
||||
<div className="suggested-filters">Suggested Filters</div>
|
||||
) : !currentFilterItem?.op ? (
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
.query-builder-search {
|
||||
.content {
|
||||
.suggested-filters {
|
||||
color: var(--bg-slate-50);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
padding: 12px 0px 8px 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-builder-search-v2 {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -11,6 +27,11 @@
|
||||
.rc-virtual-list-holder {
|
||||
height: 115px;
|
||||
}
|
||||
&.non-logs-data-source {
|
||||
.rc-virtual-list-holder {
|
||||
height: 256px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,9 @@ interface QueryBuilderSearchV2Props {
|
||||
className?: string;
|
||||
suffixIcon?: React.ReactNode;
|
||||
hardcodedAttributeKeys?: BaseAutocompleteData[];
|
||||
hasPopupContainer?: boolean;
|
||||
rootClassName?: string;
|
||||
maxTagCount?: number | 'responsive';
|
||||
}
|
||||
|
||||
export interface Option {
|
||||
@@ -121,6 +124,9 @@ function QueryBuilderSearchV2(
|
||||
suffixIcon,
|
||||
whereClauseConfig,
|
||||
hardcodedAttributeKeys,
|
||||
hasPopupContainer,
|
||||
rootClassName,
|
||||
maxTagCount,
|
||||
} = props;
|
||||
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
@@ -689,12 +695,29 @@ function QueryBuilderSearchV2(
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
setDropdownOptions(
|
||||
data?.payload?.attributeKeys?.map((key) => ({
|
||||
setDropdownOptions([
|
||||
// Add user typed option if it doesn't exist in the payload
|
||||
...(!isEmpty(tagKey) &&
|
||||
!data?.payload?.attributeKeys?.some((val) => isEqual(val.key, tagKey))
|
||||
? [
|
||||
{
|
||||
label: tagKey,
|
||||
value: {
|
||||
key: tagKey,
|
||||
dataType: DataTypes.EMPTY,
|
||||
type: '',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Map existing attribute keys from payload
|
||||
...(data?.payload?.attributeKeys?.map((key) => ({
|
||||
label: key.key,
|
||||
value: key,
|
||||
})) || [],
|
||||
);
|
||||
})) || []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (currentState === DropdownState.OPERATOR) {
|
||||
@@ -911,7 +934,10 @@ function QueryBuilderSearchV2(
|
||||
<div className="query-builder-search-v2">
|
||||
<Select
|
||||
ref={selectRef}
|
||||
getPopupContainer={popupContainer}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...(maxTagCount ? { maxTagCount } : {})}
|
||||
key={queryTags.join('.')}
|
||||
virtual={false}
|
||||
showSearch
|
||||
@@ -943,7 +969,7 @@ function QueryBuilderSearchV2(
|
||||
: '',
|
||||
className,
|
||||
)}
|
||||
rootClassName="query-builder-search"
|
||||
rootClassName={cx('query-builder-search', rootClassName)}
|
||||
disabled={isMetricsDataSource && !query.aggregateAttribute.key}
|
||||
style={selectStyle}
|
||||
onSearch={handleSearch}
|
||||
@@ -964,6 +990,7 @@ function QueryBuilderSearchV2(
|
||||
exampleQueries={suggestionsData?.payload?.example_queries || []}
|
||||
tags={tags}
|
||||
currentFilterItem={currentFilterItem}
|
||||
isLogsDataSource={isLogsDataSource}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@@ -999,7 +1026,10 @@ QueryBuilderSearchV2.defaultProps = {
|
||||
className: '',
|
||||
suffixIcon: null,
|
||||
whereClauseConfig: {},
|
||||
hasPopupContainer: true,
|
||||
rootClassName: '',
|
||||
hardcodedAttributeKeys: undefined,
|
||||
maxTagCount: undefined,
|
||||
};
|
||||
|
||||
export default QueryBuilderSearchV2;
|
||||
|
||||
@@ -20,4 +20,5 @@ export type QueryTableProps = Omit<
|
||||
dataSource?: RowData[];
|
||||
sticky?: TableProps<RowData>['sticky'];
|
||||
searchTerm?: string;
|
||||
widgetId?: string;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export function QueryTable({
|
||||
dataSource,
|
||||
sticky,
|
||||
searchTerm,
|
||||
widgetId,
|
||||
...props
|
||||
}: QueryTableProps): JSX.Element {
|
||||
const { isDownloadEnabled = false, fileName = '' } = downloadOption || {};
|
||||
@@ -95,8 +96,10 @@ export function QueryTable({
|
||||
columns={tableColumns}
|
||||
tableLayout="fixed"
|
||||
dataSource={filterTable === null ? newDataSource : filterTable}
|
||||
scroll={{ x: true }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={paginationConfig}
|
||||
widgetId={widgetId}
|
||||
shouldPersistColumnWidths
|
||||
sticky={sticky}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
|
||||
@@ -279,6 +279,17 @@ function SideNav(): JSX.Element {
|
||||
let updatedUserManagementItems: UserManagementMenuItems[] = [
|
||||
manageLicenseMenuItem,
|
||||
];
|
||||
|
||||
const isApiMonitoringEnabled = featureFlags?.find(
|
||||
(flag) => flag.name === FeatureKeys.THIRD_PARTY_API,
|
||||
)?.active;
|
||||
|
||||
if (!isApiMonitoringEnabled) {
|
||||
updatedMenuItems = updatedMenuItems.filter(
|
||||
(item) => item.key !== ROUTES.API_MONITORING,
|
||||
);
|
||||
}
|
||||
|
||||
if (isCloudUserVal || isEECloudUserVal) {
|
||||
const isOnboardingEnabled =
|
||||
featureFlags?.find((feature) => feature.name === FeatureKeys.ONBOARDING)
|
||||
|
||||
@@ -128,6 +128,7 @@ const menuItems: SidebarItem[] = [
|
||||
key: ROUTES.API_MONITORING,
|
||||
label: 'API Monitoring',
|
||||
icon: <Binoculars size={16} />,
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
key: ROUTES.LIST_ALL_ALERT,
|
||||
|
||||
@@ -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;
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
|
||||
.div-td .span-overview .second-row .add-funnel-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.span-overview {
|
||||
background: unset !important;
|
||||
|
||||
@@ -231,6 +235,24 @@
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
.add-funnel-button {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
|
||||
&__separator {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
&__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import cx from 'classnames';
|
||||
import { TableV3 } from 'components/TableV3/TableV3';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import AddSpanToFunnelModal from 'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
@@ -57,6 +59,7 @@ function SpanOverview({
|
||||
isSpanCollapsed,
|
||||
handleCollapseUncollapse,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
selectedSpan,
|
||||
}: {
|
||||
span: Span;
|
||||
@@ -64,6 +67,7 @@ function SpanOverview({
|
||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
}): JSX.Element {
|
||||
const isRootSpan = span.level === 0;
|
||||
|
||||
@@ -141,6 +145,28 @@ function SpanOverview({
|
||||
<Typography.Text className="service-name">
|
||||
{span.serviceName}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,12 +236,14 @@ function getWaterfallColumns({
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
}: {
|
||||
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
|
||||
uncollapsedNodes: string[];
|
||||
traceMetadata: ITraceMetadata;
|
||||
selectedSpan: Span | undefined;
|
||||
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
|
||||
handleAddSpanToFunnel: (span: Span) => void;
|
||||
}): ColumnDef<Span, any>[] {
|
||||
const waterfallColumns: ColumnDef<Span, any>[] = [
|
||||
columnDefHelper.display({
|
||||
@@ -228,6 +256,7 @@ function getWaterfallColumns({
|
||||
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
handleAddSpanToFunnel={handleAddSpanToFunnel}
|
||||
/>
|
||||
),
|
||||
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(
|
||||
() =>
|
||||
getWaterfallColumns({
|
||||
@@ -302,6 +342,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
}),
|
||||
[
|
||||
handleCollapseUncollapse,
|
||||
@@ -309,6 +350,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
setSelectedSpan,
|
||||
handleAddSpanToFunnel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -380,6 +422,13 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
virtualiserRef={virtualizerRef}
|
||||
setColumnWidths={setTraceFlamegraphStatsWidth}
|
||||
/>
|
||||
{selectedSpanToAddToFunnel && (
|
||||
<AddSpanToFunnelModal
|
||||
span={selectedSpanToAddToFunnel}
|
||||
isOpen={isAddSpanToFunnelModalOpen}
|
||||
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './TracesTableComponent.styles.scss';
|
||||
|
||||
import { Table } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import Controls from 'container/Controls';
|
||||
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
|
||||
@@ -54,9 +54,14 @@ function TracesTableComponent({
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns = getListColumns(
|
||||
widget.selectedTracesFields || [],
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getListColumns(
|
||||
widget.selectedTracesFields || [],
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[widget.selectedTracesFields],
|
||||
);
|
||||
|
||||
const dataLength =
|
||||
@@ -116,16 +121,18 @@ function TracesTableComponent({
|
||||
<div className="traces-table">
|
||||
<div className="resize-table">
|
||||
<OverlayScrollbar>
|
||||
<Table
|
||||
<ResizeTable
|
||||
pagination={false}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: true }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
loading={queryResponse.isFetching}
|
||||
style={tableStyles}
|
||||
dataSource={transformedQueryTableData}
|
||||
columns={columns}
|
||||
onRow={handleRow}
|
||||
sticky
|
||||
widgetId={widget.id}
|
||||
shouldPersistColumnWidths
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</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 {
|
||||
createFunnel,
|
||||
deleteFunnel,
|
||||
ErrorTraceData,
|
||||
ErrorTracesPayload,
|
||||
FunnelOverviewPayload,
|
||||
FunnelOverviewResponse,
|
||||
FunnelStepsResponse,
|
||||
getFunnelById,
|
||||
getFunnelErrorTraces,
|
||||
getFunnelOverview,
|
||||
getFunnelsList,
|
||||
getFunnelSlowTraces,
|
||||
getFunnelSteps,
|
||||
renameFunnel,
|
||||
saveFunnelDescription,
|
||||
SlowTraceData,
|
||||
SlowTracesPayload,
|
||||
updateFunnelStepDetails,
|
||||
UpdateFunnelStepDetailsPayload,
|
||||
updateFunnelSteps,
|
||||
UpdateFunnelStepsPayload,
|
||||
ValidateFunnelResponse,
|
||||
validateFunnelSteps,
|
||||
} from 'api/traceFunnels';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
|
||||
import {
|
||||
useMutation,
|
||||
UseMutationResult,
|
||||
@@ -20,19 +40,20 @@ import {
|
||||
} from 'types/api/traceFunnels';
|
||||
|
||||
export const useFunnelsList = ({
|
||||
searchQuery,
|
||||
searchQuery = '',
|
||||
}: {
|
||||
searchQuery: string;
|
||||
searchQuery?: string;
|
||||
}): UseQueryResult<SuccessResponse<FunnelData[]> | ErrorResponse, unknown> =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNELS_LIST, searchQuery],
|
||||
queryFn: () => getFunnelsList({ search: searchQuery }),
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
export const useFunnelDetails = ({
|
||||
funnelId,
|
||||
}: {
|
||||
funnelId: string;
|
||||
funnelId?: string;
|
||||
}): UseQueryResult<SuccessResponse<FunnelData> | ErrorResponse, unknown> =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnelId],
|
||||
@@ -75,3 +96,148 @@ export const useDeleteFunnel = (): UseMutationResult<
|
||||
useMutation({
|
||||
mutationFn: deleteFunnel,
|
||||
});
|
||||
|
||||
export const useUpdateFunnelSteps = (
|
||||
funnelId: string,
|
||||
notification: NotificationInstance,
|
||||
): UseMutationResult<
|
||||
SuccessResponse<FunnelData> | ErrorResponse,
|
||||
Error,
|
||||
UpdateFunnelStepsPayload
|
||||
> =>
|
||||
useMutation({
|
||||
mutationFn: updateFunnelSteps,
|
||||
mutationKey: [REACT_QUERY_KEY.UPDATE_FUNNEL_STEPS, funnelId],
|
||||
|
||||
onError: (error) => {
|
||||
notification.error({
|
||||
message: 'Failed to update funnel steps',
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const useValidateFunnelSteps = ({
|
||||
funnelId,
|
||||
selectedTime,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
funnelId: string;
|
||||
selectedTime: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): UseQueryResult<
|
||||
SuccessResponse<ValidateFunnelResponse> | ErrorResponse,
|
||||
Error
|
||||
> =>
|
||||
useQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
validateFunnelSteps(
|
||||
funnelId,
|
||||
{ start_time: startTime, end_time: endTime },
|
||||
signal,
|
||||
),
|
||||
queryKey: [REACT_QUERY_KEY.VALIDATE_FUNNEL_STEPS, funnelId, selectedTime],
|
||||
enabled: !!funnelId && !!selectedTime && !!startTime && !!endTime,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
||||
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,31 +1,38 @@
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { ChangeEvent, useCallback, useState } from 'react';
|
||||
|
||||
const useHandleTraceFunnelsSearch = (): {
|
||||
searchQuery: string;
|
||||
handleSearch: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
} => {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const [searchQuery, setSearchQuery] = useState<string>(
|
||||
urlQuery.get('search') || '',
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdateUrl = useCallback(
|
||||
debounce((value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue) {
|
||||
urlQuery.set('search', trimmedValue);
|
||||
} else {
|
||||
urlQuery.delete('search');
|
||||
}
|
||||
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
}, 300),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const { value } = e.target;
|
||||
setSearchQuery(value);
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue) {
|
||||
urlQuery.set('search', trimmedValue);
|
||||
} else {
|
||||
urlQuery.delete('search');
|
||||
}
|
||||
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
debouncedUpdateUrl(value);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -170,11 +170,7 @@ export const useOptions = (
|
||||
(option, index, self) =>
|
||||
index ===
|
||||
self.findIndex(
|
||||
(o) =>
|
||||
// to remove duplicate & empty options from list
|
||||
o.label === option.label &&
|
||||
o.value === option.value &&
|
||||
o.dataType?.toLowerCase() === option.dataType?.toLowerCase(), // handle case sensitivity
|
||||
(o) => o.label === option.label && o.value === option.value, // to remove duplicate & empty options from list
|
||||
) && option.value !== '',
|
||||
) || []
|
||||
).map((option) => {
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Button, Card, Col, Divider, Row, Switch, Typography } from 'antd';
|
||||
import MultiSelect, {
|
||||
MultiSelectOption,
|
||||
MultiSelectSection,
|
||||
} from 'components/MultiSelect';
|
||||
import { useState } from 'react';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
// Sample data for the component
|
||||
const sampleOptions: MultiSelectOption[] = [
|
||||
{ label: 'abc', value: 'abc' },
|
||||
{ label: 'acbewc', value: 'acbewc' },
|
||||
{ label: 'custom-value', value: 'custom-value' },
|
||||
{ label: 'option1', value: 'option1' },
|
||||
{ label: 'option2', value: 'option2' },
|
||||
{ label: 'another-option', value: 'another-option' },
|
||||
{ label: 'test-option', value: 'test-option' },
|
||||
{ label: 'disabled-option', value: 'disabled-option', disabled: true },
|
||||
];
|
||||
|
||||
// Sample related values for the "Related Values" section
|
||||
const relatedValues: MultiSelectOption[] = [
|
||||
{ label: 'gke-mgmt-pl-generator-e2st4-sp-f1c1bde8-skbl', value: 'gke-1' },
|
||||
{ label: 'gke-mgmt-pl-generator-e2st4-sp-f1c1bde8-skb2', value: 'gke-2' },
|
||||
{ label: 'gke-mgmt-pl-generator-e2st4-sp-f1c1bde8-skb3', value: 'gke-3' },
|
||||
];
|
||||
|
||||
// Sample all values for the "All Values" section
|
||||
const allValues: MultiSelectOption[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
label: `gke-mgmt-pl-generator-e2st4-sp-f1c1bde8-7a7w-${i + 1}`,
|
||||
value: `all-${i + 1}`,
|
||||
}));
|
||||
|
||||
// Creating sections
|
||||
const sections: MultiSelectSection[] = [
|
||||
{
|
||||
title: 'Related Values',
|
||||
options: relatedValues,
|
||||
},
|
||||
{
|
||||
title: 'ALL Values',
|
||||
options: allValues,
|
||||
},
|
||||
];
|
||||
|
||||
function DynamicVariableTestPage(): JSX.Element {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([
|
||||
'abc',
|
||||
'acbewc',
|
||||
]);
|
||||
const [loadingDemo, setLoadingDemo] = useState<boolean>(false);
|
||||
const [allowCustom, setAllowCustom] = useState<boolean>(true);
|
||||
const [showError, setShowError] = useState<boolean>(false);
|
||||
const [disabled, setDisabled] = useState<boolean>(false);
|
||||
|
||||
const handleChange = (values: string[]): void => {
|
||||
setSelectedValues(values);
|
||||
};
|
||||
|
||||
const toggleLoading = (): void => {
|
||||
setLoadingDemo((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dynamic-variable-page">
|
||||
<Card>
|
||||
<Title level={3}>Dynamic Variable MultiSelect</Title>
|
||||
<Paragraph>
|
||||
This page demonstrates the MultiSelect component with various features. The
|
||||
component is now fully reusable and production-ready with support for
|
||||
dynamic data from APIs, proper error states, accessibility, and UI
|
||||
improvements.
|
||||
</Paragraph>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Title level={5}>Basic MultiSelect</Title>
|
||||
<Text>This example shows the basic usage with pre-selected values.</Text>
|
||||
<div className="multiselect-demo-container">
|
||||
<MultiSelect
|
||||
options={sampleOptions}
|
||||
value={selectedValues}
|
||||
onChange={handleChange}
|
||||
placeholder="Search or add values..."
|
||||
label="Select options"
|
||||
/>
|
||||
</div>
|
||||
<div className="selected-values-display">
|
||||
<Title level={5}>Selected Values:</Title>
|
||||
<pre>{JSON.stringify(selectedValues, null, 2)}</pre>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Title level={5}>With Sections & All Values</Title>
|
||||
<Text>This example shows the component with additional sections.</Text>
|
||||
<div className="multiselect-demo-container">
|
||||
<MultiSelect
|
||||
options={sampleOptions}
|
||||
value={selectedValues}
|
||||
onChange={handleChange}
|
||||
placeholder="Search or add values..."
|
||||
additionalSections={sections}
|
||||
sectionMaxHeight={120}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Title level={5}>Loading State</Title>
|
||||
<Text>This example demonstrates the loading state.</Text>
|
||||
<div className="multiselect-demo-container">
|
||||
<MultiSelect
|
||||
options={sampleOptions}
|
||||
value={[]}
|
||||
onChange={(): void => {}}
|
||||
loading={loadingDemo}
|
||||
placeholder="This shows loading state..."
|
||||
/>
|
||||
<Button onClick={toggleLoading} style={{ marginTop: 16 }}>
|
||||
{loadingDemo ? 'Stop Loading' : 'Simulate Loading'}
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Title level={5}>Custom Values Configuration</Title>
|
||||
<Text>Toggle to enable/disable custom values.</Text>
|
||||
<div className="multiselect-demo-container">
|
||||
<MultiSelect
|
||||
options={sampleOptions}
|
||||
value={[]}
|
||||
onChange={(): void => {}}
|
||||
allowCustomValues={allowCustom}
|
||||
placeholder={
|
||||
allowCustom ? 'Custom values allowed...' : 'Only predefined values...'
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Switch
|
||||
checked={allowCustom}
|
||||
onChange={setAllowCustom}
|
||||
checkedChildren="Custom values on"
|
||||
unCheckedChildren="Custom values off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Title level={5}>Error State</Title>
|
||||
<Text>This example shows the component with an error.</Text>
|
||||
<div className="multiselect-demo-container">
|
||||
<MultiSelect
|
||||
options={sampleOptions}
|
||||
value={[]}
|
||||
onChange={(): void => {}}
|
||||
placeholder="Select some options..."
|
||||
error={showError ? 'Please select at least one option' : undefined}
|
||||
/>
|
||||
<Button
|
||||
onClick={(): void => setShowError(!showError)}
|
||||
style={{ marginTop: 16 }}
|
||||
type={showError ? 'primary' : 'default'}
|
||||
>
|
||||
{showError ? 'Hide Error' : 'Show Error'}
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Title level={5}>Disabled State</Title>
|
||||
<Text>This example shows the disabled state of the component.</Text>
|
||||
<div className="multiselect-demo-container">
|
||||
<MultiSelect
|
||||
options={sampleOptions}
|
||||
value={['abc', 'option1']}
|
||||
onChange={(): void => {}}
|
||||
placeholder="This component is disabled..."
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Switch
|
||||
checked={disabled}
|
||||
onChange={setDisabled}
|
||||
checkedChildren="Disabled"
|
||||
unCheckedChildren="Enabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariableTestPage;
|
||||
@@ -1,20 +0,0 @@
|
||||
.dynamic-variable-page {
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.multiselect-demo-container {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selected-values-display {
|
||||
margin-top: 24px;
|
||||
|
||||
pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
.traces-module-container {
|
||||
.funnel-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.trace-module {
|
||||
.ant-tabs-tab {
|
||||
.tab-item {
|
||||
|
||||
@@ -3,7 +3,7 @@ import './TraceDetailV2.styles.scss';
|
||||
import { Button, Tabs } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { Compass, TowerControl, Undo } from 'lucide-react';
|
||||
import { Compass, Cone, TowerControl, Undo } from 'lucide-react';
|
||||
import TraceDetail from 'pages/TraceDetail';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
@@ -33,6 +33,9 @@ function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
|
||||
if (activeKey === 'trace-details') {
|
||||
history.push(ROUTES.TRACES_EXPLORER);
|
||||
}
|
||||
if (activeKey === 'funnels') {
|
||||
history.push(ROUTES.TRACES_FUNNELS);
|
||||
}
|
||||
}}
|
||||
tabBarExtraContent={
|
||||
<Button
|
||||
@@ -61,6 +64,15 @@ export default function TraceDetailsPage(): JSX.Element {
|
||||
key: 'trace-details',
|
||||
children: <TraceDetailsV2 />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="tab-item">
|
||||
<Cone className="funnel-icon" size={16} /> Funnels
|
||||
</div>
|
||||
),
|
||||
key: 'funnels',
|
||||
children: <div />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<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 { FunnelProvider } from 'pages/TracesFunnels/FunnelContext';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import FunnelConfiguration from './components/FunnelConfiguration/FunnelConfiguration';
|
||||
import FunnelResults from './components/FunnelResults/FunnelResults';
|
||||
|
||||
function TracesFunnelDetails(): JSX.Element {
|
||||
const { funnelId } = useParams<{ funnelId: string }>();
|
||||
const { data } = useFunnelDetails({ funnelId });
|
||||
const { data, isLoading, isError } = useFunnelDetails({ funnelId });
|
||||
|
||||
if (isLoading || !data?.payload) {
|
||||
return <Spinner size="large" tip="Loading..." />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<NotFoundContainer>
|
||||
<Typography>Error loading funnel details</Typography>
|
||||
</NotFoundContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ color: 'var(--bg-vanilla-400)' }}>
|
||||
TracesFunnelDetails, {JSON.stringify(data)}
|
||||
</div>
|
||||
<FunnelProvider funnelId={funnelId}>
|
||||
<div className="traces-funnel-details">
|
||||
<div className="traces-funnel-details__steps-config">
|
||||
<FunnelConfiguration funnel={data.payload} />
|
||||
</div>
|
||||
<div className="traces-funnel-details__steps-results">
|
||||
<FunnelResults />
|
||||
</div>
|
||||
</div>
|
||||
</FunnelProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user