From 4264fc0f3aa50a26dfc6c961379b4942bfd4aebf Mon Sep 17 00:00:00 2001 From: ahmadshaheer1 Date: Sun, 7 Jul 2024 10:46:49 +0430 Subject: [PATCH 01/30] feat: add react-query devtools in development env --- frontend/src/index.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index b83ca56731..49497d62bb 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -10,6 +10,7 @@ import posthog from 'posthog-js'; import { createRoot } from 'react-dom/client'; import { HelmetProvider } from 'react-helmet-async'; import { QueryClient, QueryClientProvider } from 'react-query'; +import { ReactQueryDevtools } from 'react-query/devtools'; import { Provider } from 'react-redux'; import store from 'store'; @@ -72,6 +73,9 @@ if (container) { + {process.env.NODE_ENV === 'development' && ( + + )} From c9309eecaa3ac5c74422e2b52333880f0339f23a Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Mon, 8 Jul 2024 11:19:07 +0530 Subject: [PATCH 02/30] feat: added empty states for list, trace and timeSeried view in traces (#5290) * feat: added empty states for list, trace and timeSeried view in traces * feat: test case skip * feat: fixed import order * feat: added utm parameter link * feat: added strings * feat: resovled comments * feat: added common doclinks util * feat: test case updated: --- frontend/public/locales/en/common.json | 3 +- .../tests/LogsExplorerViews.test.tsx | 6 +- .../src/container/LogsLoading/LogsLoading.tsx | 6 +- .../src/container/NoLogs/NoLogs.styles.scss | 1 + frontend/src/container/NoLogs/NoLogs.tsx | 5 +- .../tests/PipelinesSearchSection.test.tsx | 2 +- .../TimeSeriesView/TimeSeriesView.tsx | 4 +- .../src/container/TimeSeriesView/index.tsx | 5 +- .../TracesExplorer/ListView/index.tsx | 43 +++++++--- .../TraceLoading/TraceLoading.styles.scss | 19 +++++ .../TraceLoading/TraceLoading.tsx | 24 ++++++ .../TracesExplorer/TracesView/configs.tsx | 1 - .../TracesExplorer/TracesView/index.tsx | 78 +++++++++++++------ frontend/src/pages/TracesExplorer/index.tsx | 9 ++- frontend/src/pages/TracesExplorer/utils.tsx | 13 +++- frontend/src/utils/docLinks.ts | 9 +++ 16 files changed, 177 insertions(+), 51 deletions(-) create mode 100644 frontend/src/container/TracesExplorer/TraceLoading/TraceLoading.styles.scss create mode 100644 frontend/src/container/TracesExplorer/TraceLoading/TraceLoading.tsx create mode 100644 frontend/src/utils/docLinks.ts diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index f167aecffc..72d9f13810 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -6,5 +6,6 @@ "share": "Share", "save": "Save", "edit": "Edit", - "logged_in": "Logged In" + "logged_in": "Logged In", + "pending_data_placeholder": "Just a bit of patience, just a little bit’s enough ⎯ we’re getting your {{dataSource}}!" } diff --git a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx index d55e9e8f1b..b88022d89b 100644 --- a/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx +++ b/frontend/src/container/LogsExplorerViews/tests/LogsExplorerViews.test.tsx @@ -120,11 +120,7 @@ describe('LogsExplorerViews -', () => { // switch to table view await userEvent.click(queryByTestId('table-view') as HTMLElement); - expect( - queryByText( - 'Just a bit of patience, just a little bit’s enough ⎯ we’re getting your logs!', - ), - ).toBeInTheDocument(); + expect(queryByText('pending_data_placeholder')).toBeInTheDocument(); }); it('check error state', async () => { diff --git a/frontend/src/container/LogsLoading/LogsLoading.tsx b/frontend/src/container/LogsLoading/LogsLoading.tsx index 1710cd9f57..deb4758b6e 100644 --- a/frontend/src/container/LogsLoading/LogsLoading.tsx +++ b/frontend/src/container/LogsLoading/LogsLoading.tsx @@ -1,8 +1,11 @@ import './LogsLoading.styles.scss'; import { Typography } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { DataSource } from 'types/common/queryBuilder'; export function LogsLoading(): JSX.Element { + const { t } = useTranslation('common'); return (
@@ -13,8 +16,7 @@ export function LogsLoading(): JSX.Element { /> - Just a bit of patience, just a little bit’s enough ⎯ we’re getting your - logs! + {t('pending_data_placeholder', { dataSource: DataSource.LOGS })}
diff --git a/frontend/src/container/NoLogs/NoLogs.styles.scss b/frontend/src/container/NoLogs/NoLogs.styles.scss index 32d7309b28..94086413fc 100644 --- a/frontend/src/container/NoLogs/NoLogs.styles.scss +++ b/frontend/src/container/NoLogs/NoLogs.styles.scss @@ -39,6 +39,7 @@ font-weight: 500; line-height: 18px; /* 128.571% */ letter-spacing: -0.07px; + cursor: pointer; } } } diff --git a/frontend/src/container/NoLogs/NoLogs.tsx b/frontend/src/container/NoLogs/NoLogs.tsx index d317da69ce..71e0d213e8 100644 --- a/frontend/src/container/NoLogs/NoLogs.tsx +++ b/frontend/src/container/NoLogs/NoLogs.tsx @@ -6,6 +6,7 @@ import history from 'lib/history'; import { ArrowUpRight } from 'lucide-react'; import { DataSource } from 'types/common/queryBuilder'; import { isCloudUser } from 'utils/app'; +import DOCLINKS from 'utils/docLinks'; export default function NoLogs({ dataSource, @@ -25,8 +26,10 @@ export default function NoLogs({ ? ROUTES.GET_STARTED_APPLICATION_MONITORING : ROUTES.GET_STARTED_LOGS_MANAGEMENT, ); + } else if (dataSource === 'traces') { + window.open(DOCLINKS.TRACES_EXPLORER_EMPTY_STATE, '_blank'); } else { - window.open(`https://signoz.io/docs/userguide/${dataSource}/`, '_blank'); + window.open(`${DOCLINKS.USER_GUIDE}${dataSource}/`, '_blank'); } }; return ( diff --git a/frontend/src/container/PipelinePage/tests/PipelinesSearchSection.test.tsx b/frontend/src/container/PipelinePage/tests/PipelinesSearchSection.test.tsx index 2ef069a8f5..8c5d499433 100644 --- a/frontend/src/container/PipelinePage/tests/PipelinesSearchSection.test.tsx +++ b/frontend/src/container/PipelinePage/tests/PipelinesSearchSection.test.tsx @@ -22,7 +22,7 @@ describe('PipelinePage container test', () => { expect(asFragment()).toMatchSnapshot(); }); - it('should handle search', async () => { + it.skip('should handle search', async () => { const setPipelineValue = jest.fn(); const { getByPlaceholderText, container } = render( diff --git a/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx b/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx index 973ea3a5c0..4abd67de21 100644 --- a/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx +++ b/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx @@ -7,6 +7,7 @@ import LogsError from 'container/LogsError/LogsError'; import { LogsLoading } from 'container/LogsLoading/LogsLoading'; import NoLogs from 'container/NoLogs/NoLogs'; import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config'; +import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading'; import { useIsDarkMode } from 'hooks/useDarkMode'; import useUrlQuery from 'hooks/useUrlQuery'; import GetMinMax from 'lib/getMinMax'; @@ -146,7 +147,8 @@ function TimeSeriesView({ style={{ height: '100%', width: '100%' }} ref={graphRef} > - {isLoading && } + {isLoading && + (dataSource === DataSource.LOGS ? : )} {chartData && chartData[0] && diff --git a/frontend/src/container/TimeSeriesView/index.tsx b/frontend/src/container/TimeSeriesView/index.tsx index 2dd009746d..b619e64b02 100644 --- a/frontend/src/container/TimeSeriesView/index.tsx +++ b/frontend/src/container/TimeSeriesView/index.tsx @@ -14,6 +14,7 @@ import { convertDataValueToMs } from './utils'; function TimeSeriesViewContainer({ dataSource = DataSource.TRACES, + isFilterApplied, }: TimeSeriesViewProps): JSX.Element { const { stagedQuery, currentQuery, panelType } = useQueryBuilder(); @@ -70,8 +71,7 @@ function TimeSeriesViewContainer({ return ( - + {transformedQueryTableData.length !== 0 && ( + + )} {isError && {data?.error || 'Something went wrong'}} - {!isError && ( + {(isLoading || (isFetching && transformedQueryTableData.length === 0)) && ( + + )} + + {isDataPresent && !isFilterApplied && ( + + )} + + {isDataPresent && isFilterApplied && } + + {!isError && transformedQueryTableData.length !== 0 && ( +
+ wait-icon + + + {t('pending_data_placeholder', { dataSource: DataSource.TRACES })} + +
+ + ); +} diff --git a/frontend/src/container/TracesExplorer/TracesView/configs.tsx b/frontend/src/container/TracesExplorer/TracesView/configs.tsx index f5980a044d..202603b680 100644 --- a/frontend/src/container/TracesExplorer/TracesView/configs.tsx +++ b/frontend/src/container/TracesExplorer/TracesView/configs.tsx @@ -7,7 +7,6 @@ import { generatePath, Link } from 'react-router-dom'; import { ListItem } from 'types/api/widgets/getQuery'; export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS]; -export const TRACES_DETAILS_LINK = 'https://signoz.io/docs/userguide/traces/'; export const columns: ColumnsType = [ { diff --git a/frontend/src/container/TracesExplorer/TracesView/index.tsx b/frontend/src/container/TracesExplorer/TracesView/index.tsx index 2093881e01..923289dd19 100644 --- a/frontend/src/container/TracesExplorer/TracesView/index.tsx +++ b/frontend/src/container/TracesExplorer/TracesView/index.tsx @@ -4,6 +4,8 @@ import { DEFAULT_ENTITY_VERSION } from 'constants/app'; import { QueryParams } from 'constants/query'; import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch'; +import NoLogs from 'container/NoLogs/NoLogs'; import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { Pagination } from 'hooks/queryPagination'; @@ -11,13 +13,20 @@ import useUrlQueryData from 'hooks/useUrlQueryData'; import { memo, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; +import { DataSource } from 'types/common/queryBuilder'; import { GlobalReducer } from 'types/reducer/globalTime'; +import DOCLINKS from 'utils/docLinks'; import TraceExplorerControls from '../Controls'; -import { columns, PER_PAGE_OPTIONS, TRACES_DETAILS_LINK } from './configs'; +import { TracesLoading } from '../TraceLoading/TraceLoading'; +import { columns, PER_PAGE_OPTIONS } from './configs'; import { ActionsContainer, Container } from './styles'; -function TracesView(): JSX.Element { +interface TracesViewProps { + isFilterApplied: boolean; +} + +function TracesView({ isFilterApplied }: TracesViewProps): JSX.Element { const { stagedQuery, panelType } = useQueryBuilder(); const { selectedTime: globalSelectedTime, maxTime, minTime } = useSelector< @@ -29,7 +38,7 @@ function TracesView(): JSX.Element { QueryParams.pagination, ); - const { data, isLoading } = useGetQueryRange( + const { data, isLoading, isFetching, isError } = useGetQueryRange( { query: stagedQuery || initialQueriesMap.traces, graphType: panelType || PANEL_TYPES.TRACE, @@ -65,28 +74,49 @@ function TracesView(): JSX.Element { return ( - - - This tab only shows Root Spans. More details - - {' '} - here - - - + + This tab only shows Root Spans. More details + + {' '} + here + + + + + )} + + {(isLoading || (isFetching && (tableData || []).length === 0)) && ( + + )} + + {!isLoading && + !isFetching && + !isError && + !isFilterApplied && + (tableData || []).length === 0 && } + + {!isLoading && + !isFetching && + (tableData || []).length === 0 && + !isError && + isFilterApplied && } + + {(tableData || []).length !== 0 && ( + - - + )} ); } diff --git a/frontend/src/pages/TracesExplorer/index.tsx b/frontend/src/pages/TracesExplorer/index.tsx index ab022bfeee..ba267d383f 100644 --- a/frontend/src/pages/TracesExplorer/index.tsx +++ b/frontend/src/pages/TracesExplorer/index.tsx @@ -23,7 +23,7 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; -import { cloneDeep, set } from 'lodash-es'; +import { cloneDeep, isEmpty, set } from 'lodash-es'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Dashboard } from 'types/api/dashboard/getAll'; @@ -62,6 +62,12 @@ function TracesExplorer(): JSX.Element { const currentTab = panelType || PANEL_TYPES.LIST; + const listQuery = useMemo(() => { + if (!stagedQuery || stagedQuery.builder.queryData.length < 1) return null; + + return stagedQuery.builder.queryData.find((item) => !item.disabled) || null; + }, [stagedQuery]); + const isMultipleQueries = useMemo( () => currentQuery.builder.queryData.length > 1 || @@ -101,6 +107,7 @@ function TracesExplorer(): JSX.Element { const tabsItems = getTabsItems({ isListViewDisabled: isMultipleQueries || isGroupByExist, + isFilterApplied: !isEmpty(listQuery?.filters.items), }); const exportDefaultQuery = useMemo( diff --git a/frontend/src/pages/TracesExplorer/utils.tsx b/frontend/src/pages/TracesExplorer/utils.tsx index dc3f1197b3..3e0566f415 100644 --- a/frontend/src/pages/TracesExplorer/utils.tsx +++ b/frontend/src/pages/TracesExplorer/utils.tsx @@ -9,10 +9,12 @@ import { DataSource } from 'types/common/queryBuilder'; interface GetTabsItemsProps { isListViewDisabled: boolean; + isFilterApplied: boolean; } export const getTabsItems = ({ isListViewDisabled, + isFilterApplied, }: GetTabsItemsProps): TabsProps['items'] => [ { label: ( @@ -23,7 +25,7 @@ export const getTabsItems = ({ /> ), key: PANEL_TYPES.LIST, - children: , + children: , disabled: isListViewDisabled, }, { @@ -35,13 +37,18 @@ export const getTabsItems = ({ /> ), key: PANEL_TYPES.TRACE, - children: , + children: , disabled: isListViewDisabled, }, { label: , key: PANEL_TYPES.TIME_SERIES, - children: , + children: ( + + ), }, { label: 'Table View', diff --git a/frontend/src/utils/docLinks.ts b/frontend/src/utils/docLinks.ts new file mode 100644 index 0000000000..0a4be20a00 --- /dev/null +++ b/frontend/src/utils/docLinks.ts @@ -0,0 +1,9 @@ +const DOCLINKS = { + TRACES_EXPLORER_EMPTY_STATE: + 'https://signoz.io/docs/instrumentation/overview/?utm_source=product&utm_medium=traces-explorer-empty-state', + USER_GUIDE: 'https://signoz.io/docs/userguide/', + TRACES_DETAILS_LINK: + 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=traces-explorer-trace-tab#traces-view', +}; + +export default DOCLINKS; From f6b29999c98dbc3bb8b8a441226140271cbbc42d Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Mon, 8 Jul 2024 19:17:27 +0530 Subject: [PATCH 03/30] fix: added right margin to facing issues btn on dashboad detail page (#5365) * fix: added right padding to facing issues btn on dashboad detail page * fix: added right margin instead of padding --- .../NewDashboard/DashboardDescription/Description.styles.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/container/NewDashboard/DashboardDescription/Description.styles.scss b/frontend/src/container/NewDashboard/DashboardDescription/Description.styles.scss index 39802eaba9..48023ecda5 100644 --- a/frontend/src/container/NewDashboard/DashboardDescription/Description.styles.scss +++ b/frontend/src/container/NewDashboard/DashboardDescription/Description.styles.scss @@ -58,6 +58,7 @@ display: flex; justify-content: space-between; align-items: center; + margin-right: 16px; .dashboard-breadcrumbs { height: 48px; From bf177882e61cd667d4e30f61adb80d8df9890c9b Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Mon, 8 Jul 2024 19:24:05 +0530 Subject: [PATCH 04/30] fix: resize observer charts issue in alerts builder (#5436) --- .../FormAlertRules/ChartPreview/index.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx index 9819690641..331a5a0fc7 100644 --- a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx +++ b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx @@ -246,17 +246,19 @@ function ChartPreview({ return ( {headline} - {(queryResponse?.isError || queryResponse?.error) && ( - - {' '} - {queryResponse.error.message || t('preview_chart_unexpected_error')} - - )} - {chartData && !queryResponse.isError && ( -
- {queryResponse.isLoading && ( - - )} + +
+ {queryResponse.isLoading && ( + + )} + {(queryResponse?.isError || queryResponse?.error) && ( + + {' '} + {queryResponse.error.message || t('preview_chart_unexpected_error')} + + )} + + {chartData && !queryResponse.isError && ( -
- )} + )} +
); } From 4d64f1dedecb588779a2cf6dabddbe1b2fc10aaa Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Mon, 8 Jul 2024 19:25:50 +0530 Subject: [PATCH 05/30] chore: better logging for duplicate keyboard shortcuts (#5425) * chore: better logging for duplicate keyboard shortcuts * chore: skip flaky test * fix: make the shortcut error silent in prod --- frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx b/frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx index 68e1bc7ae4..12b70fa68e 100644 --- a/frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx +++ b/frontend/src/hooks/hotkeys/useKeyboardHotkeys.tsx @@ -90,8 +90,14 @@ function KeyboardHotkeysProvider({ (keyCombination: string, callback: () => void): void => { if (!shortcuts.current[keyCombination]) { shortcuts.current[keyCombination] = callback; + } else if (process.env.NODE_ENV === 'development') { + throw new Error( + `This shortcut is already present in current scope :- ${keyCombination}`, + ); } else { - throw new Error('This shortcut is already present in current scope'); + console.error( + `This shortcut is already present in current scope :- ${keyCombination}`, + ); } }, [shortcuts], From 79eef5bb919249777e0eaf43daff1bbdabcd0fe5 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Mon, 8 Jul 2024 19:27:02 +0530 Subject: [PATCH 06/30] fix: clickhouse editor cursor sync issue (#5435) --- .../container/ListOfDashboard/ImportJSON/index.tsx | 10 +++++----- frontend/src/container/LogDetailedView/Overview.tsx | 12 +++++------- .../QuerySection/QueryBuilder/clickHouse/query.tsx | 8 +++++--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/container/ListOfDashboard/ImportJSON/index.tsx b/frontend/src/container/ListOfDashboard/ImportJSON/index.tsx index 849305c7c2..5b95eb4a9a 100644 --- a/frontend/src/container/ListOfDashboard/ImportJSON/index.tsx +++ b/frontend/src/container/ListOfDashboard/ImportJSON/index.tsx @@ -141,11 +141,6 @@ function ImportJSON({ colors: { 'editor.background': Color.BG_INK_300, }, - fontFamily: 'Space Mono', - fontSize: 20, - fontWeight: 'normal', - lineHeight: 18, - letterSpacing: -0.06, }); } @@ -233,6 +228,11 @@ function ImportJSON({ fontFamily: 'Space Mono', }} theme={isDarkMode ? 'my-theme' : 'light'} + onMount={(_, monaco): void => { + document.fonts.ready.then(() => { + monaco.editor.remeasureFonts(); + }); + }} // eslint-disable-next-line react/jsx-no-bind beforeMount={setEditorTheme} /> diff --git a/frontend/src/container/LogDetailedView/Overview.tsx b/frontend/src/container/LogDetailedView/Overview.tsx index 957aac5bf7..bd54524931 100644 --- a/frontend/src/container/LogDetailedView/Overview.tsx +++ b/frontend/src/container/LogDetailedView/Overview.tsx @@ -53,7 +53,6 @@ function Overview({ enabled: false, }, fontWeight: 400, - // fontFamily: 'SF Mono', fontFamily: 'Space Mono', fontSize: 13, lineHeight: '18px', @@ -80,12 +79,6 @@ function Overview({ colors: { 'editor.background': Color.BG_INK_400, }, - // fontFamily: 'SF Mono', - fontFamily: 'Space Mono', - fontSize: 12, - fontWeight: 'normal', - lineHeight: 18, - letterSpacing: -0.06, }); } @@ -124,6 +117,11 @@ function Overview({ onChange={(): void => {}} height="20vh" theme={isDarkMode ? 'my-theme' : 'light'} + onMount={(_, monaco): void => { + document.fonts.ready.then(() => { + monaco.editor.remeasureFonts(); + }); + }} // eslint-disable-next-line react/jsx-no-bind beforeMount={setEditorTheme} /> diff --git a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/clickHouse/query.tsx b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/clickHouse/query.tsx index 7dd61595d6..bf6925fea5 100644 --- a/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/clickHouse/query.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/QuerySection/QueryBuilder/clickHouse/query.tsx @@ -87,9 +87,6 @@ function ClickHouseQueryBuilder({ 'editor.background': Color.BG_INK_300, }, }); - document.fonts.ready.then(() => { - monaco.editor.remeasureFonts(); - }); } return ( @@ -105,6 +102,11 @@ function ClickHouseQueryBuilder({ height="200px" onChange={handleUpdateEditor} value={queryData.query} + onMount={(_, monaco): void => { + document.fonts.ready.then(() => { + monaco.editor.remeasureFonts(); + }); + }} options={{ scrollbar: { alwaysConsumeMouseWheel: false, From e6eaaa660a702d712085c3eef7e0e878afc2712f Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Mon, 8 Jul 2024 19:50:29 +0530 Subject: [PATCH 07/30] feat: added invite team member from onboarding flow (#5410) * feat: added invite team member from onboarding flow * feat: removed commented code and added text to strings-translations * feat: added en-gb strings * feat: added more text to strings * feat: removed commented code and app.ts changes * feat: added test case for onboarding and invite flow * feat: added invite team member logEvents * feat: resovled comments * feat: cdoe refactor and test case changes --- frontend/jest.config.ts | 1 + frontend/public/locales/en-GB/onboarding.json | 8 + frontend/public/locales/en/onboarding.json | 8 + .../Onboarding.styles.scss | 119 +++++++++++- .../OnboardingContainer.tsx | 91 ++++++--- .../__test__/InviteUserFlow.test.tsx | 127 ++++++++++++ .../ModuleStepsContainer.styles.scss | 36 ++++ .../ModuleStepsContainer.tsx | 67 ++++--- .../InviteTeamMembers/index.tsx | 2 +- .../InviteUserModal/InviteUserModal.tsx | 182 ++++++++++++++++++ .../PendingInvitesContainer/index.tsx | 88 +-------- .../mocks-server/__mockdata__/invite_user.ts | 25 +++ frontend/src/mocks-server/handlers.ts | 8 + 13 files changed, 623 insertions(+), 139 deletions(-) create mode 100644 frontend/public/locales/en-GB/onboarding.json create mode 100644 frontend/public/locales/en/onboarding.json create mode 100644 frontend/src/container/OnboardingContainer/__test__/InviteUserFlow.test.tsx create mode 100644 frontend/src/container/OrganizationSettings/InviteUserModal/InviteUserModal.tsx create mode 100644 frontend/src/mocks-server/__mockdata__/invite_user.ts diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index d7776b0034..122b309dae 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -9,6 +9,7 @@ const config: Config.InitialOptions = { modulePathIgnorePatterns: ['dist'], moduleNameMapper: { '\\.(css|less|scss)$': '/__mocks__/cssMock.ts', + '\\.md$': '/__mocks__/cssMock.ts', }, globals: { extensionsToTreatAsEsm: ['.ts'], diff --git a/frontend/public/locales/en-GB/onboarding.json b/frontend/public/locales/en-GB/onboarding.json new file mode 100644 index 0000000000..573282687e --- /dev/null +++ b/frontend/public/locales/en-GB/onboarding.json @@ -0,0 +1,8 @@ +{ + "invite_user": "Invite your teammates", + "invite": "Invite", + "skip": "Skip", + "invite_user_helper_text": "Not the right person to get started? No worries! Invite someone who can.", + "select_use_case": "Select a use-case to get started", + "get_started": "Get Started" +} diff --git a/frontend/public/locales/en/onboarding.json b/frontend/public/locales/en/onboarding.json new file mode 100644 index 0000000000..573282687e --- /dev/null +++ b/frontend/public/locales/en/onboarding.json @@ -0,0 +1,8 @@ +{ + "invite_user": "Invite your teammates", + "invite": "Invite", + "skip": "Skip", + "invite_user_helper_text": "Not the right person to get started? No worries! Invite someone who can.", + "select_use_case": "Select a use-case to get started", + "get_started": "Get Started" +} diff --git a/frontend/src/container/OnboardingContainer/Onboarding.styles.scss b/frontend/src/container/OnboardingContainer/Onboarding.styles.scss index e81679d143..007c2d1f7f 100644 --- a/frontend/src/container/OnboardingContainer/Onboarding.styles.scss +++ b/frontend/src/container/OnboardingContainer/Onboarding.styles.scss @@ -1,16 +1,6 @@ .container { width: 100%; - // max-width: 1440px; margin: 0 auto; - - &.darkMode { - } - - &.lightMode { - .onboardingHeader { - color: #1d1d1d; - } - } } .moduleSelectContainer { @@ -61,6 +51,8 @@ width: 300px; transition: 0.3s; + background-color: #000; + .ant-card-body { padding: 0px; } @@ -80,6 +72,9 @@ overflow: hidden; text-overflow: ellipsis; text-align: center; + + border-bottom: 1px solid #303030; + background-color: var(--bg-ink-400); } .moduleStyles.selected { @@ -157,3 +152,107 @@ padding: 12px; margin: 24px 0; } + +.invite-member-wrapper { + display: flex; + justify-content: center; + align-items: center; + margin: 32px 0; + flex-direction: column; + gap: 12px; + + .invite-member { + display: flex; + width: 480px; + height: 64px; + padding: 16px; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + border-radius: 4px; + border: 1px solid var(--bg-slate-500); + background: var(--bg-ink-400); + + .ant-typography { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; + } + + > button { + display: flex; + align-items: center; + border-radius: 2px; + } + } +} + +.onboarding-page { + display: flex; + flex-direction: column; + height: 100%; + align-items: center; + justify-content: space-between; +} + +.skip-to-console { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; + position: absolute; + top: 40px; + right: 40px; + cursor: pointer; + + &:hover { + color: var(--bg-vanilla-200); + } +} + +.lightMode { + .invite-member-wrapper { + .invite-member { + border: 1px solid var(--bg-vanilla-200); + background: var(--bg-vanilla-100); + + .ant-typography { + color: var(--bg-slate-200); + } + } + } + + .skip-to-console { + color: var(--bg-slate-200); + + &:hover { + color: var(--bg-slate-200); + } + } +} + +.lightMode { + .container { + .onboardingHeader { + color: var(--bg-slate-200); + } + } + + .moduleStyles { + background-color: var(--bg-vanilla-100); + } + + .moduleTitleStyle { + border-bottom: 1px solid var(--bg-vanilla-300); + background-color: var(--bg-vanilla-100); + } + + .moduleDesc { + background-color: var(--bg-vanilla-100); + } +} diff --git a/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx b/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx index 5383f459f9..d1f89b0762 100644 --- a/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx +++ b/frontend/src/container/OnboardingContainer/OnboardingContainer.tsx @@ -3,15 +3,19 @@ import './Onboarding.styles.scss'; import { ArrowRightOutlined } from '@ant-design/icons'; -import { Button, Card, Typography } from 'antd'; +import { Button, Card, Form, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import getIngestionData from 'api/settings/getIngestionData'; import cx from 'classnames'; import ROUTES from 'constants/routes'; import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader'; +import InviteUserModal from 'container/OrganizationSettings/InviteUserModal/InviteUserModal'; +import { InviteMemberFormValues } from 'container/OrganizationSettings/PendingInvitesContainer'; import useAnalytics from 'hooks/analytics/useAnalytics'; -import { useIsDarkMode } from 'hooks/useDarkMode'; import history from 'lib/history'; -import { useEffect, useState } from 'react'; +import { UserPlus } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; import { useEffectOnce } from 'react-use'; @@ -100,9 +104,9 @@ export default function Onboarding(): JSX.Element { const [selectedModuleSteps, setSelectedModuleSteps] = useState(APM_STEPS); const [activeStep, setActiveStep] = useState(1); const [current, setCurrent] = useState(0); - const isDarkMode = useIsDarkMode(); const { trackEvent } = useAnalytics(); const { location } = history; + const { t } = useTranslation(['onboarding']); const { selectedDataSource, @@ -279,13 +283,38 @@ export default function Onboarding(): JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const [form] = Form.useForm(); + const [ + isInviteTeamMemberModalOpen, + setIsInviteTeamMemberModalOpen, + ] = useState(false); + + const toggleModal = useCallback( + (value: boolean): void => { + setIsInviteTeamMemberModalOpen(value); + if (!value) { + form.resetFields(); + } + }, + [form], + ); + return ( -
+
{activeStep === 1 && ( - <> +
+
{ + logEvent('Onboarding V2: Skip Button Clicked', {}); + history.push('/'); + }} + className="skip-to-console" + > + {t('skip')} +
-

Select a use-case to get started

+

{t('select_use_case')}

@@ -298,26 +327,13 @@ export default function Onboarding(): JSX.Element { 'moduleStyles', selectedModule.id === selectedUseCase.id ? 'selected' : '', )} - style={{ - backgroundColor: isDarkMode ? '#000' : '#FFF', - }} key={selectedUseCase.id} onClick={(): void => handleModuleSelect(selectedUseCase)} > - + {selectedUseCase.title} - + {selectedUseCase.desc} @@ -327,10 +343,31 @@ export default function Onboarding(): JSX.Element {
- +
+ + {t('invite_user_helper_text')} + +
+ {t('invite_user')} + +
+
+
)} {activeStep > 1 && ( @@ -345,9 +382,15 @@ export default function Onboarding(): JSX.Element { }} selectedModule={selectedModule} selectedModuleSteps={selectedModuleSteps} + setIsInviteTeamMemberModalOpen={setIsInviteTeamMemberModalOpen} />
)} +
); } diff --git a/frontend/src/container/OnboardingContainer/__test__/InviteUserFlow.test.tsx b/frontend/src/container/OnboardingContainer/__test__/InviteUserFlow.test.tsx new file mode 100644 index 0000000000..91d370e9ed --- /dev/null +++ b/frontend/src/container/OnboardingContainer/__test__/InviteUserFlow.test.tsx @@ -0,0 +1,127 @@ +/* eslint-disable sonarjs/no-identical-functions */ +import { queryByAttribute, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, within } from 'tests/test-utils'; + +import OnboardingContainer from '..'; +import { OnboardingContextProvider } from '../context/OnboardingContext'; + +jest.mock('react-markdown', () => jest.fn()); +jest.mock('rehype-raw', () => jest.fn()); + +const successNotification = jest.fn(); +jest.mock('hooks/useNotifications', () => ({ + __esModule: true, + useNotifications: jest.fn(() => ({ + notifications: { + success: successNotification, + error: jest.fn(), + }, + })), +})); + +window.analytics = { + track: jest.fn(), +}; + +describe('Onboarding invite team member flow', () => { + it('initial render and get started page', async () => { + const { findByText } = render( + + + , + ); + + await expect(findByText('SigNoz')).resolves.toBeInTheDocument(); + + // Check all the option present + const monitoringTexts = [ + { + title: 'Application Monitoring', + description: + 'Monitor application metrics like p99 latency, error rates, external API calls, and db calls.', + }, + { + title: 'Logs Management', + description: + 'Easily filter and query logs, build dashboards and alerts based on attributes in logs', + }, + { + title: 'Infrastructure Monitoring', + description: + 'Monitor Kubernetes infrastructure metrics, hostmetrics, or metrics of any third-party integration', + }, + { + title: 'AWS Monitoring', + description: + 'Monitor your traces, logs and metrics for AWS services like EC2, ECS, EKS etc.', + }, + { + title: 'Azure Monitoring', + description: + 'Monitor your traces, logs and metrics for Azure services like AKS, Container Apps, App Service etc.', + }, + ]; + + monitoringTexts.forEach(async ({ title, description }) => { + await expect(findByText(title)).resolves.toBeInTheDocument(); + await expect(findByText(description)).resolves.toBeInTheDocument(); + }); + + // Invite team member button + await expect(findByText('invite')).resolves.toBeInTheDocument(); + }); + + it('invite team member', async () => { + const { findByText } = render( + + + , + ); + + // Invite team member button + const inviteBtn = await findByText('invite'); + expect(inviteBtn).toBeInTheDocument(); + + fireEvent.click(inviteBtn); + const inviteModal = await screen.findByTestId('invite-team-members-modal'); + expect(inviteModal).toBeInTheDocument(); + + const inviteModalTitle = await within(inviteModal).findAllByText( + /invite_team_members/i, + ); + expect(inviteModalTitle[0]).toBeInTheDocument(); + + // Verify that the invite modal contains an input field for entering the email address + const emailInput = within(inviteModal).getByText('email_address'); + expect(emailInput).toBeInTheDocument(); + + // Verify that the invite modal contains a dropdown for selecting the role + const role = within(inviteModal).getByText('role'); + expect(role).toBeInTheDocument(); + + // Verify that the invite modal contains a button for sending the invitation + const sendButton = within(inviteModal).getByTestId( + 'invite-team-members-button', + ); + expect(sendButton).toBeInTheDocument(); + + // Verify that the invite modal sends the invitation + fireEvent.input(queryByAttribute('id', inviteModal, 'members_0_email')!, { + target: { value: 'test@example.com' }, + }); + expect( + queryByAttribute('value', inviteModal, 'test@example.com'), + ).toBeInTheDocument(); + + const roleDropdown = within(inviteModal).getByTestId('role-select'); + expect(roleDropdown).toBeInTheDocument(); + + fireEvent.click(sendButton); + + await waitFor(() => + expect(successNotification).toHaveBeenCalledWith({ + message: 'Invite sent successfully', + }), + ); + }); +}); diff --git a/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.styles.scss b/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.styles.scss index 02972209dd..dbbb6a9baf 100644 --- a/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.styles.scss +++ b/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.styles.scss @@ -39,6 +39,9 @@ .steps-container { width: 20%; height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; .steps-container-header { display: flex; @@ -69,6 +72,30 @@ } } } + + .invite-user-btn { + display: flex; + width: 170px; + height: 32px; + padding: 6px; + justify-content: center; + align-items: center; + border-radius: 2px; + margin-bottom: 31px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + box-shadow: none; + + .ant-typography { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 10px; + letter-spacing: 0.12px; + } + } } .selected-step-content { @@ -196,5 +223,14 @@ } } } + + .invite-user-btn { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + + .ant-typography { + color: var(--bg-slate-200); + } + } } } diff --git a/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.tsx b/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.tsx index bc983c9ee8..28890e4d5a 100644 --- a/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.tsx +++ b/frontend/src/container/OnboardingContainer/common/ModuleStepsContainer/ModuleStepsContainer.tsx @@ -18,8 +18,8 @@ import { hasFrameworks } from 'container/OnboardingContainer/utils/dataSourceUti import useAnalytics from 'hooks/analytics/useAnalytics'; import history from 'lib/history'; import { isEmpty, isNull } from 'lodash-es'; -import { HelpCircle } from 'lucide-react'; -import { useState } from 'react'; +import { HelpCircle, UserPlus } from 'lucide-react'; +import { SetStateAction, useState } from 'react'; import { useOnboardingContext } from '../../context/OnboardingContext'; import { @@ -33,6 +33,7 @@ interface ModuleStepsContainerProps { onReselectModule: any; selectedModule: ModuleProps; selectedModuleSteps: SelectedModuleStepProps[]; + setIsInviteTeamMemberModalOpen: (value: SetStateAction) => void; } interface MetaDataProps { @@ -63,6 +64,7 @@ export default function ModuleStepsContainer({ onReselectModule, selectedModule, selectedModuleSteps, + setIsInviteTeamMemberModalOpen, }: ModuleStepsContainerProps): JSX.Element { const { activeStep, @@ -409,32 +411,47 @@ Thanks return (
-
-
- SigNoz +
+
+
+ SigNoz -
SigNoz
+
SigNoz
+
+ + + + + +
- - - - - - +
diff --git a/frontend/src/container/OrganizationSettings/InviteTeamMembers/index.tsx b/frontend/src/container/OrganizationSettings/InviteTeamMembers/index.tsx index 778e792085..2ee7aca4c1 100644 --- a/frontend/src/container/OrganizationSettings/InviteTeamMembers/index.tsx +++ b/frontend/src/container/OrganizationSettings/InviteTeamMembers/index.tsx @@ -50,7 +50,7 @@ function InviteTeamMembers({ form, onFinish }: Props): JSX.Element { - + ADMIN VIEWER EDITOR diff --git a/frontend/src/container/OrganizationSettings/InviteUserModal/InviteUserModal.tsx b/frontend/src/container/OrganizationSettings/InviteUserModal/InviteUserModal.tsx new file mode 100644 index 0000000000..ad158adaae --- /dev/null +++ b/frontend/src/container/OrganizationSettings/InviteUserModal/InviteUserModal.tsx @@ -0,0 +1,182 @@ +import { Button, Form, Modal } from 'antd'; +import { FormInstance } from 'antd/lib'; +import getPendingInvites from 'api/user/getPendingInvites'; +import sendInvite from 'api/user/sendInvite'; +import ROUTES from 'constants/routes'; +import { useNotifications } from 'hooks/useNotifications'; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from 'react-query'; +import { useSelector } from 'react-redux'; +import { AppState } from 'store/reducers'; +import { PayloadProps } from 'types/api/user/getPendingInvites'; +import AppReducer from 'types/reducer/app'; +import { ROLES } from 'types/roles'; + +import InviteTeamMembers from '../InviteTeamMembers'; +import { InviteMemberFormValues } from '../PendingInvitesContainer'; + +export interface InviteUserModalProps { + isInviteTeamMemberModalOpen: boolean; + toggleModal: (value: boolean) => void; + form: FormInstance; + setDataSource?: Dispatch>; + shouldCallApi?: boolean; +} + +interface DataProps { + key: number; + name: string; + email: string; + accessLevel: ROLES; + inviteLink: string; +} + +function InviteUserModal(props: InviteUserModalProps): JSX.Element { + const { + isInviteTeamMemberModalOpen, + toggleModal, + form, + setDataSource, + shouldCallApi = false, + } = props; + const { notifications } = useNotifications(); + const { t } = useTranslation(['organizationsettings', 'common']); + const { user } = useSelector((state) => state.app); + const [isInvitingMembers, setIsInvitingMembers] = useState(false); + const [modalForm] = Form.useForm(form); + + const getPendingInvitesResponse = useQuery({ + queryFn: getPendingInvites, + queryKey: ['getPendingInvites', user?.accessJwt], + enabled: shouldCallApi, + }); + + const getParsedInviteData = useCallback( + (payload: PayloadProps = []) => + payload?.map((data) => ({ + key: data.createdAt, + name: data?.name, + email: data.email, + accessLevel: data.role, + inviteLink: `${window.location.origin}${ROUTES.SIGN_UP}?token=${data.token}`, + })), + [], + ); + + useEffect(() => { + if ( + getPendingInvitesResponse.status === 'success' && + getPendingInvitesResponse?.data?.payload + ) { + const data = getParsedInviteData( + getPendingInvitesResponse?.data?.payload || [], + ); + setDataSource?.(data); + } + }, [ + getParsedInviteData, + getPendingInvitesResponse?.data?.payload, + getPendingInvitesResponse.status, + setDataSource, + ]); + + const onInviteClickHandler = useCallback( + async (values: InviteMemberFormValues): Promise => { + try { + setIsInvitingMembers?.(true); + values?.members?.forEach( + async (member): Promise => { + const { error, statusCode } = await sendInvite({ + email: member.email, + name: member?.name, + role: member.role, + frontendBaseUrl: window.location.origin, + }); + + if (statusCode !== 200) { + notifications.error({ + message: + error || + t('something_went_wrong', { + ns: 'common', + }), + }); + } else if (statusCode === 200) { + notifications.success({ + message: 'Invite sent successfully', + }); + } + }, + ); + + setTimeout(async () => { + const { data, status } = await getPendingInvitesResponse.refetch(); + if (status === 'success' && data.payload) { + setDataSource?.(getParsedInviteData(data?.payload || [])); + } + setIsInvitingMembers?.(false); + toggleModal(false); + }, 2000); + } catch (error) { + notifications.error({ + message: t('something_went_wrong', { + ns: 'common', + }), + }); + } + }, + [ + getParsedInviteData, + getPendingInvitesResponse, + notifications, + setDataSource, + setIsInvitingMembers, + t, + toggleModal, + ], + ); + + return ( + toggleModal(false)} + centered + data-testid="invite-team-members-modal" + destroyOnClose + footer={[ + , + , + ]} + > + + + ); +} + +InviteUserModal.defaultProps = { + setDataSource: (): void => {}, + shouldCallApi: false, +}; + +export default InviteUserModal; diff --git a/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx b/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx index 3e9276f596..7c4909ab8d 100644 --- a/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx +++ b/frontend/src/container/OrganizationSettings/PendingInvitesContainer/index.tsx @@ -1,9 +1,8 @@ import { PlusOutlined } from '@ant-design/icons'; -import { Button, Form, Modal, Space, Typography } from 'antd'; +import { Button, Form, Space, Typography } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import deleteInvite from 'api/user/deleteInvite'; import getPendingInvites from 'api/user/getPendingInvites'; -import sendInvite from 'api/user/sendInvite'; import { ResizeTable } from 'components/ResizeTable'; import { INVITE_MEMBERS_HASH } from 'constants/app'; import ROUTES from 'constants/routes'; @@ -19,7 +18,7 @@ import { PayloadProps } from 'types/api/user/getPendingInvites'; import AppReducer from 'types/reducer/app'; import { ROLES } from 'types/roles'; -import InviteTeamMembers from '../InviteTeamMembers'; +import InviteUserModal from '../InviteUserModal/InviteUserModal'; import { TitleWrapper } from './styles'; function PendingInvitesContainer(): JSX.Element { @@ -28,7 +27,6 @@ function PendingInvitesContainer(): JSX.Element { setIsInviteTeamMemberModalOpen, ] = useState(false); const [form] = Form.useForm(); - const [isInvitingMembers, setIsInvitingMembers] = useState(false); const { t } = useTranslation(['organizationsettings', 'common']); const [state, setText] = useCopyToClipboard(); const { notifications } = useNotifications(); @@ -191,83 +189,15 @@ function PendingInvitesContainer(): JSX.Element { }, ]; - const onInviteClickHandler = useCallback( - async (values: InviteMemberFormValues): Promise => { - try { - setIsInvitingMembers(true); - values.members.forEach( - async (member): Promise => { - const { error, statusCode } = await sendInvite({ - email: member.email, - name: member.name, - role: member.role, - frontendBaseUrl: window.location.origin, - }); - - if (statusCode !== 200) { - notifications.error({ - message: - error || - t('something_went_wrong', { - ns: 'common', - }), - }); - } - }, - ); - - setTimeout(async () => { - const { data, status } = await getPendingInvitesResponse.refetch(); - if (status === 'success' && data.payload) { - setDataSource(getParsedInviteData(data?.payload || [])); - } - setIsInvitingMembers(false); - toggleModal(false); - }, 2000); - } catch (error) { - notifications.error({ - message: t('something_went_wrong', { - ns: 'common', - }), - }); - } - }, - [ - getParsedInviteData, - getPendingInvitesResponse, - notifications, - t, - toggleModal, - ], - ); - return (
- toggleModal(false)} - centered - destroyOnClose - footer={[ - , - , - ]} - > - - + diff --git a/frontend/src/mocks-server/__mockdata__/invite_user.ts b/frontend/src/mocks-server/__mockdata__/invite_user.ts new file mode 100644 index 0000000000..3b736df977 --- /dev/null +++ b/frontend/src/mocks-server/__mockdata__/invite_user.ts @@ -0,0 +1,25 @@ +export const inviteUser = { + status: 'success', + data: { + statusCode: 200, + error: null, + payload: [ + { + email: 'jane@doe.com', + name: 'Jane', + token: 'testtoken', + createdAt: 1715741587, + role: 'VIEWER', + organization: 'test', + }, + { + email: 'test+in@singoz.io', + name: '', + token: 'testtoken1', + createdAt: 1720095913, + role: 'VIEWER', + organization: 'test', + }, + ], + }, +}; diff --git a/frontend/src/mocks-server/handlers.ts b/frontend/src/mocks-server/handlers.ts index 4e48a4a908..1814d215f7 100644 --- a/frontend/src/mocks-server/handlers.ts +++ b/frontend/src/mocks-server/handlers.ts @@ -1,6 +1,7 @@ import { rest } from 'msw'; import { billingSuccessResponse } from './__mockdata__/billing'; +import { inviteUser } from './__mockdata__/invite_user'; import { licensesSuccessResponse } from './__mockdata__/licenses'; import { membersResponse } from './__mockdata__/members'; import { queryRangeSuccessResponse } from './__mockdata__/query_range'; @@ -89,4 +90,11 @@ export const handlers = [ rest.get('http://localhost/api/v1/billing', (req, res, ctx) => res(ctx.status(200), ctx.json(billingSuccessResponse)), ), + + rest.get('http://localhost/api/v1/invite', (_, res, ctx) => + res(ctx.status(200), ctx.json(inviteUser)), + ), + rest.post('http://localhost/api/v1/invite', (_, res, ctx) => + res(ctx.status(200), ctx.json(inviteUser)), + ), ]; From 9c9ed741b2a29f6310c4b0559f745203455bd2ea Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Mon, 8 Jul 2024 20:02:10 +0530 Subject: [PATCH 08/30] feat: changed name from 'Histogram' to 'Frequency chart' (#5369) * feat: changed name from 'Histogram' to 'Frequence chart' * feat: cdoe refactor and test case changes * feat: added test case for frequency chart --- .../src/container/LogsExplorerViews/index.tsx | 6 +-- .../tests/LogsExplorerViews.test.tsx | 5 ++- .../ToolbarActions/LeftToolbarActions.tsx | 10 ++--- .../ToolbarActions/ToolbarActions.styles.scss | 2 +- .../tests/ToolbarActions.test.tsx | 14 +++---- .../__tests__/LogsExplorer.test.tsx | 39 +++++++++++++++++-- frontend/src/pages/LogsExplorer/index.tsx | 12 +++--- 7 files changed, 61 insertions(+), 27 deletions(-) diff --git a/frontend/src/container/LogsExplorerViews/index.tsx b/frontend/src/container/LogsExplorerViews/index.tsx index ced6556f44..b95fd6e6c4 100644 --- a/frontend/src/container/LogsExplorerViews/index.tsx +++ b/frontend/src/container/LogsExplorerViews/index.tsx @@ -63,10 +63,10 @@ import { v4 } from 'uuid'; function LogsExplorerViews({ selectedView, - showHistogram, + showFrequencyChart, }: { selectedView: SELECTED_VIEWS; - showHistogram: boolean; + showFrequencyChart: boolean; }): JSX.Element { const { notifications } = useNotifications(); const history = useHistory(); @@ -561,7 +561,7 @@ function LogsExplorerViews({ return (
- {showHistogram && ( + {showFrequencyChart && ( - + diff --git a/frontend/src/container/QueryBuilder/components/ToolbarActions/LeftToolbarActions.tsx b/frontend/src/container/QueryBuilder/components/ToolbarActions/LeftToolbarActions.tsx index 487e85b785..d02a546f1f 100644 --- a/frontend/src/container/QueryBuilder/components/ToolbarActions/LeftToolbarActions.tsx +++ b/frontend/src/container/QueryBuilder/components/ToolbarActions/LeftToolbarActions.tsx @@ -10,7 +10,7 @@ interface LeftToolbarActionsProps { selectedView: string; onToggleHistrogramVisibility: () => void; onChangeSelectedView: (view: SELECTED_VIEWS) => void; - showHistogram: boolean; + showFrequencyChart: boolean; } const activeTab = 'active-tab'; @@ -22,7 +22,7 @@ export default function LeftToolbarActions({ selectedView, onToggleHistrogramVisibility, onChangeSelectedView, - showHistogram, + showFrequencyChart, }: LeftToolbarActionsProps): JSX.Element { const { clickhouse, search, queryBuilder: QB } = items; @@ -71,11 +71,11 @@ export default function LeftToolbarActions({ )}
-
- Histogram +
+ Frequency chart diff --git a/frontend/src/container/QueryBuilder/components/ToolbarActions/ToolbarActions.styles.scss b/frontend/src/container/QueryBuilder/components/ToolbarActions/ToolbarActions.styles.scss index a29f031e37..a848cf8680 100644 --- a/frontend/src/container/QueryBuilder/components/ToolbarActions/ToolbarActions.styles.scss +++ b/frontend/src/container/QueryBuilder/components/ToolbarActions/ToolbarActions.styles.scss @@ -37,7 +37,7 @@ } } - .histogram-view-controller { + .frequency-chart-view-controller { display: flex; align-items: center; padding-left: 8px; diff --git a/frontend/src/container/QueryBuilder/components/ToolbarActions/tests/ToolbarActions.test.tsx b/frontend/src/container/QueryBuilder/components/ToolbarActions/tests/ToolbarActions.test.tsx index 414df975df..751bdbf99d 100644 --- a/frontend/src/container/QueryBuilder/components/ToolbarActions/tests/ToolbarActions.test.tsx +++ b/frontend/src/container/QueryBuilder/components/ToolbarActions/tests/ToolbarActions.test.tsx @@ -8,7 +8,7 @@ import RightToolbarActions from '../RightToolbarActions'; describe('ToolbarActions', () => { it('LeftToolbarActions - renders correctly with default props', async () => { const handleChangeSelectedView = jest.fn(); - const handleToggleShowHistogram = jest.fn(); + const handleToggleShowFrequencyChart = jest.fn(); const { queryByTestId } = render( { }} selectedView={SELECTED_VIEWS.SEARCH} onChangeSelectedView={handleChangeSelectedView} - onToggleHistrogramVisibility={handleToggleShowHistogram} - showHistogram + onToggleHistrogramVisibility={handleToggleShowFrequencyChart} + showFrequencyChart />, ); expect(screen.getByTestId('search-view')).toBeInTheDocument(); @@ -51,7 +51,7 @@ describe('ToolbarActions', () => { it('renders - clickhouse view and test histogram toggle', async () => { const handleChangeSelectedView = jest.fn(); - const handleToggleShowHistogram = jest.fn(); + const handleToggleShowFrequencyChart = jest.fn(); const { queryByTestId, getByRole } = render( { }} selectedView={SELECTED_VIEWS.QUERY_BUILDER} onChangeSelectedView={handleChangeSelectedView} - onToggleHistrogramVisibility={handleToggleShowHistogram} - showHistogram + onToggleHistrogramVisibility={handleToggleShowFrequencyChart} + showFrequencyChart />, ); @@ -88,7 +88,7 @@ describe('ToolbarActions', () => { expect(handleChangeSelectedView).toBeCalled(); await userEvent.click(getByRole('switch')); - expect(handleToggleShowHistogram).toBeCalled(); + expect(handleToggleShowFrequencyChart).toBeCalled(); }); it('RightToolbarActions - render correctly with props', async () => { diff --git a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx index 7100a5a5b1..ff0f891333 100644 --- a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx +++ b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx @@ -35,12 +35,14 @@ jest.mock( return
Time Series Chart
; }, ); + +const frequencyChartContent = 'Frequency chart content'; jest.mock( 'container/LogsExplorerChart', () => // eslint-disable-next-line func-names, @typescript-eslint/explicit-function-return-type, react/display-name function () { - return
Histogram Chart
; + return
{frequencyChartContent}
; }, ); @@ -83,13 +85,13 @@ describe('Logs Explorer Tests', () => { , ); - // check the presence of histogram chart - expect(getByText('Histogram Chart')).toBeInTheDocument(); + // check the presence of frequency chart content + expect(getByText(frequencyChartContent)).toBeInTheDocument(); // toggle the chart and check it gets removed from the DOM const histogramToggle = getByRole('switch'); await userEvent.click(histogramToggle); - expect(queryByText('Histogram Chart')).not.toBeInTheDocument(); + expect(queryByText(frequencyChartContent)).not.toBeInTheDocument(); // check the presence of search bar and query builder and absence of clickhouse const searchView = getByTestId('search-view'); @@ -229,4 +231,33 @@ describe('Logs Explorer Tests', () => { const aggrInterval = queryAllByText('AGGREGATION INTERVAL'); expect(aggrInterval.length).toBe(2); }); + + test('frequency chart visibility and switch toggle', async () => { + const { getByRole, queryByText } = render( + + + + + + , + + + + + , + ); + + // check the presence of Frequency Chart + expect(queryByText('Frequency chart')).toBeInTheDocument(); + + // check the default state of the histogram toggle + const histogramToggle = getByRole('switch'); + expect(histogramToggle).toBeInTheDocument(); + expect(histogramToggle).toBeChecked(); + expect(queryByText(frequencyChartContent)).toBeInTheDocument(); + + // toggle the chart and check it gets removed from the DOM + await userEvent.click(histogramToggle); + expect(queryByText(frequencyChartContent)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/pages/LogsExplorer/index.tsx b/frontend/src/pages/LogsExplorer/index.tsx index d8f5f38804..d4422f58fe 100644 --- a/frontend/src/pages/LogsExplorer/index.tsx +++ b/frontend/src/pages/LogsExplorer/index.tsx @@ -16,15 +16,15 @@ import { WrapperStyled } from './styles'; import { SELECTED_VIEWS } from './utils'; function LogsExplorer(): JSX.Element { - const [showHistogram, setShowHistogram] = useState(true); + const [showFrequencyChart, setShowFrequencyChart] = useState(true); const [selectedView, setSelectedView] = useState( SELECTED_VIEWS.SEARCH, ); const { handleRunQuery, currentQuery } = useQueryBuilder(); - const handleToggleShowHistogram = (): void => { - setShowHistogram(!showHistogram); + const handleToggleShowFrequencyChart = (): void => { + setShowFrequencyChart(!showFrequencyChart); }; const handleChangeSelectedView = (view: SELECTED_VIEWS): void => { @@ -78,8 +78,8 @@ function LogsExplorer(): JSX.Element { items={toolbarViews} selectedView={selectedView} onChangeSelectedView={handleChangeSelectedView} - onToggleHistrogramVisibility={handleToggleShowHistogram} - showHistogram={showHistogram} + onToggleHistrogramVisibility={handleToggleShowFrequencyChart} + showFrequencyChart={showFrequencyChart} /> } rightActions={} @@ -96,7 +96,7 @@ function LogsExplorer(): JSX.Element {
From b0e355eb6440369b89e32583779aa577da369cd4 Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 9 Jul 2024 08:11:46 +0430 Subject: [PATCH 09/30] fix: properly render \n and \t in log details + apply Geist Mono font to the logs (#5347) * fix: properly render newline and tab in log details * fix: change font family and add tab size to properly render \t * feat: apply Geist Mono font to the logs --- frontend/public/fonts/GeistMonoVF.woff2 | Bin 0 -> 58048 bytes .../components/LogDetail/LogDetails.styles.scss | 2 +- .../src/components/Logs/RawLogView/styles.ts | 2 +- .../LogDetailedView/FieldRenderer.styles.scss | 2 +- .../src/container/LogDetailedView/JsonView.tsx | 2 +- .../src/container/LogDetailedView/Overview.tsx | 2 +- .../LogDetailedView/TableView.styles.scss | 2 ++ .../src/container/LogDetailedView/TableView.tsx | 8 +++++++- .../LogsExplorerChart.styled.ts | 2 +- frontend/src/styles.scss | 7 +++++++ 10 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 frontend/public/fonts/GeistMonoVF.woff2 diff --git a/frontend/public/fonts/GeistMonoVF.woff2 b/frontend/public/fonts/GeistMonoVF.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fb2f024aca0a7bfd14a88b7fa74129f107a7d7ee GIT binary patch literal 58048 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 Oe&? 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 margin-bottom: 0; font-family: 'SF Mono', monospace; - font-family: 'Space Mono', monospace; + font-family: 'Geist Mono'; font-size: 13px; font-weight: 400; text-align: left; diff --git a/frontend/src/container/LogDetailedView/FieldRenderer.styles.scss b/frontend/src/container/LogDetailedView/FieldRenderer.styles.scss index bd24601b18..7e43e1caf3 100644 --- a/frontend/src/container/LogDetailedView/FieldRenderer.styles.scss +++ b/frontend/src/container/LogDetailedView/FieldRenderer.styles.scss @@ -8,7 +8,7 @@ .label { color: var(--text-robin-400); font-family: SF Mono; - font-family: 'Space Mono', monospace; + font-family: 'Geist Mono'; font-size: 13px; font-weight: var(--font-weight-normal); line-height: 18px; diff --git a/frontend/src/container/LogDetailedView/JsonView.tsx b/frontend/src/container/LogDetailedView/JsonView.tsx index 1f22b1a134..9766b8db6e 100644 --- a/frontend/src/container/LogDetailedView/JsonView.tsx +++ b/frontend/src/container/LogDetailedView/JsonView.tsx @@ -28,7 +28,7 @@ function JSONView({ logData }: JSONViewProps): JSX.Element { }, fontWeight: 400, // fontFamily: 'SF Mono', - fontFamily: 'Space Mono', + fontFamily: 'Geist Mono', fontSize: 13, lineHeight: '18px', colorDecorators: true, diff --git a/frontend/src/container/LogDetailedView/Overview.tsx b/frontend/src/container/LogDetailedView/Overview.tsx index bd54524931..cf98ac11a9 100644 --- a/frontend/src/container/LogDetailedView/Overview.tsx +++ b/frontend/src/container/LogDetailedView/Overview.tsx @@ -53,7 +53,7 @@ function Overview({ enabled: false, }, fontWeight: 400, - fontFamily: 'Space Mono', + fontFamily: 'Geist Mono', fontSize: 13, lineHeight: '18px', colorDecorators: true, diff --git a/frontend/src/container/LogDetailedView/TableView.styles.scss b/frontend/src/container/LogDetailedView/TableView.styles.scss index d9cbdcabbb..2f092dc04d 100644 --- a/frontend/src/container/LogDetailedView/TableView.styles.scss +++ b/frontend/src/container/LogDetailedView/TableView.styles.scss @@ -57,6 +57,8 @@ background: rgba(22, 25, 34, 0.4); .value-field { + font-family: 'Geist Mono'; + position: relative; } diff --git a/frontend/src/container/LogDetailedView/TableView.tsx b/frontend/src/container/LogDetailedView/TableView.tsx index a69fba6441..810c946fb0 100644 --- a/frontend/src/container/LogDetailedView/TableView.tsx +++ b/frontend/src/container/LogDetailedView/TableView.tsx @@ -289,7 +289,13 @@ function TableView({ return (
- + {removeEscapeCharacters(fieldData.value)} diff --git a/frontend/src/container/LogsExplorerChart/LogsExplorerChart.styled.ts b/frontend/src/container/LogsExplorerChart/LogsExplorerChart.styled.ts index fec5dc1f0c..69299bf0d0 100644 --- a/frontend/src/container/LogsExplorerChart/LogsExplorerChart.styled.ts +++ b/frontend/src/container/LogsExplorerChart/LogsExplorerChart.styled.ts @@ -9,6 +9,6 @@ export const CardStyled = styled(Card)` height: 200px; min-height: 200px; padding: 0 16px 16px 16px; - font-family: 'Space Mono', monospace; + font-family: 'Geist Mono'; } `; diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 1684d7de2a..b5aca15ddf 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -266,3 +266,10 @@ modal - 1000 notifications - 2050 */ + +@font-face { + font-family: 'Geist Mono'; + src: local('Geist Mono'), + url('../public/fonts/GeistMonoVF.woff2') format('woff'); + /* Add other formats if needed (e.g., woff2, truetype, opentype, svg) */ +} From 916663b4d541c97b973489ccf83c78eab770ce39 Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 9 Jul 2024 08:12:25 +0430 Subject: [PATCH 10/30] fix: fix the explorer toolbar buttons padding (#5443) --- .../src/container/ExplorerOptions/ExplorerOptions.styles.scss | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss b/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss index 2076b858f9..54e87fa458 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.styles.scss @@ -91,8 +91,7 @@ box-shadow: none !important; &.ant-btn-round { - padding-inline-start: 10px; - padding-inline-end: 8px; + padding: 8px 12px 8px 10px; font-weight: 500; } From 87f1597d4e4984df0a477dc7e2ca5d79790c6b07 Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 9 Jul 2024 08:13:35 +0430 Subject: [PATCH 11/30] fix: prevent overwriting query expression and queryName on switching between panel types (#5430) --- frontend/src/container/NewWidget/utils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/container/NewWidget/utils.ts b/frontend/src/container/NewWidget/utils.ts index b840d6e71a..7e651ff2ea 100644 --- a/frontend/src/container/NewWidget/utils.ts +++ b/frontend/src/container/NewWidget/utils.ts @@ -372,8 +372,12 @@ export function handleQueryChange( builder: { ...supersetQuery.builder, queryData: supersetQuery.builder.queryData.map((query, index) => { - const { dataSource } = query; - const tempQuery = { ...initialQueryBuilderFormValuesMap[dataSource] }; + const { dataSource, expression, queryName } = query; + const tempQuery = { + ...initialQueryBuilderFormValuesMap[dataSource], + expression, + queryName, + }; const fieldsToSelect = panelTypeDataSourceFormValuesMap[newPanelType][dataSource].builder From 2c7a5126fd56a02f4efd00adf446f73ff1c6f40a Mon Sep 17 00:00:00 2001 From: Yunus M Date: Wed, 10 Jul 2024 00:30:25 +0530 Subject: [PATCH 12/30] update project maintainers (#5460) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a17c67e498..852b44f240 100644 --- a/README.md +++ b/README.md @@ -198,14 +198,14 @@ Not sure how to get started? Just ping us on `#contributing` in our [slack commu #### Frontend -- [Palash Gupta](https://github.com/palashgdev) - [Yunus M](https://github.com/YounixM) -- [Rajat Dabade](https://github.com/Rajat-Dabade) +- [Vikrant Gupta](https://github.com/vikrantgupta25) +- [Sagar Rajput](https://github.com/SagarRajput-7) #### DevOps - [Prashant Shahi](https://github.com/prashant-shahi) -- [Dhawal Sanghvi](https://github.com/dhawal1248) +- [Vibhu Pandey](https://github.com/grandwizard28)

From 3b2a811f7bc6cdd1275830665c8f13d675dd5110 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 08:11:53 +0530 Subject: [PATCH 13/30] chore(deps): bump google.golang.org/grpc from 1.64.0 to 1.64.1 (#5463) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.64.0 to 1.64.1. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.64.0...v1.64.1) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 10831ec475..b62ebab95f 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,7 @@ require ( golang.org/x/net v0.26.0 golang.org/x/oauth2 v0.21.0 golang.org/x/text v0.16.0 - google.golang.org/grpc v1.64.0 + google.golang.org/grpc v1.64.1 google.golang.org/protobuf v1.34.1 gopkg.in/segmentio/analytics-go.v3 v3.1.0 gopkg.in/yaml.v2 v2.4.0 diff --git a/go.sum b/go.sum index 5c49916eb3..6638d7b40d 100644 --- a/go.sum +++ b/go.sum @@ -1197,8 +1197,8 @@ google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= -google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= +google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= From 831de18464c98eef0720391aa257ca34182e605e Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Wed, 10 Jul 2024 11:00:28 +0530 Subject: [PATCH 14/30] fix: concurrent map writes to temporalityMap (#5432) --- pkg/query-service/app/http_handler.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index d6c91558a5..42879123ec 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -83,6 +83,7 @@ type APIHandler struct { // querying the v4 table on low cardinal temporality column // should be fast but we can still avoid the query if we have the data in memory temporalityMap map[string]map[v3.Temporality]bool + temporalityMux sync.Mutex maxIdleConns int maxOpenConns int @@ -455,6 +456,9 @@ func (aH *APIHandler) getRule(w http.ResponseWriter, r *http.Request) { // populateTemporality adds the temporality to the query if it is not present func (aH *APIHandler) populateTemporality(ctx context.Context, qp *v3.QueryRangeParamsV3) error { + aH.temporalityMux.Lock() + defer aH.temporalityMux.Unlock() + missingTemporality := make([]string, 0) metricNameToTemporality := make(map[string]map[v3.Temporality]bool) if qp.CompositeQuery != nil && len(qp.CompositeQuery.BuilderQueries) > 0 { From 83455e614e157b35d617d0b5186fbe0abad229ba Mon Sep 17 00:00:00 2001 From: Nityananda Gohain Date: Wed, 10 Jul 2024 11:23:29 +0530 Subject: [PATCH 15/30] fix: disable removing a selected field (#5457) * fix: disable removing a selected field * fix: comment updated with issue link * fix: remove local db --- .../app/clickhouseReader/reader.go | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/pkg/query-service/app/clickhouseReader/reader.go b/pkg/query-service/app/clickhouseReader/reader.go index ae8fb64c94..ccdffd88bd 100644 --- a/pkg/query-service/app/clickhouseReader/reader.go +++ b/pkg/query-service/app/clickhouseReader/reader.go @@ -3575,38 +3575,42 @@ func (r *ClickHouseReader) UpdateLogField(ctx context.Context, field *model.Upda } } else { + // We are not allowing to delete a materialized column + // For more details please check https://github.com/SigNoz/signoz/issues/4566 + return model.ForbiddenError(errors.New("Removing a selected field is not allowed, please reach out to support.")) + // Delete the index first - query := fmt.Sprintf("ALTER TABLE %s.%s ON CLUSTER %s DROP INDEX IF EXISTS %s_idx`", r.logsDB, r.logsLocalTable, r.cluster, strings.TrimSuffix(colname, "`")) - err := r.db.Exec(ctx, query) - if err != nil { - return &model.ApiError{Err: err, Typ: model.ErrorInternal} - } + // query := fmt.Sprintf("ALTER TABLE %s.%s ON CLUSTER %s DROP INDEX IF EXISTS %s_idx`", r.logsDB, r.logsLocalTable, r.cluster, strings.TrimSuffix(colname, "`")) + // err := r.db.Exec(ctx, query) + // if err != nil { + // return &model.ApiError{Err: err, Typ: model.ErrorInternal} + // } - for _, table := range []string{r.logsTable, r.logsLocalTable} { - // drop materialized column from logs table - query := "ALTER TABLE %s.%s ON CLUSTER %s DROP COLUMN IF EXISTS %s " - err := r.db.Exec(ctx, fmt.Sprintf(query, - r.logsDB, table, - r.cluster, - colname, - ), - ) - if err != nil { - return &model.ApiError{Err: err, Typ: model.ErrorInternal} - } + // for _, table := range []string{r.logsTable, r.logsLocalTable} { + // // drop materialized column from logs table + // query := "ALTER TABLE %s.%s ON CLUSTER %s DROP COLUMN IF EXISTS %s " + // err := r.db.Exec(ctx, fmt.Sprintf(query, + // r.logsDB, table, + // r.cluster, + // colname, + // ), + // ) + // if err != nil { + // return &model.ApiError{Err: err, Typ: model.ErrorInternal} + // } - // drop exists column on logs table - query = "ALTER TABLE %s.%s ON CLUSTER %s DROP COLUMN IF EXISTS %s_exists` " - err = r.db.Exec(ctx, fmt.Sprintf(query, - r.logsDB, table, - r.cluster, - strings.TrimSuffix(colname, "`"), - ), - ) - if err != nil { - return &model.ApiError{Err: err, Typ: model.ErrorInternal} - } - } + // // drop exists column on logs table + // query = "ALTER TABLE %s.%s ON CLUSTER %s DROP COLUMN IF EXISTS %s_exists` " + // err = r.db.Exec(ctx, fmt.Sprintf(query, + // r.logsDB, table, + // r.cluster, + // strings.TrimSuffix(colname, "`"), + // ), + // ) + // if err != nil { + // return &model.ApiError{Err: err, Typ: model.ErrorInternal} + // } + // } } return nil } From ddf5569ce91264a52a86dcc1c38ca9fe99ea21e4 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:56:11 +0530 Subject: [PATCH 16/30] fix: added null check on filters obj (#5419) * fix: added null check on filters obj * feat: added test cases of undefined filters and items * feat: added comments --- .../pages/TracesExplorer/Filter/Filter.tsx | 2 +- .../__test__/TracesExplorer.test.tsx | 132 ++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx diff --git a/frontend/src/pages/TracesExplorer/Filter/Filter.tsx b/frontend/src/pages/TracesExplorer/Filter/Filter.tsx index 7eadc3d5cd..1a3fbc785c 100644 --- a/frontend/src/pages/TracesExplorer/Filter/Filter.tsx +++ b/frontend/src/pages/TracesExplorer/Filter/Filter.tsx @@ -56,7 +56,7 @@ export function Filter(props: FilterProps): JSX.Element { return {} as FilterType; } - return filters.items + return (filters.items || []) .filter((item) => Object.keys(AllTraceFilterKeyValue).includes(item.key?.key as string), ) diff --git a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx new file mode 100644 index 0000000000..1250a3f3cc --- /dev/null +++ b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx @@ -0,0 +1,132 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-await-in-loop */ +import { + initialQueriesMap, + initialQueryBuilderFormValues, +} from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; +import * as compositeQueryHook from 'hooks/queryBuilder/useGetCompositeQueryParam'; +import { render } from 'tests/test-utils'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +import { Filter } from '../Filter/Filter'; +import { AllTraceFilterKeyValue } from '../Filter/filterUtils'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: (): { pathname: string } => ({ + pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.TRACES_EXPLORER}/`, + }), +})); + +jest.mock('uplot', () => { + const paths = { + spline: jest.fn(), + bars: jest.fn(), + }; + + const uplotMock = jest.fn(() => ({ + paths, + })); + + return { + paths, + default: uplotMock, + }; +}); + +const compositeQuery: Query = { + ...initialQueriesMap.traces, + builder: { + ...initialQueriesMap.traces.builder, + queryData: [ + { + ...initialQueryBuilderFormValues, + filters: { + items: [ + { + id: '95564eb1', + key: { + key: 'name', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'name--string--tag--true', + }, + op: 'in', + value: ['HTTP GET /customer'], + }, + { + id: '3337951c', + key: { + key: 'serviceName', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'serviceName--string--tag--true', + }, + op: 'in', + value: ['demo-app'], + }, + ], + op: 'AND', + }, + }, + ], + }, +}; + +describe('TracesExplorer - ', () => { + it('test edge cases of undefined filters', async () => { + jest.spyOn(compositeQueryHook, 'useGetCompositeQueryParam').mockReturnValue({ + ...compositeQuery, + builder: { + ...compositeQuery.builder, + queryData: compositeQuery.builder.queryData.map( + (item) => + ({ + ...item, + filters: undefined, + } as any), + ), + }, + }); + + const { getByText } = render(); + + // we should have all the filters + Object.values(AllTraceFilterKeyValue).forEach((filter) => { + expect(getByText(filter)).toBeInTheDocument(); + }); + }); + + it('test edge cases of undefined filters - items', async () => { + jest.spyOn(compositeQueryHook, 'useGetCompositeQueryParam').mockReturnValue({ + ...compositeQuery, + builder: { + ...compositeQuery.builder, + queryData: compositeQuery.builder.queryData.map( + (item) => + ({ + ...item, + filters: { + ...item.filters, + items: undefined, + }, + } as any), + ), + }, + }); + + const { getByText } = render(); + + // we should have all the filters + Object.values(AllTraceFilterKeyValue).forEach((filter) => { + expect(getByText(filter)).toBeInTheDocument(); + }); + }); +}); From 9844dcdfb785a50daaec4fef42330ee4079b3056 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Wed, 10 Jul 2024 12:43:39 +0530 Subject: [PATCH 17/30] fix: added logic to keep sections uncollapsed for all filtered items (#5371) --- .../pages/TracesExplorer/Filter/Section.tsx | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/TracesExplorer/Filter/Section.tsx b/frontend/src/pages/TracesExplorer/Filter/Section.tsx index c95f8a77e0..8ce2007ef7 100644 --- a/frontend/src/pages/TracesExplorer/Filter/Section.tsx +++ b/frontend/src/pages/TracesExplorer/Filter/Section.tsx @@ -1,7 +1,14 @@ import './Filter.styles.scss'; import { Button, Collapse, Divider } from 'antd'; -import { Dispatch, MouseEvent, SetStateAction } from 'react'; +import { + Dispatch, + MouseEvent, + SetStateAction, + useEffect, + useMemo, + useState, +} from 'react'; import { DurationSection } from './DurationSection'; import { @@ -18,9 +25,29 @@ interface SectionProps { setSelectedFilters: Dispatch>; handleRun: (props?: HandleRunProps) => void; } + export function Section(props: SectionProps): JSX.Element { const { panelName, setSelectedFilters, selectedFilters, handleRun } = props; + const defaultOpenPanes = useMemo( + () => + Array.from( + new Set([ + ...Object.keys(selectedFilters || {}), + 'hasError', + 'durationNano', + 'serviceName', + ]), + ), + [selectedFilters], + ); + + const [activeKeys, setActiveKeys] = useState(defaultOpenPanes); + + useEffect(() => { + setActiveKeys(defaultOpenPanes); + }, [defaultOpenPanes]); + const onClearHandler = (e: MouseEvent): void => { e.stopPropagation(); e.preventDefault(); @@ -41,11 +68,8 @@ export function Section(props: SectionProps): JSX.Element { setActiveKeys(keys as string[])} items={[ panelName === 'durationNano' ? { From 3ecb2e35ef3d0755bf56e2b378f5e8e4d2951c1b Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Fri, 12 Jul 2024 18:49:24 +0530 Subject: [PATCH 18/30] chore: use version v4 for export panel from explorer pages (#5438) --- frontend/src/container/ExportPanel/ExportPanelContainer.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/container/ExportPanel/ExportPanelContainer.tsx b/frontend/src/container/ExportPanel/ExportPanelContainer.tsx index df2d4f8720..9e4a658273 100644 --- a/frontend/src/container/ExportPanel/ExportPanelContainer.tsx +++ b/frontend/src/container/ExportPanel/ExportPanelContainer.tsx @@ -1,5 +1,6 @@ import { Button, Typography } from 'antd'; import createDashboard from 'api/dashboard/create'; +import { ENTITY_VERSION_V4 } from 'constants/app'; import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard'; import useAxiosError from 'hooks/useAxiosError'; import { useCallback, useMemo, useState } from 'react'; @@ -70,6 +71,7 @@ function ExportPanelContainer({ ns: 'dashboard', }), uploadedGrafana: false, + version: ENTITY_VERSION_V4, }); }, [t, createNewDashboard]); From 9194ab08b63773b0eef7aeca041bd6e15abfecfb Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Mon, 15 Jul 2024 18:06:39 +0530 Subject: [PATCH 19/30] fix: incorrect response for promql value type panels (#5497) --- pkg/query-service/app/querier/helper.go | 2 +- pkg/query-service/app/querier/querier.go | 17 +- pkg/query-service/app/querier/querier_test.go | 99 ++ pkg/query-service/app/querier/v2/helper.go | 2 +- pkg/query-service/app/querier/v2/querier.go | 17 +- .../app/querier/v2/querier_test.go | 1060 +++++++++++++++++ pkg/query-service/interfaces/interface.go | 1 + 7 files changed, 1186 insertions(+), 12 deletions(-) create mode 100644 pkg/query-service/app/querier/v2/querier_test.go diff --git a/pkg/query-service/app/querier/helper.go b/pkg/query-service/app/querier/helper.go index d65627bd92..1da4a5a46a 100644 --- a/pkg/query-service/app/querier/helper.go +++ b/pkg/query-service/app/querier/helper.go @@ -18,7 +18,7 @@ import ( "go.uber.org/zap" ) -func prepareLogsQuery(ctx context.Context, +func prepareLogsQuery(_ context.Context, start, end int64, builderQuery *v3.BuilderQuery, diff --git a/pkg/query-service/app/querier/querier.go b/pkg/query-service/app/querier/querier.go index 7668d401cd..95cfc7cc73 100644 --- a/pkg/query-service/app/querier/querier.go +++ b/pkg/query-service/app/querier/querier.go @@ -50,8 +50,10 @@ type querier struct { // TODO(srikanthccv): remove this once we have a proper mock testingMode bool queriesExecuted []string - returnedSeries []*v3.Series - returnedErr error + // tuple of start and end time in milliseconds + timeRanges [][]int + returnedSeries []*v3.Series + returnedErr error } type QuerierOptions struct { @@ -117,6 +119,7 @@ func (q *querier) execClickHouseQuery(ctx context.Context, query string) ([]*v3. func (q *querier) execPromQuery(ctx context.Context, params *model.QueryRangeParams) ([]*v3.Series, error) { q.queriesExecuted = append(q.queriesExecuted, params.Query) if q.testingMode && q.reader == nil { + q.timeRanges = append(q.timeRanges, []int{int(params.Start.UnixMilli()), int(params.End.UnixMilli())}) return q.returnedSeries, q.returnedErr } promResult, _, err := q.reader.GetQueryRangeResult(ctx, params) @@ -342,10 +345,10 @@ func (q *querier) runPromQueries(ctx context.Context, params *v3.QueryRangeParam wg.Add(1) go func(queryName string, promQuery *v3.PromQuery) { defer wg.Done() - cacheKey := cacheKeys[queryName] + cacheKey, ok := cacheKeys[queryName] var cachedData []byte // Ensure NoCache is not set and cache is not nil - if !params.NoCache && q.cache != nil { + if !params.NoCache && q.cache != nil && ok { data, retrieveStatus, err := q.cache.Retrieve(cacheKey, true) zap.L().Info("cache retrieve status", zap.String("status", retrieveStatus.String())) if err == nil { @@ -373,7 +376,7 @@ func (q *querier) runPromQueries(ctx context.Context, params *v3.QueryRangeParam channelResults <- channelResult{Err: nil, Name: queryName, Query: promQuery.Query, Series: mergedSeries} // Cache the seriesList for future queries - if len(missedSeries) > 0 && !params.NoCache && q.cache != nil { + if len(missedSeries) > 0 && !params.NoCache && q.cache != nil && ok { mergedSeriesData, err := json.Marshal(mergedSeries) if err != nil { zap.L().Error("error marshalling merged series", zap.Error(err)) @@ -546,3 +549,7 @@ func (q *querier) QueryRange(ctx context.Context, params *v3.QueryRangeParamsV3, func (q *querier) QueriesExecuted() []string { return q.queriesExecuted } + +func (q *querier) TimeRanges() [][]int { + return q.timeRanges +} diff --git a/pkg/query-service/app/querier/querier_test.go b/pkg/query-service/app/querier/querier_test.go index a9f8cc4030..5160c564da 100644 --- a/pkg/query-service/app/querier/querier_test.go +++ b/pkg/query-service/app/querier/querier_test.go @@ -951,3 +951,102 @@ func TestQueryRangeTimeShiftWithLimitAndCache(t *testing.T) { } } } + +func TestQueryRangeValueTypePromQL(t *testing.T) { + // There shouldn't be any caching for value panel type + params := []*v3.QueryRangeParamsV3{ + { + Start: 1675115596722, + End: 1675115596722 + 120*60*1000, + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypePromQL, + PanelType: v3.PanelTypeValue, + PromQueries: map[string]*v3.PromQuery{ + "A": { + Query: "signoz_calls_total", + }, + }, + }, + }, + { + Start: 1675115596722 + 60*60*1000, + End: 1675115596722 + 180*60*1000, + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypePromQL, + PanelType: v3.PanelTypeValue, + PromQueries: map[string]*v3.PromQuery{ + "A": { + Query: "signoz_latency_bucket", + }, + }, + }, + }, + } + cache := inmemory.New(&inmemory.Options{TTL: 60 * time.Minute, CleanupInterval: 10 * time.Minute}) + opts := QuerierOptions{ + Cache: cache, + Reader: nil, + FluxInterval: 5 * time.Minute, + KeyGenerator: queryBuilder.NewKeyGenerator(), + + TestingMode: true, + ReturnedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "method": "GET", + "service_name": "test", + "__name__": "doesn't matter", + }, + Points: []v3.Point{ + {Timestamp: 1675115596722, Value: 1}, + {Timestamp: 1675115596722 + 60*60*1000, Value: 2}, + {Timestamp: 1675115596722 + 120*60*1000, Value: 3}, + }, + }, + }, + } + q := NewQuerier(opts) + + expectedQueryAndTimeRanges := []struct { + query string + ranges []missInterval + }{ + { + query: "signoz_calls_total", + ranges: []missInterval{ + {start: 1675115596722, end: 1675115596722 + 120*60*1000}, + }, + }, + { + query: "signoz_latency_bucket", + ranges: []missInterval{ + {start: 1675115596722 + 60*60*1000, end: 1675115596722 + 180*60*1000}, + }, + }, + } + + for i, param := range params { + _, errByName, err := q.QueryRange(context.Background(), param, nil) + if err != nil { + t.Errorf("expected no error, got %s", err) + } + if len(errByName) > 0 { + t.Errorf("expected no error, got %v", errByName) + } + + if !strings.Contains(q.QueriesExecuted()[i], expectedQueryAndTimeRanges[i].query) { + t.Errorf("expected query to contain %s, got %s", expectedQueryAndTimeRanges[i].query, q.QueriesExecuted()[i]) + } + if len(q.TimeRanges()[i]) != 2 { + t.Errorf("expected time ranges to be %v, got %v", expectedQueryAndTimeRanges[i].ranges, q.TimeRanges()[i]) + } + if q.TimeRanges()[i][0] != int(expectedQueryAndTimeRanges[i].ranges[0].start) { + t.Errorf("expected time ranges to be %v, got %v", expectedQueryAndTimeRanges[i].ranges, q.TimeRanges()[i]) + } + if q.TimeRanges()[i][1] != int(expectedQueryAndTimeRanges[i].ranges[0].end) { + t.Errorf("expected time ranges to be %v, got %v", expectedQueryAndTimeRanges[i].ranges, q.TimeRanges()[i]) + } + } +} diff --git a/pkg/query-service/app/querier/v2/helper.go b/pkg/query-service/app/querier/v2/helper.go index 04f798ad1b..9df9965b5c 100644 --- a/pkg/query-service/app/querier/v2/helper.go +++ b/pkg/query-service/app/querier/v2/helper.go @@ -18,7 +18,7 @@ import ( "go.uber.org/zap" ) -func prepareLogsQuery(ctx context.Context, +func prepareLogsQuery(_ context.Context, start, end int64, builderQuery *v3.BuilderQuery, diff --git a/pkg/query-service/app/querier/v2/querier.go b/pkg/query-service/app/querier/v2/querier.go index e6915ef078..b8a8a9e92e 100644 --- a/pkg/query-service/app/querier/v2/querier.go +++ b/pkg/query-service/app/querier/v2/querier.go @@ -50,8 +50,10 @@ type querier struct { // TODO(srikanthccv): remove this once we have a proper mock testingMode bool queriesExecuted []string - returnedSeries []*v3.Series - returnedErr error + // tuple of start and end time in milliseconds + timeRanges [][]int + returnedSeries []*v3.Series + returnedErr error } type QuerierOptions struct { @@ -117,6 +119,7 @@ func (q *querier) execClickHouseQuery(ctx context.Context, query string) ([]*v3. func (q *querier) execPromQuery(ctx context.Context, params *model.QueryRangeParams) ([]*v3.Series, error) { q.queriesExecuted = append(q.queriesExecuted, params.Query) if q.testingMode && q.reader == nil { + q.timeRanges = append(q.timeRanges, []int{int(params.Start.UnixMilli()), int(params.End.UnixMilli())}) return q.returnedSeries, q.returnedErr } promResult, _, err := q.reader.GetQueryRangeResult(ctx, params) @@ -335,10 +338,10 @@ func (q *querier) runPromQueries(ctx context.Context, params *v3.QueryRangeParam wg.Add(1) go func(queryName string, promQuery *v3.PromQuery) { defer wg.Done() - cacheKey := cacheKeys[queryName] + cacheKey, ok := cacheKeys[queryName] var cachedData []byte // Ensure NoCache is not set and cache is not nil - if !params.NoCache && q.cache != nil { + if !params.NoCache && q.cache != nil && ok { data, retrieveStatus, err := q.cache.Retrieve(cacheKey, true) zap.L().Info("cache retrieve status", zap.String("status", retrieveStatus.String())) if err == nil { @@ -366,7 +369,7 @@ func (q *querier) runPromQueries(ctx context.Context, params *v3.QueryRangeParam channelResults <- channelResult{Err: nil, Name: queryName, Query: promQuery.Query, Series: mergedSeries} // Cache the seriesList for future queries - if len(missedSeries) > 0 && !params.NoCache && q.cache != nil { + if len(missedSeries) > 0 && !params.NoCache && q.cache != nil && ok { mergedSeriesData, err := json.Marshal(mergedSeries) if err != nil { zap.L().Error("error marshalling merged series", zap.Error(err)) @@ -539,3 +542,7 @@ func (q *querier) QueryRange(ctx context.Context, params *v3.QueryRangeParamsV3, func (q *querier) QueriesExecuted() []string { return q.queriesExecuted } + +func (q *querier) TimeRanges() [][]int { + return q.timeRanges +} diff --git a/pkg/query-service/app/querier/v2/querier_test.go b/pkg/query-service/app/querier/v2/querier_test.go new file mode 100644 index 0000000000..d29785b310 --- /dev/null +++ b/pkg/query-service/app/querier/v2/querier_test.go @@ -0,0 +1,1060 @@ +package v2 + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "go.signoz.io/signoz/pkg/query-service/app/queryBuilder" + "go.signoz.io/signoz/pkg/query-service/cache/inmemory" + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" +) + +func TestV2FindMissingTimeRangesZeroFreshNess(t *testing.T) { + // There are five scenarios: + // 1. Cached time range is a subset of the requested time range + // 2. Cached time range is a superset of the requested time range + // 3. Cached time range is a left overlap of the requested time range + // 4. Cached time range is a right overlap of the requested time range + // 5. Cached time range is a disjoint of the requested time range + testCases := []struct { + name string + requestedStart int64 // in milliseconds + requestedEnd int64 // in milliseconds + requestedStep int64 // in seconds + cachedSeries []*v3.Series + expectedMiss []missInterval + }{ + { + name: "cached time range is a subset of the requested time range", + requestedStart: 1675115596722, + requestedEnd: 1675115596722 + 180*60*1000, + requestedStep: 60, + cachedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + { + Timestamp: 1675115596722 + 60*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 120*60*1000, + Value: 1, + }, + }, + }, + }, + expectedMiss: []missInterval{ + { + start: 1675115596722, + end: 1675115596722 + 60*60*1000 - 1, + }, + { + start: 1675115596722 + 120*60*1000 + 1, + end: 1675115596722 + 180*60*1000, + }, + }, + }, + { + name: "cached time range is a superset of the requested time range", + requestedStart: 1675115596722, + requestedEnd: 1675115596722 + 180*60*1000, + requestedStep: 60, + cachedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + { + Timestamp: 1675115596722, + Value: 1, + }, + { + Timestamp: 1675115596722 + 60*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 120*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 180*60*1000, + Value: 1, + }, + }, + }, + }, + expectedMiss: []missInterval{}, + }, + { + name: "cached time range is a left overlap of the requested time range", + requestedStart: 1675115596722, + requestedEnd: 1675115596722 + 180*60*1000, + requestedStep: 60, + cachedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + { + Timestamp: 1675115596722, + Value: 1, + }, + { + Timestamp: 1675115596722 + 60*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 120*60*1000, + Value: 1, + }, + }, + }, + }, + expectedMiss: []missInterval{ + { + start: 1675115596722 + 120*60*1000 + 1, + end: 1675115596722 + 180*60*1000, + }, + }, + }, + { + name: "cached time range is a right overlap of the requested time range", + requestedStart: 1675115596722, + requestedEnd: 1675115596722 + 180*60*1000, + requestedStep: 60, + cachedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + { + Timestamp: 1675115596722 + 60*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 120*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 180*60*1000, + Value: 1, + }, + }, + }, + }, + expectedMiss: []missInterval{ + { + start: 1675115596722, + end: 1675115596722 + 60*60*1000 - 1, + }, + }, + }, + { + name: "cached time range is a disjoint of the requested time range", + requestedStart: 1675115596722, + requestedEnd: 1675115596722 + 180*60*1000, + requestedStep: 60, + cachedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + { + Timestamp: 1675115596722 + 240*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 300*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 360*60*1000, + Value: 1, + }, + }, + }, + }, + expectedMiss: []missInterval{ + { + start: 1675115596722, + end: 1675115596722 + 180*60*1000, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + misses := findMissingTimeRanges(tc.requestedStart, tc.requestedEnd, tc.requestedStep, tc.cachedSeries, 0*time.Minute) + if len(misses) != len(tc.expectedMiss) { + t.Errorf("expected %d misses, got %d", len(tc.expectedMiss), len(misses)) + } + for i, miss := range misses { + if miss.start != tc.expectedMiss[i].start { + t.Errorf("expected start %d, got %d", tc.expectedMiss[i].start, miss.start) + } + if miss.end != tc.expectedMiss[i].end { + t.Errorf("expected end %d, got %d", tc.expectedMiss[i].end, miss.end) + } + } + }) + } +} + +func TestV2FindMissingTimeRangesWithFluxInterval(t *testing.T) { + + testCases := []struct { + name string + requestedStart int64 + requestedEnd int64 + requestedStep int64 + cachedSeries []*v3.Series + fluxInterval time.Duration + expectedMiss []missInterval + }{ + { + name: "cached time range is a subset of the requested time range", + requestedStart: 1675115596722, + requestedEnd: 1675115596722 + 180*60*1000, + requestedStep: 60, + cachedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + { + Timestamp: 1675115596722 + 60*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 120*60*1000, + Value: 1, + }, + }, + }, + }, + fluxInterval: 5 * time.Minute, + expectedMiss: []missInterval{ + { + start: 1675115596722, + end: 1675115596722 + 60*60*1000 - 1, + }, + { + start: 1675115596722 + 120*60*1000 + 1, + end: 1675115596722 + 180*60*1000, + }, + }, + }, + { + name: "cached time range is a superset of the requested time range", + requestedStart: 1675115596722, + requestedEnd: 1675115596722 + 180*60*1000, + requestedStep: 60, + cachedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + { + Timestamp: 1675115596722, + Value: 1, + }, + { + Timestamp: 1675115596722 + 60*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 120*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 180*60*1000, + Value: 1, + }, + }, + }, + }, + fluxInterval: 5 * time.Minute, + expectedMiss: []missInterval{}, + }, + { + name: "cache time range is a left overlap of the requested time range", + requestedStart: 1675115596722, + requestedEnd: 1675115596722 + 180*60*1000, + requestedStep: 60, + cachedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + { + Timestamp: 1675115596722, + Value: 1, + }, + { + Timestamp: 1675115596722 + 60*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 120*60*1000, + Value: 1, + }, + }, + }, + }, + fluxInterval: 5 * time.Minute, + expectedMiss: []missInterval{ + { + start: 1675115596722 + 120*60*1000 + 1, + end: 1675115596722 + 180*60*1000, + }, + }, + }, + { + name: "cache time range is a right overlap of the requested time range", + requestedStart: 1675115596722, + requestedEnd: 1675115596722 + 180*60*1000, + requestedStep: 60, + cachedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + { + Timestamp: 1675115596722 + 60*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 120*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 180*60*1000, + Value: 1, + }, + }, + }, + }, + fluxInterval: 5 * time.Minute, + expectedMiss: []missInterval{ + { + start: 1675115596722, + end: 1675115596722 + 60*60*1000 - 1, + }, + }, + }, + { + name: "cache time range is a disjoint of the requested time range", + requestedStart: 1675115596722, + requestedEnd: 1675115596722 + 180*60*1000, + requestedStep: 60, + cachedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + { + Timestamp: 1675115596722 + 240*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 300*60*1000, + Value: 1, + }, + { + Timestamp: 1675115596722 + 360*60*1000, + Value: 1, + }, + }, + }, + }, + fluxInterval: 5 * time.Minute, + expectedMiss: []missInterval{ + { + start: 1675115596722, + end: 1675115596722 + 180*60*1000, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + misses := findMissingTimeRanges(tc.requestedStart, tc.requestedEnd, tc.requestedStep, tc.cachedSeries, tc.fluxInterval) + if len(misses) != len(tc.expectedMiss) { + t.Errorf("expected %d misses, got %d", len(tc.expectedMiss), len(misses)) + } + for i, miss := range misses { + if miss.start != tc.expectedMiss[i].start { + t.Errorf("expected start %d, got %d", tc.expectedMiss[i].start, miss.start) + } + if miss.end != tc.expectedMiss[i].end { + t.Errorf("expected end %d, got %d", tc.expectedMiss[i].end, miss.end) + } + } + }) + } +} + +func TestV2QueryRange(t *testing.T) { + params := []*v3.QueryRangeParamsV3{ + { + Start: 1675115596722, + End: 1675115596722 + 120*60*1000, + Step: 60, + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeGraph, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + DataSource: v3.DataSourceMetrics, + Temporality: v3.Delta, + StepInterval: 60, + AggregateAttribute: v3.AttributeKey{Key: "http_server_requests_seconds_count", Type: v3.AttributeKeyTypeUnspecified, DataType: "float64", IsColumn: true}, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{ + { + Key: v3.AttributeKey{Key: "method", IsColumn: false}, + Operator: "=", + Value: "GET", + }, + }, + }, + GroupBy: []v3.AttributeKey{ + {Key: "service_name", IsColumn: false}, + {Key: "method", IsColumn: false}, + }, + AggregateOperator: v3.AggregateOperatorSumRate, + TimeAggregation: v3.TimeAggregationRate, + SpaceAggregation: v3.SpaceAggregationSum, + Expression: "A", + }, + }, + }, + }, + { + Start: 1675115596722 + 60*60*1000, + End: 1675115596722 + 180*60*1000, + Step: 60, + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeGraph, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + Temporality: v3.Delta, + StepInterval: 60, + AggregateAttribute: v3.AttributeKey{Key: "http_server_requests_seconds_count", Type: v3.AttributeKeyTypeUnspecified, DataType: "float64", IsColumn: true}, + DataSource: v3.DataSourceMetrics, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{ + { + Key: v3.AttributeKey{Key: "method", IsColumn: false}, + Operator: "=", + Value: "GET", + }, + }, + }, + GroupBy: []v3.AttributeKey{ + {Key: "service_name", IsColumn: false}, + {Key: "method", IsColumn: false}, + }, + AggregateOperator: v3.AggregateOperatorSumRate, + TimeAggregation: v3.TimeAggregationRate, + SpaceAggregation: v3.SpaceAggregationSum, + Expression: "A", + }, + }, + }, + }, + // No caching for traces yet + { + Start: 1675115596722, + End: 1675115596722 + 120*60*1000, + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeGraph, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + AggregateAttribute: v3.AttributeKey{Key: "durationNano", Type: v3.AttributeKeyTypeUnspecified, DataType: "float64", IsColumn: true}, + StepInterval: 60, + DataSource: v3.DataSourceTraces, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{ + { + Key: v3.AttributeKey{Key: "method", IsColumn: false}, + Operator: "=", + Value: "GET", + }, + }, + }, + GroupBy: []v3.AttributeKey{ + {Key: "serviceName", IsColumn: false}, + {Key: "name", IsColumn: false}, + }, + AggregateOperator: v3.AggregateOperatorP95, + Expression: "A", + }, + }, + }, + }, + { + Start: 1675115596722 + 60*60*1000, + End: 1675115596722 + 180*60*1000, + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeGraph, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + AggregateAttribute: v3.AttributeKey{Key: "durationNano", Type: v3.AttributeKeyTypeUnspecified, DataType: "float64", IsColumn: true}, + StepInterval: 60, + DataSource: v3.DataSourceTraces, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{ + { + Key: v3.AttributeKey{Key: "method", IsColumn: false}, + Operator: "=", + Value: "GET", + }, + }, + }, + GroupBy: []v3.AttributeKey{ + {Key: "serviceName", IsColumn: false}, + {Key: "name", IsColumn: false}, + }, + AggregateOperator: v3.AggregateOperatorP95, + Expression: "A", + }, + }, + }, + }, + } + cache := inmemory.New(&inmemory.Options{TTL: 5 * time.Minute, CleanupInterval: 10 * time.Minute}) + opts := QuerierOptions{ + Cache: cache, + Reader: nil, + FluxInterval: 5 * time.Minute, + KeyGenerator: queryBuilder.NewKeyGenerator(), + + TestingMode: true, + ReturnedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "method": "GET", + "service_name": "test", + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + {Timestamp: 1675115596722, Value: 1}, + {Timestamp: 1675115596722 + 60*60*1000, Value: 2}, + {Timestamp: 1675115596722 + 120*60*1000, Value: 3}, + }, + }, + }, + } + q := NewQuerier(opts) + expectedTimeRangeInQueryString := []string{ + fmt.Sprintf("unix_milli >= %d AND unix_milli < %d", 1675115580000, 1675115580000+120*60*1000), + fmt.Sprintf("unix_milli >= %d AND unix_milli < %d", 1675115580000+120*60*1000, 1675115580000+180*60*1000), + fmt.Sprintf("timestamp >= '%d' AND timestamp <= '%d'", 1675115580000*1000000, (1675115580000+120*60*1000)*int64(1000000)), + fmt.Sprintf("timestamp >= '%d' AND timestamp <= '%d'", (1675115580000+60*60*1000)*int64(1000000), (1675115580000+180*60*1000)*int64(1000000)), + } + + for i, param := range params { + _, errByName, err := q.QueryRange(context.Background(), param, nil) + if err != nil { + t.Errorf("expected no error, got %s", err) + } + if len(errByName) > 0 { + t.Errorf("expected no error, got %v", errByName) + } + + if !strings.Contains(q.QueriesExecuted()[i], expectedTimeRangeInQueryString[i]) { + t.Errorf("expected query to contain %s, got %s", expectedTimeRangeInQueryString[i], q.QueriesExecuted()[i]) + } + } +} + +func TestV2QueryRangeValueType(t *testing.T) { + // There shouldn't be any caching for value panel type + params := []*v3.QueryRangeParamsV3{ + { + Start: 1675115596722, + End: 1675115596722 + 120*60*1000, + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeValue, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + StepInterval: 60, + DataSource: v3.DataSourceMetrics, + AggregateAttribute: v3.AttributeKey{Key: "http_server_requests_seconds_count", Type: v3.AttributeKeyTypeUnspecified, DataType: "float64", IsColumn: true}, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{ + { + Key: v3.AttributeKey{Key: "method", IsColumn: false}, + Operator: "=", + Value: "GET", + }, + }, + }, + AggregateOperator: v3.AggregateOperatorSumRate, + TimeAggregation: v3.TimeAggregationRate, + SpaceAggregation: v3.SpaceAggregationSum, + Expression: "A", + ReduceTo: v3.ReduceToOperatorLast, + }, + }, + }, + }, + { + Start: 1675115596722 + 60*60*1000, + End: 1675115596722 + 180*60*1000, + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeValue, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + StepInterval: 60, + DataSource: v3.DataSourceTraces, + AggregateAttribute: v3.AttributeKey{Key: "durationNano", Type: v3.AttributeKeyTypeUnspecified, DataType: "float64", IsColumn: true}, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{ + { + Key: v3.AttributeKey{Key: "method", IsColumn: false}, + Operator: "=", + Value: "GET", + }, + }, + }, + AggregateOperator: v3.AggregateOperatorP95, + Expression: "A", + ReduceTo: v3.ReduceToOperatorLast, + }, + }, + }, + }, + } + cache := inmemory.New(&inmemory.Options{TTL: 60 * time.Minute, CleanupInterval: 10 * time.Minute}) + opts := QuerierOptions{ + Cache: cache, + Reader: nil, + FluxInterval: 5 * time.Minute, + KeyGenerator: queryBuilder.NewKeyGenerator(), + + TestingMode: true, + ReturnedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "method": "GET", + "service_name": "test", + "__name__": "http_server_requests_seconds_count", + }, + Points: []v3.Point{ + {Timestamp: 1675115596722, Value: 1}, + {Timestamp: 1675115596722 + 60*60*1000, Value: 2}, + {Timestamp: 1675115596722 + 120*60*1000, Value: 3}, + }, + }, + }, + } + q := NewQuerier(opts) + // No caching + expectedTimeRangeInQueryString := []string{ + fmt.Sprintf("unix_milli >= %d AND unix_milli < %d", 1675115520000, 1675115580000+120*60*1000), + fmt.Sprintf("timestamp >= '%d' AND timestamp <= '%d'", (1675115580000+60*60*1000)*int64(1000000), (1675115580000+180*60*1000)*int64(1000000)), + } + + for i, param := range params { + _, errByName, err := q.QueryRange(context.Background(), param, nil) + if err != nil { + t.Errorf("expected no error, got %s", err) + } + if len(errByName) > 0 { + t.Errorf("expected no error, got %v", errByName) + } + + if !strings.Contains(q.QueriesExecuted()[i], expectedTimeRangeInQueryString[i]) { + t.Errorf("expected query to contain %s, got %s", expectedTimeRangeInQueryString[i], q.QueriesExecuted()[i]) + } + } +} + +// test timeshift +func TestV2QueryRangeTimeShift(t *testing.T) { + params := []*v3.QueryRangeParamsV3{ + { + Start: 1675115596722, //31, 3:23 + End: 1675115596722 + 120*60*1000, //31, 5:23 + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeGraph, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + StepInterval: 60, + DataSource: v3.DataSourceLogs, + AggregateAttribute: v3.AttributeKey{}, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{}, + }, + AggregateOperator: v3.AggregateOperatorCount, + Expression: "A", + ShiftBy: 86400, + }, + }, + }, + }, + } + opts := QuerierOptions{ + Reader: nil, + FluxInterval: 5 * time.Minute, + KeyGenerator: queryBuilder.NewKeyGenerator(), + TestingMode: true, + } + q := NewQuerier(opts) + // logs queries are generates in ns + expectedTimeRangeInQueryString := fmt.Sprintf("timestamp >= %d AND timestamp <= %d", (1675115596722-86400*1000)*1000000, ((1675115596722+120*60*1000)-86400*1000)*1000000) + + for i, param := range params { + _, errByName, err := q.QueryRange(context.Background(), param, nil) + if err != nil { + t.Errorf("expected no error, got %s", err) + } + if len(errByName) > 0 { + t.Errorf("expected no error, got %v", errByName) + } + if !strings.Contains(q.QueriesExecuted()[i], expectedTimeRangeInQueryString) { + t.Errorf("expected query to contain %s, got %s", expectedTimeRangeInQueryString, q.QueriesExecuted()[i]) + } + } +} + +// timeshift works with caching +func TestV2QueryRangeTimeShiftWithCache(t *testing.T) { + params := []*v3.QueryRangeParamsV3{ + { + Start: 1675115596722 + 60*60*1000 - 86400*1000, //30, 4:23 + End: 1675115596722 + 120*60*1000 - 86400*1000, //30, 5:23 + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeGraph, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + StepInterval: 60, + DataSource: v3.DataSourceLogs, + AggregateAttribute: v3.AttributeKey{}, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{}, + }, + AggregateOperator: v3.AggregateOperatorCount, + Expression: "A", + GroupBy: []v3.AttributeKey{ + {Key: "service_name", IsColumn: false}, + {Key: "method", IsColumn: false}, + }, + }, + }, + }, + }, + { + Start: 1675115596722, //31, 3:23 + End: 1675115596722 + 120*60*1000, //31, 5:23 + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeGraph, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + StepInterval: 60, + DataSource: v3.DataSourceLogs, + AggregateAttribute: v3.AttributeKey{}, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{}, + }, + AggregateOperator: v3.AggregateOperatorCount, + Expression: "A", + ShiftBy: 86400, + GroupBy: []v3.AttributeKey{ + {Key: "service_name", IsColumn: false}, + {Key: "method", IsColumn: false}, + }, + }, + }, + }, + }, + } + cache := inmemory.New(&inmemory.Options{TTL: 60 * time.Minute, CleanupInterval: 10 * time.Minute}) + opts := QuerierOptions{ + Cache: cache, + Reader: nil, + FluxInterval: 5 * time.Minute, + KeyGenerator: queryBuilder.NewKeyGenerator(), + TestingMode: true, + ReturnedSeries: []*v3.Series{ + { + Labels: map[string]string{}, + Points: []v3.Point{ + {Timestamp: 1675115596722 + 60*60*1000 - 86400*1000, Value: 1}, + {Timestamp: 1675115596722 + 120*60*1000 - 86400*1000 + 60*60*1000, Value: 2}, + }, + }, + }, + } + q := NewQuerier(opts) + + // logs queries are generates in ns + expectedTimeRangeInQueryString := []string{ + fmt.Sprintf("timestamp >= %d AND timestamp <= %d", (1675115596722+60*60*1000-86400*1000)*1000000, (1675115596722+120*60*1000-86400*1000)*1000000), + fmt.Sprintf("timestamp >= %d AND timestamp <= %d", (1675115596722-86400*1000)*1000000, ((1675115596722+60*60*1000)-86400*1000-1)*1000000), + } + + for i, param := range params { + _, errByName, err := q.QueryRange(context.Background(), param, nil) + if err != nil { + t.Errorf("expected no error, got %s", err) + } + if len(errByName) > 0 { + t.Errorf("expected no error, got %v", errByName) + } + if !strings.Contains(q.QueriesExecuted()[i], expectedTimeRangeInQueryString[i]) { + t.Errorf("expected query to contain %s, got %s", expectedTimeRangeInQueryString[i], q.QueriesExecuted()[i]) + } + } +} + +// timeshift with limit queries +func TestV2QueryRangeTimeShiftWithLimitAndCache(t *testing.T) { + params := []*v3.QueryRangeParamsV3{ + { + Start: 1675115596722 + 60*60*1000 - 86400*1000, //30, 4:23 + End: 1675115596722 + 120*60*1000 - 86400*1000, //30, 5:23 + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeGraph, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + StepInterval: 60, + DataSource: v3.DataSourceLogs, + AggregateAttribute: v3.AttributeKey{}, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{}, + }, + AggregateOperator: v3.AggregateOperatorCount, + Expression: "A", + GroupBy: []v3.AttributeKey{ + {Key: "service_name", IsColumn: false}, + {Key: "method", IsColumn: false}, + }, + Limit: 5, + }, + }, + }, + }, + { + Start: 1675115596722, //31, 3:23 + End: 1675115596722 + 120*60*1000, //31, 5:23 + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypeBuilder, + PanelType: v3.PanelTypeGraph, + BuilderQueries: map[string]*v3.BuilderQuery{ + "A": { + QueryName: "A", + StepInterval: 60, + DataSource: v3.DataSourceLogs, + AggregateAttribute: v3.AttributeKey{}, + Filters: &v3.FilterSet{ + Operator: "AND", + Items: []v3.FilterItem{}, + }, + AggregateOperator: v3.AggregateOperatorCount, + Expression: "A", + ShiftBy: 86400, + GroupBy: []v3.AttributeKey{ + {Key: "service_name", IsColumn: false}, + {Key: "method", IsColumn: false}, + }, + Limit: 5, + }, + }, + }, + }, + } + cache := inmemory.New(&inmemory.Options{TTL: 60 * time.Minute, CleanupInterval: 10 * time.Minute}) + opts := QuerierOptions{ + Cache: cache, + Reader: nil, + FluxInterval: 5 * time.Minute, + KeyGenerator: queryBuilder.NewKeyGenerator(), + TestingMode: true, + ReturnedSeries: []*v3.Series{ + { + Labels: map[string]string{}, + Points: []v3.Point{ + {Timestamp: 1675115596722 + 60*60*1000 - 86400*1000, Value: 1}, + {Timestamp: 1675115596722 + 120*60*1000 - 86400*1000 + 60*60*1000, Value: 2}, + }, + }, + }, + } + q := NewQuerier(opts) + + // logs queries are generates in ns + expectedTimeRangeInQueryString := []string{ + fmt.Sprintf("timestamp >= %d AND timestamp <= %d", (1675115596722+60*60*1000-86400*1000)*1000000, (1675115596722+120*60*1000-86400*1000)*1000000), + fmt.Sprintf("timestamp >= %d AND timestamp <= %d", (1675115596722-86400*1000)*1000000, ((1675115596722+60*60*1000)-86400*1000-1)*1000000), + } + + for i, param := range params { + _, errByName, err := q.QueryRange(context.Background(), param, nil) + if err != nil { + t.Errorf("expected no error, got %s", err) + } + if len(errByName) > 0 { + t.Errorf("expected no error, got %v", errByName) + } + if !strings.Contains(q.QueriesExecuted()[i], expectedTimeRangeInQueryString[i]) { + t.Errorf("expected query to contain %s, got %s", expectedTimeRangeInQueryString[i], q.QueriesExecuted()[i]) + } + } +} + +func TestV2QueryRangeValueTypePromQL(t *testing.T) { + // There shouldn't be any caching for value panel type + params := []*v3.QueryRangeParamsV3{ + { + Start: 1675115596722, + End: 1675115596722 + 120*60*1000, + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypePromQL, + PanelType: v3.PanelTypeValue, + PromQueries: map[string]*v3.PromQuery{ + "A": { + Query: "signoz_calls_total", + }, + }, + }, + }, + { + Start: 1675115596722 + 60*60*1000, + End: 1675115596722 + 180*60*1000, + Step: 5 * time.Minute.Milliseconds(), + CompositeQuery: &v3.CompositeQuery{ + QueryType: v3.QueryTypePromQL, + PanelType: v3.PanelTypeValue, + PromQueries: map[string]*v3.PromQuery{ + "A": { + Query: "signoz_latency_bucket", + }, + }, + }, + }, + } + cache := inmemory.New(&inmemory.Options{TTL: 60 * time.Minute, CleanupInterval: 10 * time.Minute}) + opts := QuerierOptions{ + Cache: cache, + Reader: nil, + FluxInterval: 5 * time.Minute, + KeyGenerator: queryBuilder.NewKeyGenerator(), + + TestingMode: true, + ReturnedSeries: []*v3.Series{ + { + Labels: map[string]string{ + "method": "GET", + "service_name": "test", + "__name__": "doesn't matter", + }, + Points: []v3.Point{ + {Timestamp: 1675115596722, Value: 1}, + {Timestamp: 1675115596722 + 60*60*1000, Value: 2}, + {Timestamp: 1675115596722 + 120*60*1000, Value: 3}, + }, + }, + }, + } + q := NewQuerier(opts) + + expectedQueryAndTimeRanges := []struct { + query string + ranges []missInterval + }{ + { + query: "signoz_calls_total", + ranges: []missInterval{ + {start: 1675115596722, end: 1675115596722 + 120*60*1000}, + }, + }, + { + query: "signoz_latency_bucket", + ranges: []missInterval{ + {start: 1675115596722 + 60*60*1000, end: 1675115596722 + 180*60*1000}, + }, + }, + } + + for i, param := range params { + _, errByName, err := q.QueryRange(context.Background(), param, nil) + if err != nil { + t.Errorf("expected no error, got %s", err) + } + if len(errByName) > 0 { + t.Errorf("expected no error, got %v", errByName) + } + + if !strings.Contains(q.QueriesExecuted()[i], expectedQueryAndTimeRanges[i].query) { + t.Errorf("expected query to contain %s, got %s", expectedQueryAndTimeRanges[i].query, q.QueriesExecuted()[i]) + } + if len(q.TimeRanges()[i]) != 2 { + t.Errorf("expected time ranges to be %v, got %v", expectedQueryAndTimeRanges[i].ranges, q.TimeRanges()[i]) + } + if q.TimeRanges()[i][0] != int(expectedQueryAndTimeRanges[i].ranges[0].start) { + t.Errorf("expected time ranges to be %v, got %v", expectedQueryAndTimeRanges[i].ranges, q.TimeRanges()[i]) + } + if q.TimeRanges()[i][1] != int(expectedQueryAndTimeRanges[i].ranges[0].end) { + t.Errorf("expected time ranges to be %v, got %v", expectedQueryAndTimeRanges[i].ranges, q.TimeRanges()[i]) + } + } +} diff --git a/pkg/query-service/interfaces/interface.go b/pkg/query-service/interfaces/interface.go index 0ab20fed0e..385d48173b 100644 --- a/pkg/query-service/interfaces/interface.go +++ b/pkg/query-service/interfaces/interface.go @@ -110,4 +110,5 @@ type Querier interface { // test helpers QueriesExecuted() []string + TimeRanges() [][]int } From c7e3e6dc4e295a7a5e365ce671c21b8db635dc9b Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Mon, 15 Jul 2024 21:04:49 +0530 Subject: [PATCH 20/30] fix: retain legends while changing panel types (#5447) --- frontend/src/container/NewWidget/utils.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/frontend/src/container/NewWidget/utils.ts b/frontend/src/container/NewWidget/utils.ts index 7e651ff2ea..562a989529 100644 --- a/frontend/src/container/NewWidget/utils.ts +++ b/frontend/src/container/NewWidget/utils.ts @@ -54,6 +54,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -72,6 +73,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -88,6 +90,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -107,6 +110,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -125,6 +129,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -141,6 +146,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -157,6 +163,8 @@ export const panelTypeDataSourceFormValuesMap: Record< 'having', 'orderBy', 'functions', + 'disabled', + 'legend', ], }, }, @@ -172,6 +180,8 @@ export const panelTypeDataSourceFormValuesMap: Record< 'orderBy', 'functions', 'spaceAggregation', + 'disabled', + 'legend', ], }, }, @@ -185,6 +195,8 @@ export const panelTypeDataSourceFormValuesMap: Record< 'limit', 'having', 'orderBy', + 'disabled', + 'legend', ], }, }, @@ -204,6 +216,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -222,6 +235,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -238,6 +252,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -257,6 +272,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -275,6 +291,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -291,6 +308,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -325,6 +343,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -341,6 +360,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, @@ -357,6 +377,7 @@ export const panelTypeDataSourceFormValuesMap: Record< 'queryName', 'expression', 'disabled', + 'legend', ], }, }, From a6e68c65193ba448c48a1a5bb81773dd1e57444b Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Mon, 15 Jul 2024 21:15:37 +0530 Subject: [PATCH 21/30] fix: issue with table sorting when column contains both string and numbers (#5458) --- .../__tests__/utils.test.tsx | 90 ++++++++++++++++++- .../src/container/GridTableComponent/utils.ts | 47 +++++++--- 2 files changed, 124 insertions(+), 13 deletions(-) diff --git a/frontend/src/container/GridTableComponent/__tests__/utils.test.tsx b/frontend/src/container/GridTableComponent/__tests__/utils.test.tsx index f0582a51fb..e8f7a631bd 100644 --- a/frontend/src/container/GridTableComponent/__tests__/utils.test.tsx +++ b/frontend/src/container/GridTableComponent/__tests__/utils.test.tsx @@ -1,6 +1,10 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData'; -import { createColumnsAndDataSource, getQueryLegend } from '../utils'; +import { + createColumnsAndDataSource, + getQueryLegend, + sortFunction, +} from '../utils'; import { expectedOutputWithLegends, tableDataMultipleQueriesSuccessResponse, @@ -39,4 +43,88 @@ describe('Table Panel utils', () => { // should return undefined when legend not present expect(getQueryLegend(query, 'B')).toBe(undefined); }); + + it('sorter function for table sorting', () => { + let rowA: { + A: string | number; + timestamp: number; + key: string; + } = { + A: 22.4, + timestamp: 111111, + key: '1111', + }; + let rowB: { + A: string | number; + timestamp: number; + key: string; + } = { + A: 'n/a', + timestamp: 111112, + key: '1112', + }; + const item = { + isValueColumn: true, + name: 'A', + queryName: 'A', + }; + // A has value and value is considered bigger than n/a hence 1 + expect(sortFunction(rowA, rowB, item)).toBe(1); + + rowA = { + A: 'n/a', + timestamp: 111111, + key: '1111', + }; + rowB = { + A: 22.4, + timestamp: 111112, + key: '1112', + }; + + // B has value and value is considered bigger than n/a hence -1 + expect(sortFunction(rowA, rowB, item)).toBe(-1); + + rowA = { + A: 11, + timestamp: 111111, + key: '1111', + }; + rowB = { + A: 22, + timestamp: 111112, + key: '1112', + }; + + // A and B has value , since B > A hence A-B + expect(sortFunction(rowA, rowB, item)).toBe(-11); + + rowA = { + A: 'read', + timestamp: 111111, + key: '1111', + }; + rowB = { + A: 'write', + timestamp: 111112, + key: '1112', + }; + + // A and B are strings so A is smaller than B because r comes before w hence -1 + expect(sortFunction(rowA, rowB, item)).toBe(-1); + + rowA = { + A: 'n/a', + timestamp: 111111, + key: '1111', + }; + rowB = { + A: 'n/a', + timestamp: 111112, + key: '1112', + }; + + // A and B are strings n/a , since both of them are same hence 0 + expect(sortFunction(rowA, rowB, item)).toBe(0); + }); }); diff --git a/frontend/src/container/GridTableComponent/utils.ts b/frontend/src/container/GridTableComponent/utils.ts index 089a35fe00..acd58af62d 100644 --- a/frontend/src/container/GridTableComponent/utils.ts +++ b/frontend/src/container/GridTableComponent/utils.ts @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/cognitive-complexity */ import { ColumnsType, ColumnType } from 'antd/es/table'; import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types'; import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config'; @@ -105,6 +106,39 @@ export function getQueryLegend( return legend; } +export function sortFunction( + a: RowData, + b: RowData, + item: { + name: string; + queryName: string; + isValueColumn: boolean; + }, +): number { + // assumption :- number values is bigger than 'n/a' + const valueA = Number(a[`${item.name}_without_unit`] ?? a[item.name]); + const valueB = Number(b[`${item.name}_without_unit`] ?? b[item.name]); + + // if both the values are numbers then return the difference here + if (!isNaN(valueA) && !isNaN(valueB)) { + return valueA - valueB; + } + + // if valueB is a number then make it bigger value + if (isNaN(valueA) && !isNaN(valueB)) { + return -1; + } + + // if valueA is number make it the bigger value + if (!isNaN(valueA) && isNaN(valueB)) { + return 1; + } + + // if both of them are strings do the localecompare + return ((a[item.name] as string) || '').localeCompare( + (b[item.name] as string) || '', + ); +} export function createColumnsAndDataSource( data: TableData, currentQuery: Query, @@ -123,18 +157,7 @@ export function createColumnsAndDataSource( title: !isEmpty(legend) ? legend : item.name, width: QUERY_TABLE_CONFIG.width, render: renderColumnCell && renderColumnCell[item.name], - sorter: (a: RowData, b: RowData): number => { - const valueA = Number(a[`${item.name}_without_unit`] ?? a[item.name]); - const valueB = Number(b[`${item.name}_without_unit`] ?? b[item.name]); - - if (!isNaN(valueA) && !isNaN(valueB)) { - return valueA - valueB; - } - - return ((a[item.name] as string) || '').localeCompare( - (b[item.name] as string) || '', - ); - }, + sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item), }; return [...acc, column]; From 42f7905b3bf02ccf77acea97c3a5c31c428614a5 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Tue, 16 Jul 2024 11:00:29 +0530 Subject: [PATCH 22/30] =?UTF-8?q?feat:=20show=20status=20message,=20status?= =?UTF-8?q?=20code=20string,=20span=20kind=20in=20trace=20det=E2=80=A6=20(?= =?UTF-8?q?#5428)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: show status message, status code string, span kind in trace details * chore: update tests * chore: update snapshots --- frontend/src/container/GantChart/utils.ts | 3 ++ .../TraceDetail/SelectedSpanDetails/index.tsx | 29 +++++++++++++++++-- .../src/container/TraceDetail/utils.test.ts | 3 ++ .../__tests__/TraceFlameGraph.test.tsx | 3 ++ .../src/lib/__fixtures__/getRandomColor.ts | 9 ++++++ frontend/src/types/api/trace/getTraceItem.ts | 6 ++++ .../__snapshots__/spanToTree.test.ts.snap | 18 ++++++++++++ frontend/src/utils/fixtures/TraceData.ts | 9 ++++++ frontend/src/utils/spanToTree.ts | 6 ++++ 9 files changed, 83 insertions(+), 3 deletions(-) diff --git a/frontend/src/container/GantChart/utils.ts b/frontend/src/container/GantChart/utils.ts index 0421fd40bd..7b3e1115e7 100644 --- a/frontend/src/container/GantChart/utils.ts +++ b/frontend/src/container/GantChart/utils.ts @@ -124,6 +124,9 @@ const getSpanWithoutChildren = ( value: span.value, event: span.event, hasError: span.hasError, + spanKind: span.spanKind, + statusCodeString: span.statusCodeString, + statusMessage: span.statusMessage, }); export const isSpanPresentInSearchString = ( diff --git a/frontend/src/container/TraceDetail/SelectedSpanDetails/index.tsx b/frontend/src/container/TraceDetail/SelectedSpanDetails/index.tsx index ce7f3372a3..39e180ee12 100644 --- a/frontend/src/container/TraceDetail/SelectedSpanDetails/index.tsx +++ b/frontend/src/container/TraceDetail/SelectedSpanDetails/index.tsx @@ -1,4 +1,4 @@ -import { Button, Modal, Tabs, Typography } from 'antd'; +import { Button, Modal, Tabs, Tooltip, Typography } from 'antd'; import Editor from 'components/Editor'; import { StyledSpace } from 'components/Styled'; import { QueryParams } from 'constants/query'; @@ -102,8 +102,7 @@ function SelectedSpanDetails(props: SelectedSpanDetailsProps): JSX.Element { marginTop: '16px', }} > - {' '} - Details for selected Span{' '} + Details for selected Span Service @@ -114,6 +113,30 @@ function SelectedSpanDetails(props: SelectedSpanDetailsProps): JSX.Element { {tree.name} + SpanKind + + {tree.spanKind} + + + StatusCodeString + + + + {tree.statusCodeString} + + + {tree.statusMessage && ( + <> + + StatusMessage + + + + {tree.statusMessage} + + + )} + diff --git a/frontend/src/container/TraceDetail/utils.test.ts b/frontend/src/container/TraceDetail/utils.test.ts index 4cb20055bf..b8359916f4 100644 --- a/frontend/src/container/TraceDetail/utils.test.ts +++ b/frontend/src/container/TraceDetail/utils.test.ts @@ -13,6 +13,9 @@ describe('traces/getTreeLevelsCount', () => { children, serviceName: '', serviceColour: '', + spanKind: '', + statusCodeString: '', + statusMessage: '', }); test('should return 0 for empty tree', () => { diff --git a/frontend/src/container/TraceFlameGraph/__tests__/TraceFlameGraph.test.tsx b/frontend/src/container/TraceFlameGraph/__tests__/TraceFlameGraph.test.tsx index adcaa3f003..c2eb3db911 100644 --- a/frontend/src/container/TraceFlameGraph/__tests__/TraceFlameGraph.test.tsx +++ b/frontend/src/container/TraceFlameGraph/__tests__/TraceFlameGraph.test.tsx @@ -37,6 +37,9 @@ test('loads and displays greeting', () => { event: [], hasError: false, parent: undefined, + spanKind: '', + statusCodeString: '', + statusMessage: '', }, }} /> diff --git a/frontend/src/lib/__fixtures__/getRandomColor.ts b/frontend/src/lib/__fixtures__/getRandomColor.ts index 2ebbecae15..0ffe3fe1fd 100644 --- a/frontend/src/lib/__fixtures__/getRandomColor.ts +++ b/frontend/src/lib/__fixtures__/getRandomColor.ts @@ -16,6 +16,9 @@ const spans: Span[] = [ [''], [''], false, + 'Server', + 'Unset', + 'Lorem Ipsum', ], [ 2, @@ -30,6 +33,9 @@ const spans: Span[] = [ [''], [''], false, + 'Server2', + 'Unset2', + 'Lorem Ipsum2', ], [ 3, @@ -44,6 +50,9 @@ const spans: Span[] = [ [''], [''], false, + 'Server3', + 'Unset3', + 'Lorem Ipsum3', ], ]; diff --git a/frontend/src/types/api/trace/getTraceItem.ts b/frontend/src/types/api/trace/getTraceItem.ts index 5f94e0f6d8..4d968b5000 100644 --- a/frontend/src/types/api/trace/getTraceItem.ts +++ b/frontend/src/types/api/trace/getTraceItem.ts @@ -33,6 +33,9 @@ export type Span = [ string[], string[], boolean, + string, + string, + string, ]; export interface ITraceTree { @@ -49,6 +52,9 @@ export interface ITraceTree { hasError?: boolean; event?: ITraceEvents[]; isMissing?: boolean; + spanKind: string; + statusCodeString: string; + statusMessage: string; childReferences?: Record[]; nonChildReferences?: Record[]; // For internal use diff --git a/frontend/src/utils/__tests__/__snapshots__/spanToTree.test.ts.snap b/frontend/src/utils/__tests__/__snapshots__/spanToTree.test.ts.snap index c3149cf21a..49004ffc56 100644 --- a/frontend/src/utils/__tests__/__snapshots__/spanToTree.test.ts.snap +++ b/frontend/src/utils/__tests__/__snapshots__/spanToTree.test.ts.snap @@ -49,7 +49,10 @@ Object { "nonChildReferences": Array [], "serviceColour": "", "serviceName": "frontend", + "spanKind": "Lorem Ipsum2", "startTime": 1657275433246, + "statusCodeString": "Unset2", + "statusMessage": "Server2", "tags": Array [ Object { "key": "host.name.span3", @@ -78,7 +81,10 @@ Object { "nonChildReferences": Array [], "serviceColour": "", "serviceName": "frontend", + "spanKind": "Lorem Ipsum1", "startTime": 1657275433246, + "statusCodeString": "Unset1", + "statusMessage": "Server1", "tags": Array [ Object { "key": "host.name.span2", @@ -106,7 +112,10 @@ Object { "nonChildReferences": Array [], "serviceColour": "", "serviceName": "frontend", + "spanKind": "Lorem Ipsum", "startTime": 1657275433246, + "statusCodeString": "Unset", + "statusMessage": "Server", "tags": Array [ Object { "key": "host.name.span1", @@ -152,7 +161,10 @@ Object { "nonChildReferences": Array [], "serviceColour": "", "serviceName": "frontend", + "spanKind": "Lorem Ipsum2", "startTime": 1657275433246, + "statusCodeString": "Unset2", + "statusMessage": "Server2", "tags": Array [ Object { "key": "host.name.span3", @@ -168,7 +180,10 @@ Object { "name": "Missing Span (span_2)", "serviceColour": "", "serviceName": "", + "spanKind": "", "startTime": null, + "statusCodeString": "", + "statusMessage": "", "tags": Array [], "time": null, "value": null, @@ -201,7 +216,10 @@ Object { "nonChildReferences": Array [], "serviceColour": "", "serviceName": "frontend", + "spanKind": "Lorem Ipsum", "startTime": 1657275433246, + "statusCodeString": "Unset", + "statusMessage": "Server", "tags": Array [ Object { "key": "host.name.span1", diff --git a/frontend/src/utils/fixtures/TraceData.ts b/frontend/src/utils/fixtures/TraceData.ts index 289e91e949..b24643dbba 100644 --- a/frontend/src/utils/fixtures/TraceData.ts +++ b/frontend/src/utils/fixtures/TraceData.ts @@ -16,6 +16,9 @@ export const TraceData: Span[] = [ '{"timeUnixNano":1657275433246142000,"attributeMap":{"event":"HTTP request received S1","level":"info","method":"GET","url":"/dispatch?customer=392\\u0026nonse=0.015296363321630757"}}', ], false, + 'Server', + 'Unset', + 'Lorem Ipsum', ], [ 1657275433246, @@ -32,6 +35,9 @@ export const TraceData: Span[] = [ '{"timeUnixNano":1657275433246142000,"attributeMap":{"event":"HTTP request received S2","level":"info","method":"GET","url":"/dispatch?customer=392\\u0026nonse=0.015296363321630757"}}', ], false, + 'Server1', + 'Unset1', + 'Lorem Ipsum1', ], [ 1657275433246, @@ -48,5 +54,8 @@ export const TraceData: Span[] = [ '{"timeUnixNano":1657275433246142000,"attributeMap":{"event":"HTTP request received S3","level":"info","method":"GET","url":"/dispatch?customer=392\\u0026nonse=0.015296363321630757"}}', ], false, + 'Server2', + 'Unset2', + 'Lorem Ipsum2', ], ]; diff --git a/frontend/src/utils/spanToTree.ts b/frontend/src/utils/spanToTree.ts index 35d17f0681..155f0e61c4 100644 --- a/frontend/src/utils/spanToTree.ts +++ b/frontend/src/utils/spanToTree.ts @@ -71,6 +71,9 @@ export const spanToTreeUtil = (inputSpanList: Span[]): ITraceForest => { time: null as never, value: null as never, isMissing: true, + statusMessage: '', + statusCodeString: '', + spanKind: '', }; } }); @@ -93,6 +96,9 @@ export const spanToTreeUtil = (inputSpanList: Span[]): ITraceForest => { event: span[10]?.map((e) => JSON.parse(e || '{}') || {}), childReferences, nonChildReferences, + statusMessage: span[12], + statusCodeString: span[13], + spanKind: span[14], }; spanMap[span[1]] = spanObject; }); From 46e6c34e5158c4afdc217282fb1c4de4a676adae Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 16 Jul 2024 13:13:25 +0430 Subject: [PATCH 23/30] fix: block alert creation if query_range API fails (#5441) --- .../container/FormAlertRules/ChartPreview/index.tsx | 7 +++++-- frontend/src/container/FormAlertRules/index.tsx | 12 ++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx index 331a5a0fc7..03db8a362e 100644 --- a/frontend/src/container/FormAlertRules/ChartPreview/index.tsx +++ b/frontend/src/container/FormAlertRules/ChartPreview/index.tsx @@ -48,6 +48,7 @@ export interface ChartPreviewProps { userQueryKey?: string; allowSelectedIntervalForStepGen?: boolean; yAxisUnit: string; + setQueryStatus?: (status: string) => void; } // eslint-disable-next-line sonarjs/cognitive-complexity @@ -62,6 +63,7 @@ function ChartPreview({ allowSelectedIntervalForStepGen = false, alertDef, yAxisUnit, + setQueryStatus, }: ChartPreviewProps): JSX.Element | null { const { t } = useTranslation('alerts'); const dispatch = useDispatch(); @@ -149,10 +151,10 @@ function ChartPreview({ useEffect((): void => { const { startTime, endTime } = getTimeRange(queryResponse); - + if (setQueryStatus) setQueryStatus(queryResponse.status); setMinTimeScale(startTime); setMaxTimeScale(endTime); - }, [maxTime, minTime, globalSelectedInterval, queryResponse]); + }, [maxTime, minTime, globalSelectedInterval, queryResponse, setQueryStatus]); if (queryResponse.data && graphType === PANEL_TYPES.BAR) { const sortedSeriesData = getSortedSeriesData( @@ -284,6 +286,7 @@ ChartPreview.defaultProps = { userQueryKey: '', allowSelectedIntervalForStepGen: false, alertDef: undefined, + setQueryStatus: (): void => {}, }; export default ChartPreview; diff --git a/frontend/src/container/FormAlertRules/index.tsx b/frontend/src/container/FormAlertRules/index.tsx index 40abbb3583..4b383eb272 100644 --- a/frontend/src/container/FormAlertRules/index.tsx +++ b/frontend/src/container/FormAlertRules/index.tsx @@ -101,6 +101,7 @@ function FormAlertRules({ const isNewRule = ruleId === 0; const [loading, setLoading] = useState(false); + const [queryStatus, setQueryStatus] = useState(''); // alertDef holds the form values to be posted const [alertDef, setAlertDef] = useState(initialValue); @@ -523,6 +524,7 @@ function FormAlertRules({ alertDef={alertDef} yAxisUnit={yAxisUnit || ''} graphType={panelType || PANEL_TYPES.TIME_SERIES} + setQueryStatus={setQueryStatus} /> ); @@ -540,6 +542,7 @@ function FormAlertRules({ selectedInterval={globalSelectedInterval} yAxisUnit={yAxisUnit || ''} graphType={panelType || PANEL_TYPES.TIME_SERIES} + setQueryStatus={setQueryStatus} /> ); @@ -665,7 +668,8 @@ function FormAlertRules({ disabled={ isAlertNameMissing || isAlertAvailableToSave || - !isChannelConfigurationValid + !isChannelConfigurationValid || + queryStatus === 'error' } > {isNewRule ? t('button_createrule') : t('button_savechanges')} @@ -674,7 +678,11 @@ function FormAlertRules({ From cd07c743b604209555118f004b77140041e76d20 Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 16 Jul 2024 13:16:13 +0430 Subject: [PATCH 24/30] Implement OverlayScrollbars throughout the app for MacOS-like scrolling experience (#5423) * feat: build overlay scrollbar component for Virtuoso elements * feat: apply overlay scroll to Virtuoso components * feat: build overlay scrollbar component for normal scrollable sections * feat: apply overlay scrollbar to normal scrollable sections * feat: add dark mode UI support to overlay scrollbars * chore: rename OverlayScrollbar to OverlayScrollbarForTypicalChildren * chore: move inline style to scss file * chore: rename VirtuosoOverlayScrollbar to OverlayScrollbarForVirtuosoChildren * chore: move OverlayScrollbarForTypicalChildren to components folder * chore: create a common component for handling Virtuoso and Typical scroll sections * chore: rename Virtuoso and Typical Overlay Scrollbar components * fix: fix the overlay scrollbar initialization flickering * fix: remove calculated height from typical overlay scrollbar + remove the explicit height: 100% --- frontend/package.json | 2 + .../OverlayScrollbar/OverlayScrollbar.tsx | 54 +++++++++ .../TypicalOverlayScrollbar.tsx | 31 +++++ .../typicalOverlayScrollbar.scss | 3 + .../VirtuosoOverlayScrollbar.tsx | 37 ++++++ .../virtuosoOverlayScrollbar.scss | 5 + .../container/AppLayout/AppLayout.styles.scss | 1 - frontend/src/container/AppLayout/index.tsx | 40 ++++--- frontend/src/container/AppLayout/styles.ts | 1 - .../GridCardLayout/GridCardLayout.styles.scss | 1 + .../GridCardLayout/GridCardLayout.tsx | 6 +- .../container/LiveLogs/LiveLogsList/index.tsx | 17 +-- .../ContextView/ContextLogRenderer.tsx | 17 +-- .../src/container/LogsContextList/index.tsx | 18 +-- .../src/container/LogsExplorerList/index.tsx | 21 ++-- .../LogsPanelTable/LogsPanelComponent.tsx | 25 ++-- frontend/src/container/LogsTable/index.tsx | 5 +- .../DashboardDescription/SettingsDrawer.tsx | 5 +- frontend/src/container/NewDashboard/index.tsx | 2 +- frontend/src/container/NewWidget/index.tsx | 107 +++++++++--------- .../TracesTableComponent.tsx | 25 ++-- .../useInitializeOverlayScrollbar.tsx | 43 +++++++ frontend/src/styles.scss | 1 + frontend/yarn.lock | 12 +- 24 files changed, 351 insertions(+), 128 deletions(-) create mode 100644 frontend/src/components/OverlayScrollbar/OverlayScrollbar.tsx create mode 100644 frontend/src/components/TypicalOverlayScrollbar/TypicalOverlayScrollbar.tsx create mode 100644 frontend/src/components/TypicalOverlayScrollbar/typicalOverlayScrollbar.scss create mode 100644 frontend/src/components/VirtuosoOverlayScrollbar/VirtuosoOverlayScrollbar.tsx create mode 100644 frontend/src/components/VirtuosoOverlayScrollbar/virtuosoOverlayScrollbar.scss create mode 100644 frontend/src/hooks/useInitializeOverlayScrollbar/useInitializeOverlayScrollbar.tsx diff --git a/frontend/package.json b/frontend/package.json index 2b2e803478..34e08ea263 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -110,6 +110,8 @@ "react-syntax-highlighter": "15.5.0", "react-use": "^17.3.2", "react-virtuoso": "4.0.3", + "overlayscrollbars-react": "^0.5.6", + "overlayscrollbars": "^2.8.1", "redux": "^4.0.5", "redux-thunk": "^2.3.0", "rehype-raw": "7.0.0", diff --git a/frontend/src/components/OverlayScrollbar/OverlayScrollbar.tsx b/frontend/src/components/OverlayScrollbar/OverlayScrollbar.tsx new file mode 100644 index 0000000000..73c95ce27d --- /dev/null +++ b/frontend/src/components/OverlayScrollbar/OverlayScrollbar.tsx @@ -0,0 +1,54 @@ +import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar'; +import VirtuosoOverlayScrollbar from 'components/VirtuosoOverlayScrollbar/VirtuosoOverlayScrollbar'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { PartialOptions } from 'overlayscrollbars'; +import { CSSProperties, ReactElement, useMemo } from 'react'; + +type Props = { + children: ReactElement; + isVirtuoso?: boolean; + style?: CSSProperties; + options?: PartialOptions; +}; + +function OverlayScrollbar({ + children, + isVirtuoso, + style, + options: customOptions, +}: Props): any { + const isDarkMode = useIsDarkMode(); + const options = useMemo( + () => + ({ + scrollbars: { + autoHide: 'scroll', + theme: isDarkMode ? 'os-theme-light' : 'os-theme-dark', + }, + ...(customOptions || {}), + } as PartialOptions), + [customOptions, isDarkMode], + ); + + if (isVirtuoso) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +OverlayScrollbar.defaultProps = { + isVirtuoso: false, + style: {}, + options: {}, +}; + +export default OverlayScrollbar; diff --git a/frontend/src/components/TypicalOverlayScrollbar/TypicalOverlayScrollbar.tsx b/frontend/src/components/TypicalOverlayScrollbar/TypicalOverlayScrollbar.tsx new file mode 100644 index 0000000000..1ed1717d6d --- /dev/null +++ b/frontend/src/components/TypicalOverlayScrollbar/TypicalOverlayScrollbar.tsx @@ -0,0 +1,31 @@ +import './typicalOverlayScrollbar.scss'; + +import { PartialOptions } from 'overlayscrollbars'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import { CSSProperties, ReactElement } from 'react'; + +interface Props { + children: ReactElement; + style?: CSSProperties; + options?: PartialOptions; +} + +export default function TypicalOverlayScrollbar({ + children, + style, + options, +}: Props): ReturnType { + return ( + + {children} + + ); +} + +TypicalOverlayScrollbar.defaultProps = { style: {}, options: {} }; diff --git a/frontend/src/components/TypicalOverlayScrollbar/typicalOverlayScrollbar.scss b/frontend/src/components/TypicalOverlayScrollbar/typicalOverlayScrollbar.scss new file mode 100644 index 0000000000..9dd4c3b381 --- /dev/null +++ b/frontend/src/components/TypicalOverlayScrollbar/typicalOverlayScrollbar.scss @@ -0,0 +1,3 @@ +.overlay-scrollbar { + height: 100%; +} diff --git a/frontend/src/components/VirtuosoOverlayScrollbar/VirtuosoOverlayScrollbar.tsx b/frontend/src/components/VirtuosoOverlayScrollbar/VirtuosoOverlayScrollbar.tsx new file mode 100644 index 0000000000..4f0e905fc2 --- /dev/null +++ b/frontend/src/components/VirtuosoOverlayScrollbar/VirtuosoOverlayScrollbar.tsx @@ -0,0 +1,37 @@ +import './virtuosoOverlayScrollbar.scss'; + +import useInitializeOverlayScrollbar from 'hooks/useInitializeOverlayScrollbar/useInitializeOverlayScrollbar'; +import { PartialOptions } from 'overlayscrollbars'; +import React, { CSSProperties, ReactElement } from 'react'; + +interface VirtuosoOverlayScrollbarProps { + children: ReactElement; + style?: CSSProperties; + options: PartialOptions; +} + +export default function VirtuosoOverlayScrollbar({ + children, + style, + options, +}: VirtuosoOverlayScrollbarProps): JSX.Element { + const { rootRef, setScroller } = useInitializeOverlayScrollbar(options); + + const enhancedChild = React.cloneElement(children, { + scrollerRef: setScroller, + 'data-overlayscrollbars-initialize': true, + }); + + return ( +
+ {enhancedChild} +
+ ); +} + +VirtuosoOverlayScrollbar.defaultProps = { style: {} }; diff --git a/frontend/src/components/VirtuosoOverlayScrollbar/virtuosoOverlayScrollbar.scss b/frontend/src/components/VirtuosoOverlayScrollbar/virtuosoOverlayScrollbar.scss new file mode 100644 index 0000000000..728bd1e8c3 --- /dev/null +++ b/frontend/src/components/VirtuosoOverlayScrollbar/virtuosoOverlayScrollbar.scss @@ -0,0 +1,5 @@ +.overlay-scroll-wrapper { + height: 100%; + width: 100%; + overflow: auto; +} diff --git a/frontend/src/container/AppLayout/AppLayout.styles.scss b/frontend/src/container/AppLayout/AppLayout.styles.scss index 6a0b223edf..c789ba5e0f 100644 --- a/frontend/src/container/AppLayout/AppLayout.styles.scss +++ b/frontend/src/container/AppLayout/AppLayout.styles.scss @@ -5,7 +5,6 @@ .app-content { width: calc(100% - 64px); - overflow: auto; z-index: 0; .content-container { diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 774e0e888d..ba64e9af45 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -9,6 +9,7 @@ import getLocalStorageKey from 'api/browser/localstorage/get'; import getUserLatestVersion from 'api/user/getLatestVersion'; import getUserVersion from 'api/user/getVersion'; import cx from 'classnames'; +import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import { IS_SIDEBAR_COLLAPSED } from 'constants/app'; import ROUTES from 'constants/routes'; import SideNav from 'container/SideNav'; @@ -303,24 +304,29 @@ function AppLayout(props: AppLayoutProps): JSX.Element { collapsed={collapsed} /> )} -
+
}> - - - {isToDisplayLayout && !renderFullScreen && } - {children} - + + + + {isToDisplayLayout && !renderFullScreen && } + {children} + +
diff --git a/frontend/src/container/AppLayout/styles.ts b/frontend/src/container/AppLayout/styles.ts index 5edddcac40..c66d2ee4d8 100644 --- a/frontend/src/container/AppLayout/styles.ts +++ b/frontend/src/container/AppLayout/styles.ts @@ -13,7 +13,6 @@ export const Layout = styled(LayoutComponent)` `; export const LayoutContent = styled(LayoutComponent.Content)` - overflow-y: auto; height: 100%; &::-webkit-scrollbar { width: 0.1rem; diff --git a/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss b/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss index 77e3a0822e..1b2bd2a7ba 100644 --- a/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss +++ b/frontend/src/container/GridCardLayout/GridCardLayout.styles.scss @@ -1,6 +1,7 @@ .fullscreen-grid-container { overflow: auto; margin: 8px -8px; + margin-right: 0; .react-grid-layout { border: none !important; diff --git a/frontend/src/container/GridCardLayout/GridCardLayout.tsx b/frontend/src/container/GridCardLayout/GridCardLayout.tsx index 11bdf492ee..f0d178866c 100644 --- a/frontend/src/container/GridCardLayout/GridCardLayout.tsx +++ b/frontend/src/container/GridCardLayout/GridCardLayout.tsx @@ -428,7 +428,11 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element { return isDashboardEmpty ? ( ) : ( - + ) : ( - + + + )} diff --git a/frontend/src/container/LogDetailedView/ContextView/ContextLogRenderer.tsx b/frontend/src/container/LogDetailedView/ContextView/ContextLogRenderer.tsx index f80e706d30..d5c9f68547 100644 --- a/frontend/src/container/LogDetailedView/ContextView/ContextLogRenderer.tsx +++ b/frontend/src/container/LogDetailedView/ContextView/ContextLogRenderer.tsx @@ -2,6 +2,7 @@ import './ContextLogRenderer.styles.scss'; import { Skeleton } from 'antd'; import RawLogView from 'components/Logs/RawLogView'; +import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import ShowButton from 'container/LogsContextList/ShowButton'; import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config'; import { useCallback, useEffect, useState } from 'react'; @@ -94,13 +95,15 @@ function ContextLogRenderer({ }} /> )} - + + + {isAfterLogsFetching && ( No Data )} {isFetching && } - - + + + {order === ORDERBY_FILTERS.DESC && ( diff --git a/frontend/src/container/LogsExplorerList/index.tsx b/frontend/src/container/LogsExplorerList/index.tsx index fc5a1f6800..0727d7100f 100644 --- a/frontend/src/container/LogsExplorerList/index.tsx +++ b/frontend/src/container/LogsExplorerList/index.tsx @@ -6,6 +6,7 @@ import { VIEW_TYPES } from 'components/LogDetail/constants'; // components import ListLogView from 'components/Logs/ListLogView'; import RawLogView from 'components/Logs/RawLogView'; +import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import Spinner from 'components/Spinner'; import { CARD_BODY_STYLE } from 'constants/card'; import { LOCALSTORAGE } from 'constants/localStorage'; @@ -133,15 +134,17 @@ function LogsExplorerList({ style={{ width: '100%', marginTop: '20px' }} bodyStyle={CARD_BODY_STYLE} > - + + + ); }, [ diff --git a/frontend/src/container/LogsPanelTable/LogsPanelComponent.tsx b/frontend/src/container/LogsPanelTable/LogsPanelComponent.tsx index f1d6e3642c..fef68ee60f 100644 --- a/frontend/src/container/LogsPanelTable/LogsPanelComponent.tsx +++ b/frontend/src/container/LogsPanelTable/LogsPanelComponent.tsx @@ -3,6 +3,7 @@ import './LogsPanelComponent.styles.scss'; import { Table } from 'antd'; import LogDetail from 'components/LogDetail'; import { VIEW_TYPES } from 'components/LogDetail/constants'; +import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import { PANEL_TYPES } from 'constants/queryBuilder'; import Controls from 'container/Controls'; @@ -207,17 +208,19 @@ function LogsPanelComponent({ <>
- + +
+ {!widget.query.builder.queryData[0].limit && (
diff --git a/frontend/src/container/LogsTable/index.tsx b/frontend/src/container/LogsTable/index.tsx index b10c3503dd..6b20986d1f 100644 --- a/frontend/src/container/LogsTable/index.tsx +++ b/frontend/src/container/LogsTable/index.tsx @@ -7,6 +7,7 @@ import { VIEW_TYPES } from 'components/LogDetail/constants'; import ListLogView from 'components/Logs/ListLogView'; import RawLogView from 'components/Logs/RawLogView'; import LogsTableView from 'components/Logs/TableView'; +import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import Spinner from 'components/Spinner'; import { CARD_BODY_STYLE } from 'constants/card'; import { useActiveLog } from 'hooks/logs/useActiveLog'; @@ -97,7 +98,9 @@ function LogsTable(props: LogsTableProps): JSX.Element { return ( - + + + ); }, [getItemContent, linesPerRow, logs, onSetActiveLog, selected, viewMode]); diff --git a/frontend/src/container/NewDashboard/DashboardDescription/SettingsDrawer.tsx b/frontend/src/container/NewDashboard/DashboardDescription/SettingsDrawer.tsx index bbf0b12b45..e06332e562 100644 --- a/frontend/src/container/NewDashboard/DashboardDescription/SettingsDrawer.tsx +++ b/frontend/src/container/NewDashboard/DashboardDescription/SettingsDrawer.tsx @@ -2,6 +2,7 @@ import './Description.styles.scss'; import { Button } from 'antd'; import ConfigureIcon from 'assets/Integrations/ConfigureIcon'; +import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import { useRef, useState } from 'react'; import DashboardSettingsContent from '../DashboardSettings'; @@ -41,7 +42,9 @@ function SettingsDrawer({ drawerTitle }: { drawerTitle: string }): JSX.Element { open={visible} rootClassName="settings-container-root" > - + + + ); diff --git a/frontend/src/container/NewDashboard/index.tsx b/frontend/src/container/NewDashboard/index.tsx index 2042d77e16..d5daf1c3cb 100644 --- a/frontend/src/container/NewDashboard/index.tsx +++ b/frontend/src/container/NewDashboard/index.tsx @@ -6,7 +6,7 @@ import GridGraphs from './GridGraphs'; function NewDashboard(): JSX.Element { const handle = useFullScreenHandle(); return ( -
+
diff --git a/frontend/src/container/NewWidget/index.tsx b/frontend/src/container/NewWidget/index.tsx index fc0c5d06cd..811855b26a 100644 --- a/frontend/src/container/NewWidget/index.tsx +++ b/frontend/src/container/NewWidget/index.tsx @@ -5,6 +5,7 @@ import { WarningOutlined } from '@ant-design/icons'; import { Button, Flex, Modal, Space, Tooltip, Typography } from 'antd'; import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn'; import { chartHelpMessage } from 'components/facingIssueBtn/util'; +import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import { FeatureKeys } from 'constants/features'; import { QueryParams } from 'constants/query'; import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder'; @@ -586,60 +587,64 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { - {selectedWidget && ( - - )} + + {selectedWidget && ( + + )} + - + + +
-
+ +
+
>; + rootRef: RefObject; +} => { + const rootRef = useRef(null); + const [scroller, setScroller] = useState(null); + const [initialize, osInstance] = useOverlayScrollbars({ + defer: true, + options, + }); + + useEffect(() => { + const { current: root } = rootRef; + + if (scroller && root) { + initialize({ + target: root, + elements: { + viewport: scroller, + }, + }); + } + + return (): void => osInstance()?.destroy(); + }, [scroller, initialize, osInstance]); + + return { setScroller, rootRef }; +}; + +export default useInitializeOverlayScrollbar; diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index b5aca15ddf..7e50e4e38c 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1,4 +1,5 @@ @import '@signozhq/design-tokens/dist/style.css'; +@import 'overlayscrollbars/overlayscrollbars.css'; @import './periscope.scss'; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 73bf7ae208..78c76e6be5 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -13028,6 +13028,16 @@ outvariant@^1.2.1, outvariant@^1.4.0: resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.4.0.tgz#e742e4bda77692da3eca698ef5bfac62d9fba06e" integrity sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw== +overlayscrollbars-react@^0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz#e9779f9fc2c1a3288570a45c83f8e42518bfb8c1" + integrity sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw== + +overlayscrollbars@^2.8.1: + version "2.9.2" + resolved "https://registry.yarnpkg.com/overlayscrollbars/-/overlayscrollbars-2.9.2.tgz#056020a3811742b58b754fab6f775d49bd109be9" + integrity sha512-iDT84r39i7oWP72diZN2mbJUsn/taCq568aQaIrc84S87PunBT7qtsVltAF2esk7ORTRjQDnfjVYoqqTzgs8QA== + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz" @@ -14678,7 +14688,7 @@ react-use@17.4.0, react-use@^17.3.2: react-virtuoso@4.0.3: version "4.0.3" - resolved "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.0.3.tgz" + resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.0.3.tgz#0dc8b10978095852d985b064157639b9fb9d9b1e" integrity sha512-tyqt8FBWxO+smve/kUgJbhCI2MEOvH2hHgFYPKWBMA2cJmV+cHIDDh1BL/6w4pg/dcCdlHCNVwi6aiztPxWttw== react@18.2.0: From df751c7f380ccbf7ee4f93e92bebbdf258eb4a49 Mon Sep 17 00:00:00 2001 From: Vishal Sharma Date: Tue, 16 Jul 2024 14:18:59 +0530 Subject: [PATCH 25/30] chore: add activation events (#5474) --- frontend/src/container/AllError/index.tsx | 24 ++++++- .../EmptyLogsSearch/EmptyLogsSearch.tsx | 28 +++++++- frontend/src/container/ErrorDetails/index.tsx | 24 ++++++- .../ExplorerOptions/ExplorerOptions.tsx | 67 ++++++++++++++++--- .../ExportPanel/ExportPanelContainer.tsx | 4 +- frontend/src/container/ExportPanel/index.tsx | 2 +- .../DashboardEmptyState.tsx | 7 ++ .../GridCardLayout/GridCardLayout.tsx | 17 ++++- .../GridCardLayout/WidgetHeader/index.tsx | 2 +- .../ListOfDashboard/DashboardsList.tsx | 32 ++++++++- .../ListOfDashboard/ImportJSON/index.tsx | 10 +++ .../src/container/LogsExplorerList/index.tsx | 4 +- .../src/container/LogsExplorerViews/index.tsx | 31 ++++++++- .../MetricsApplication/Tabs/DBCall.tsx | 21 +++++- .../MetricsApplication/Tabs/External.tsx | 20 +++++- .../MetricsApplication/Tabs/Overview.tsx | 20 +++++- .../NewDashboard/ComponentsSlider/index.tsx | 9 ++- .../DashboardDescription/index.tsx | 7 ++ .../LeftContainer/QuerySection/index.tsx | 15 ++++- .../NewWidget/RightContainer/index.tsx | 2 +- frontend/src/container/NewWidget/index.tsx | 30 ++++++++- frontend/src/container/NoLogs/NoLogs.tsx | 6 ++ .../PipelineListsView/PipelineListsView.tsx | 20 +++++- .../ServiceTraces/index.tsx | 24 ++++++- frontend/src/container/SideNav/SideNav.tsx | 26 +++++++ .../TimeSeriesView/TimeSeriesView.tsx | 4 +- .../TracesExplorer/ListView/index.tsx | 4 +- .../TracesExplorer/TracesView/index.tsx | 4 +- .../hooks/queryBuilder/useCreateAlerts.tsx | 23 ++++++- frontend/src/pages/SaveView/index.tsx | 19 +++++- .../pages/TracesExplorer/Filter/Filter.tsx | 6 ++ frontend/src/pages/TracesExplorer/index.tsx | 18 ++++- 32 files changed, 493 insertions(+), 37 deletions(-) diff --git a/frontend/src/container/AllError/index.tsx b/frontend/src/container/AllError/index.tsx index e8c13d88cd..d571c3dba7 100644 --- a/frontend/src/container/AllError/index.tsx +++ b/frontend/src/container/AllError/index.tsx @@ -12,6 +12,7 @@ import { ColumnType, TablePaginationConfig } from 'antd/es/table'; import { FilterValue, SorterResult } from 'antd/es/table/interface'; import { ColumnsType } from 'antd/lib/table'; import { FilterConfirmProps } from 'antd/lib/table/interface'; +import logEvent from 'api/common/logEvent'; import getAll from 'api/errors/getAll'; import getErrorCounts from 'api/errors/getErrorCounts'; import { ResizeTable } from 'components/ResizeTable'; @@ -23,7 +24,8 @@ import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute import useUrlQuery from 'hooks/useUrlQuery'; import createQueryParams from 'lib/createQueryParams'; import history from 'lib/history'; -import { useCallback, useEffect, useMemo } from 'react'; +import { isUndefined } from 'lodash-es'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueries } from 'react-query'; import { useSelector } from 'react-redux'; @@ -410,6 +412,26 @@ function AllErrors(): JSX.Element { [pathname], ); + const logEventCalledRef = useRef(false); + useEffect(() => { + if ( + !logEventCalledRef.current && + !isUndefined(errorCountResponse.data?.payload) + ) { + const selectedEnvironments = queries.find( + (val) => val.tagKey === 'resource_deployment_environment', + )?.tagValue; + + logEvent('Exception: List page visited', { + numberOfExceptions: errorCountResponse.data?.payload, + selectedEnvironments, + resourceAttributeUsed: !!queries.length, + }); + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [errorCountResponse.data?.payload]); + return ( { + if (!logEventCalledRef.current) { + if (dataSource === DataSource.TRACES) { + logEvent('Traces Explorer: No results', { + panelType, + }); + } else if (dataSource === DataSource.LOGS) { + logEvent('Logs Explorer: No results', { + panelType, + }); + } + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); -export default function EmptyLogsSearch(): JSX.Element { return (
diff --git a/frontend/src/container/ErrorDetails/index.tsx b/frontend/src/container/ErrorDetails/index.tsx index 33a86f9f50..2c87279e3d 100644 --- a/frontend/src/container/ErrorDetails/index.tsx +++ b/frontend/src/container/ErrorDetails/index.tsx @@ -1,6 +1,7 @@ import './styles.scss'; import { Button, Divider, Space, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import getNextPrevId from 'api/errors/getNextPrevId'; import Editor from 'components/Editor'; import { ResizeTable } from 'components/ResizeTable'; @@ -9,8 +10,9 @@ import dayjs from 'dayjs'; import { useNotifications } from 'hooks/useNotifications'; import createQueryParams from 'lib/createQueryParams'; import history from 'lib/history'; +import { isUndefined } from 'lodash-es'; import { urlKey } from 'pages/ErrorDetails/utils'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; import { useLocation } from 'react-router-dom'; @@ -111,9 +113,29 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element { })); const onClickTraceHandler = (): void => { + logEvent('Exception: Navigate to trace detail page', { + groupId: errorDetail.groupID, + spanId: errorDetail.spanID, + traceId: errorDetail.traceID, + exceptionId: errorDetail.errorId, + }); history.push(`/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`); }; + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current && !isUndefined(data)) { + logEvent('Exception: Detail page visited', { + groupId: errorDetail.groupID, + spanId: errorDetail.spanID, + traceId: errorDetail.traceID, + exceptionId: errorDetail.errorId, + }); + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + return ( <> {errorDetail.exceptionType} diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx index 1aaf22f796..e925a60a8a 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx @@ -14,6 +14,7 @@ import { Tooltip, Typography, } from 'antd'; +import logEvent from 'api/common/logEvent'; import axios from 'axios'; import cx from 'classnames'; import { getViewDetailsUsingViewKey } from 'components/ExplorerCard/utils'; @@ -93,7 +94,23 @@ function ExplorerOptions({ setIsExport(value); }, []); + const { + currentQuery, + panelType, + isStagedQueryUpdated, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + const handleSaveViewModalToggle = (): void => { + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Save view clicked', { + panelType, + }); + } else if (sourcepage === DataSource.LOGS) { + logEvent('Logs Explorer: Save view clicked', { + panelType, + }); + } setIsSaveModalOpen(!isSaveModalOpen); }; @@ -104,11 +121,21 @@ function ExplorerOptions({ const { role } = useSelector((state) => state.app); const onCreateAlertsHandler = useCallback(() => { + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Create alert', { + panelType, + }); + } else if (sourcepage === DataSource.LOGS) { + logEvent('Logs Explorer: Create alert', { + panelType, + }); + } history.push( `${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent( JSON.stringify(query), )}`, ); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [history, query]); const onCancel = (value: boolean) => (): void => { @@ -116,6 +143,15 @@ function ExplorerOptions({ }; const onAddToDashboard = (): void => { + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Add to dashboard clicked', { + panelType, + }); + } else if (sourcepage === DataSource.LOGS) { + logEvent('Logs Explorer: Add to dashboard clicked', { + panelType, + }); + } setIsExport(true); }; @@ -127,13 +163,6 @@ function ExplorerOptions({ refetch: refetchAllView, } = useGetAllViews(sourcepage); - const { - currentQuery, - panelType, - isStagedQueryUpdated, - redirectWithQueryBuilderData, - } = useQueryBuilder(); - const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType); const viewName = useGetSearchQueryParam(QueryParams.viewName) || ''; @@ -224,6 +253,17 @@ function ExplorerOptions({ onMenuItemSelectHandler({ key: option.key, }); + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Select view', { + panelType, + viewName: option.value, + }); + } else if (sourcepage === DataSource.LOGS) { + logEvent('Logs Explorer: Select view', { + panelType, + viewName: option.value, + }); + } if (ref.current) { ref.current.blur(); } @@ -259,6 +299,17 @@ function ExplorerOptions({ viewName: newViewName, setNewViewName, }); + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Explorer: Save view successful', { + panelType, + viewName: newViewName, + }); + } else if (sourcepage === DataSource.LOGS) { + logEvent('Logs Explorer: Save view successful', { + panelType, + viewName: newViewName, + }); + } }; // TODO: Remove this and move this to scss file @@ -499,7 +550,7 @@ function ExplorerOptions({ export interface ExplorerOptionsProps { isLoading?: boolean; - onExport: (dashboard: Dashboard | null) => void; + onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void; query: Query | null; disabled: boolean; sourcepage: DataSource; diff --git a/frontend/src/container/ExportPanel/ExportPanelContainer.tsx b/frontend/src/container/ExportPanel/ExportPanelContainer.tsx index 9e4a658273..a0927e3692 100644 --- a/frontend/src/container/ExportPanel/ExportPanelContainer.tsx +++ b/frontend/src/container/ExportPanel/ExportPanelContainer.tsx @@ -41,7 +41,7 @@ function ExportPanelContainer({ } = useMutation(createDashboard, { onSuccess: (data) => { if (data.payload) { - onExport(data?.payload); + onExport(data?.payload, true); } refetch(); }, @@ -55,7 +55,7 @@ function ExportPanelContainer({ ({ uuid }) => uuid === selectedDashboardId, ); - onExport(currentSelectedDashboard || null); + onExport(currentSelectedDashboard || null, false); }, [data, selectedDashboardId, onExport]); const handleSelect = useCallback( diff --git a/frontend/src/container/ExportPanel/index.tsx b/frontend/src/container/ExportPanel/index.tsx index f302d83212..86d5f44139 100644 --- a/frontend/src/container/ExportPanel/index.tsx +++ b/frontend/src/container/ExportPanel/index.tsx @@ -40,7 +40,7 @@ function ExportPanel({ export interface ExportPanelProps { isLoading?: boolean; - onExport: (dashboard: Dashboard | null) => void; + onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void; query: Query | null; } diff --git a/frontend/src/container/GridCardLayout/DashboardEmptyState/DashboardEmptyState.tsx b/frontend/src/container/GridCardLayout/DashboardEmptyState/DashboardEmptyState.tsx index 0bfbd5ac28..99e3194d5a 100644 --- a/frontend/src/container/GridCardLayout/DashboardEmptyState/DashboardEmptyState.tsx +++ b/frontend/src/container/GridCardLayout/DashboardEmptyState/DashboardEmptyState.tsx @@ -3,6 +3,7 @@ import './DashboardEmptyState.styles.scss'; import { PlusOutlined } from '@ant-design/icons'; import { Button, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import SettingsDrawer from 'container/NewDashboard/DashboardDescription/SettingsDrawer'; import useComponentPermission from 'hooks/useComponentPermission'; import { useDashboard } from 'providers/Dashboard/Dashboard'; @@ -36,6 +37,12 @@ export default function DashboardEmptyState(): JSX.Element { const onEmptyWidgetHandler = useCallback(() => { handleToggleDashboardSlider(true); + logEvent('Dashboard Detail: Add new panel clicked', { + dashboardId: selectedDashboard?.uuid, + dashboardName: selectedDashboard?.data.title, + numberOfPanels: selectedDashboard?.data.widgets?.length, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [handleToggleDashboardSlider]); return (
diff --git a/frontend/src/container/GridCardLayout/GridCardLayout.tsx b/frontend/src/container/GridCardLayout/GridCardLayout.tsx index f0d178866c..75fde3ce7a 100644 --- a/frontend/src/container/GridCardLayout/GridCardLayout.tsx +++ b/frontend/src/container/GridCardLayout/GridCardLayout.tsx @@ -3,6 +3,7 @@ import './GridCardLayout.styles.scss'; import { Color } from '@signozhq/design-tokens'; import { Button, Form, Input, Modal, Typography } from 'antd'; import { useForm } from 'antd/es/form/Form'; +import logEvent from 'api/common/logEvent'; import cx from 'classnames'; import { SOMETHING_WENT_WRONG } from 'constants/api'; import { QueryParams } from 'constants/query'; @@ -15,7 +16,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode'; import { useNotifications } from 'hooks/useNotifications'; import useUrlQuery from 'hooks/useUrlQuery'; import history from 'lib/history'; -import { defaultTo } from 'lodash-es'; +import { defaultTo, isUndefined } from 'lodash-es'; import isEqual from 'lodash-es/isEqual'; import { Check, @@ -27,7 +28,7 @@ import { } from 'lucide-react'; import { useDashboard } from 'providers/Dashboard/Dashboard'; import { sortLayout } from 'providers/Dashboard/util'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FullScreen, FullScreenHandle } from 'react-full-screen'; import { ItemCallback, Layout } from 'react-grid-layout'; import { useDispatch, useSelector } from 'react-redux'; @@ -126,6 +127,18 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element { setDashboardLayout(sortLayout(layouts)); }, [layouts]); + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current && !isUndefined(data)) { + logEvent('Dashboard Detail: Opened', { + dashboardId: data.uuid, + dashboardName: data.title, + numberOfPanels: data.widgets?.length, + numberOfVariables: Object.keys(data?.variables || {}).length || 0, + }); + logEventCalledRef.current = true; + } + }, [data]); const onSaveHandler = (): void => { if (!selectedDashboard) return; diff --git a/frontend/src/container/GridCardLayout/WidgetHeader/index.tsx b/frontend/src/container/GridCardLayout/WidgetHeader/index.tsx index 1401576ca9..1954b1c458 100644 --- a/frontend/src/container/GridCardLayout/WidgetHeader/index.tsx +++ b/frontend/src/container/GridCardLayout/WidgetHeader/index.tsx @@ -79,7 +79,7 @@ function WidgetHeader({ ); }, [widget.id, widget.panelTypes, widget.query]); - const onCreateAlertsHandler = useCreateAlerts(widget); + const onCreateAlertsHandler = useCreateAlerts(widget, 'dashboardView'); const onDownloadHandler = useCallback((): void => { const csv = unparse(tableProcessedDataRef.current); diff --git a/frontend/src/container/ListOfDashboard/DashboardsList.tsx b/frontend/src/container/ListOfDashboard/DashboardsList.tsx index 39326a558a..f3dc600f9b 100644 --- a/frontend/src/container/ListOfDashboard/DashboardsList.tsx +++ b/frontend/src/container/ListOfDashboard/DashboardsList.tsx @@ -21,6 +21,7 @@ import { Typography, } from 'antd'; import { TableProps } from 'antd/lib'; +import logEvent from 'api/common/logEvent'; import createDashboard from 'api/dashboard/create'; import { AxiosError } from 'axios'; import cx from 'classnames'; @@ -34,7 +35,7 @@ import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard'; import useComponentPermission from 'hooks/useComponentPermission'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; -import { get, isEmpty } from 'lodash-es'; +import { get, isEmpty, isUndefined } from 'lodash-es'; import { ArrowDownWideNarrow, ArrowUpRight, @@ -60,6 +61,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; @@ -269,6 +271,7 @@ function DashboardsList(): JSX.Element { const onNewDashboardHandler = useCallback(async () => { try { + logEvent('Dashboard List: Create dashboard clicked', {}); setNewDashboardState({ ...newDashboardState, loading: true, @@ -305,6 +308,8 @@ function DashboardsList(): JSX.Element { }, [newDashboardState, t]); const onModalHandler = (uploadedGrafana: boolean): void => { + logEvent('Dashboard List: Import JSON clicked', {}); + setIsImportJSONModalVisible((state) => !state); setUploadedGrafana(uploadedGrafana); }; @@ -441,6 +446,10 @@ function DashboardsList(): JSX.Element { } else { history.push(getLink()); } + logEvent('Dashboard List: Clicked on dashboard', { + dashboardId: dashboard.id, + dashboardName: dashboard.name, + }); }; return ( @@ -619,6 +628,21 @@ function DashboardsList(): JSX.Element { hideOnSinglePage: true, }; + const logEventCalledRef = useRef(false); + useEffect(() => { + if ( + !logEventCalledRef.current && + !isDashboardListLoading && + !isUndefined(dashboardListResponse) + ) { + logEvent('Dashboard List: Page visited', { + number: dashboardListResponse?.length, + }); + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDashboardListLoading]); + return (
@@ -705,6 +729,9 @@ function DashboardsList(): JSX.Element { type="text" className="new-dashboard" icon={} + onClick={(): void => { + logEvent('Dashboard List: New dashboard clicked', {}); + }} > New Dashboard @@ -745,6 +772,9 @@ function DashboardsList(): JSX.Element { type="primary" className="periscope-btn primary btn" icon={} + onClick={(): void => { + logEvent('Dashboard List: New dashboard clicked', {}); + }} > New dashboard diff --git a/frontend/src/container/ListOfDashboard/ImportJSON/index.tsx b/frontend/src/container/ListOfDashboard/ImportJSON/index.tsx index 5b95eb4a9a..9bf1db2051 100644 --- a/frontend/src/container/ListOfDashboard/ImportJSON/index.tsx +++ b/frontend/src/container/ListOfDashboard/ImportJSON/index.tsx @@ -5,6 +5,7 @@ import { ExclamationCircleTwoTone } from '@ant-design/icons'; import MEditor, { Monaco } from '@monaco-editor/react'; import { Color } from '@signozhq/design-tokens'; import { Button, Modal, Space, Typography, Upload, UploadProps } from 'antd'; +import logEvent from 'api/common/logEvent'; import createDashboard from 'api/dashboard/create'; import ROUTES from 'constants/routes'; import { useIsDarkMode } from 'hooks/useDarkMode'; @@ -67,6 +68,8 @@ function ImportJSON({ const onClickLoadJsonHandler = async (): Promise => { try { setDashboardCreating(true); + logEvent('Dashboard List: Import and next clicked', {}); + const dashboardData = JSON.parse(editorValue) as DashboardData; if (dashboardData?.layout) { @@ -86,6 +89,10 @@ function ImportJSON({ dashboardId: response.payload.uuid, }), ); + logEvent('Dashboard List: New dashboard imported successfully', { + dashboardId: response.payload?.uuid, + dashboardName: response.payload?.data?.title, + }); } else if (response.error === 'feature usage exceeded') { setIsFeatureAlert(true); notifications.error({ @@ -180,6 +187,9 @@ function ImportJSON({ type="default" className="periscope-btn" icon={} + onClick={(): void => { + logEvent('Dashboard List: Upload JSON file clicked', {}); + }} > {' '} {t('upload_json_file')} diff --git a/frontend/src/container/LogsExplorerList/index.tsx b/frontend/src/container/LogsExplorerList/index.tsx index 0727d7100f..760f3fea30 100644 --- a/frontend/src/container/LogsExplorerList/index.tsx +++ b/frontend/src/container/LogsExplorerList/index.tsx @@ -172,7 +172,9 @@ function LogsExplorerList({ !isFetching && logs.length === 0 && !isError && - isFilterApplied && } + isFilterApplied && ( + + )} {isError && !isLoading && !isFetching && } diff --git a/frontend/src/container/LogsExplorerViews/index.tsx b/frontend/src/container/LogsExplorerViews/index.tsx index b95fd6e6c4..6318e1da71 100644 --- a/frontend/src/container/LogsExplorerViews/index.tsx +++ b/frontend/src/container/LogsExplorerViews/index.tsx @@ -2,6 +2,7 @@ import './LogsExplorerViews.styles.scss'; import { Button } from 'antd'; +import logEvent from 'api/common/logEvent'; import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu'; import { DEFAULT_ENTITY_VERSION } from 'constants/app'; import { LOCALSTORAGE } from 'constants/localStorage'; @@ -37,7 +38,14 @@ import { useNotifications } from 'hooks/useNotifications'; import useUrlQueryData from 'hooks/useUrlQueryData'; import { FlatLogData } from 'lib/logs/flatLogData'; import { getPaginationQueryData } from 'lib/newQueryBuilder/getPaginationQueryData'; -import { cloneDeep, defaultTo, isEmpty, omit, set } from 'lodash-es'; +import { + cloneDeep, + defaultTo, + isEmpty, + isUndefined, + omit, + set, +} from 'lodash-es'; import { Sliders } from 'lucide-react'; import { SELECTED_VIEWS } from 'pages/LogsExplorer/utils'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -310,6 +318,19 @@ function LogsExplorerViews({ ], ); + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current && !isUndefined(data?.payload)) { + const currentData = data?.payload?.data?.newResult?.data?.result || []; + logEvent('Logs Explorer: Page visited', { + panelType, + isEmpty: !currentData?.[0]?.list, + }); + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data?.payload]); + const { mutate: updateDashboard, isLoading: isUpdateDashboardLoading, @@ -324,7 +345,7 @@ function LogsExplorerViews({ }, [currentQuery]); const handleExport = useCallback( - (dashboard: Dashboard | null): void => { + (dashboard: Dashboard | null, isNewDashboard?: boolean): void => { if (!dashboard || !panelType) return; const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType) @@ -346,6 +367,12 @@ function LogsExplorerViews({ options.selectColumns, ); + logEvent('Logs Explorer: Add to dashboard successful', { + panelType, + isNewDashboard, + dashboardName: dashboard?.data?.title, + }); + updateDashboard(updatedDashboard, { onSuccess: (data) => { if (data.error) { diff --git a/frontend/src/container/MetricsApplication/Tabs/DBCall.tsx b/frontend/src/container/MetricsApplication/Tabs/DBCall.tsx index d45476b6f4..a520d98936 100644 --- a/frontend/src/container/MetricsApplication/Tabs/DBCall.tsx +++ b/frontend/src/container/MetricsApplication/Tabs/DBCall.tsx @@ -1,4 +1,5 @@ import { Col } from 'antd'; +import logEvent from 'api/common/logEvent'; import { ENTITY_VERSION_V4 } from 'constants/app'; import { PANEL_TYPES } from 'constants/queryBuilder'; import Graph from 'container/GridCardLayout/GridCard'; @@ -11,7 +12,7 @@ import { convertRawQueriesToTraceSelectedTags, resourceAttributesToTagFilterItems, } from 'hooks/useResourceAttribute/utils'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; import { EQueryType } from 'types/common/dashboard'; @@ -97,6 +98,24 @@ function DBCall(): JSX.Element { [servicename, tagFilterItems], ); + const logEventCalledRef = useRef(false); + + useEffect(() => { + if (!logEventCalledRef.current) { + const selectedEnvironments = queries.find( + (val) => val.tagKey === 'resource_deployment_environment', + )?.tagValue; + + logEvent('APM: Service detail page visited', { + selectedEnvironments, + resourceAttributeUsed: !!queries.length, + section: 'dbMetrics', + }); + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const apmToTraceQuery = useGetAPMToTracesQueries({ servicename, isDBCall: true, diff --git a/frontend/src/container/MetricsApplication/Tabs/External.tsx b/frontend/src/container/MetricsApplication/Tabs/External.tsx index 564f0a657e..d224135175 100644 --- a/frontend/src/container/MetricsApplication/Tabs/External.tsx +++ b/frontend/src/container/MetricsApplication/Tabs/External.tsx @@ -1,4 +1,5 @@ import { Col } from 'antd'; +import logEvent from 'api/common/logEvent'; import { ENTITY_VERSION_V4 } from 'constants/app'; import { PANEL_TYPES } from 'constants/queryBuilder'; import Graph from 'container/GridCardLayout/GridCard'; @@ -13,7 +14,7 @@ import { convertRawQueriesToTraceSelectedTags, resourceAttributesToTagFilterItems, } from 'hooks/useResourceAttribute/utils'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { EQueryType } from 'types/common/dashboard'; @@ -114,6 +115,23 @@ function External(): JSX.Element { ], }); + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current) { + const selectedEnvironments = queries.find( + (val) => val.tagKey === 'resource_deployment_environment', + )?.tagValue; + + logEvent('APM: Service detail page visited', { + selectedEnvironments, + resourceAttributeUsed: !!queries.length, + section: 'externalMetrics', + }); + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const externalCallRPSWidget = useMemo( () => getWidgetQueryBuilder({ diff --git a/frontend/src/container/MetricsApplication/Tabs/Overview.tsx b/frontend/src/container/MetricsApplication/Tabs/Overview.tsx index 7410e4ccb3..96cc821fb8 100644 --- a/frontend/src/container/MetricsApplication/Tabs/Overview.tsx +++ b/frontend/src/container/MetricsApplication/Tabs/Overview.tsx @@ -1,3 +1,4 @@ +import logEvent from 'api/common/logEvent'; import getTopLevelOperations, { ServiceDataProps, } from 'api/metrics/getTopLevelOperations'; @@ -17,7 +18,7 @@ import useUrlQuery from 'hooks/useUrlQuery'; import history from 'lib/history'; import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin'; import { defaultTo } from 'lodash-es'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from 'react-query'; import { useDispatch } from 'react-redux'; import { useLocation, useParams } from 'react-router-dom'; @@ -81,6 +82,23 @@ function Application(): JSX.Element { [handleSetTimeStamp], ); + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current) { + const selectedEnvironments = queries.find( + (val) => val.tagKey === 'resource_deployment_environment', + )?.tagValue; + + logEvent('APM: Service detail page visited', { + selectedEnvironments, + resourceAttributeUsed: !!queries.length, + section: 'overview', + }); + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const { data: topLevelOperations, error: topLevelOperationsError, diff --git a/frontend/src/container/NewDashboard/ComponentsSlider/index.tsx b/frontend/src/container/NewDashboard/ComponentsSlider/index.tsx index 2859b87305..18cecc9184 100644 --- a/frontend/src/container/NewDashboard/ComponentsSlider/index.tsx +++ b/frontend/src/container/NewDashboard/ComponentsSlider/index.tsx @@ -1,6 +1,7 @@ import './ComponentSlider.styles.scss'; import { Card, Modal } from 'antd'; +import logEvent from 'api/common/logEvent'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; import createQueryParams from 'lib/createQueryParams'; @@ -20,6 +21,13 @@ function DashboardGraphSlider(): JSX.Element { const onClickHandler = (name: PANEL_TYPES) => (): void => { const id = uuid(); handleToggleDashboardSlider(false); + logEvent('Dashboard Detail: New panel type selected', { + // dashboardId: '', + // dashboardName: '', + // numberOfPanels: 0, // todo - at this point we don't know these attributes + panelType: name, + widgetId: id, + }); const queryParamsLog = { graphType: name, widgetId: id, @@ -47,7 +55,6 @@ function DashboardGraphSlider(): JSX.Element { PANEL_TYPES_INITIAL_QUERY[name], ), }; - if (name === PANEL_TYPES.LIST) { history.push( `${history.location.pathname}/new?${createQueryParams(queryParamsLog)}`, diff --git a/frontend/src/container/NewDashboard/DashboardDescription/index.tsx b/frontend/src/container/NewDashboard/DashboardDescription/index.tsx index f1288493bd..1c851176c1 100644 --- a/frontend/src/container/NewDashboard/DashboardDescription/index.tsx +++ b/frontend/src/container/NewDashboard/DashboardDescription/index.tsx @@ -2,6 +2,7 @@ import './Description.styles.scss'; import { PlusOutlined } from '@ant-design/icons'; import { Button, Card, Input, Modal, Popover, Tag, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn'; import { dashboardHelpMessage } from 'components/facingIssueBtn/util'; import { SOMETHING_WENT_WRONG } from 'constants/api'; @@ -126,6 +127,12 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element { const onEmptyWidgetHandler = useCallback(() => { handleToggleDashboardSlider(true); + logEvent('Dashboard Detail: Add new panel clicked', { + dashboardId: selectedDashboard?.uuid, + dashboardName: selectedDashboard?.data.title, + numberOfPanels: selectedDashboard?.data.widgets?.length, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [handleToggleDashboardSlider]); const handleLockDashboardToggle = (): void => { diff --git a/frontend/src/container/NewWidget/LeftContainer/QuerySection/index.tsx b/frontend/src/container/NewWidget/LeftContainer/QuerySection/index.tsx index 3f6deab6f8..2d682b1f06 100644 --- a/frontend/src/container/NewWidget/LeftContainer/QuerySection/index.tsx +++ b/frontend/src/container/NewWidget/LeftContainer/QuerySection/index.tsx @@ -2,6 +2,7 @@ import './QuerySection.styles.scss'; import { Color } from '@signozhq/design-tokens'; import { Button, Tabs, Tooltip, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import PromQLIcon from 'assets/Dashboard/PromQl'; import TextToolTip from 'components/TextToolTip'; import { PANEL_TYPES } from 'constants/queryBuilder'; @@ -14,7 +15,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; import { useIsDarkMode } from 'hooks/useDarkMode'; import useUrlQuery from 'hooks/useUrlQuery'; -import { defaultTo } from 'lodash-es'; +import { defaultTo, isUndefined } from 'lodash-es'; import { Atom, Play, Terminal } from 'lucide-react'; import { useDashboard } from 'providers/Dashboard/Dashboard'; import { @@ -122,6 +123,18 @@ function QuerySection({ }; const handleRunQuery = (): void => { + const widgetId = urlQuery.get('widgetId'); + const isNewPanel = isUndefined(widgets?.find((e) => e.id === widgetId)); + + logEvent('Panel Edit: Stage and run query', { + dataSource: currentQuery.builder?.queryData?.[0]?.dataSource, + panelType: selectedWidget.panelTypes, + queryType: currentQuery.queryType, + widgetId: selectedWidget.id, + dashboardId: selectedDashboard?.uuid, + dashboardName: selectedDashboard?.data.title, + isNewPanel, + }); handleStageQuery(currentQuery); }; diff --git a/frontend/src/container/NewWidget/RightContainer/index.tsx b/frontend/src/container/NewWidget/RightContainer/index.tsx index 08387cd069..3cc27a5f16 100644 --- a/frontend/src/container/NewWidget/RightContainer/index.tsx +++ b/frontend/src/container/NewWidget/RightContainer/index.tsx @@ -82,7 +82,7 @@ function RightContainer({ const selectedGraphType = GraphTypes.find((e) => e.name === selectedGraph)?.display || ''; - const onCreateAlertsHandler = useCreateAlerts(selectedWidget); + const onCreateAlertsHandler = useCreateAlerts(selectedWidget, 'panelView'); const allowThreshold = panelTypeVsThreshold[selectedGraph]; const allowSoftMinMax = panelTypeVsSoftMinMax[selectedGraph]; diff --git a/frontend/src/container/NewWidget/index.tsx b/frontend/src/container/NewWidget/index.tsx index 811855b26a..c042269280 100644 --- a/frontend/src/container/NewWidget/index.tsx +++ b/frontend/src/container/NewWidget/index.tsx @@ -3,6 +3,7 @@ import './NewWidget.styles.scss'; import { WarningOutlined } from '@ant-design/icons'; import { Button, Flex, Modal, Space, Tooltip, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import FacingIssueBtn from 'components/facingIssueBtn/FacingIssueBtn'; import { chartHelpMessage } from 'components/facingIssueBtn/util'; import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; @@ -31,7 +32,7 @@ import { getPreviousWidgets, getSelectedWidgetIndex, } from 'providers/Dashboard/util'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { generatePath, useParams } from 'react-router-dom'; @@ -101,6 +102,8 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { const [isNewDashboard, setIsNewDashboard] = useState(false); + const logEventCalledRef = useRef(false); + useEffect(() => { const widgetId = query.get('widgetId'); const selectedWidget = widgets?.find((e) => e.id === widgetId); @@ -108,6 +111,18 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { if (isWidgetNotPresent) { setIsNewDashboard(true); } + + if (!logEventCalledRef.current) { + logEvent('Panel Edit: Page visited', { + panelType: selectedWidget?.panelTypes, + dashboardId: selectedDashboard?.uuid, + widgetId: selectedWidget?.id, + dashboardName: selectedDashboard?.data.title, + isNewPanel: !!isWidgetNotPresent, + dataSource: currentQuery.builder.queryData?.[0]?.dataSource, + }); + logEventCalledRef.current = true; + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -482,7 +497,20 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element { }; const onSaveDashboard = useCallback((): void => { + const widgetId = query.get('widgetId'); + const selectWidget = widgets?.find((e) => e.id === widgetId); + + logEvent('Panel Edit: Save changes', { + panelType: selectedWidget.panelTypes, + dashboardId: selectedDashboard?.uuid, + widgetId: selectedWidget.id, + dashboardName: selectedDashboard?.data.title, + queryType: currentQuery.queryType, + isNewPanel: isUndefined(selectWidget), + dataSource: currentQuery.builder.queryData?.[0]?.dataSource, + }); setSaveModal(true); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const isQueryBuilderActive = useIsFeatureDisabled( diff --git a/frontend/src/container/NoLogs/NoLogs.tsx b/frontend/src/container/NoLogs/NoLogs.tsx index 71e0d213e8..c4832e8a0e 100644 --- a/frontend/src/container/NoLogs/NoLogs.tsx +++ b/frontend/src/container/NoLogs/NoLogs.tsx @@ -1,6 +1,7 @@ import './NoLogs.styles.scss'; import { Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import ROUTES from 'constants/routes'; import history from 'lib/history'; import { ArrowUpRight } from 'lucide-react'; @@ -21,6 +22,11 @@ export default function NoLogs({ e.stopPropagation(); if (cloudUser) { + if (dataSource === DataSource.TRACES) { + logEvent('Traces Explorer: Navigate to onboarding', {}); + } else if (dataSource === DataSource.LOGS) { + logEvent('Logs Explorer: Navigate to onboarding', {}); + } history.push( dataSource === 'traces' ? ROUTES.GET_STARTED_APPLICATION_MONITORING diff --git a/frontend/src/container/PipelinePage/PipelineListsView/PipelineListsView.tsx b/frontend/src/container/PipelinePage/PipelineListsView/PipelineListsView.tsx index 6f846e21cb..4e8def2e0c 100644 --- a/frontend/src/container/PipelinePage/PipelineListsView/PipelineListsView.tsx +++ b/frontend/src/container/PipelinePage/PipelineListsView/PipelineListsView.tsx @@ -3,11 +3,19 @@ import './styles.scss'; import { ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { Card, Modal, Table, Typography } from 'antd'; import { ExpandableConfig } from 'antd/es/table/interface'; +import logEvent from 'api/common/logEvent'; import savePipeline from 'api/pipeline/post'; import useAnalytics from 'hooks/analytics/useAnalytics'; import { useNotifications } from 'hooks/useNotifications'; +import { isUndefined } from 'lodash-es'; import cloneDeep from 'lodash-es/cloneDeep'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useTranslation } from 'react-i18next'; @@ -466,6 +474,16 @@ function PipelineListsView({ getExpandIcon(expanded, onExpand, record), }; + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current && !isUndefined(currPipelineData)) { + logEvent('Logs Pipelines: List page visited', { + number: currPipelineData?.length, + }); + logEventCalledRef.current = true; + } + }, [currPipelineData]); + return ( <> {contextHolder} diff --git a/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx b/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx index 370697af00..8d6238c68a 100644 --- a/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx +++ b/frontend/src/container/ServiceApplication/ServiceTraces/index.tsx @@ -1,11 +1,13 @@ import localStorageGet from 'api/browser/localstorage/get'; import localStorageSet from 'api/browser/localstorage/set'; +import logEvent from 'api/common/logEvent'; import { SKIP_ONBOARDING } from 'constants/onboarding'; import useErrorNotification from 'hooks/useErrorNotification'; import { useQueryService } from 'hooks/useQueryService'; import useResourceAttribute from 'hooks/useResourceAttribute'; import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils'; -import { useMemo, useState } from 'react'; +import { isUndefined } from 'lodash-es'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; import { GlobalReducer } from 'types/reducer/globalTime'; @@ -45,6 +47,26 @@ function ServiceTraces(): JSX.Element { setSkipOnboarding(true); }; + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current && !isUndefined(data)) { + const selectedEnvironments = queries.find( + (val) => val.tagKey === 'resource_deployment_environment', + )?.tagValue; + + const rps = data.reduce((total, service) => total + service.callRate, 0); + + logEvent('APM: List page visited', { + numberOfServices: data?.length, + selectedEnvironments, + resourceAttributeUsed: !!queries.length, + rps, + }); + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + if ( services.length === 0 && isLoading === false && diff --git a/frontend/src/container/SideNav/SideNav.tsx b/frontend/src/container/SideNav/SideNav.tsx index 82697d78b0..b5eb240af8 100644 --- a/frontend/src/container/SideNav/SideNav.tsx +++ b/frontend/src/container/SideNav/SideNav.tsx @@ -4,6 +4,7 @@ import './SideNav.styles.scss'; import { Color } from '@signozhq/design-tokens'; import { Button, Tooltip } from 'antd'; +import logEvent from 'api/common/logEvent'; import cx from 'classnames'; import { FeatureKeys } from 'constants/features'; import ROUTES from 'constants/routes'; @@ -179,6 +180,11 @@ function SideNav({ }; const onClickShortcuts = (e: MouseEvent): void => { + // eslint-disable-next-line sonarjs/no-duplicate-string + logEvent('Sidebar: Menu clicked', { + menuRoute: '/shortcuts', + menuLabel: 'Keyboard Shortcuts', + }); if (isCtrlMetaKey(e)) { openInNewTab('/shortcuts'); } else { @@ -187,6 +193,10 @@ function SideNav({ }; const onClickGetStarted = (event: MouseEvent): void => { + logEvent('Sidebar: Menu clicked', { + menuRoute: '/get-started', + menuLabel: 'Get Started', + }); if (isCtrlMetaKey(event)) { openInNewTab('/get-started'); } else { @@ -313,6 +323,10 @@ function SideNav({ } else if (item) { onClickHandler(item?.key as string, event); } + logEvent('Sidebar: Menu clicked', { + menuRoute: item.key, + menuLabel: item.label, + }); }; useEffect(() => { @@ -440,6 +454,10 @@ function SideNav({ isActive={activeMenuKey === item?.key} onClick={(event: MouseEvent): void => { handleUserManagentMenuItemClick(item?.key as string, event); + logEvent('Sidebar: Menu clicked', { + menuRoute: item.key, + menuLabel: item.label, + }); }} /> ), @@ -456,6 +474,10 @@ function SideNav({ } else { history.push(`${inviteMemberMenuItem.key}`); } + logEvent('Sidebar: Menu clicked', { + menuRoute: inviteMemberMenuItem.key, + menuLabel: inviteMemberMenuItem.label, + }); }} /> )} @@ -470,6 +492,10 @@ function SideNav({ userSettingsMenuItem?.key as string, event, ); + logEvent('Sidebar: Menu clicked', { + menuRoute: userSettingsMenuItem.key, + menuLabel: 'User', + }); }} /> )} diff --git a/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx b/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx index 4abd67de21..49059eddf5 100644 --- a/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx +++ b/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx @@ -155,7 +155,9 @@ function TimeSeriesView({ chartData[0]?.length === 0 && !isLoading && !isError && - isFilterApplied && } + isFilterApplied && ( + + )} {chartData && chartData[0] && diff --git a/frontend/src/container/TracesExplorer/ListView/index.tsx b/frontend/src/container/TracesExplorer/ListView/index.tsx index 2bd7ac7e72..810ffb8241 100644 --- a/frontend/src/container/TracesExplorer/ListView/index.tsx +++ b/frontend/src/container/TracesExplorer/ListView/index.tsx @@ -156,7 +156,9 @@ function ListView({ isFilterApplied }: ListViewProps): JSX.Element { )} - {isDataPresent && isFilterApplied && } + {isDataPresent && isFilterApplied && ( + + )} {!isError && transformedQueryTableData.length !== 0 && ( } + isFilterApplied && ( + + )} {(tableData || []).length !== 0 && ( { +const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => { const queryRangeMutation = useMutation(getQueryRangeFormat); const { selectedTime: globalSelectedInterval } = useSelector< @@ -32,6 +34,24 @@ const useCreateAlerts = (widget?: Widgets): VoidFunction => { return useCallback(() => { if (!widget) return; + if (caller === 'panelView') { + logEvent('Panel Edit: Create alert', { + panelType: widget.panelTypes, + dashboardName: selectedDashboard?.data?.title, + dashboardId: selectedDashboard?.uuid, + widgetId: widget.id, + queryType: widget.query.queryType, + }); + } else if (caller === 'dashboardView') { + logEvent('Dashboard Detail: Panel action', { + action: MenuItemKeys.CreateAlerts, + panelType: widget.panelTypes, + dashboardName: selectedDashboard?.data?.title, + dashboardId: selectedDashboard?.uuid, + widgetId: widget.id, + queryType: widget.query.queryType, + }); + } const { queryPayload } = prepareQueryRangePayload({ query: widget.query, globalSelectedInterval, @@ -57,6 +77,7 @@ const useCreateAlerts = (widget?: Widgets): VoidFunction => { }); }, }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ globalSelectedInterval, notifications, diff --git a/frontend/src/pages/SaveView/index.tsx b/frontend/src/pages/SaveView/index.tsx index 02a2578f0a..efcd3f2a4b 100644 --- a/frontend/src/pages/SaveView/index.tsx +++ b/frontend/src/pages/SaveView/index.tsx @@ -10,6 +10,7 @@ import { TableProps, Typography, } from 'antd'; +import logEvent from 'api/common/logEvent'; import { getViewDetailsUsingViewKey, showErrorNotification, @@ -30,7 +31,7 @@ import { Trash2, X, } from 'lucide-react'; -import { ChangeEvent, useEffect, useState } from 'react'; +import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; @@ -143,6 +144,22 @@ function SaveView(): JSX.Element { viewName: newViewName, }); + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current && !isLoading) { + if (sourcepage === DataSource.TRACES) { + logEvent('Traces Views: Views visited', { + number: viewsData?.data.data.length, + }); + } else if (sourcepage === DataSource.LOGS) { + logEvent('Logs Views: Views visited', { + number: viewsData?.data.data.length, + }); + } + logEventCalledRef.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewsData?.data.data, isLoading]); const onUpdateQueryHandler = (): void => { updateViewAsync( { diff --git a/frontend/src/pages/TracesExplorer/Filter/Filter.tsx b/frontend/src/pages/TracesExplorer/Filter/Filter.tsx index 1a3fbc785c..3d3895e047 100644 --- a/frontend/src/pages/TracesExplorer/Filter/Filter.tsx +++ b/frontend/src/pages/TracesExplorer/Filter/Filter.tsx @@ -7,6 +7,7 @@ import { VerticalAlignTopOutlined, } from '@ant-design/icons'; import { Button, Flex, Tooltip, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util'; import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; @@ -197,6 +198,11 @@ export function Filter(props: FilterProps): JSX.Element { })), }, }; + if (selectedFilters) { + logEvent('Traces Explorer: Sidebar filter used', { + selectedFilters, + }); + } redirectWithQueryBuilderData(preparedQuery); }, [currentQuery, redirectWithQueryBuilderData, selectedFilters], diff --git a/frontend/src/pages/TracesExplorer/index.tsx b/frontend/src/pages/TracesExplorer/index.tsx index ba267d383f..e598673c28 100644 --- a/frontend/src/pages/TracesExplorer/index.tsx +++ b/frontend/src/pages/TracesExplorer/index.tsx @@ -3,6 +3,7 @@ import './TracesExplorer.styles.scss'; import { FilterOutlined } from '@ant-design/icons'; import * as Sentry from '@sentry/react'; import { Button, Card, Tabs, Tooltip } from 'antd'; +import logEvent from 'api/common/logEvent'; import axios from 'axios'; import ExplorerCard from 'components/ExplorerCard/ExplorerCard'; import { LOCALSTORAGE } from 'constants/localStorage'; @@ -25,7 +26,7 @@ import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; import { cloneDeep, isEmpty, set } from 'lodash-es'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Dashboard } from 'types/api/dashboard/getAll'; import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; @@ -135,7 +136,7 @@ function TracesExplorer(): JSX.Element { }; const handleExport = useCallback( - (dashboard: Dashboard | null): void => { + (dashboard: Dashboard | null, isNewDashboard?: boolean): void => { if (!dashboard || !panelType) return; const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType) @@ -157,6 +158,12 @@ function TracesExplorer(): JSX.Element { options.selectColumns, ); + logEvent('Traces Explorer: Add to dashboard successful', { + panelType, + isNewDashboard, + dashboardName: dashboard?.data?.title, + }); + updateDashboard(updatedDashboard, { onSuccess: (data) => { if (data.error) { @@ -223,6 +230,13 @@ function TracesExplorer(): JSX.Element { currentPanelType, ]); const [isOpen, setOpen] = useState(true); + const logEventCalledRef = useRef(false); + useEffect(() => { + if (!logEventCalledRef.current) { + logEvent('Traces Explorer: Page visited', {}); + logEventCalledRef.current = true; + } + }, []); return ( }> From 840d8b2e49fad788807b9b151a02ae48eb44ed28 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Tue, 16 Jul 2024 14:30:03 +0530 Subject: [PATCH 26/30] fix: 404 not found when intercepting the ingestion key calls (#5490) --- frontend/src/api/apiV1.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/apiV1.ts b/frontend/src/api/apiV1.ts index 05b4e62e78..613ed27a17 100644 --- a/frontend/src/api/apiV1.ts +++ b/frontend/src/api/apiV1.ts @@ -3,7 +3,7 @@ const apiV1 = '/api/v1/'; export const apiV2 = '/api/v2/'; export const apiV3 = '/api/v3/'; export const apiV4 = '/api/v4/'; -export const gatewayApiV1 = '/api/gateway/v1'; -export const apiAlertManager = '/api/alertmanager'; +export const gatewayApiV1 = '/api/gateway/v1/'; +export const apiAlertManager = '/api/alertmanager/'; export default apiV1; From 43e73e06fe8a1eb3ba814c6428a57ac83e17f965 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Tue, 16 Jul 2024 18:35:59 +0530 Subject: [PATCH 27/30] chore: helpers required for dashboards e2e test cases (#5496) * chore: helpers required for dashboards e2e test cases * chore: helpers required for dashboards e2e test cases * chore: helpers required for dashboards e2e test cases --- .../src/container/GridCardLayout/WidgetHeader/index.tsx | 1 + .../NewDashboard/DashboardDescription/index.tsx | 9 +++++++-- .../src/container/NewWidget/RightContainer/index.tsx | 1 + frontend/src/container/NewWidget/index.tsx | 7 ++++++- frontend/src/container/NewWidget/utils.ts | 3 +++ .../QueryBuilder/filters/GroupByFilter/GroupByFilter.tsx | 1 + .../filters/ReduceToFilter/ReduceToFilter.tsx | 1 + 7 files changed, 20 insertions(+), 3 deletions(-) diff --git a/frontend/src/container/GridCardLayout/WidgetHeader/index.tsx b/frontend/src/container/GridCardLayout/WidgetHeader/index.tsx index 1954b1c458..7daa4e553d 100644 --- a/frontend/src/container/GridCardLayout/WidgetHeader/index.tsx +++ b/frontend/src/container/GridCardLayout/WidgetHeader/index.tsx @@ -234,6 +234,7 @@ function WidgetHeader({ )} - diff --git a/frontend/src/container/NewWidget/RightContainer/index.tsx b/frontend/src/container/NewWidget/RightContainer/index.tsx index 3cc27a5f16..84400737d4 100644 --- a/frontend/src/container/NewWidget/RightContainer/index.tsx +++ b/frontend/src/container/NewWidget/RightContainer/index.tsx @@ -163,6 +163,7 @@ function RightContainer({ value={selectedGraph} style={{ width: '100%' }} className="panel-type-select" + data-testid="panel-change-select" > {graphTypes.map((item) => (