mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-12 07:49:01 +08:00
feat: trace funnels list page (#7324)
* chore: add a new tab for traces funnels * feat: funnels list page basic UI * feat: learn more component * feat: get funnels list data from mock API, and handle data, loading and empty states * chore(SignozModal): add width prop and improve button styles * feat: implement funnel rename * refactor: overall improvements * feat: implement sorting in traces funnels list page * feat: add sort column key and order to url params * chore: move useFunnels to hooks/TracesFunnels * feat: implement traces funnels search and refactor search and sort by extracting to custom hooks * chore: overall improvements to rename trace funnel modal * chore: make the rename input auto-focusable * feat: handle create funnel modal * feat: delete funnel modal and functionality * fix: fix the layout shift in funnel item caused by getContainer={false} * chore: overall improvements and use live api in traces funnels * feat: create traces funnels details basic page + funnel -> details redirection * fix: funnels traces light mode UI * fix: properly display created at in funnels list item + preventDefault * refactor: extract FunnelItemPopover into a separate component * chore: hide funnel tab from traces explorer * chore: add check to display trace funnels tab only in dev environment * chore: improve funnels modals light mode * chore: overall improvements * fix: properly pass funnel details link * chore: address PR review changes
This commit is contained in:
parent
1f9b13dc35
commit
2c87d96d75
@ -54,6 +54,7 @@
|
|||||||
"SUPPORT": "SigNoz | Support",
|
"SUPPORT": "SigNoz | Support",
|
||||||
"LOGS_SAVE_VIEWS": "SigNoz | Logs Saved Views",
|
"LOGS_SAVE_VIEWS": "SigNoz | Logs Saved Views",
|
||||||
"TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views",
|
"TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views",
|
||||||
|
"TRACES_FUNNELS": "SigNoz | Traces Funnels",
|
||||||
"DEFAULT": "Open source Observability Platform | SigNoz",
|
"DEFAULT": "Open source Observability Platform | SigNoz",
|
||||||
"SHORTCUTS": "SigNoz | Shortcuts",
|
"SHORTCUTS": "SigNoz | Shortcuts",
|
||||||
"INTEGRATIONS": "SigNoz | Integrations",
|
"INTEGRATIONS": "SigNoz | Integrations",
|
||||||
|
@ -42,6 +42,17 @@ export const TracesSaveViews = Loadable(
|
|||||||
import(/* webpackChunkName: "Traces Save Views" */ 'pages/TracesModulePage'),
|
import(/* webpackChunkName: "Traces Save Views" */ 'pages/TracesModulePage'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const TracesFunnels = Loadable(
|
||||||
|
() =>
|
||||||
|
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),
|
||||||
|
);
|
||||||
|
export const TracesFunnelDetails = Loadable(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesFunnelDetails'
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const TraceFilter = Loadable(
|
export const TraceFilter = Loadable(
|
||||||
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
|
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
|
||||||
);
|
);
|
||||||
|
@ -52,6 +52,8 @@ import {
|
|||||||
TraceDetail,
|
TraceDetail,
|
||||||
TraceFilter,
|
TraceFilter,
|
||||||
TracesExplorer,
|
TracesExplorer,
|
||||||
|
TracesFunnelDetails,
|
||||||
|
TracesFunnels,
|
||||||
TracesSaveViews,
|
TracesSaveViews,
|
||||||
UnAuthorized,
|
UnAuthorized,
|
||||||
UsageExplorerPage,
|
UsageExplorerPage,
|
||||||
@ -236,6 +238,20 @@ const routes: AppRoutes[] = [
|
|||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
key: 'TRACES_SAVE_VIEWS',
|
key: 'TRACES_SAVE_VIEWS',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ROUTES.TRACES_FUNNELS,
|
||||||
|
exact: true,
|
||||||
|
component: TracesFunnels,
|
||||||
|
isPrivate: true,
|
||||||
|
key: 'TRACES_FUNNELS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ROUTES.TRACES_FUNNELS_DETAIL,
|
||||||
|
exact: true,
|
||||||
|
component: TracesFunnelDetails,
|
||||||
|
isPrivate: true,
|
||||||
|
key: 'TRACES_FUNNELS_DETAIL',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ROUTES.CHANNELS_NEW,
|
path: ROUTES.CHANNELS_NEW,
|
||||||
exact: true,
|
exact: true,
|
||||||
|
109
frontend/src/api/traceFunnels/index.ts
Normal file
109
frontend/src/api/traceFunnels/index.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import axios from 'api';
|
||||||
|
import { AxiosResponse } from 'axios';
|
||||||
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
|
import {
|
||||||
|
CreateFunnelPayload,
|
||||||
|
CreateFunnelResponse,
|
||||||
|
FunnelData,
|
||||||
|
} from 'types/api/traceFunnels';
|
||||||
|
|
||||||
|
const FUNNELS_BASE_PATH = '/trace-funnels';
|
||||||
|
|
||||||
|
export const createFunnel = async (
|
||||||
|
payload: CreateFunnelPayload,
|
||||||
|
): Promise<SuccessResponse<CreateFunnelResponse> | ErrorResponse> => {
|
||||||
|
const response: AxiosResponse = await axios.post(
|
||||||
|
`${FUNNELS_BASE_PATH}/new-funnel`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: 'Funnel created successfully',
|
||||||
|
payload: response.data,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GetFunnelsListParams {
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFunnelsList = async ({
|
||||||
|
search = '',
|
||||||
|
}: GetFunnelsListParams = {}): Promise<
|
||||||
|
SuccessResponse<FunnelData[]> | ErrorResponse
|
||||||
|
> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search.length) {
|
||||||
|
params.set('search', search);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: AxiosResponse = await axios.get(
|
||||||
|
`${FUNNELS_BASE_PATH}/list${
|
||||||
|
params.toString() ? `?${params.toString()}` : ''
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: '',
|
||||||
|
payload: response.data,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFunnelById = async (
|
||||||
|
funnelId: string,
|
||||||
|
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||||
|
const response: AxiosResponse = await axios.get(
|
||||||
|
`${FUNNELS_BASE_PATH}/get/${funnelId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: '',
|
||||||
|
payload: response.data,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RenameFunnelPayload {
|
||||||
|
id: string;
|
||||||
|
funnel_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renameFunnel = async (
|
||||||
|
payload: RenameFunnelPayload,
|
||||||
|
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||||
|
const response: AxiosResponse = await axios.put(
|
||||||
|
`${FUNNELS_BASE_PATH}/${payload.id}/update`,
|
||||||
|
{ funnel_name: payload.funnel_name },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: 'Funnel renamed successfully',
|
||||||
|
payload: response.data,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DeleteFunnelPayload {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteFunnel = async (
|
||||||
|
payload: DeleteFunnelPayload,
|
||||||
|
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
|
||||||
|
const response: AxiosResponse = await axios.delete(
|
||||||
|
`${FUNNELS_BASE_PATH}/delete/${payload.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
message: 'Funnel deleted successfully',
|
||||||
|
payload: response.data,
|
||||||
|
};
|
||||||
|
};
|
14
frontend/src/components/LearnMore/LearnMore.styles.scss
Normal file
14
frontend/src/components/LearnMore/LearnMore.styles.scss
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
.learn-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0;
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px; /* 128.571% */
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
&,&:hover {
|
||||||
|
color: var(--bg-robin-400) !important;
|
||||||
|
}
|
||||||
|
}
|
34
frontend/src/components/LearnMore/LearnMore.tsx
Normal file
34
frontend/src/components/LearnMore/LearnMore.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import './LearnMore.styles.scss';
|
||||||
|
|
||||||
|
import { Color } from '@signozhq/design-tokens';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import { ArrowUpRight } from 'lucide-react';
|
||||||
|
|
||||||
|
type LearnMoreProps = {
|
||||||
|
text?: string;
|
||||||
|
url?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LearnMore({ text, url, onClick }: LearnMoreProps): JSX.Element {
|
||||||
|
const handleClick = (): void => {
|
||||||
|
onClick?.();
|
||||||
|
if (url) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Button type="link" className="learn-more" onClick={handleClick}>
|
||||||
|
<div className="learn-more__text">{text}</div>
|
||||||
|
<ArrowUpRight size={16} color={Color.BG_ROBIN_400} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LearnMore.defaultProps = {
|
||||||
|
text: 'Learn more',
|
||||||
|
url: '',
|
||||||
|
onClick: (): void => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LearnMore;
|
@ -67,6 +67,17 @@
|
|||||||
background: var(--bg-ink-300);
|
background: var(--bg-ink-300);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-modal-footer {
|
||||||
|
.ant-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
&-icon {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lightMode {
|
.lightMode {
|
||||||
|
@ -4,14 +4,14 @@ import { Modal, ModalProps } from 'antd';
|
|||||||
|
|
||||||
function SignozModal({
|
function SignozModal({
|
||||||
children,
|
children,
|
||||||
|
width = 672,
|
||||||
rootClassName = '',
|
rootClassName = '',
|
||||||
...rest
|
...rest
|
||||||
}: ModalProps): JSX.Element {
|
}: ModalProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
centered
|
centered
|
||||||
width={672}
|
width={width}
|
||||||
cancelText="Close"
|
cancelText="Close"
|
||||||
rootClassName={`signoz-modal ${rootClassName}`}
|
rootClassName={`signoz-modal ${rootClassName}`}
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
@ -56,6 +56,7 @@ export const DATE_TIME_FORMATS = {
|
|||||||
|
|
||||||
// Formats with dash separator
|
// Formats with dash separator
|
||||||
DASH_DATETIME: 'MMM D, YYYY ⎯ HH:mm:ss',
|
DASH_DATETIME: 'MMM D, YYYY ⎯ HH:mm:ss',
|
||||||
|
FUNNELS_LIST_DATE: 'MMM D ⎯ HH:mm:ss',
|
||||||
DASH_DATETIME_UTC: 'MMM D, YYYY ⎯ HH:mm:ss (UTC Z)',
|
DASH_DATETIME_UTC: 'MMM D, YYYY ⎯ HH:mm:ss (UTC Z)',
|
||||||
DASH_TIME_DATE: 'HH:mm:ss ⎯ MMM D, YYYY (UTC Z)',
|
DASH_TIME_DATE: 'HH:mm:ss ⎯ MMM D, YYYY (UTC Z)',
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -51,4 +51,6 @@ export const REACT_QUERY_KEY = {
|
|||||||
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
|
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
|
||||||
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
|
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
|
||||||
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
|
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
|
||||||
};
|
GET_FUNNELS_LIST: 'GET_FUNNELS_LIST',
|
||||||
|
GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS',
|
||||||
|
} as const;
|
||||||
|
@ -56,6 +56,8 @@ const ROUTES = {
|
|||||||
SUPPORT: '/support',
|
SUPPORT: '/support',
|
||||||
LOGS_SAVE_VIEWS: '/logs/saved-views',
|
LOGS_SAVE_VIEWS: '/logs/saved-views',
|
||||||
TRACES_SAVE_VIEWS: '/traces/saved-views',
|
TRACES_SAVE_VIEWS: '/traces/saved-views',
|
||||||
|
TRACES_FUNNELS: '/traces/funnels',
|
||||||
|
TRACES_FUNNELS_DETAIL: '/traces/funnels/:funnelId',
|
||||||
WORKSPACE_LOCKED: '/workspace-locked',
|
WORKSPACE_LOCKED: '/workspace-locked',
|
||||||
WORKSPACE_SUSPENDED: '/workspace-suspended',
|
WORKSPACE_SUSPENDED: '/workspace-suspended',
|
||||||
SHORTCUTS: '/shortcuts',
|
SHORTCUTS: '/shortcuts',
|
||||||
|
@ -357,6 +357,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
|||||||
const isInfraMonitoring = (): boolean =>
|
const isInfraMonitoring = (): boolean =>
|
||||||
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
|
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
|
||||||
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
|
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
|
||||||
|
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
|
||||||
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
|
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
|
||||||
|
|
||||||
const isDashboardView = (): boolean =>
|
const isDashboardView = (): boolean =>
|
||||||
@ -661,7 +662,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
|||||||
? 0
|
? 0
|
||||||
: '0 1rem',
|
: '0 1rem',
|
||||||
|
|
||||||
...(isTraceDetailsView() ? { margin: 0 } : {}),
|
...(isTraceDetailsView() || isTracesFunnels() ? { margin: 0 } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isToDisplayLayout && !renderFullScreen && <TopNav />}
|
{isToDisplayLayout && !renderFullScreen && <TopNav />}
|
||||||
|
@ -207,6 +207,8 @@ export const routesToSkip = [
|
|||||||
ROUTES.LOGS_PIPELINES,
|
ROUTES.LOGS_PIPELINES,
|
||||||
ROUTES.TRACES_EXPLORER,
|
ROUTES.TRACES_EXPLORER,
|
||||||
ROUTES.TRACES_SAVE_VIEWS,
|
ROUTES.TRACES_SAVE_VIEWS,
|
||||||
|
ROUTES.TRACES_FUNNELS,
|
||||||
|
ROUTES.TRACES_FUNNELS_DETAIL,
|
||||||
ROUTES.SHORTCUTS,
|
ROUTES.SHORTCUTS,
|
||||||
ROUTES.INTEGRATIONS,
|
ROUTES.INTEGRATIONS,
|
||||||
ROUTES.DASHBOARD,
|
ROUTES.DASHBOARD,
|
||||||
|
77
frontend/src/hooks/TracesFunnels/useFunnels.tsx
Normal file
77
frontend/src/hooks/TracesFunnels/useFunnels.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
createFunnel,
|
||||||
|
deleteFunnel,
|
||||||
|
getFunnelById,
|
||||||
|
getFunnelsList,
|
||||||
|
renameFunnel,
|
||||||
|
} from 'api/traceFunnels';
|
||||||
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
UseMutationResult,
|
||||||
|
useQuery,
|
||||||
|
UseQueryResult,
|
||||||
|
} from 'react-query';
|
||||||
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
|
import {
|
||||||
|
CreateFunnelPayload,
|
||||||
|
CreateFunnelResponse,
|
||||||
|
FunnelData,
|
||||||
|
} from 'types/api/traceFunnels';
|
||||||
|
|
||||||
|
export const useFunnelsList = ({
|
||||||
|
searchQuery,
|
||||||
|
}: {
|
||||||
|
searchQuery: string;
|
||||||
|
}): UseQueryResult<SuccessResponse<FunnelData[]> | ErrorResponse, unknown> =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [REACT_QUERY_KEY.GET_FUNNELS_LIST, searchQuery],
|
||||||
|
queryFn: () => getFunnelsList({ search: searchQuery }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useFunnelDetails = ({
|
||||||
|
funnelId,
|
||||||
|
}: {
|
||||||
|
funnelId: string;
|
||||||
|
}): UseQueryResult<SuccessResponse<FunnelData> | ErrorResponse, unknown> =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnelId],
|
||||||
|
queryFn: () => getFunnelById(funnelId),
|
||||||
|
enabled: !!funnelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCreateFunnel = (): UseMutationResult<
|
||||||
|
SuccessResponse<CreateFunnelResponse> | ErrorResponse,
|
||||||
|
Error,
|
||||||
|
CreateFunnelPayload
|
||||||
|
> =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: createFunnel,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RenameFunnelPayload {
|
||||||
|
id: string;
|
||||||
|
funnel_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRenameFunnel = (): UseMutationResult<
|
||||||
|
SuccessResponse<FunnelData> | ErrorResponse,
|
||||||
|
Error,
|
||||||
|
RenameFunnelPayload
|
||||||
|
> =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: renameFunnel,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DeleteFunnelPayload {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeleteFunnel = (): UseMutationResult<
|
||||||
|
SuccessResponse<FunnelData> | ErrorResponse,
|
||||||
|
Error,
|
||||||
|
DeleteFunnelPayload
|
||||||
|
> =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: deleteFunnel,
|
||||||
|
});
|
@ -0,0 +1,37 @@
|
|||||||
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
|
import { ChangeEvent, useState } from 'react';
|
||||||
|
|
||||||
|
const useHandleTraceFunnelsSearch = (): {
|
||||||
|
searchQuery: string;
|
||||||
|
handleSearch: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
} => {
|
||||||
|
const { safeNavigate } = useSafeNavigate();
|
||||||
|
|
||||||
|
const urlQuery = useUrlQuery();
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>(
|
||||||
|
urlQuery.get('search') || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const 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() });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchQuery,
|
||||||
|
handleSearch,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useHandleTraceFunnelsSearch;
|
@ -0,0 +1,70 @@
|
|||||||
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { FunnelData } from 'types/api/traceFunnels';
|
||||||
|
|
||||||
|
interface SortOrder {
|
||||||
|
columnKey: string;
|
||||||
|
order: 'ascend' | 'descend';
|
||||||
|
}
|
||||||
|
|
||||||
|
const useHandleTraceFunnelsSort = ({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: FunnelData[];
|
||||||
|
}): {
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
handleSort: (key: string) => void;
|
||||||
|
sortedData: FunnelData[];
|
||||||
|
} => {
|
||||||
|
const { safeNavigate } = useSafeNavigate();
|
||||||
|
const urlQuery = useUrlQuery();
|
||||||
|
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>({
|
||||||
|
columnKey: urlQuery.get('columnKey') || 'creation_timestamp',
|
||||||
|
order: (urlQuery.get('order') as 'ascend' | 'descend') || 'descend',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (key: string): void => {
|
||||||
|
setSortOrder((prev) => {
|
||||||
|
const newOrder: SortOrder =
|
||||||
|
prev.columnKey === key
|
||||||
|
? { columnKey: key, order: prev.order === 'ascend' ? 'descend' : 'ascend' }
|
||||||
|
: { columnKey: key, order: 'descend' };
|
||||||
|
|
||||||
|
urlQuery.set('columnKey', newOrder.columnKey);
|
||||||
|
urlQuery.set('order', newOrder.order);
|
||||||
|
|
||||||
|
return newOrder;
|
||||||
|
});
|
||||||
|
safeNavigate({ search: urlQuery.toString() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedData = useMemo(
|
||||||
|
() =>
|
||||||
|
data.length > 0
|
||||||
|
? [...data].sort((a, b) => {
|
||||||
|
const { columnKey, order } = sortOrder;
|
||||||
|
let aValue = a[columnKey as keyof FunnelData];
|
||||||
|
let bValue = b[columnKey as keyof FunnelData];
|
||||||
|
|
||||||
|
// Fallback to creation timestamp if invalid key
|
||||||
|
if (typeof aValue !== 'number' || typeof bValue !== 'number') {
|
||||||
|
aValue = a.creation_timestamp;
|
||||||
|
bValue = b.creation_timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return order === 'ascend' ? aValue - bValue : bValue - aValue;
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
[sortOrder, data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sortOrder,
|
||||||
|
handleSort,
|
||||||
|
sortedData,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useHandleTraceFunnelsSort;
|
@ -0,0 +1,14 @@
|
|||||||
|
import { useFunnelDetails } from 'hooks/TracesFunnels/useFunnels';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
function TracesFunnelDetails(): JSX.Element {
|
||||||
|
const { funnelId } = useParams<{ funnelId: string }>();
|
||||||
|
const { data } = useFunnelDetails({ funnelId });
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'var(--bg-vanilla-400)' }}>
|
||||||
|
TracesFunnelDetails, {JSON.stringify(data)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TracesFunnelDetails;
|
3
frontend/src/pages/TracesFunnelDetails/index.tsx
Normal file
3
frontend/src/pages/TracesFunnelDetails/index.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import TracesFunnelDetails from './TracesFunnelDetails';
|
||||||
|
|
||||||
|
export default TracesFunnelDetails;
|
109
frontend/src/pages/TracesFunnels/TracesFunnels.styles.scss
Normal file
109
frontend/src/pages/TracesFunnels/TracesFunnels.styles.scss
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
@import './components/Header/Header.styles.scss';
|
||||||
|
@import './components/SearchBar/SearchBar.styles.scss';
|
||||||
|
|
||||||
|
.traces-funnels {
|
||||||
|
margin-top: 113px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
.ant-btn-icon {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
width: calc(100% - 30px);
|
||||||
|
max-width: 736px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
&__loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
.ant-skeleton-button {
|
||||||
|
height: 82px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort popover styles
|
||||||
|
.sort-funnels {
|
||||||
|
.ant-popover-content {
|
||||||
|
.ant-popover-inner {
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg-ink-400);
|
||||||
|
border: 1px solid var(--bg-slate-500);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.sort-content {
|
||||||
|
padding: 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
|
||||||
|
.sort-heading {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
padding: 6px 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btns {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-slate-500);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light mode styles
|
||||||
|
.lightMode {
|
||||||
|
.traces-funnels {
|
||||||
|
&__content {
|
||||||
|
.title {
|
||||||
|
color: var(--bg-ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-funnels {
|
||||||
|
.ant-popover-content {
|
||||||
|
.ant-popover-inner {
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
|
|
||||||
|
.sort-content {
|
||||||
|
.sort-heading {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-btns {
|
||||||
|
color: var(--bg-ink-500);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,103 @@
|
|||||||
|
import '../RenameFunnel/RenameFunnel.styles.scss';
|
||||||
|
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import SignozModal from 'components/SignozModal/SignozModal';
|
||||||
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { useCreateFunnel } from 'hooks/TracesFunnels/useFunnels';
|
||||||
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
|
import { Check, X } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from 'react-query';
|
||||||
|
import { generatePath } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface CreateFunnelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateFunnel({ isOpen, onClose }: CreateFunnelProps): JSX.Element {
|
||||||
|
const [funnelName, setFunnelName] = useState<string>('');
|
||||||
|
const createFunnelMutation = useCreateFunnel();
|
||||||
|
const { notifications } = useNotifications();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { safeNavigate } = useSafeNavigate();
|
||||||
|
|
||||||
|
const handleCreate = (): void => {
|
||||||
|
createFunnelMutation.mutate(
|
||||||
|
{
|
||||||
|
funnel_name: funnelName,
|
||||||
|
creation_timestamp: new Date().getTime(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
notifications.success({
|
||||||
|
message: 'Funnel created successfully',
|
||||||
|
});
|
||||||
|
setFunnelName('');
|
||||||
|
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
||||||
|
onClose();
|
||||||
|
if (data?.payload?.funnel_id) {
|
||||||
|
safeNavigate(
|
||||||
|
generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
|
||||||
|
funnelId: data.payload.funnel_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notifications.error({
|
||||||
|
message: 'Failed to create funnel',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (): void => {
|
||||||
|
setFunnelName('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SignozModal
|
||||||
|
open={isOpen}
|
||||||
|
title="Create new funnel"
|
||||||
|
width={384}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
rootClassName="funnel-modal"
|
||||||
|
cancelText="Cancel"
|
||||||
|
okText="Create Funnel"
|
||||||
|
okButtonProps={{
|
||||||
|
icon: <Check size={14} />,
|
||||||
|
loading: createFunnelMutation.isLoading,
|
||||||
|
type: 'primary',
|
||||||
|
className: 'funnel-modal__ok-btn',
|
||||||
|
onClick: handleCreate,
|
||||||
|
disabled: !funnelName.trim(),
|
||||||
|
}}
|
||||||
|
cancelButtonProps={{
|
||||||
|
icon: <X size={14} />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'funnel-modal__cancel-btn',
|
||||||
|
onClick: handleCancel,
|
||||||
|
}}
|
||||||
|
getContainer={document.getElementById('root') || undefined}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div className="funnel-modal-content">
|
||||||
|
<span className="funnel-modal-content__label">Enter funnel name</span>
|
||||||
|
<Input
|
||||||
|
className="funnel-modal-content__input"
|
||||||
|
value={funnelName}
|
||||||
|
onChange={(e): void => setFunnelName(e.target.value)}
|
||||||
|
placeholder="Eg. checkout dropoff funnel"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SignozModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateFunnel;
|
@ -0,0 +1,25 @@
|
|||||||
|
.signoz-modal.delete-funnel-modal {
|
||||||
|
.funnel-modal__ok-btn {
|
||||||
|
background: var(--bg-cherry-500) !important;
|
||||||
|
}
|
||||||
|
.delete-funnel-modal-content {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px; /* 142.857% */
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
.ant-modal-header {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.ant-modal-body {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.delete-funnel-modal-content {
|
||||||
|
color: var(--bg-ink-100) !important;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,84 @@
|
|||||||
|
import '../RenameFunnel/RenameFunnel.styles.scss';
|
||||||
|
import './DeleteFunnel.styles.scss';
|
||||||
|
|
||||||
|
import SignozModal from 'components/SignozModal/SignozModal';
|
||||||
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
|
import { useDeleteFunnel } from 'hooks/TracesFunnels/useFunnels';
|
||||||
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
|
import { Trash2, X } from 'lucide-react';
|
||||||
|
import { useQueryClient } from 'react-query';
|
||||||
|
|
||||||
|
interface DeleteFunnelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
funnelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteFunnel({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
funnelId,
|
||||||
|
}: DeleteFunnelProps): JSX.Element {
|
||||||
|
const deleteFunnelMutation = useDeleteFunnel();
|
||||||
|
const { notifications } = useNotifications();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleDelete = (): void => {
|
||||||
|
deleteFunnelMutation.mutate(
|
||||||
|
{
|
||||||
|
id: funnelId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.success({
|
||||||
|
message: 'Funnel deleted successfully',
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notifications.error({
|
||||||
|
message: 'Failed to delete funnel',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (): void => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SignozModal
|
||||||
|
open={isOpen}
|
||||||
|
title="Delete this funnel"
|
||||||
|
width={390}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
rootClassName="funnel-modal delete-funnel-modal"
|
||||||
|
cancelText="Cancel"
|
||||||
|
okText="Delete Funnel"
|
||||||
|
okButtonProps={{
|
||||||
|
icon: <Trash2 size={14} />,
|
||||||
|
loading: deleteFunnelMutation.isLoading,
|
||||||
|
type: 'primary',
|
||||||
|
className: 'funnel-modal__ok-btn',
|
||||||
|
onClick: handleDelete,
|
||||||
|
}}
|
||||||
|
cancelButtonProps={{
|
||||||
|
icon: <X size={14} />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'funnel-modal__cancel-btn',
|
||||||
|
onClick: handleCancel,
|
||||||
|
}}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div className="delete-funnel-modal-content">
|
||||||
|
Deleting the funnel would stop further analytics using this funnel. This is
|
||||||
|
irreversible and cannot be undone.
|
||||||
|
</div>
|
||||||
|
</SignozModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteFunnel;
|
@ -0,0 +1,82 @@
|
|||||||
|
.funnels-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 105px 172px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px dashed var(--bg-slate-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
&__icon {
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: var(--bg-vanilla-100);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__new-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-robin-500);
|
||||||
|
border: none;
|
||||||
|
width: 153px;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-robin-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.funnels-empty {
|
||||||
|
&__content {
|
||||||
|
border: 1px dashed var(--bg-vanilla-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
color: var(--bg-ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: var(--bg-ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
import './FunnelsEmptyState.styles.scss';
|
||||||
|
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import LearnMore from 'components/LearnMore/LearnMore';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FunnelsEmptyStateProps {
|
||||||
|
onCreateFunnel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunnelsEmptyState({
|
||||||
|
onCreateFunnel,
|
||||||
|
}: FunnelsEmptyStateProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="funnels-empty">
|
||||||
|
<div className="funnels-empty__content">
|
||||||
|
<section className="funnels-empty__header">
|
||||||
|
<img
|
||||||
|
src="/Icons/alert_emoji.svg"
|
||||||
|
alt="funnels-empty-icon"
|
||||||
|
className="funnels-empty__icon"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="funnels-empty__title">No funnels yet. </span>
|
||||||
|
<span className="funnels-empty__subtitle">
|
||||||
|
Create a funnel to start analyzing your data
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="funnels-empty__actions">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Plus size={16} />}
|
||||||
|
onClick={onCreateFunnel}
|
||||||
|
className="funnels-empty__new-btn"
|
||||||
|
>
|
||||||
|
New funnel
|
||||||
|
</Button>
|
||||||
|
<LearnMore />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FunnelsEmptyState;
|
@ -0,0 +1,114 @@
|
|||||||
|
import { Button, Popover } from 'antd';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import { Ellipsis, PencilLine, Trash2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { FunnelData } from 'types/api/traceFunnels';
|
||||||
|
|
||||||
|
import DeleteFunnel from '../DeleteFunnel/DeleteFunnel';
|
||||||
|
import RenameFunnel from '../RenameFunnel/RenameFunnel';
|
||||||
|
|
||||||
|
interface FunnelItemPopoverProps {
|
||||||
|
isPopoverOpen: boolean;
|
||||||
|
setIsPopoverOpen: (isOpen: boolean) => void;
|
||||||
|
funnel: FunnelData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FunnelItemActionsProps {
|
||||||
|
setIsPopoverOpen: (isOpen: boolean) => void;
|
||||||
|
setIsRenameModalOpen: (isOpen: boolean) => void;
|
||||||
|
setIsDeleteModalOpen: (isOpen: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunnelItemActions({
|
||||||
|
setIsPopoverOpen,
|
||||||
|
setIsRenameModalOpen,
|
||||||
|
setIsDeleteModalOpen,
|
||||||
|
}: FunnelItemActionsProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="funnel-item__actions">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="funnel-item__action-btn"
|
||||||
|
icon={<PencilLine size={14} />}
|
||||||
|
onClick={(): void => {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
setIsRenameModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="funnel-item__action-btn funnel-item__action-btn--delete"
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
onClick={(): void => {
|
||||||
|
setIsPopoverOpen(false);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunnelItemPopover({
|
||||||
|
isPopoverOpen,
|
||||||
|
setIsPopoverOpen,
|
||||||
|
funnel,
|
||||||
|
}: FunnelItemPopoverProps): JSX.Element {
|
||||||
|
const [isRenameModalOpen, setIsRenameModalOpen] = useState<boolean>(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleRenameCancel = (): void => {
|
||||||
|
setIsRenameModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const preventDefault = (e: React.MouseEvent | React.KeyboardEvent): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||||
|
<div onClick={preventDefault} role="button" tabIndex={0}>
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
rootClassName="funnel-item__actions"
|
||||||
|
open={isPopoverOpen}
|
||||||
|
onOpenChange={setIsPopoverOpen}
|
||||||
|
content={
|
||||||
|
<FunnelItemActions
|
||||||
|
setIsDeleteModalOpen={setIsDeleteModalOpen}
|
||||||
|
setIsPopoverOpen={setIsPopoverOpen}
|
||||||
|
setIsRenameModalOpen={setIsRenameModalOpen}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
placement="bottomRight"
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<Ellipsis
|
||||||
|
className={cx('funnel-item__action-icon', {
|
||||||
|
'funnel-item__action-icon--active': isPopoverOpen,
|
||||||
|
})}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<DeleteFunnel
|
||||||
|
isOpen={isDeleteModalOpen}
|
||||||
|
onClose={(): void => setIsDeleteModalOpen(false)}
|
||||||
|
funnelId={funnel.id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RenameFunnel
|
||||||
|
isOpen={isRenameModalOpen}
|
||||||
|
onClose={handleRenameCancel}
|
||||||
|
funnelId={funnel.id}
|
||||||
|
initialName={funnel.funnel_name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FunnelItemPopover;
|
@ -0,0 +1,156 @@
|
|||||||
|
.funnels-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funnel-item {
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
border: 1px solid var(--bg-slate-500);
|
||||||
|
background: var(--bg-ink-400);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: var(--bg-vanilla-100);
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action-icon {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover &__action-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
.ant-popover-inner {
|
||||||
|
width: 187px;
|
||||||
|
height: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
background: linear-gradient(
|
||||||
|
139deg,
|
||||||
|
rgba(18, 19, 23, 0.8) 0%,
|
||||||
|
rgba(18, 19, 23, 0.9) 98.68%
|
||||||
|
);
|
||||||
|
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action-btn {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 14px;
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--delete {
|
||||||
|
color: var(--bg-cherry-400);
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-cherry-600) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__details {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__created-at,
|
||||||
|
&__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px; /* 128.571% */
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
&__user-avatar {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-slate-300);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light mode styles
|
||||||
|
.lightMode {
|
||||||
|
.funnel-item {
|
||||||
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: var(--bg-ink-500);
|
||||||
|
}
|
||||||
|
&__actions {
|
||||||
|
.ant-popover-inner {
|
||||||
|
background: unset;
|
||||||
|
border: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__action-btn {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
border: unset;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--delete {
|
||||||
|
color: var(--bg-cherry-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__created-at {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-avatar {
|
||||||
|
background: var(--bg-vanilla-200);
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
import './FunnelsList.styles.scss';
|
||||||
|
|
||||||
|
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { CalendarClock } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { generatePath, Link } from 'react-router-dom';
|
||||||
|
import { FunnelData } from 'types/api/traceFunnels';
|
||||||
|
|
||||||
|
import FunnelItemPopover from './FunnelItemPopover';
|
||||||
|
|
||||||
|
interface FunnelListItemProps {
|
||||||
|
funnel: FunnelData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunnelListItem({ funnel }: FunnelListItemProps): JSX.Element {
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||||
|
const funnelDetailsLink = generatePath(ROUTES.TRACES_FUNNELS_DETAIL, {
|
||||||
|
funnelId: funnel.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={funnelDetailsLink} className="funnel-item">
|
||||||
|
<div className="funnel-item__header">
|
||||||
|
<div className="funnel-item__title">
|
||||||
|
<div>{funnel.funnel_name}</div>
|
||||||
|
</div>
|
||||||
|
<FunnelItemPopover
|
||||||
|
isPopoverOpen={isPopoverOpen}
|
||||||
|
setIsPopoverOpen={setIsPopoverOpen}
|
||||||
|
funnel={funnel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="funnel-item__details">
|
||||||
|
<div className="funnel-item__created-at">
|
||||||
|
<CalendarClock size={14} />
|
||||||
|
<div>
|
||||||
|
{dayjs(funnel.creation_timestamp).format(
|
||||||
|
DATE_TIME_FORMATS.FUNNELS_LIST_DATE,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="funnel-item__user">
|
||||||
|
{funnel.user && (
|
||||||
|
<div className="funnel-item__user-avatar">
|
||||||
|
{funnel.user.substring(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>{funnel.user}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FunnelsListProps {
|
||||||
|
data: FunnelData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunnelsList({ data }: FunnelsListProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="funnels-list">
|
||||||
|
{data.map((funnel) => (
|
||||||
|
<FunnelListItem key={funnel.id} funnel={funnel} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FunnelsList;
|
@ -0,0 +1,33 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
color: var(--bg-vanilla-100);
|
||||||
|
font-size: 18px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 28px;
|
||||||
|
letter-spacing: -0.09px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.header {
|
||||||
|
&__title {
|
||||||
|
color: var(--bg-ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
function Header(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="header">
|
||||||
|
<div className="header__title">Funnels</div>
|
||||||
|
<div className="header__subtitle">Create and manage tracing funnels.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
@ -0,0 +1,91 @@
|
|||||||
|
.funnel-modal.signoz-modal {
|
||||||
|
.ant-modal-header,
|
||||||
|
.ant-modal-body,
|
||||||
|
.ant-modal-footer {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.ant-modal-body {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.ant-modal-footer {
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 4px;
|
||||||
|
|
||||||
|
.funnel-modal__ok-btn,
|
||||||
|
.funnel-modal__cancel-btn {
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 24px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funnel-modal__ok-btn {
|
||||||
|
width: 138px;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--bg-robin-500);
|
||||||
|
margin: 0 !important;
|
||||||
|
color: var(--bg-vanilla-100);
|
||||||
|
font-weight: 500;
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.funnel-modal__cancel-btn {
|
||||||
|
background-color: var(--bg-slate-500);
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
width: 74px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.funnel-modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
color: var(--bg-vanilla-100);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
background: var(--bg-ink-300);
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light mode styles
|
||||||
|
.lightMode {
|
||||||
|
.funnel-modal-content {
|
||||||
|
&__label {
|
||||||
|
color: var(--bg-ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
border-color: var(--bg-vanilla-300);
|
||||||
|
color: var(--bg-ink-500);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--bg-robin-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.funnel-modal__cancel-btn {
|
||||||
|
background-color: var(--bg-slate-100) !important;
|
||||||
|
color: var(--bg-vanilla-100) !important;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,94 @@
|
|||||||
|
import './RenameFunnel.styles.scss';
|
||||||
|
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import SignozModal from 'components/SignozModal/SignozModal';
|
||||||
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
|
import { useRenameFunnel } 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 RenameFunnelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
funnelId: string;
|
||||||
|
initialName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenameFunnel({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
funnelId,
|
||||||
|
initialName,
|
||||||
|
}: RenameFunnelProps): JSX.Element {
|
||||||
|
const [newFunnelName, setNewFunnelName] = useState<string>(initialName);
|
||||||
|
const renameFunnelMutation = useRenameFunnel();
|
||||||
|
const { notifications } = useNotifications();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleRename = (): void => {
|
||||||
|
renameFunnelMutation.mutate(
|
||||||
|
{ id: funnelId, funnel_name: newFunnelName },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.success({
|
||||||
|
message: 'Funnel renamed successfully',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
notifications.error({
|
||||||
|
message: 'Failed to rename funnel',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (): void => {
|
||||||
|
setNewFunnelName(initialName);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SignozModal
|
||||||
|
open={isOpen}
|
||||||
|
title="Rename Funnel"
|
||||||
|
width={384}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
rootClassName="funnel-modal"
|
||||||
|
cancelText="Cancel"
|
||||||
|
okText="Rename Funnel"
|
||||||
|
okButtonProps={{
|
||||||
|
icon: <Check size={14} />,
|
||||||
|
loading: renameFunnelMutation.isLoading,
|
||||||
|
type: 'primary',
|
||||||
|
className: 'funnel-modal__ok-btn',
|
||||||
|
onClick: handleRename,
|
||||||
|
disabled: newFunnelName === initialName,
|
||||||
|
}}
|
||||||
|
cancelButtonProps={{
|
||||||
|
icon: <X size={14} />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'funnel-modal__cancel-btn',
|
||||||
|
onClick: handleCancel,
|
||||||
|
}}
|
||||||
|
getContainer={document.getElementById('root') || undefined}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div className="funnel-modal-content">
|
||||||
|
<span className="funnel-modal-content__label">Enter a new name</span>
|
||||||
|
<Input
|
||||||
|
className="funnel-modal-content__input"
|
||||||
|
value={newFunnelName}
|
||||||
|
onChange={(e): void => setNewFunnelName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SignozModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RenameFunnel;
|
@ -0,0 +1,147 @@
|
|||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 8px;
|
||||||
|
.ant-input-prefix {
|
||||||
|
margin-inline-end: 6px;
|
||||||
|
}
|
||||||
|
&,
|
||||||
|
input {
|
||||||
|
font-family: Inter;
|
||||||
|
background: var(--bg-ink-300);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-style: normal;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__new-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-robin-500);
|
||||||
|
border-radius: 1.5px;
|
||||||
|
width: 156px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
.ant-btn-icon {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-robin-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sort-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
width: 66px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
background: var(--bg-ink-300);
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 10px;
|
||||||
|
letter-spacing: 0.12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-popover {
|
||||||
|
.ant-popover-content {
|
||||||
|
.ant-popover-inner {
|
||||||
|
display: flex;
|
||||||
|
padding: 0px;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
background: linear-gradient(
|
||||||
|
139deg,
|
||||||
|
rgba(18, 19, 23, 0.8) 0%,
|
||||||
|
rgba(18, 19, 23, 0.9) 98.68%
|
||||||
|
);
|
||||||
|
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
gap: 16px;
|
||||||
|
.sort-popover-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 140px;
|
||||||
|
&__heading {
|
||||||
|
color: var(--bg-slate-200);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px; /* 163.636% */
|
||||||
|
letter-spacing: 0.88px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 12px 18px 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__button {
|
||||||
|
text-align: start;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.14px;
|
||||||
|
padding: 12px 18px 12px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.sort-popover {
|
||||||
|
.ant-popover-content {
|
||||||
|
.ant-popover-inner {
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&-content {
|
||||||
|
&__heading {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__button {
|
||||||
|
color: var(--bg-ink-500);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.search__sort-btn {
|
||||||
|
background: var(--bg-vanilla-300);
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
border: unset;
|
||||||
|
}
|
||||||
|
.search__input,
|
||||||
|
.search__input input {
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
&::placeholder {
|
||||||
|
color: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
import { Color } from '@signozhq/design-tokens';
|
||||||
|
import { Button, Input, Popover, Typography } from 'antd';
|
||||||
|
import { ArrowDownWideNarrow, Check, Plus, Search } from 'lucide-react';
|
||||||
|
import { ChangeEvent } from 'react';
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
searchQuery: string;
|
||||||
|
sortOrder: {
|
||||||
|
columnKey: string;
|
||||||
|
order: 'ascend' | 'descend';
|
||||||
|
};
|
||||||
|
onSearch: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onSort: (key: string) => void;
|
||||||
|
onCreateFunnel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchBar({
|
||||||
|
searchQuery,
|
||||||
|
sortOrder,
|
||||||
|
onSearch,
|
||||||
|
onSort,
|
||||||
|
onCreateFunnel,
|
||||||
|
}: SearchBarProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="search">
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
content={
|
||||||
|
<div className="sort-popover-content">
|
||||||
|
<Typography.Text className="sort-popover-content__heading">
|
||||||
|
Sort By
|
||||||
|
</Typography.Text>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="sort-popover-content__button"
|
||||||
|
onClick={(): void => onSort('creation_timestamp')}
|
||||||
|
>
|
||||||
|
Last created
|
||||||
|
{sortOrder.columnKey === 'creation_timestamp' && <Check size={14} />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="sort-popover-content__button"
|
||||||
|
onClick={(): void => onSort('updated_timestamp')}
|
||||||
|
>
|
||||||
|
Last updated
|
||||||
|
{sortOrder.columnKey === 'updated_timestamp' && <Check size={14} />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
rootClassName="sort-popover"
|
||||||
|
placement="bottomRight"
|
||||||
|
arrow={false}
|
||||||
|
>
|
||||||
|
<Button type="text" className="search__sort-btn">
|
||||||
|
<ArrowDownWideNarrow size={12} data-testid="sort-by" />
|
||||||
|
<div className="search__sort-btn-text">Sort</div>
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
<Input
|
||||||
|
className="search__input"
|
||||||
|
placeholder="Search by name, description, or tags..."
|
||||||
|
prefix={
|
||||||
|
<Search
|
||||||
|
size={12}
|
||||||
|
color={Color.BG_VANILLA_400}
|
||||||
|
style={{ opacity: '0.4' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={onSearch}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Plus size={16} />}
|
||||||
|
className="search__new-btn"
|
||||||
|
onClick={onCreateFunnel}
|
||||||
|
>
|
||||||
|
New funnel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchBar;
|
98
frontend/src/pages/TracesFunnels/index.tsx
Normal file
98
frontend/src/pages/TracesFunnels/index.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import './TracesFunnels.styles.scss';
|
||||||
|
|
||||||
|
import { Skeleton } from 'antd';
|
||||||
|
import { useFunnelsList } from 'hooks/TracesFunnels/useFunnels';
|
||||||
|
import useHandleTraceFunnelsSearch from 'hooks/TracesFunnels/useHandleTraceFunnelsSearch';
|
||||||
|
import useHandleTraceFunnelsSort from 'hooks/TracesFunnels/useHandleTraceFunnelsSort';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { FunnelData } from 'types/api/traceFunnels';
|
||||||
|
|
||||||
|
import CreateFunnel from './components/CreateFunnel/CreateFunnel';
|
||||||
|
import FunnelsEmptyState from './components/FunnelsEmptyState/FunnelsEmptyState';
|
||||||
|
import FunnelsList from './components/FunnelsList/FunnelsList';
|
||||||
|
import Header from './components/Header/Header';
|
||||||
|
import SearchBar from './components/SearchBar/SearchBar';
|
||||||
|
|
||||||
|
interface TracesFunnelsContentRendererProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
data: FunnelData[];
|
||||||
|
onCreateFunnel: () => void;
|
||||||
|
}
|
||||||
|
function TracesFunnelsContentRenderer({
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
data,
|
||||||
|
onCreateFunnel,
|
||||||
|
}: TracesFunnelsContentRendererProps): JSX.Element {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="traces-funnels__loading">
|
||||||
|
{Array(6)
|
||||||
|
.fill(0)
|
||||||
|
.map((item, index) => (
|
||||||
|
<Skeleton.Button
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={`skeleton-item ${index}`}
|
||||||
|
active
|
||||||
|
size="large"
|
||||||
|
shape="default"
|
||||||
|
block
|
||||||
|
className="traces-funnels__loading-skeleton"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <div>Something went wrong</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <FunnelsEmptyState onCreateFunnel={onCreateFunnel} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FunnelsList data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TracesFunnels(): JSX.Element {
|
||||||
|
const { searchQuery, handleSearch } = useHandleTraceFunnelsSearch();
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
|
||||||
|
const { data, isLoading, isError } = useFunnelsList({ searchQuery });
|
||||||
|
|
||||||
|
const { sortOrder, handleSort, sortedData } = useHandleTraceFunnelsSort({
|
||||||
|
data: data?.payload || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateFunnel = (): void => {
|
||||||
|
setIsCreateModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="traces-funnels">
|
||||||
|
<div className="traces-funnels__content">
|
||||||
|
<Header />
|
||||||
|
<SearchBar
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onSort={handleSort}
|
||||||
|
onCreateFunnel={handleCreateFunnel}
|
||||||
|
/>
|
||||||
|
<TracesFunnelsContentRenderer
|
||||||
|
isError={isError}
|
||||||
|
isLoading={isLoading}
|
||||||
|
data={sortedData}
|
||||||
|
onCreateFunnel={handleCreateFunnel}
|
||||||
|
/>
|
||||||
|
<CreateFunnel
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={(): void => setIsCreateModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TracesFunnels;
|
@ -14,6 +14,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.funnel-icon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lightMode {
|
.lightMode {
|
||||||
|
@ -5,12 +5,17 @@ import { TabRoutes } from 'components/RouteTab/types';
|
|||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { tracesExplorer, tracesSaveView } from './constants';
|
import { tracesExplorer, tracesFunnel, tracesSaveView } from './constants';
|
||||||
|
|
||||||
function TracesModulePage(): JSX.Element {
|
function TracesModulePage(): JSX.Element {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const routes: TabRoutes[] = [tracesExplorer, tracesSaveView];
|
const routes: TabRoutes[] = [
|
||||||
|
tracesExplorer,
|
||||||
|
// TODO(shaheer): remove this check after everything is ready
|
||||||
|
process.env.NODE_ENV === 'development' ? tracesFunnel : null,
|
||||||
|
tracesSaveView,
|
||||||
|
].filter(Boolean) as TabRoutes[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="traces-module-container">
|
<div className="traces-module-container">
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { TabRoutes } from 'components/RouteTab/types';
|
import { TabRoutes } from 'components/RouteTab/types';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import { Compass, TowerControl } from 'lucide-react';
|
import { Compass, Cone, TowerControl } from 'lucide-react';
|
||||||
import SaveView from 'pages/SaveView';
|
import SaveView from 'pages/SaveView';
|
||||||
import TracesExplorer from 'pages/TracesExplorer';
|
import TracesExplorer from 'pages/TracesExplorer';
|
||||||
|
import TracesFunnels from 'pages/TracesFunnels';
|
||||||
|
|
||||||
export const tracesExplorer: TabRoutes = {
|
export const tracesExplorer: TabRoutes = {
|
||||||
Component: TracesExplorer,
|
Component: TracesExplorer,
|
||||||
@ -15,6 +16,17 @@ export const tracesExplorer: TabRoutes = {
|
|||||||
key: ROUTES.TRACES_EXPLORER,
|
key: ROUTES.TRACES_EXPLORER,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const tracesFunnel: TabRoutes = {
|
||||||
|
Component: TracesFunnels,
|
||||||
|
name: (
|
||||||
|
<div className="tab-item">
|
||||||
|
<Cone className="funnel-icon" size={16} /> Funnels
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
route: ROUTES.TRACES_FUNNELS,
|
||||||
|
key: ROUTES.TRACES_FUNNELS,
|
||||||
|
};
|
||||||
|
|
||||||
export const tracesSaveView: TabRoutes = {
|
export const tracesSaveView: TabRoutes = {
|
||||||
Component: SaveView,
|
Component: SaveView,
|
||||||
name: (
|
name: (
|
||||||
|
31
frontend/src/types/api/traceFunnels/index.ts
Normal file
31
frontend/src/types/api/traceFunnels/index.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { TagFilter } from '../queryBuilder/queryBuilderData';
|
||||||
|
|
||||||
|
export interface FunnelStep {
|
||||||
|
id: string;
|
||||||
|
funnel_order: number;
|
||||||
|
service_name: string;
|
||||||
|
span_name: string;
|
||||||
|
filters: TagFilter;
|
||||||
|
latency_pointer: 'start' | 'end';
|
||||||
|
latency_type: 'p95' | 'p99' | 'p90';
|
||||||
|
has_errors: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunnelData {
|
||||||
|
id: string;
|
||||||
|
funnel_name: string;
|
||||||
|
creation_timestamp: number;
|
||||||
|
updated_timestamp: number;
|
||||||
|
user: string;
|
||||||
|
steps?: FunnelStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFunnelPayload {
|
||||||
|
funnel_name: string;
|
||||||
|
user?: string;
|
||||||
|
creation_timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFunnelResponse {
|
||||||
|
funnel_id: string;
|
||||||
|
}
|
@ -101,6 +101,8 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
|||||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||||
LOGS_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
LOGS_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||||
TRACES_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
TRACES_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||||
|
TRACES_FUNNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||||
|
TRACES_FUNNELS_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||||
API_KEYS: ['ADMIN'],
|
API_KEYS: ['ADMIN'],
|
||||||
CUSTOM_DOMAIN_SETTINGS: ['ADMIN'],
|
CUSTOM_DOMAIN_SETTINGS: ['ADMIN'],
|
||||||
LOGS_BASE: [],
|
LOGS_BASE: [],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user