diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 4dbd2ff90b..b8e267b6a9 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -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", diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index 767ab8b7b6..6e3668b1fa 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -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'), ); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 80673146a2..4182e063e4 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -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, diff --git a/frontend/src/api/traceFunnels/index.ts b/frontend/src/api/traceFunnels/index.ts new file mode 100644 index 0000000000..75453aca3c --- /dev/null +++ b/frontend/src/api/traceFunnels/index.ts @@ -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 | 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 | 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 | 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 | 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 | 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, + }; +}; diff --git a/frontend/src/components/LearnMore/LearnMore.styles.scss b/frontend/src/components/LearnMore/LearnMore.styles.scss new file mode 100644 index 0000000000..334254d1c6 --- /dev/null +++ b/frontend/src/components/LearnMore/LearnMore.styles.scss @@ -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; + } +} diff --git a/frontend/src/components/LearnMore/LearnMore.tsx b/frontend/src/components/LearnMore/LearnMore.tsx new file mode 100644 index 0000000000..cd5e76449e --- /dev/null +++ b/frontend/src/components/LearnMore/LearnMore.tsx @@ -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 ( + + ); +} + +LearnMore.defaultProps = { + text: 'Learn more', + url: '', + onClick: (): void => {}, +}; + +export default LearnMore; diff --git a/frontend/src/components/SignozModal/SignozModal.style.scss b/frontend/src/components/SignozModal/SignozModal.style.scss index 246959a16a..2acecbee68 100644 --- a/frontend/src/components/SignozModal/SignozModal.style.scss +++ b/frontend/src/components/SignozModal/SignozModal.style.scss @@ -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 { diff --git a/frontend/src/components/SignozModal/SignozModal.tsx b/frontend/src/components/SignozModal/SignozModal.tsx index e99551edf0..15ad1320c4 100644 --- a/frontend/src/components/SignozModal/SignozModal.tsx +++ b/frontend/src/components/SignozModal/SignozModal.tsx @@ -4,14 +4,14 @@ import { Modal, ModalProps } from 'antd'; function SignozModal({ children, - + width = 672, rootClassName = '', ...rest }: ModalProps): JSX.Element { return ( 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 && } diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts index db46b7e578..15b4c70a81 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts @@ -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, diff --git a/frontend/src/hooks/TracesFunnels/useFunnels.tsx b/frontend/src/hooks/TracesFunnels/useFunnels.tsx new file mode 100644 index 0000000000..92251ce06b --- /dev/null +++ b/frontend/src/hooks/TracesFunnels/useFunnels.tsx @@ -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 | ErrorResponse, unknown> => + useQuery({ + queryKey: [REACT_QUERY_KEY.GET_FUNNELS_LIST, searchQuery], + queryFn: () => getFunnelsList({ search: searchQuery }), + }); + +export const useFunnelDetails = ({ + funnelId, +}: { + funnelId: string; +}): UseQueryResult | ErrorResponse, unknown> => + useQuery({ + queryKey: [REACT_QUERY_KEY.GET_FUNNEL_DETAILS, funnelId], + queryFn: () => getFunnelById(funnelId), + enabled: !!funnelId, + }); + +export const useCreateFunnel = (): UseMutationResult< + SuccessResponse | ErrorResponse, + Error, + CreateFunnelPayload +> => + useMutation({ + mutationFn: createFunnel, + }); + +interface RenameFunnelPayload { + id: string; + funnel_name: string; +} + +export const useRenameFunnel = (): UseMutationResult< + SuccessResponse | ErrorResponse, + Error, + RenameFunnelPayload +> => + useMutation({ + mutationFn: renameFunnel, + }); + +interface DeleteFunnelPayload { + id: string; +} + +export const useDeleteFunnel = (): UseMutationResult< + SuccessResponse | ErrorResponse, + Error, + DeleteFunnelPayload +> => + useMutation({ + mutationFn: deleteFunnel, + }); diff --git a/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSearch.tsx b/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSearch.tsx new file mode 100644 index 0000000000..529f48c29c --- /dev/null +++ b/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSearch.tsx @@ -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) => void; +} => { + const { safeNavigate } = useSafeNavigate(); + + const urlQuery = useUrlQuery(); + const [searchQuery, setSearchQuery] = useState( + urlQuery.get('search') || '', + ); + + const handleSearch = (e: ChangeEvent): 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; diff --git a/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSort.tsx b/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSort.tsx new file mode 100644 index 0000000000..84aea69775 --- /dev/null +++ b/frontend/src/hooks/TracesFunnels/useHandleTraceFunnelsSort.tsx @@ -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({ + 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; diff --git a/frontend/src/pages/TracesFunnelDetails/TracesFunnelDetails.tsx b/frontend/src/pages/TracesFunnelDetails/TracesFunnelDetails.tsx new file mode 100644 index 0000000000..96260dbb0e --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/TracesFunnelDetails.tsx @@ -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 ( +
+ TracesFunnelDetails, {JSON.stringify(data)} +
+ ); +} + +export default TracesFunnelDetails; diff --git a/frontend/src/pages/TracesFunnelDetails/index.tsx b/frontend/src/pages/TracesFunnelDetails/index.tsx new file mode 100644 index 0000000000..b74f5c7b1c --- /dev/null +++ b/frontend/src/pages/TracesFunnelDetails/index.tsx @@ -0,0 +1,3 @@ +import TracesFunnelDetails from './TracesFunnelDetails'; + +export default TracesFunnelDetails; diff --git a/frontend/src/pages/TracesFunnels/TracesFunnels.styles.scss b/frontend/src/pages/TracesFunnels/TracesFunnels.styles.scss new file mode 100644 index 0000000000..df80118b58 --- /dev/null +++ b/frontend/src/pages/TracesFunnels/TracesFunnels.styles.scss @@ -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); + } + } + } + } + } + } +} diff --git a/frontend/src/pages/TracesFunnels/components/CreateFunnel/CreateFunnel.tsx b/frontend/src/pages/TracesFunnels/components/CreateFunnel/CreateFunnel.tsx new file mode 100644 index 0000000000..16331f9dd7 --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/CreateFunnel/CreateFunnel.tsx @@ -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(''); + 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 ( + , + loading: createFunnelMutation.isLoading, + type: 'primary', + className: 'funnel-modal__ok-btn', + onClick: handleCreate, + disabled: !funnelName.trim(), + }} + cancelButtonProps={{ + icon: , + type: 'text', + className: 'funnel-modal__cancel-btn', + onClick: handleCancel, + }} + getContainer={document.getElementById('root') || undefined} + destroyOnClose + > +
+ Enter funnel name + setFunnelName(e.target.value)} + placeholder="Eg. checkout dropoff funnel" + autoFocus + /> +
+
+ ); +} + +export default CreateFunnel; diff --git a/frontend/src/pages/TracesFunnels/components/DeleteFunnel/DeleteFunnel.styles.scss b/frontend/src/pages/TracesFunnels/components/DeleteFunnel/DeleteFunnel.styles.scss new file mode 100644 index 0000000000..3d21ceefed --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/DeleteFunnel/DeleteFunnel.styles.scss @@ -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; + } +} diff --git a/frontend/src/pages/TracesFunnels/components/DeleteFunnel/DeleteFunnel.tsx b/frontend/src/pages/TracesFunnels/components/DeleteFunnel/DeleteFunnel.tsx new file mode 100644 index 0000000000..e0f4825533 --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/DeleteFunnel/DeleteFunnel.tsx @@ -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 ( + , + loading: deleteFunnelMutation.isLoading, + type: 'primary', + className: 'funnel-modal__ok-btn', + onClick: handleDelete, + }} + cancelButtonProps={{ + icon: , + type: 'text', + className: 'funnel-modal__cancel-btn', + onClick: handleCancel, + }} + destroyOnClose + > +
+ Deleting the funnel would stop further analytics using this funnel. This is + irreversible and cannot be undone. +
+
+ ); +} + +export default DeleteFunnel; diff --git a/frontend/src/pages/TracesFunnels/components/FunnelsEmptyState/FunnelsEmptyState.styles.scss b/frontend/src/pages/TracesFunnels/components/FunnelsEmptyState/FunnelsEmptyState.styles.scss new file mode 100644 index 0000000000..4de4c204ba --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/FunnelsEmptyState/FunnelsEmptyState.styles.scss @@ -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); + } + } +} diff --git a/frontend/src/pages/TracesFunnels/components/FunnelsEmptyState/FunnelsEmptyState.tsx b/frontend/src/pages/TracesFunnels/components/FunnelsEmptyState/FunnelsEmptyState.tsx new file mode 100644 index 0000000000..8307fbea53 --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/FunnelsEmptyState/FunnelsEmptyState.tsx @@ -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 ( +
+
+
+ funnels-empty-icon +
+ No funnels yet. + + Create a funnel to start analyzing your data + +
+
+ +
+ + +
+
+
+ ); +} + +export default FunnelsEmptyState; diff --git a/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelItemPopover.tsx b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelItemPopover.tsx new file mode 100644 index 0000000000..2016754ace --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelItemPopover.tsx @@ -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 ( +
+ + +
+ ); +} + +function FunnelItemPopover({ + isPopoverOpen, + setIsPopoverOpen, + funnel, +}: FunnelItemPopoverProps): JSX.Element { + const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(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 +
+ + } + placement="bottomRight" + arrow={false} + > + + + + setIsDeleteModalOpen(false)} + funnelId={funnel.id} + /> + + +
+ ); +} + +export default FunnelItemPopover; diff --git a/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.styles.scss b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.styles.scss new file mode 100644 index 0000000000..8cba2e46ec --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.styles.scss @@ -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); + } + } +} diff --git a/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.tsx b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.tsx new file mode 100644 index 0000000000..6c8a285eee --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/FunnelsList/FunnelsList.tsx @@ -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(false); + const funnelDetailsLink = generatePath(ROUTES.TRACES_FUNNELS_DETAIL, { + funnelId: funnel.id, + }); + + return ( + +
+
+
{funnel.funnel_name}
+
+ +
+ +
+
+ +
+ {dayjs(funnel.creation_timestamp).format( + DATE_TIME_FORMATS.FUNNELS_LIST_DATE, + )} +
+
+ +
+ {funnel.user && ( +
+ {funnel.user.substring(0, 1).toUpperCase()} +
+ )} +
{funnel.user}
+
+
+ + ); +} + +interface FunnelsListProps { + data: FunnelData[]; +} + +function FunnelsList({ data }: FunnelsListProps): JSX.Element { + return ( +
+ {data.map((funnel) => ( + + ))} +
+ ); +} + +export default FunnelsList; diff --git a/frontend/src/pages/TracesFunnels/components/Header/Header.styles.scss b/frontend/src/pages/TracesFunnels/components/Header/Header.styles.scss new file mode 100644 index 0000000000..e8529f890c --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/Header/Header.styles.scss @@ -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); + } + } +} diff --git a/frontend/src/pages/TracesFunnels/components/Header/Header.tsx b/frontend/src/pages/TracesFunnels/components/Header/Header.tsx new file mode 100644 index 0000000000..c08fcf030b --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/Header/Header.tsx @@ -0,0 +1,10 @@ +function Header(): JSX.Element { + return ( +
+
Funnels
+
Create and manage tracing funnels.
+
+ ); +} + +export default Header; diff --git a/frontend/src/pages/TracesFunnels/components/RenameFunnel/RenameFunnel.styles.scss b/frontend/src/pages/TracesFunnels/components/RenameFunnel/RenameFunnel.styles.scss new file mode 100644 index 0000000000..7cc6be8473 --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/RenameFunnel/RenameFunnel.styles.scss @@ -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; + } +} diff --git a/frontend/src/pages/TracesFunnels/components/RenameFunnel/RenameFunnel.tsx b/frontend/src/pages/TracesFunnels/components/RenameFunnel/RenameFunnel.tsx new file mode 100644 index 0000000000..2f24fd99d2 --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/RenameFunnel/RenameFunnel.tsx @@ -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(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 ( + , + loading: renameFunnelMutation.isLoading, + type: 'primary', + className: 'funnel-modal__ok-btn', + onClick: handleRename, + disabled: newFunnelName === initialName, + }} + cancelButtonProps={{ + icon: , + type: 'text', + className: 'funnel-modal__cancel-btn', + onClick: handleCancel, + }} + getContainer={document.getElementById('root') || undefined} + destroyOnClose + > +
+ Enter a new name + setNewFunnelName(e.target.value)} + autoFocus + /> +
+
+ ); +} + +export default RenameFunnel; diff --git a/frontend/src/pages/TracesFunnels/components/SearchBar/SearchBar.styles.scss b/frontend/src/pages/TracesFunnels/components/SearchBar/SearchBar.styles.scss new file mode 100644 index 0000000000..456addf0fc --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/SearchBar/SearchBar.styles.scss @@ -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; + } + } +} diff --git a/frontend/src/pages/TracesFunnels/components/SearchBar/SearchBar.tsx b/frontend/src/pages/TracesFunnels/components/SearchBar/SearchBar.tsx new file mode 100644 index 0000000000..1cfcd3c10c --- /dev/null +++ b/frontend/src/pages/TracesFunnels/components/SearchBar/SearchBar.tsx @@ -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) => void; + onSort: (key: string) => void; + onCreateFunnel: () => void; +} + +function SearchBar({ + searchQuery, + sortOrder, + onSearch, + onSort, + onCreateFunnel, +}: SearchBarProps): JSX.Element { + return ( +
+ + + Sort By + + + +
+ } + rootClassName="sort-popover" + placement="bottomRight" + arrow={false} + > + + + + } + value={searchQuery} + onChange={onSearch} + /> + + + ); +} + +export default SearchBar; diff --git a/frontend/src/pages/TracesFunnels/index.tsx b/frontend/src/pages/TracesFunnels/index.tsx new file mode 100644 index 0000000000..962bad7e49 --- /dev/null +++ b/frontend/src/pages/TracesFunnels/index.tsx @@ -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 ( +
+ {Array(6) + .fill(0) + .map((item, index) => ( + + ))} +
+ ); + } + + if (isError) { + return
Something went wrong
; + } + + if (data.length === 0) { + return ; + } + + return ; +} + +function TracesFunnels(): JSX.Element { + const { searchQuery, handleSearch } = useHandleTraceFunnelsSearch(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const { data, isLoading, isError } = useFunnelsList({ searchQuery }); + + const { sortOrder, handleSort, sortedData } = useHandleTraceFunnelsSort({ + data: data?.payload || [], + }); + + const handleCreateFunnel = (): void => { + setIsCreateModalOpen(true); + }; + + return ( +
+
+
+ + + setIsCreateModalOpen(false)} + /> +
+
+ ); +} + +export default TracesFunnels; diff --git a/frontend/src/pages/TracesModulePage/TracesModulePage.styles.scss b/frontend/src/pages/TracesModulePage/TracesModulePage.styles.scss index 4f12ebc414..937b17ec04 100644 --- a/frontend/src/pages/TracesModulePage/TracesModulePage.styles.scss +++ b/frontend/src/pages/TracesModulePage/TracesModulePage.styles.scss @@ -14,6 +14,9 @@ align-items: center; gap: 8px; } + .funnel-icon { + transform: rotate(180deg); + } } .lightMode { diff --git a/frontend/src/pages/TracesModulePage/TracesModulePage.tsx b/frontend/src/pages/TracesModulePage/TracesModulePage.tsx index 12ae6db970..7889093ac3 100644 --- a/frontend/src/pages/TracesModulePage/TracesModulePage.tsx +++ b/frontend/src/pages/TracesModulePage/TracesModulePage.tsx @@ -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 (
diff --git a/frontend/src/pages/TracesModulePage/constants.tsx b/frontend/src/pages/TracesModulePage/constants.tsx index 24d2047d01..90ecd5fffa 100644 --- a/frontend/src/pages/TracesModulePage/constants.tsx +++ b/frontend/src/pages/TracesModulePage/constants.tsx @@ -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: ( +
+ Funnels +
+ ), + route: ROUTES.TRACES_FUNNELS, + key: ROUTES.TRACES_FUNNELS, +}; + export const tracesSaveView: TabRoutes = { Component: SaveView, name: ( diff --git a/frontend/src/types/api/traceFunnels/index.ts b/frontend/src/types/api/traceFunnels/index.ts new file mode 100644 index 0000000000..26d4b05002 --- /dev/null +++ b/frontend/src/types/api/traceFunnels/index.ts @@ -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; +} diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index f9db6b6100..c6b289163f 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -101,6 +101,8 @@ export const routePermission: Record = { 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: [],