mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-12 04:39: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",
|
||||
"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",
|
||||
|
@ -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'),
|
||||
);
|
||||
|
@ -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,
|
||||
|
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);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
&-icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -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 />}
|
||||
|
@ -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,
|
||||
|
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;
|
||||
gap: 8px;
|
||||
}
|
||||
.funnel-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
|
@ -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">
|
||||
|
@ -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: (
|
||||
|
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'],
|
||||
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: [],
|
||||
|
Loading…
x
Reference in New Issue
Block a user