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:
Shaheer Kochai 2025-03-22 13:43:18 +04:30 committed by GitHub
parent 1f9b13dc35
commit 2c87d96d75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1816 additions and 7 deletions

View File

@ -54,6 +54,7 @@
"SUPPORT": "SigNoz | Support",
"LOGS_SAVE_VIEWS": "SigNoz | Logs Saved Views",
"TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views",
"TRACES_FUNNELS": "SigNoz | Traces Funnels",
"DEFAULT": "Open source Observability Platform | SigNoz",
"SHORTCUTS": "SigNoz | Shortcuts",
"INTEGRATIONS": "SigNoz | Integrations",

View File

@ -42,6 +42,17 @@ export const TracesSaveViews = Loadable(
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(
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
);

View File

@ -52,6 +52,8 @@ import {
TraceDetail,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
TracesFunnels,
TracesSaveViews,
UnAuthorized,
UsageExplorerPage,
@ -236,6 +238,20 @@ const routes: AppRoutes[] = [
isPrivate: true,
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,
exact: true,

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

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

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

View File

@ -67,6 +67,17 @@
background: var(--bg-ink-300);
}
}
.ant-modal-footer {
.ant-btn {
display: flex;
align-items: center;
gap: 4px;
&-icon {
margin: 0 !important;
}
}
}
}
.lightMode {

View File

@ -4,14 +4,14 @@ import { Modal, ModalProps } from 'antd';
function SignozModal({
children,
width = 672,
rootClassName = '',
...rest
}: ModalProps): JSX.Element {
return (
<Modal
centered
width={672}
width={width}
cancelText="Close"
rootClassName={`signoz-modal ${rootClassName}`}
// eslint-disable-next-line react/jsx-props-no-spreading

View File

@ -56,6 +56,7 @@ export const DATE_TIME_FORMATS = {
// Formats with dash separator
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_TIME_DATE: 'HH:mm:ss ⎯ MMM D, YYYY (UTC Z)',
} as const;

View File

@ -51,4 +51,6 @@ export const REACT_QUERY_KEY = {
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
};
GET_FUNNELS_LIST: 'GET_FUNNELS_LIST',
GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS',
} as const;

View File

@ -56,6 +56,8 @@ const ROUTES = {
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views',
TRACES_FUNNELS: '/traces/funnels',
TRACES_FUNNELS_DETAIL: '/traces/funnels/:funnelId',
WORKSPACE_LOCKED: '/workspace-locked',
WORKSPACE_SUSPENDED: '/workspace-suspended',
SHORTCUTS: '/shortcuts',

View File

@ -357,6 +357,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isInfraMonitoring = (): boolean =>
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
const isDashboardView = (): boolean =>
@ -661,7 +662,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
? 0
: '0 1rem',
...(isTraceDetailsView() ? { margin: 0 } : {}),
...(isTraceDetailsView() || isTracesFunnels() ? { margin: 0 } : {}),
}}
>
{isToDisplayLayout && !renderFullScreen && <TopNav />}

View File

@ -207,6 +207,8 @@ export const routesToSkip = [
ROUTES.LOGS_PIPELINES,
ROUTES.TRACES_EXPLORER,
ROUTES.TRACES_SAVE_VIEWS,
ROUTES.TRACES_FUNNELS,
ROUTES.TRACES_FUNNELS_DETAIL,
ROUTES.SHORTCUTS,
ROUTES.INTEGRATIONS,
ROUTES.DASHBOARD,

View 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,
});

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
import TracesFunnelDetails from './TracesFunnelDetails';
export default TracesFunnelDetails;

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -14,6 +14,9 @@
align-items: center;
gap: 8px;
}
.funnel-icon {
transform: rotate(180deg);
}
}
.lightMode {

View File

@ -5,12 +5,17 @@ import { TabRoutes } from 'components/RouteTab/types';
import history from 'lib/history';
import { useLocation } from 'react-router-dom';
import { tracesExplorer, tracesSaveView } from './constants';
import { tracesExplorer, tracesFunnel, tracesSaveView } from './constants';
function TracesModulePage(): JSX.Element {
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 (
<div className="traces-module-container">

View File

@ -1,8 +1,9 @@
import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import { Compass, TowerControl } from 'lucide-react';
import { Compass, Cone, TowerControl } from 'lucide-react';
import SaveView from 'pages/SaveView';
import TracesExplorer from 'pages/TracesExplorer';
import TracesFunnels from 'pages/TracesFunnels';
export const tracesExplorer: TabRoutes = {
Component: TracesExplorer,
@ -15,6 +16,17 @@ export const tracesExplorer: TabRoutes = {
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 = {
Component: SaveView,
name: (

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

View File

@ -101,6 +101,8 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_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'],
CUSTOM_DOMAIN_SETTINGS: ['ADMIN'],
LOGS_BASE: [],