From 5dc5b2e3668e4452b27dd30b1fe3ddbfea9ac3d8 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Fri, 30 Aug 2024 15:16:44 +0530 Subject: [PATCH 01/43] chore: added logeEvents in Kafka-ui (#5810) * chore: added logeEvents in Kafka-ui * chore: changed event logic for graph data fetch --- .../GridCardLayout/GridCard/index.tsx | 4 +++ .../GridCardLayout/GridCard/types.ts | 1 + .../MQDetailPage/MQDetailPage.tsx | 6 ++++ .../MQDetails/MQTables/MQTables.tsx | 30 ++++++++++++---- .../pages/MessagingQueues/MQGraph/MQGraph.tsx | 14 +++++++- .../pages/MessagingQueues/MessagingQueues.tsx | 35 ++++++++++++++++--- 6 files changed, 79 insertions(+), 11 deletions(-) diff --git a/frontend/src/container/GridCardLayout/GridCard/index.tsx b/frontend/src/container/GridCardLayout/GridCard/index.tsx index d7d2e729cb..444978f61d 100644 --- a/frontend/src/container/GridCardLayout/GridCard/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/index.tsx @@ -34,6 +34,7 @@ function GridCardGraph({ onClickHandler, onDragSelect, customTooltipElement, + dataAvailable, }: GridCardGraphProps): JSX.Element { const dispatch = useDispatch(); const [errorMessage, setErrorMessage] = useState(); @@ -180,6 +181,9 @@ function GridCardGraph({ onError: (error) => { setErrorMessage(error.message); }, + onSettled: (data) => { + dataAvailable?.(Boolean(data?.payload?.data?.result?.length)); + }, }, ); diff --git a/frontend/src/container/GridCardLayout/GridCard/types.ts b/frontend/src/container/GridCardLayout/GridCard/types.ts index d0edede5a1..05d3368096 100644 --- a/frontend/src/container/GridCardLayout/GridCard/types.ts +++ b/frontend/src/container/GridCardLayout/GridCard/types.ts @@ -44,6 +44,7 @@ export interface GridCardGraphProps { version?: string; onDragSelect: (start: number, end: number) => void; customTooltipElement?: HTMLDivElement; + dataAvailable?: (isDataAvailable: boolean) => void; } export interface GetGraphVisibilityStateOnLegendClickProps { diff --git a/frontend/src/pages/MessagingQueues/MQDetailPage/MQDetailPage.tsx b/frontend/src/pages/MessagingQueues/MQDetailPage/MQDetailPage.tsx index f82a2f605d..8fa697f6af 100644 --- a/frontend/src/pages/MessagingQueues/MQDetailPage/MQDetailPage.tsx +++ b/frontend/src/pages/MessagingQueues/MQDetailPage/MQDetailPage.tsx @@ -1,9 +1,11 @@ import '../MessagingQueues.styles.scss'; import { Select, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import ROUTES from 'constants/routes'; import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; import { ListMinus } from 'lucide-react'; +import { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { MessagingQueuesViewType } from '../MessagingQueuesUtils'; @@ -15,6 +17,10 @@ import MessagingQueuesGraph from '../MQGraph/MQGraph'; function MQDetailPage(): JSX.Element { const history = useHistory(); + useEffect(() => { + logEvent('Messaging Queues: Detail page visited', {}); + }, []); + return (
diff --git a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.tsx b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.tsx index 3de380cc2d..9555f7f228 100644 --- a/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.tsx +++ b/frontend/src/pages/MessagingQueues/MQDetails/MQTables/MQTables.tsx @@ -1,6 +1,7 @@ import './MQTables.styles.scss'; import { Skeleton, Table, Typography } from 'antd'; +import logEvent from 'api/common/logEvent'; import axios from 'axios'; import { isNumber } from 'chart.js/helpers'; import { ColumnTypeRender } from 'components/Logs/TableView/types'; @@ -17,7 +18,7 @@ import { RowData, SelectedTimelineQuery, } from 'pages/MessagingQueues/MessagingQueuesUtils'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation } from 'react-query'; import { useHistory } from 'react-router-dom'; @@ -169,11 +170,28 @@ function MessagingQueuesTable({ // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => getConsumerDetails(props), [currentTab, props]); - const isEmptyDetails = (timelineQueryData: SelectedTimelineQuery): boolean => - isEmpty(timelineQueryData) || - (!timelineQueryData?.group && - !timelineQueryData?.topic && - !timelineQueryData?.partition); + const isLogEventCalled = useRef(false); + + const isEmptyDetails = (timelineQueryData: SelectedTimelineQuery): boolean => { + const isEmptyDetail = + isEmpty(timelineQueryData) || + (!timelineQueryData?.group && + !timelineQueryData?.topic && + !timelineQueryData?.partition); + + if (!isEmptyDetail && !isLogEventCalled.current) { + logEvent('Messaging Queues: More details viewed', { + 'tab-option': ConsumerLagDetailTitle[currentTab], + variables: { + group: timelineQueryData?.group, + topic: timelineQueryData?.topic, + partition: timelineQueryData?.partition, + }, + }); + isLogEventCalled.current = true; + } + return isEmptyDetail; + }; return (
diff --git a/frontend/src/pages/MessagingQueues/MQGraph/MQGraph.tsx b/frontend/src/pages/MessagingQueues/MQGraph/MQGraph.tsx index cd7cdd74c4..7d00fbf69f 100644 --- a/frontend/src/pages/MessagingQueues/MQGraph/MQGraph.tsx +++ b/frontend/src/pages/MessagingQueues/MQGraph/MQGraph.tsx @@ -1,3 +1,4 @@ +import logEvent from 'api/common/logEvent'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; import { ViewMenuAction } from 'container/GridCardLayout/config'; @@ -6,7 +7,7 @@ import { Card } from 'container/GridCardLayout/styles'; import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory'; import { useIsDarkMode } from 'hooks/useDarkMode'; import useUrlQuery from 'hooks/useUrlQuery'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; import { UpdateTimeInterval } from 'store/actions'; @@ -34,8 +35,10 @@ function MessagingQueuesGraph(): JSX.Element { () => getWidgetQueryBuilder(getWidgetQuery({ filterItems })), [filterItems], ); + const history = useHistory(); const location = useLocation(); + const isLogEventCalled = useRef(false); const messagingQueueCustomTooltipText = (): HTMLDivElement => { const customText = document.createElement('div'); @@ -66,6 +69,14 @@ function MessagingQueuesGraph(): JSX.Element { [dispatch, history, pathname, urlQuery], ); + const checkIfDataExists = (isDataAvailable: boolean): void => { + if (!isLogEventCalled.current) { + isLogEventCalled.current = true; + logEvent('Messaging Queues: Graph data fetched', { + isDataAvailable, + }); + } + }; return ( ); diff --git a/frontend/src/pages/MessagingQueues/MessagingQueues.tsx b/frontend/src/pages/MessagingQueues/MessagingQueues.tsx index 727dc514b9..103fc15827 100644 --- a/frontend/src/pages/MessagingQueues/MessagingQueues.tsx +++ b/frontend/src/pages/MessagingQueues/MessagingQueues.tsx @@ -3,9 +3,11 @@ import './MessagingQueues.styles.scss'; import { ExclamationCircleFilled } from '@ant-design/icons'; import { Color } from '@signozhq/design-tokens'; import { Button, Modal } from 'antd'; +import logEvent from 'api/common/logEvent'; import ROUTES from 'constants/routes'; import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2'; import { Calendar, ListMinus } from 'lucide-react'; +import { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { isCloudUser } from 'utils/app'; @@ -21,12 +23,20 @@ function MessagingQueues(): JSX.Element { const { confirm } = Modal; const showConfirm = (): void => { + logEvent('Messaging Queues: View details clicked', { + page: 'Messaging Queues Overview', + source: 'Consumer Latency view', + }); + confirm({ icon: , content: 'Before navigating to the details page, please make sure you have configured all the required setup to ensure correct data monitoring.', className: 'overview-confirm-modal', onOk() { + logEvent('Messaging Queues: Proceed button clicked', { + page: 'Messaging Queues Overview', + }); history.push(ROUTES.MESSAGING_QUEUES_DETAIL); }, okText: 'Proceed', @@ -35,7 +45,11 @@ function MessagingQueues(): JSX.Element { const isCloudUserVal = isCloudUser(); - const getStartedRedirect = (link: string): void => { + const getStartedRedirect = (link: string, sourceCard: string): void => { + logEvent('Messaging Queues: Get started clicked', { + source: sourceCard, + link: isCloudUserVal ? link : KAFKA_SETUP_DOC_LINK, + }); if (isCloudUserVal) { history.push(link); } else { @@ -43,6 +57,10 @@ function MessagingQueues(): JSX.Element { } }; + useEffect(() => { + logEvent('Messaging Queues: Overview page visited', {}); + }, []); + return (
@@ -70,7 +88,10 @@ function MessagingQueues(): JSX.Element {
-

Configure Producer

-

- Connect your consumer and producer data sources to start monitoring. -

+

{t('configureProducer.title')}

+

{t('configureProducer.description')}

-

Monitor kafka

-

- Set up your Kafka monitoring to track consumer and producer activities. -

+

{t('monitorKafka.title')}

+

{t('monitorKafka.description')}

@@ -152,7 +145,7 @@ function MessagingQueues(): JSX.Element {
From 709c2860869a58be97ade3220b8b47952b359678 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Tue, 3 Sep 2024 00:24:06 +0530 Subject: [PATCH 06/43] fix: handle operator case change in api and ui (#5835) --- .../QueryBuilderSearchV2.tsx | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx index 4c800e7d6d..3ddeef85bc 100644 --- a/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx +++ b/frontend/src/container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2.tsx @@ -58,6 +58,8 @@ import { PLACEHOLDER } from '../QueryBuilderSearch/constant'; import { TypographyText } from '../QueryBuilderSearch/style'; import { checkCommaInValue, + getOperatorFromValue, + getOperatorValue, getTagToken, isInNInOperator, } from '../QueryBuilderSearch/utils'; @@ -103,8 +105,8 @@ function getInitTags(query: IBuilderQuery): ITag[] { return query.filters.items.map((item) => ({ id: item.id, key: item.key as BaseAutocompleteData, - op: item.op, - value: `${item.value}`, + op: getOperatorFromValue(item.op), + value: item.value, })); } @@ -332,7 +334,7 @@ function QueryBuilderSearchV2( { key: currentFilterItem?.key, op: currentFilterItem?.op, - value: tagValue.join(','), + value: tagValue, } as ITag, ]); return; @@ -700,17 +702,28 @@ function QueryBuilderSearchV2( items: [], }; tags.forEach((tag) => { + const computedTagValue = + tag.value && + Array.isArray(tag.value) && + tag.value[tag.value.length - 1] === '' + ? tag.value?.slice(0, -1) + : tag.value ?? ''; filterTags.items.push({ id: tag.id || uuid().slice(0, 8), key: tag.key, - op: tag.op, - value: tag.value, + op: getOperatorValue(tag.op), + value: computedTagValue, }); }); if (!isEqual(query.filters, filterTags)) { onChange(filterTags); - setTags(filterTags.items as ITag[]); + setTags( + filterTags.items.map((tag) => ({ + ...tag, + op: getOperatorFromValue(tag.op), + })) as ITag[], + ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tags]); From 1066b217cb0e9e9d5b521dd9a8656c75002b685c Mon Sep 17 00:00:00 2001 From: Nityananda Gohain Date: Wed, 4 Sep 2024 10:35:13 +0530 Subject: [PATCH 07/43] fix: fix logic for cache (#5811) * fix: fix logic for cache * fix: replace cache during error * fix: add todo comment for replaceCachedData --------- Co-authored-by: Srikanth Chekuri --- pkg/query-service/app/querier/helper.go | 15 +++++++++--- pkg/query-service/app/querier/querier.go | 19 +++++++++++---- pkg/query-service/app/querier/querier_test.go | 21 ++++++++++------- pkg/query-service/app/querier/v2/helper.go | 12 +++++++--- pkg/query-service/app/querier/v2/querier.go | 22 +++++++++++++----- .../app/querier/v2/querier_test.go | 23 +++++++++++-------- 6 files changed, 78 insertions(+), 34 deletions(-) diff --git a/pkg/query-service/app/querier/helper.go b/pkg/query-service/app/querier/helper.go index 1da4a5a46a..7c45cc8781 100644 --- a/pkg/query-service/app/querier/helper.go +++ b/pkg/query-service/app/querier/helper.go @@ -122,7 +122,7 @@ func (q *querier) runBuilderQuery( cachedData = data } } - misses := q.findMissingTimeRanges(start, end, builderQuery.StepInterval, cachedData) + misses, replaceCachedData := q.findMissingTimeRanges(start, end, builderQuery.StepInterval, cachedData) missedSeries := make([]*v3.Series, 0) cachedSeries := make([]*v3.Series, 0) for _, miss := range misses { @@ -147,6 +147,9 @@ func (q *querier) runBuilderQuery( zap.L().Error("error unmarshalling cached data", zap.Error(err)) } mergedSeries := mergeSerieses(cachedSeries, missedSeries) + if replaceCachedData { + mergedSeries = missedSeries + } var mergedSeriesData []byte var marshallingErr error @@ -257,7 +260,7 @@ func (q *querier) runBuilderQuery( cachedData = data } } - misses := q.findMissingTimeRanges(start, end, builderQuery.StepInterval, cachedData) + misses, replaceCachedData := q.findMissingTimeRanges(start, end, builderQuery.StepInterval, cachedData) missedSeries := make([]*v3.Series, 0) cachedSeries := make([]*v3.Series, 0) for _, miss := range misses { @@ -294,6 +297,9 @@ func (q *querier) runBuilderQuery( zap.L().Error("error unmarshalling cached data", zap.Error(err)) } mergedSeries := mergeSerieses(cachedSeries, missedSeries) + if replaceCachedData { + mergedSeries = missedSeries + } var mergedSeriesData []byte var marshallingErr error missedSeriesLen := len(missedSeries) @@ -360,7 +366,7 @@ func (q *querier) runBuilderExpression( } } step := postprocess.StepIntervalForFunction(params, queryName) - misses := q.findMissingTimeRanges(params.Start, params.End, step, cachedData) + misses, replaceCachedData := q.findMissingTimeRanges(params.Start, params.End, step, cachedData) missedSeries := make([]*v3.Series, 0) cachedSeries := make([]*v3.Series, 0) for _, miss := range misses { @@ -384,6 +390,9 @@ func (q *querier) runBuilderExpression( zap.L().Error("error unmarshalling cached data", zap.Error(err)) } mergedSeries := mergeSerieses(cachedSeries, missedSeries) + if replaceCachedData { + mergedSeries = missedSeries + } var mergedSeriesData []byte missedSeriesLen := len(missedSeries) diff --git a/pkg/query-service/app/querier/querier.go b/pkg/query-service/app/querier/querier.go index 64e4a33ed2..86a77da114 100644 --- a/pkg/query-service/app/querier/querier.go +++ b/pkg/query-service/app/querier/querier.go @@ -149,7 +149,12 @@ func (q *querier) execPromQuery(ctx context.Context, params *model.QueryRangePar // // The [End - fluxInterval, End] is always added to the list of misses, because // the data might still be in flux and not yet available in the database. -func findMissingTimeRanges(start, end, step int64, seriesList []*v3.Series, fluxInterval time.Duration) (misses []missInterval) { +// +// replaceCacheData is used to indicate if the cache data should be replaced instead of merging +// with the new data +// TODO: Remove replaceCacheData with a better logic +func findMissingTimeRanges(start, end, step int64, seriesList []*v3.Series, fluxInterval time.Duration) (misses []missInterval, replaceCacheData bool) { + replaceCacheData = false var cachedStart, cachedEnd int64 for idx := range seriesList { series := seriesList[idx] @@ -204,6 +209,7 @@ func findMissingTimeRanges(start, end, step int64, seriesList []*v3.Series, flux // Case 5: Cached time range is a disjoint of the requested time range // Add a miss for the entire requested time range misses = append(misses, missInterval{start: start, end: end}) + replaceCacheData = true } // remove the struts with start > end @@ -214,16 +220,16 @@ func findMissingTimeRanges(start, end, step int64, seriesList []*v3.Series, flux validMisses = append(validMisses, miss) } } - return validMisses + return validMisses, replaceCacheData } // findMissingTimeRanges finds the missing time ranges in the cached data // and returns them as a list of misses -func (q *querier) findMissingTimeRanges(start, end, step int64, cachedData []byte) (misses []missInterval) { +func (q *querier) findMissingTimeRanges(start, end, step int64, cachedData []byte) (misses []missInterval, replaceCachedData bool) { var cachedSeriesList []*v3.Series if err := json.Unmarshal(cachedData, &cachedSeriesList); err != nil { // In case of error, we return the entire range as a miss - return []missInterval{{start: start, end: end}} + return []missInterval{{start: start, end: end}}, true } return findMissingTimeRanges(start, end, step, cachedSeriesList, q.fluxInterval) } @@ -355,7 +361,7 @@ func (q *querier) runPromQueries(ctx context.Context, params *v3.QueryRangeParam cachedData = data } } - misses := q.findMissingTimeRanges(params.Start, params.End, params.Step, cachedData) + misses, replaceCachedData := q.findMissingTimeRanges(params.Start, params.End, params.Step, cachedData) missedSeries := make([]*v3.Series, 0) cachedSeries := make([]*v3.Series, 0) for _, miss := range misses { @@ -372,6 +378,9 @@ func (q *querier) runPromQueries(ctx context.Context, params *v3.QueryRangeParam zap.L().Error("error unmarshalling cached data", zap.Error(err)) } mergedSeries := mergeSerieses(cachedSeries, missedSeries) + if replaceCachedData { + mergedSeries = missedSeries + } channelResults <- channelResult{Err: nil, Name: queryName, Query: promQuery.Query, Series: mergedSeries} diff --git a/pkg/query-service/app/querier/querier_test.go b/pkg/query-service/app/querier/querier_test.go index 962ca3832a..aecb7b27ba 100644 --- a/pkg/query-service/app/querier/querier_test.go +++ b/pkg/query-service/app/querier/querier_test.go @@ -20,12 +20,13 @@ func TestFindMissingTimeRangesZeroFreshNess(t *testing.T) { // 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 string + requestedStart int64 // in milliseconds + requestedEnd int64 // in milliseconds + requestedStep int64 // in seconds + cachedSeries []*v3.Series + expectedMiss []missInterval + replaceCachedData bool }{ { name: "cached time range is a subset of the requested time range", @@ -190,15 +191,19 @@ func TestFindMissingTimeRangesZeroFreshNess(t *testing.T) { end: 1675115596722 + 180*60*1000, }, }, + replaceCachedData: true, }, } 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) + misses, replaceCachedData := 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)) } + if replaceCachedData != tc.replaceCachedData { + t.Errorf("expected replaceCachedData %t, got %t", tc.replaceCachedData, replaceCachedData) + } 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) @@ -395,7 +400,7 @@ func TestFindMissingTimeRangesWithFluxInterval(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - misses := findMissingTimeRanges(tc.requestedStart, tc.requestedEnd, tc.requestedStep, tc.cachedSeries, tc.fluxInterval) + 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)) } diff --git a/pkg/query-service/app/querier/v2/helper.go b/pkg/query-service/app/querier/v2/helper.go index 9ee90fb913..de9d591f7f 100644 --- a/pkg/query-service/app/querier/v2/helper.go +++ b/pkg/query-service/app/querier/v2/helper.go @@ -123,7 +123,7 @@ func (q *querier) runBuilderQuery( cachedData = data } } - misses := q.findMissingTimeRanges(start, end, builderQuery.StepInterval, cachedData) + misses, replaceCachedData := q.findMissingTimeRanges(start, end, builderQuery.StepInterval, cachedData) missedSeries := make([]*v3.Series, 0) cachedSeries := make([]*v3.Series, 0) for _, miss := range misses { @@ -148,7 +148,9 @@ func (q *querier) runBuilderQuery( zap.L().Error("error unmarshalling cached data", zap.Error(err)) } mergedSeries := mergeSerieses(cachedSeries, missedSeries) - + if replaceCachedData { + mergedSeries = missedSeries + } var mergedSeriesData []byte var marshallingErr error missedSeriesLen := len(missedSeries) @@ -257,7 +259,7 @@ func (q *querier) runBuilderQuery( cachedData = data } } - misses := q.findMissingTimeRanges(start, end, builderQuery.StepInterval, cachedData) + misses, replaceCachedData := q.findMissingTimeRanges(start, end, builderQuery.StepInterval, cachedData) missedSeries := make([]*v3.Series, 0) cachedSeries := make([]*v3.Series, 0) for _, miss := range misses { @@ -294,6 +296,10 @@ func (q *querier) runBuilderQuery( zap.L().Error("error unmarshalling cached data", zap.Error(err)) } mergedSeries := mergeSerieses(cachedSeries, missedSeries) + if replaceCachedData { + mergedSeries = missedSeries + } + var mergedSeriesData []byte var marshallingErr error missedSeriesLen := len(missedSeries) diff --git a/pkg/query-service/app/querier/v2/querier.go b/pkg/query-service/app/querier/v2/querier.go index 5e0c18afb5..d0c3a77d13 100644 --- a/pkg/query-service/app/querier/v2/querier.go +++ b/pkg/query-service/app/querier/v2/querier.go @@ -153,7 +153,12 @@ func (q *querier) execPromQuery(ctx context.Context, params *model.QueryRangePar // // The [End - fluxInterval, End] is always added to the list of misses, because // the data might still be in flux and not yet available in the database. -func findMissingTimeRanges(start, end, step int64, seriesList []*v3.Series, fluxInterval time.Duration) (misses []missInterval) { +// +// replaceCacheData is used to indicate if the cache data should be replaced instead of merging +// with the new data +// TODO: Remove replaceCacheData with a better logic +func findMissingTimeRanges(start, end, step int64, seriesList []*v3.Series, fluxInterval time.Duration) (misses []missInterval, replaceCacheData bool) { + replaceCacheData = false var cachedStart, cachedEnd int64 for idx := range seriesList { series := seriesList[idx] @@ -168,6 +173,8 @@ func findMissingTimeRanges(start, end, step int64, seriesList []*v3.Series, flux } } + // time.Now is used because here we are considering the case where data might not + // be fully ingested for last (fluxInterval) minutes endMillis := time.Now().UnixMilli() adjustStep := int64(math.Min(float64(step), 60)) roundedMillis := endMillis - (endMillis % (adjustStep * 1000)) @@ -206,6 +213,7 @@ func findMissingTimeRanges(start, end, step int64, seriesList []*v3.Series, flux // Case 5: Cached time range is a disjoint of the requested time range // Add a miss for the entire requested time range misses = append(misses, missInterval{start: start, end: end}) + replaceCacheData = true } // remove the struts with start > end @@ -216,16 +224,16 @@ func findMissingTimeRanges(start, end, step int64, seriesList []*v3.Series, flux validMisses = append(validMisses, miss) } } - return validMisses + return validMisses, replaceCacheData } // findMissingTimeRanges finds the missing time ranges in the cached data // and returns them as a list of misses -func (q *querier) findMissingTimeRanges(start, end, step int64, cachedData []byte) (misses []missInterval) { +func (q *querier) findMissingTimeRanges(start, end, step int64, cachedData []byte) (misses []missInterval, replaceCachedData bool) { var cachedSeriesList []*v3.Series if err := json.Unmarshal(cachedData, &cachedSeriesList); err != nil { // In case of error, we return the entire range as a miss - return []missInterval{{start: start, end: end}} + return []missInterval{{start: start, end: end}}, true } return findMissingTimeRanges(start, end, step, cachedSeriesList, q.fluxInterval) } @@ -363,7 +371,7 @@ func (q *querier) runPromQueries(ctx context.Context, params *v3.QueryRangeParam cachedData = data } } - misses := q.findMissingTimeRanges(params.Start, params.End, params.Step, cachedData) + misses, replaceCachedData := q.findMissingTimeRanges(params.Start, params.End, params.Step, cachedData) missedSeries := make([]*v3.Series, 0) cachedSeries := make([]*v3.Series, 0) for _, miss := range misses { @@ -380,7 +388,9 @@ func (q *querier) runPromQueries(ctx context.Context, params *v3.QueryRangeParam zap.L().Error("error unmarshalling cached data", zap.Error(err)) } mergedSeries := mergeSerieses(cachedSeries, missedSeries) - + if replaceCachedData { + mergedSeries = missedSeries + } channelResults <- channelResult{Err: nil, Name: queryName, Query: promQuery.Query, Series: mergedSeries} // Cache the seriesList for future queries diff --git a/pkg/query-service/app/querier/v2/querier_test.go b/pkg/query-service/app/querier/v2/querier_test.go index b8309c68ff..5707e9f70d 100644 --- a/pkg/query-service/app/querier/v2/querier_test.go +++ b/pkg/query-service/app/querier/v2/querier_test.go @@ -12,7 +12,7 @@ import ( v3 "go.signoz.io/signoz/pkg/query-service/model/v3" ) -func TestV2FindMissingTimeRangesZeroFreshNess(t *testing.T) { +func TestFindMissingTimeRangesZeroFreshNess(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 @@ -20,12 +20,13 @@ func TestV2FindMissingTimeRangesZeroFreshNess(t *testing.T) { // 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 string + requestedStart int64 // in milliseconds + requestedEnd int64 // in milliseconds + requestedStep int64 // in seconds + cachedSeries []*v3.Series + expectedMiss []missInterval + replaceCachedData bool }{ { name: "cached time range is a subset of the requested time range", @@ -190,15 +191,19 @@ func TestV2FindMissingTimeRangesZeroFreshNess(t *testing.T) { end: 1675115596722 + 180*60*1000, }, }, + replaceCachedData: true, }, } 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) + misses, replaceCachedData := 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)) } + if replaceCachedData != tc.replaceCachedData { + t.Errorf("expected replaceCachedData %t, got %t", tc.replaceCachedData, replaceCachedData) + } 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) @@ -395,7 +400,7 @@ func TestV2FindMissingTimeRangesWithFluxInterval(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - misses := findMissingTimeRanges(tc.requestedStart, tc.requestedEnd, tc.requestedStep, tc.cachedSeries, tc.fluxInterval) + 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)) } From be7a687088d0dd018c40f07321fe4bfa4a8d5ae3 Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Wed, 4 Sep 2024 18:09:40 +0530 Subject: [PATCH 08/43] chore: make prepare task configurable (#5806) --- pkg/query-service/rules/manager.go | 203 ++++++++++++---------- pkg/query-service/rules/prom_rule.go | 8 +- pkg/query-service/rules/prom_rule_task.go | 5 +- pkg/query-service/rules/promrule_test.go | 12 +- 4 files changed, 123 insertions(+), 105 deletions(-) diff --git a/pkg/query-service/rules/manager.go b/pkg/query-service/rules/manager.go index c21873f230..768c753cb8 100644 --- a/pkg/query-service/rules/manager.go +++ b/pkg/query-service/rules/manager.go @@ -12,8 +12,6 @@ import ( "github.com/google/uuid" - "github.com/go-kit/log" - "go.uber.org/zap" "errors" @@ -27,6 +25,17 @@ import ( "go.signoz.io/signoz/pkg/query-service/utils/labels" ) +type PrepareTaskOptions struct { + Rule *PostableRule + TaskName string + RuleDB RuleDB + Logger *zap.Logger + Reader interfaces.Reader + FF interfaces.FeatureLookup + ManagerOpts *ManagerOptions + NotifyFunc NotifyFunc +} + const taskNamesuffix = "webAppEditor" func ruleIdFromTaskName(n string) string { @@ -56,13 +65,15 @@ type ManagerOptions struct { DBConn *sqlx.DB Context context.Context - Logger log.Logger + Logger *zap.Logger ResendDelay time.Duration DisableRules bool FeatureFlags interfaces.FeatureLookup Reader interfaces.Reader EvalDelay time.Duration + + PrepareTaskFunc func(opts PrepareTaskOptions) (Task, error) } // The Manager manages recording and alerting rules. @@ -78,10 +89,12 @@ type Manager struct { // datastore to store alert definitions ruleDB RuleDB - logger log.Logger + logger *zap.Logger featureFlags interfaces.FeatureLookup reader interfaces.Reader + + prepareTaskFunc func(opts PrepareTaskOptions) (Task, error) } func defaultOptions(o *ManagerOptions) *ManagerOptions { @@ -94,9 +107,69 @@ func defaultOptions(o *ManagerOptions) *ManagerOptions { if o.ResendDelay == time.Duration(0) { o.ResendDelay = 1 * time.Minute } + if o.Logger == nil { + o.Logger = zap.L() + } + if o.PrepareTaskFunc == nil { + o.PrepareTaskFunc = defaultPrepareTaskFunc + } return o } +func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) { + + rules := make([]Rule, 0) + var task Task + + ruleId := ruleIdFromTaskName(opts.TaskName) + if opts.Rule.RuleType == RuleTypeThreshold { + // create a threshold rule + tr, err := NewThresholdRule( + ruleId, + opts.Rule, + ThresholdRuleOpts{ + EvalDelay: opts.ManagerOpts.EvalDelay, + }, + opts.FF, + opts.Reader, + ) + + if err != nil { + return task, err + } + + rules = append(rules, tr) + + // create ch rule task for evalution + task = newTask(TaskTypeCh, opts.TaskName, taskNamesuffix, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB) + + } else if opts.Rule.RuleType == RuleTypeProm { + + // create promql rule + pr, err := NewPromRule( + ruleId, + opts.Rule, + opts.Logger, + PromRuleOpts{}, + opts.Reader, + ) + + if err != nil { + return task, err + } + + rules = append(rules, pr) + + // create promql rule task for evalution + task = newTask(TaskTypeProm, opts.TaskName, taskNamesuffix, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB) + + } else { + return nil, fmt.Errorf("unsupported rule type. Supported types: %s, %s", RuleTypeProm, RuleTypeThreshold) + } + + return task, nil +} + // NewManager returns an implementation of Manager, ready to be started // by calling the Run method. func NewManager(o *ManagerOptions) (*Manager, error) { @@ -116,15 +189,16 @@ func NewManager(o *ManagerOptions) (*Manager, error) { telemetry.GetInstance().SetAlertsInfoCallback(db.GetAlertsInfo) m := &Manager{ - tasks: map[string]Task{}, - rules: map[string]Rule{}, - notifier: notifier, - ruleDB: db, - opts: o, - block: make(chan struct{}), - logger: o.Logger, - featureFlags: o.FeatureFlags, - reader: o.Reader, + tasks: map[string]Task{}, + rules: map[string]Rule{}, + notifier: notifier, + ruleDB: db, + opts: o, + block: make(chan struct{}), + logger: o.Logger, + featureFlags: o.FeatureFlags, + reader: o.Reader, + prepareTaskFunc: o.PrepareTaskFunc, } return m, nil } @@ -251,13 +325,26 @@ func (m *Manager) editTask(rule *PostableRule, taskName string) error { zap.L().Debug("editing a rule task", zap.String("name", taskName)) - newTask, err := m.prepareTask(false, rule, taskName) + newTask, err := m.prepareTaskFunc(PrepareTaskOptions{ + Rule: rule, + TaskName: taskName, + RuleDB: m.ruleDB, + Logger: m.logger, + Reader: m.reader, + FF: m.featureFlags, + ManagerOpts: m.opts, + NotifyFunc: m.prepareNotifyFunc(), + }) if err != nil { zap.L().Error("loading tasks failed", zap.Error(err)) return errors.New("error preparing rule with given parameters, previous rule set restored") } + for _, r := range newTask.Rules() { + m.rules[r.ID()] = r + } + // If there is an old task with the same identifier, stop it and wait for // it to finish the current iteration. Then copy it into the new group. oldTask, ok := m.tasks[taskName] @@ -357,7 +444,20 @@ func (m *Manager) addTask(rule *PostableRule, taskName string) error { defer m.mtx.Unlock() zap.L().Debug("adding a new rule task", zap.String("name", taskName)) - newTask, err := m.prepareTask(false, rule, taskName) + newTask, err := m.prepareTaskFunc(PrepareTaskOptions{ + Rule: rule, + TaskName: taskName, + RuleDB: m.ruleDB, + Logger: m.logger, + Reader: m.reader, + FF: m.featureFlags, + ManagerOpts: m.opts, + NotifyFunc: m.prepareNotifyFunc(), + }) + + for _, r := range newTask.Rules() { + m.rules[r.ID()] = r + } if err != nil { zap.L().Error("creating rule task failed", zap.String("name", taskName), zap.Error(err)) @@ -382,77 +482,6 @@ func (m *Manager) addTask(rule *PostableRule, taskName string) error { return nil } -// prepareTask prepares a rule task from postable rule -func (m *Manager) prepareTask(acquireLock bool, r *PostableRule, taskName string) (Task, error) { - - if acquireLock { - m.mtx.Lock() - defer m.mtx.Unlock() - } - - rules := make([]Rule, 0) - var task Task - - if r.AlertName == "" { - zap.L().Error("task load failed, at least one rule must be set", zap.String("name", taskName)) - return task, fmt.Errorf("task load failed, at least one rule must be set") - } - - ruleId := ruleIdFromTaskName(taskName) - if r.RuleType == RuleTypeThreshold { - // create a threshold rule - tr, err := NewThresholdRule( - ruleId, - r, - ThresholdRuleOpts{ - EvalDelay: m.opts.EvalDelay, - }, - m.featureFlags, - m.reader, - ) - - if err != nil { - return task, err - } - - rules = append(rules, tr) - - // create ch rule task for evalution - task = newTask(TaskTypeCh, taskName, taskNamesuffix, time.Duration(r.Frequency), rules, m.opts, m.prepareNotifyFunc(), m.ruleDB) - - // add rule to memory - m.rules[ruleId] = tr - - } else if r.RuleType == RuleTypeProm { - - // create promql rule - pr, err := NewPromRule( - ruleId, - r, - log.With(m.logger, "alert", r.AlertName), - PromRuleOpts{}, - m.reader, - ) - - if err != nil { - return task, err - } - - rules = append(rules, pr) - - // create promql rule task for evalution - task = newTask(TaskTypeProm, taskName, taskNamesuffix, time.Duration(r.Frequency), rules, m.opts, m.prepareNotifyFunc(), m.ruleDB) - - // add rule to memory - m.rules[ruleId] = pr - - } else { - return nil, fmt.Errorf("unsupported rule type. Supported types: %s, %s", RuleTypeProm, RuleTypeThreshold) - } - - return task, nil -} - // RuleTasks returns the list of manager's rule tasks. func (m *Manager) RuleTasks() []Task { m.mtx.RLock() @@ -783,7 +812,7 @@ func (m *Manager) TestNotification(ctx context.Context, ruleStr string) (int, *m rule, err = NewPromRule( alertname, parsedRule, - log.With(m.logger, "alert", alertname), + m.logger, PromRuleOpts{ SendAlways: true, }, diff --git a/pkg/query-service/rules/prom_rule.go b/pkg/query-service/rules/prom_rule.go index 06f9ae311d..c8159c49c8 100644 --- a/pkg/query-service/rules/prom_rule.go +++ b/pkg/query-service/rules/prom_rule.go @@ -8,8 +8,6 @@ import ( "sync" "time" - "github.com/go-kit/log" - "github.com/go-kit/log/level" "go.uber.org/zap" plabels "github.com/prometheus/prometheus/model/labels" @@ -54,7 +52,7 @@ type PromRule struct { // map of active alerts active map[uint64]*Alert - logger log.Logger + logger *zap.Logger opts PromRuleOpts reader interfaces.Reader @@ -63,7 +61,7 @@ type PromRule struct { func NewPromRule( id string, postableRule *PostableRule, - logger log.Logger, + logger *zap.Logger, opts PromRuleOpts, reader interfaces.Reader, ) (*PromRule, error) { @@ -405,7 +403,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( result, err := tmpl.Expand() if err != nil { result = fmt.Sprintf("", err) - level.Warn(r.logger).Log("msg", "Expanding alert template failed", "err", err, "data", tmplData) + r.logger.Warn("Expanding alert template failed", zap.Error(err), zap.Any("data", tmplData)) } return result } diff --git a/pkg/query-service/rules/prom_rule_task.go b/pkg/query-service/rules/prom_rule_task.go index 13c24ca1fa..f2f11cd494 100644 --- a/pkg/query-service/rules/prom_rule_task.go +++ b/pkg/query-service/rules/prom_rule_task.go @@ -7,7 +7,6 @@ import ( "sync" "time" - "github.com/go-kit/log" opentracing "github.com/opentracing/opentracing-go" plabels "github.com/prometheus/prometheus/model/labels" "go.signoz.io/signoz/pkg/query-service/common" @@ -33,7 +32,7 @@ type PromRuleTask struct { terminated chan struct{} pause bool - logger log.Logger + logger *zap.Logger notify NotifyFunc ruleDB RuleDB @@ -60,7 +59,7 @@ func newPromRuleTask(name, file string, frequency time.Duration, rules []Rule, o terminated: make(chan struct{}), notify: notify, ruleDB: ruleDB, - logger: log.With(opts.Logger, "group", name), + logger: opts.Logger, } } diff --git a/pkg/query-service/rules/promrule_test.go b/pkg/query-service/rules/promrule_test.go index a06b510f2e..6b67253668 100644 --- a/pkg/query-service/rules/promrule_test.go +++ b/pkg/query-service/rules/promrule_test.go @@ -7,17 +7,9 @@ import ( pql "github.com/prometheus/prometheus/promql" "github.com/stretchr/testify/assert" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" + "go.uber.org/zap" ) -type testLogger struct { - t *testing.T -} - -func (l testLogger) Log(args ...interface{}) error { - l.t.Log(args...) - return nil -} - func TestPromRuleShouldAlert(t *testing.T) { postableRule := PostableRule{ AlertName: "Test Rule", @@ -611,7 +603,7 @@ func TestPromRuleShouldAlert(t *testing.T) { postableRule.RuleCondition.MatchType = MatchType(c.matchType) postableRule.RuleCondition.Target = &c.target - rule, err := NewPromRule("69", &postableRule, testLogger{t}, PromRuleOpts{}, nil) + rule, err := NewPromRule("69", &postableRule, zap.NewNop(), PromRuleOpts{}, nil) if err != nil { assert.NoError(t, err) } From 3544ffdcc67f8dc64ff229bacb3c27e923e24548 Mon Sep 17 00:00:00 2001 From: CheetoDa <31571545+Calm-Rock@users.noreply.github.com> Date: Wed, 4 Sep 2024 18:24:54 +0530 Subject: [PATCH 09/43] chore: fixed hostmetrics dashboard link (#5851) --- .../md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md | 2 +- .../AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md | 2 +- .../ECSExternal/md-docs/ecsExternal-createDaemonService.md | 2 +- .../md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md | 2 +- .../md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md index 83bb67039b..2c313c455a 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md @@ -51,7 +51,7 @@ aws ecs list-tasks --cluster ${CLUSTER_NAME} --region ${REGION} To verify that the data is being sent to SigNoz Cloud, you can go to the dashboard section of SigNoz and import one of the following dashboards below: - [instancemetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/chore/ecs-dashboards/ecs-infra-metrics/instance-metrics.json) -- [hostmetrics-with-variable.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics-with-variable.json) +- [hostmetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics.json)   diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md index 83bb67039b..2c313c455a 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md @@ -51,7 +51,7 @@ aws ecs list-tasks --cluster ${CLUSTER_NAME} --region ${REGION} To verify that the data is being sent to SigNoz Cloud, you can go to the dashboard section of SigNoz and import one of the following dashboards below: - [instancemetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/chore/ecs-dashboards/ecs-infra-metrics/instance-metrics.json) -- [hostmetrics-with-variable.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics-with-variable.json) +- [hostmetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics.json)   diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud From 6019b38da55829346e95c421d70a1b3cf7eab264 Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Wed, 4 Sep 2024 18:30:04 +0530 Subject: [PATCH 10/43] fix: use better value for threshold value in alert description (#5844) --- pkg/query-service/rules/prom_rule.go | 27 ++ pkg/query-service/rules/promrule_test.go | 223 ++++++++++------ pkg/query-service/rules/threshold_rule.go | 28 ++ .../rules/threshold_rule_test.go | 244 ++++++++++++------ 4 files changed, 351 insertions(+), 171 deletions(-) diff --git a/pkg/query-service/rules/prom_rule.go b/pkg/query-service/rules/prom_rule.go index c8159c49c8..a9890a9503 100644 --- a/pkg/query-service/rules/prom_rule.go +++ b/pkg/query-service/rules/prom_rule.go @@ -591,6 +591,16 @@ func (r *PromRule) shouldAlert(series pql.Series) (pql.Sample, bool) { break } } + // use min value from the series + if shouldAlert { + var minValue float64 = math.Inf(1) + for _, smpl := range series.Floats { + if smpl.F < minValue { + minValue = smpl.F + } + } + alertSmpl = pql.Sample{F: minValue, Metric: series.Metric} + } } else if r.compareOp() == ValueIsBelow { for _, smpl := range series.Floats { if smpl.F >= r.targetVal() { @@ -598,6 +608,15 @@ func (r *PromRule) shouldAlert(series pql.Series) (pql.Sample, bool) { break } } + if shouldAlert { + var maxValue float64 = math.Inf(-1) + for _, smpl := range series.Floats { + if smpl.F > maxValue { + maxValue = smpl.F + } + } + alertSmpl = pql.Sample{F: maxValue, Metric: series.Metric} + } } else if r.compareOp() == ValueIsEq { for _, smpl := range series.Floats { if smpl.F != r.targetVal() { @@ -612,6 +631,14 @@ func (r *PromRule) shouldAlert(series pql.Series) (pql.Sample, bool) { break } } + if shouldAlert { + for _, smpl := range series.Floats { + if !math.IsInf(smpl.F, 0) && !math.IsNaN(smpl.F) { + alertSmpl = pql.Sample{F: smpl.F, Metric: series.Metric} + break + } + } + } } case OnAverage: // If the average of all samples matches the condition, the rule is firing. diff --git a/pkg/query-service/rules/promrule_test.go b/pkg/query-service/rules/promrule_test.go index 6b67253668..fef7630bbd 100644 --- a/pkg/query-service/rules/promrule_test.go +++ b/pkg/query-service/rules/promrule_test.go @@ -30,11 +30,12 @@ func TestPromRuleShouldAlert(t *testing.T) { } cases := []struct { - values pql.Series - expectAlert bool - compareOp string - matchType string - target float64 + values pql.Series + expectAlert bool + compareOp string + matchType string + target float64 + expectedAlertSample v3.Point }{ // Test cases for Equals Always { @@ -47,10 +48,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -108,10 +110,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -123,10 +126,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -138,10 +142,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -169,10 +174,43 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "2", // Always - target: 1.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "2", // Always + target: 1.5, + expectedAlertSample: v3.Point{Value: 2.0}, + }, + { + values: pql.Series{ + Floats: []pql.FPoint{ + {F: 11.0}, + {F: 4.0}, + {F: 3.0}, + {F: 7.0}, + {F: 12.0}, + }, + }, + expectAlert: true, + compareOp: "1", // Above + matchType: "2", // Always + target: 2.0, + expectedAlertSample: v3.Point{Value: 3.0}, + }, + { + values: pql.Series{ + Floats: []pql.FPoint{ + {F: 11.0}, + {F: 4.0}, + {F: 3.0}, + {F: 7.0}, + {F: 12.0}, + }, + }, + expectAlert: true, + compareOp: "2", // Below + matchType: "2", // Always + target: 13.0, + expectedAlertSample: v3.Point{Value: 12.0}, }, { values: pql.Series{ @@ -200,10 +238,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "1", // Once - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "1", // Once + target: 4.5, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: pql.Series{ @@ -261,10 +300,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: pql.Series{ @@ -292,10 +332,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 0.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: pql.Series{ @@ -322,10 +363,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: pql.Series{ @@ -337,10 +379,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, // Test cases for Less Than Always { @@ -353,10 +396,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "2", // Always - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "2", // Always + target: 4, + expectedAlertSample: v3.Point{Value: 1.5}, }, { values: pql.Series{ @@ -384,10 +428,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "1", // Once - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "1", // Once + target: 4, + expectedAlertSample: v3.Point{Value: 2.5}, }, { values: pql.Series{ @@ -415,10 +460,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "3", // OnAverage - target: 6.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "3", // OnAverage + target: 6.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: pql.Series{ @@ -445,10 +491,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: pql.Series{ @@ -475,10 +522,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: pql.Series{ @@ -490,10 +538,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "3", // OnAverage - target: 12.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "3", // OnAverage + target: 12.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, // Test cases for InTotal { @@ -506,10 +555,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 30.0}, }, { values: pql.Series{ @@ -532,10 +582,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 10.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "4", // InTotal - target: 9.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "4", // InTotal + target: 9.0, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: pql.Series{ @@ -555,10 +606,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 10.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "4", // InTotal - target: 10.0, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "4", // InTotal + target: 10.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: pql.Series{ @@ -579,10 +631,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 10.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: pql.Series{ diff --git a/pkg/query-service/rules/threshold_rule.go b/pkg/query-service/rules/threshold_rule.go index 9bdecbc63d..e657af9288 100644 --- a/pkg/query-service/rules/threshold_rule.go +++ b/pkg/query-service/rules/threshold_rule.go @@ -1205,6 +1205,16 @@ func (r *ThresholdRule) shouldAlert(series v3.Series) (Sample, bool) { break } } + // use min value from the series + if shouldAlert { + var minValue float64 = math.Inf(1) + for _, smpl := range series.Points { + if smpl.Value < minValue { + minValue = smpl.Value + } + } + alertSmpl = Sample{Point: Point{V: minValue}, Metric: lblsNormalized, MetricOrig: lbls} + } } else if r.compareOp() == ValueIsBelow { for _, smpl := range series.Points { if smpl.Value >= r.targetVal() { @@ -1212,6 +1222,15 @@ func (r *ThresholdRule) shouldAlert(series v3.Series) (Sample, bool) { break } } + if shouldAlert { + var maxValue float64 = math.Inf(-1) + for _, smpl := range series.Points { + if smpl.Value > maxValue { + maxValue = smpl.Value + } + } + alertSmpl = Sample{Point: Point{V: maxValue}, Metric: lblsNormalized, MetricOrig: lbls} + } } else if r.compareOp() == ValueIsEq { for _, smpl := range series.Points { if smpl.Value != r.targetVal() { @@ -1226,6 +1245,15 @@ func (r *ThresholdRule) shouldAlert(series v3.Series) (Sample, bool) { break } } + // use any non-inf or nan value from the series + if shouldAlert { + for _, smpl := range series.Points { + if !math.IsInf(smpl.Value, 0) && !math.IsNaN(smpl.Value) { + alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} + break + } + } + } } case OnAverage: // If the average of all samples matches the condition, the rule is firing. diff --git a/pkg/query-service/rules/threshold_rule_test.go b/pkg/query-service/rules/threshold_rule_test.go index 05bd613900..6cfeac83d9 100644 --- a/pkg/query-service/rules/threshold_rule_test.go +++ b/pkg/query-service/rules/threshold_rule_test.go @@ -42,11 +42,12 @@ func TestThresholdRuleShouldAlert(t *testing.T) { } cases := []struct { - values v3.Series - expectAlert bool - compareOp string - matchType string - target float64 + values v3.Series + expectAlert bool + compareOp string + matchType string + target float64 + expectedAlertSample v3.Point }{ // Test cases for Equals Always { @@ -59,10 +60,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -120,10 +122,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -135,10 +138,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -150,10 +154,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -181,10 +186,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "2", // Always - target: 1.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "2", // Always + target: 1.5, + expectedAlertSample: v3.Point{Value: 2.0}, }, { values: v3.Series{ @@ -212,10 +218,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "1", // Once - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "1", // Once + target: 4.5, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: v3.Series{ @@ -273,10 +280,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: v3.Series{ @@ -304,10 +312,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 0.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: v3.Series{ @@ -334,10 +343,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: v3.Series{ @@ -349,10 +359,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, // Test cases for Less Than Always { @@ -365,10 +376,27 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "2", // Always - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "2", // Always + target: 4, + expectedAlertSample: v3.Point{Value: 1.5}, + }, + { + values: v3.Series{ + Points: []v3.Point{ + {Value: 1.5}, + {Value: 2.5}, + {Value: 1.5}, + {Value: 3.5}, + {Value: 1.5}, + }, + }, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "2", // Always + target: 4, + expectedAlertSample: v3.Point{Value: 3.5}, }, { values: v3.Series{ @@ -396,10 +424,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "1", // Once - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "1", // Once + target: 4, + expectedAlertSample: v3.Point{Value: 2.5}, }, { values: v3.Series{ @@ -427,10 +456,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "3", // OnAverage - target: 6.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "3", // OnAverage + target: 6.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: v3.Series{ @@ -457,10 +487,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: v3.Series{ @@ -487,10 +518,43 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, + }, + { + values: v3.Series{ + Points: []v3.Point{ + {Value: 11.0}, + {Value: 4.0}, + {Value: 3.0}, + {Value: 7.0}, + {Value: 12.0}, + }, + }, + expectAlert: true, + compareOp: "1", // Above + matchType: "2", // Always + target: 2.0, + expectedAlertSample: v3.Point{Value: 3.0}, + }, + { + values: v3.Series{ + Points: []v3.Point{ + {Value: 11.0}, + {Value: 4.0}, + {Value: 3.0}, + {Value: 7.0}, + {Value: 12.0}, + }, + }, + expectAlert: true, + compareOp: "2", // Below + matchType: "2", // Always + target: 13.0, + expectedAlertSample: v3.Point{Value: 12.0}, }, { values: v3.Series{ @@ -502,10 +566,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "3", // OnAverage - target: 12.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "3", // OnAverage + target: 12.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, // Test cases for InTotal { @@ -518,10 +583,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 30.0}, }, { values: v3.Series{ @@ -544,10 +610,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 10.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "4", // InTotal - target: 9.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "4", // InTotal + target: 9.0, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: v3.Series{ @@ -567,10 +634,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 10.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "4", // InTotal - target: 10.0, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "4", // InTotal + target: 10.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: v3.Series{ @@ -591,10 +659,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 10.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: v3.Series{ @@ -626,8 +695,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { values.Points[i].Timestamp = time.Now().UnixMilli() } - _, shoulAlert := rule.shouldAlert(c.values) + smpl, shoulAlert := rule.shouldAlert(c.values) assert.Equal(t, c.expectAlert, shoulAlert, "Test case %d", idx) + if shoulAlert { + assert.Equal(t, c.expectedAlertSample.Value, smpl.V, "Test case %d", idx) + } } } From e97d0ea51c4c58c153537f113221802b18429a44 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Wed, 4 Sep 2024 21:26:10 +0530 Subject: [PATCH 11/43] Feat: alert history (#5774) * feat: tabs and filters for alert history page (#5655) * feat: alert history page route and component setup * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * chore: unused components and files cleanup * chore: improve alert history and overview route paths * chore: use parent selector in scss files * chore: alert -> alerts * feat: alert rule details metadata header (#5675) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * chore: unused components and files cleanup * feat: copy to clipboard component * feat: see more component * feat: key value label component * feat: alert rule details meta data header * fix: apply the missing changes * chore: uncomment the alert status with static data * chore: compress the alert status svg icons and define props, types, and defaultProps * feat: alert rule history skeleton using static data (#5688) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: top contributors UI using static data * feat: avg. resolution time and total triggered stats card UI using static data * feat: tabs component * feat: timeline tabs and filters * feat: overall status graph UI using dummy data with graph placeholder * feat: timeline table and pagination UI using dummy data * fix: bugfix in reset tabs * feat: add popover to go to logs/traces to top contributors and timeline table * chore: remove comments * chore: rename AlertIcon to AlertState * fix: add cursor pointer to timeline table rows * feat: add parent tabs to alert history * chore: add icon to the configure tab * fix: display popover on hovering the more button in see more component * fix: wrap key value label * feat: alert rule history enable/disable toggle UI * Feat: get alert history data from API (#5718) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: data state renderer component * feat: get total triggered and avg. resolution cards data from API * fix: hide stats card if we get NaN * chore: improve rule stats types * feat: get top contributors data from API * feat: get timeline table data from API * fix: properly render change percentage indicator * feat: total triggered and avg resolution empty states * fix: fix stats height issue that would cause short border-right in empty case * feat: top contributors empty state * fix: fix table and graph borders * feat: build alert timeline labels filter and handle client side filtering * fix: select the first tab on clicking reset * feat: set param and send in payload on clicking timeline filter tabs * Feat: alert history timeline remaining subtasks except graphs (#5720) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: implement timeline table sorting * chore: add initial count to see more and alert labels * chore: move PaginationInfoText component to /periscope * chore: implement top contributor rows using Ant Table * feat: top contributors view all * fix: hide border for last row and prevent layout shift in top contributors by specifying height * feat: properly display duration in average resolution time * fix: properly display normal alert rule state * feat: add/remove view all top contributors param to url on opening/closing view all * feat: calculate start and end time from relative time and add/remove param to url * fix: fix console warnings * fix: enable timeline table query only if start and end times exist * feat: handle enable/disable alert rule toggle request * chore: replace string values with constants * fix: hide stats card if only past data is available + remove unnecessary states from AlertState * fix: redirect configure alert rule to alert overview tab * fix: display total triggers in timeline chart wrapper based on API response data * fix: choosing the same relative time doesn't udpate start and end time * Feat: total triggered and avg. resolution time graph (#5750) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: handle enable/disable alert rule toggle request * feat: stats card line chart * fix: overall improvements to stats card graph * fix: overall UI improvements to match the Figma screens * chore: remove duplicate hook * fix: make the changes w.r.t timeline table API changes to prevent breaking the page * fix: update stats card null check based on updated API response * feat: stats card no previous data UI * feat: redirect to 404 page if rule id is invalid * chore: improve alert enable toggle success toast message * feat: get top contributors row and timeline table row related logs and traces links from API * feat: get total items from API and make pagination work * feat: implement timeline filters based on API response * fix: in case of current and target units, convert the value unit in timeline table * fix: timeline table y axis unit null check * fix: hide stats card graph if only a single entry is there in timeseries * chore: redirect alert from all alerts to overview tab * fix: prevent adding extra unnecessary params on clicking alerts top level tabs * chore: use conditional alert popover in timeline table and import the scss file * fix: prevent infinity if we receive totalPastTriggers as '0' * fix: improve UI to be pixel perfect based on figma designs * fix: fix the incorrect change direction * fix: add height to top contributors row * feat: alert history light mode * fix: remove the extra padding from alert overview query builder tabs * chore: overall improvements * chore: remove mock file * fix: overall improvements * fix: add dark mode support for top contributors empty state * chore: improve timeline chart placeholder bg in light mode * Feat: alert history horizontal timeline chart (#5773) * feat: timeline horizontal chart * fix: remove the labels from horizontal timeline chart * chore: add null check to timeline chart * chore: hide cursor from timeline chart * fix: fix the blank container being displayed in loading state * fix: alert history UI fixes (#5776) * fix: remove extra padding from alert overview query section tabs * fix: add padding to alert overview container * fix: improve breadcrumb click behavior * chore: temporarily hide reset button from alert details timepicker * fix: improve breadcrumb click behavior * chore: hide alert firing since * fix: don't use the data state renderer for timeline table * fix: alert history pr review changes (#5778) * chore: rename alert history scss files in pascal case * fix: use proper variables * chore: use color variable for action button dropdown item * chore: improve the directory structure for alert history components * chore: move inline style to scss file and extract dropdown renderer component * chore: use colors from Color instead of css variables inside tsx files * chore: return null in default case * chore: update alert details spinner tip * chore: timelinePlugin warnings and remove file wide warning disabling * chore: change Arial to Geist Mono in timeline plugin * feat: alert history remaining feats (#5825) * fix: add switch case for inactive state to alert state component * feat: add API enabled label search similar to Query Builder * feat: add reset button to date and time picker * feat: add vertical timeline chart using static data * chore: use Colors instead of hex + dummy data for 90 days * fix: label search light mode UI * fix: remove placeholder logic, and display vertical charts if more than 1 day * chore: extract dayjs manipulate types to a constant * fix: hide the overflow of top contributors card * fix: throw instead of return error to prevent breaking alert history page in case of error * chore: temporarily comment alert history vertical charts * chore: calculate start and end times from relative time and remove query params (#5828) * chore: calculate start and end times from relative time and remove query params * fix: hide reset button if selected time is 30m * feat: alert history dropdown functionality (#5833) * feat: alert history dropdown actions * chore: use query keys from react query key constant * fix: properly handle error states for alert rule APIs * fix: handle dropdown state using onOpenChange to fix clicking delete not closing the dropdown * Fix: bugfixes and overall improvements to alert history (#5841) * fix: don't display severity label * chore: remove id from alert header * chore: add tooltip to enable/disable alert toggle * chore: update enable/disbale toast message * fix: set default relative time to 6h if relative time is not provided * chore: update empty top contributors text and remove configure alert * chore: temporarily hide value column from timeline column * fix: use correct links for logs and traces in alert popover * fix: properly set timeline table offset * fix: display all values in graph * fix: resolve conflicts * chore: remove style for value column in timeline table * chore: temporarily hide labels search * fix: incorrect current page in pagination info text * chore: remove label QB search * chore: remove value column * chore: remove commented code * fix: show traces button when trace link is available * fix: display horizontal chart even for a single entry * fix: show inactive state in horizontal similar to normal state * fix: properly render inactive state in horizontal chart * fix: properly handle preserving alert toggle between overview and history tabs * feat: get page size from query param * chore: remove commented code + minor refactor * chore: remove tsconfi.tmp * fix: don't add default relative time if start and times exist in the url * feat: display date range preview for stat cards * chore: remove custom dropdown renderer component * Fix: UI feedback changes (#5852) * fix: add divider before delete button * fix: timeline section title color in lightmode * fix: remove the extra border from alert history tabs * fix: populate alert rule disabled state on toggling alert state (#5854) --------- Co-authored-by: Shaheer Kochai --- frontend/public/locales/en-GB/titles.json | 4 +- frontend/public/locales/en/titles.json | 2 + frontend/src/AppRoutes/index.tsx | 33 +- frontend/src/AppRoutes/pageComponents.ts | 8 + frontend/src/AppRoutes/routes.ts | 16 + frontend/src/api/alerts/create.ts | 24 +- frontend/src/api/alerts/delete.ts | 20 +- frontend/src/api/alerts/get.ts | 22 +- frontend/src/api/alerts/patch.ts | 24 +- frontend/src/api/alerts/put.ts | 24 +- frontend/src/api/alerts/ruleStats.ts | 28 + frontend/src/api/alerts/timelineGraph.ts | 33 + frontend/src/api/alerts/timelineTable.ts | 36 + frontend/src/api/alerts/topContributors.ts | 33 + .../src/assets/AlertHistory/ConfigureIcon.tsx | 41 ++ frontend/src/assets/AlertHistory/LogsIcon.tsx | 65 ++ .../AlertHistory/SeverityCriticalIcon.tsx | 39 ++ .../assets/AlertHistory/SeverityErrorIcon.tsx | 42 ++ .../assets/AlertHistory/SeverityInfoIcon.tsx | 46 ++ .../AlertHistory/SeverityWarningIcon.tsx | 42 ++ .../AlertDetailsFilters/Filters.styles.scss | 14 + .../AlertDetailsFilters/Filters.tsx | 11 + .../TabsAndFilters/Tabs/Tabs.styles.scss | 5 + .../components/TabsAndFilters/Tabs/Tabs.tsx | 41 ++ .../TabsAndFilters/TabsAndFilters.styles.scss | 18 + .../TabsAndFilters/TabsAndFilters.tsx | 16 + .../components/TabsAndFilters/constants.ts | 5 + frontend/src/constants/global.ts | 13 + frontend/src/constants/reactQueryKeys.ts | 9 + frontend/src/constants/routes.ts | 2 + .../AlertHistory/AlertHistory.styles.scss | 5 + .../container/AlertHistory/AlertHistory.tsx | 22 + .../AlertPopover/AlertPopover.styles.scss | 3 + .../AlertPopover/AlertPopover.tsx | 114 ++++ .../AverageResolutionCard.tsx | 28 + .../Statistics/Statistics.styles.scss | 14 + .../AlertHistory/Statistics/Statistics.tsx | 23 + .../StatsCard/StatsCard.styles.scss | 112 ++++ .../Statistics/StatsCard/StatsCard.tsx | 158 +++++ .../StatsCard/StatsGraph/StatsGraph.tsx | 90 +++ .../Statistics/StatsCard/utils.ts | 12 + .../StatsCardsRenderer/StatsCardsRenderer.tsx | 102 +++ .../TopContributorsCard.styles.scss | 191 ++++++ .../TopContributorsCard.tsx | 84 +++ .../TopContributorsContent.tsx | 32 + .../TopContributorsRows.tsx | 87 +++ .../TopContributorsCard/ViewAllDrawer.tsx | 46 ++ .../Statistics/TopContributorsCard/types.ts | 6 + .../TopContributorsRenderer.tsx | 42 ++ .../TotalTriggeredCard/TotalTriggeredCard.tsx | 26 + .../Timeline/Graph/Graph.styles.scss | 52 ++ .../AlertHistory/Timeline/Graph/Graph.tsx | 184 +++++ .../AlertHistory/Timeline/Graph/constants.ts | 33 + .../Timeline/GraphWrapper/GraphWrapper.tsx | 67 ++ .../Timeline/Table/Table.styles.scss | 134 ++++ .../AlertHistory/Timeline/Table/Table.tsx | 56 ++ .../AlertHistory/Timeline/Table/types.ts | 9 + .../Timeline/Table/useTimelineTable.tsx | 53 ++ .../TabsAndFilters/TabsAndFilters.styles.scss | 32 + .../TabsAndFilters/TabsAndFilters.tsx | 90 +++ .../Timeline/Timeline.styles.scss | 22 + .../AlertHistory/Timeline/Timeline.tsx | 32 + .../AlertHistory/Timeline/constants.ts | 2 + .../src/container/AlertHistory/constants.ts | 1 + frontend/src/container/AlertHistory/index.tsx | 3 + frontend/src/container/AlertHistory/types.ts | 15 + frontend/src/container/AppLayout/index.tsx | 4 + .../FormAlertRules/QuerySection.styles.scss | 4 + .../src/container/FormAlertRules/index.tsx | 3 +- .../container/ListAlertRules/ListAlert.tsx | 2 +- .../DateTimeSelectionV2.styles.scss | 12 + .../TopNav/DateTimeSelectionV2/config.ts | 2 + .../TopNav/DateTimeSelectionV2/index.tsx | 148 ++-- .../src/lib/uPlotLib/plugins/heatmapPlugin.ts | 49 ++ .../lib/uPlotLib/plugins/timelinePlugin.ts | 632 ++++++++++++++++++ .../AlertDetails/AlertDetails.styles.scss | 189 ++++++ .../src/pages/AlertDetails/AlertDetails.tsx | 123 ++++ .../ActionButtons/ActionButtons.styles.scss | 63 ++ .../ActionButtons/ActionButtons.tsx | 111 +++ .../AlertHeader/AlertHeader.styles.scss | 50 ++ .../AlertDetails/AlertHeader/AlertHeader.tsx | 66 ++ .../AlertLabels/AlertLabels.styles.scss | 5 + .../AlertHeader/AlertLabels/AlertLabels.tsx | 31 + .../AlertSeverity/AlertSeverity.styles.scss | 40 ++ .../AlertSeverity/AlertSeverity.tsx | 42 ++ .../AlertState/AlertState.styles.scss | 10 + .../AlertHeader/AlertState/AlertState.tsx | 73 ++ .../AlertStatus/AlertStatus.styles.scss | 22 + .../AlertHeader/AlertStatus/AlertStatus.tsx | 54 ++ .../AlertHeader/AlertStatus/types.ts | 18 + frontend/src/pages/AlertDetails/hooks.tsx | 525 +++++++++++++++ frontend/src/pages/AlertDetails/index.tsx | 3 + frontend/src/pages/AlertDetails/types.ts | 6 + frontend/src/pages/AlertHistory/index.tsx | 3 + frontend/src/pages/AlertList/index.tsx | 45 +- .../src/pages/EditRules/EditRules.styles.scss | 31 +- frontend/src/pages/EditRules/index.tsx | 19 +- .../CopyToClipboard.styles.scss | 39 ++ .../CopyToClipboard/CopyToClipboard.tsx | 54 ++ .../components/CopyToClipboard/index.tsx | 3 + .../DataStateRenderer/DataStateRenderer.tsx | 46 ++ .../components/DataStateRenderer/index.tsx | 3 + .../KeyValueLabel/KeyValueLabel.styles.scss | 37 + .../KeyValueLabel/KeyValueLabel.tsx | 18 + .../components/KeyValueLabel/index.tsx | 3 + .../PaginationInfoText/PaginationInfoText.tsx | 24 + .../components/SeeMore/SeeMore.styles.scss | 26 + .../periscope/components/SeeMore/SeeMore.tsx | 48 ++ .../periscope/components/SeeMore/index.tsx | 3 + .../components/Tabs2/Tabs2.styles.scss | 48 ++ .../src/periscope/components/Tabs2/Tabs2.tsx | 80 +++ .../src/periscope/components/Tabs2/index.tsx | 3 + frontend/src/providers/Alert.tsx | 43 ++ frontend/src/types/api/alerts/def.ts | 66 +- frontend/src/types/api/alerts/ruleStats.ts | 7 + .../src/types/api/alerts/timelineGraph.ts | 7 + .../src/types/api/alerts/timelineTable.ts | 13 + .../src/types/api/alerts/topContributors.ts | 7 + frontend/src/utils/calculateChange.ts | 31 + frontend/src/utils/permission/index.ts | 2 + frontend/src/utils/timeUtils.ts | 97 +++ 121 files changed, 5627 insertions(+), 164 deletions(-) create mode 100644 frontend/src/api/alerts/ruleStats.ts create mode 100644 frontend/src/api/alerts/timelineGraph.ts create mode 100644 frontend/src/api/alerts/timelineTable.ts create mode 100644 frontend/src/api/alerts/topContributors.ts create mode 100644 frontend/src/assets/AlertHistory/ConfigureIcon.tsx create mode 100644 frontend/src/assets/AlertHistory/LogsIcon.tsx create mode 100644 frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx create mode 100644 frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx create mode 100644 frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx create mode 100644 frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx create mode 100644 frontend/src/components/AlertDetailsFilters/Filters.styles.scss create mode 100644 frontend/src/components/AlertDetailsFilters/Filters.tsx create mode 100644 frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss create mode 100644 frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx create mode 100644 frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss create mode 100644 frontend/src/components/TabsAndFilters/TabsAndFilters.tsx create mode 100644 frontend/src/components/TabsAndFilters/constants.ts create mode 100644 frontend/src/container/AlertHistory/AlertHistory.styles.scss create mode 100644 frontend/src/container/AlertHistory/AlertHistory.tsx create mode 100644 frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss create mode 100644 frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss create mode 100644 frontend/src/container/AlertHistory/Statistics/Statistics.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss create mode 100644 frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Graph/constants.ts create mode 100644 frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/Table.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/types.ts create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss create mode 100644 frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss create mode 100644 frontend/src/container/AlertHistory/Timeline/Timeline.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/constants.ts create mode 100644 frontend/src/container/AlertHistory/constants.ts create mode 100644 frontend/src/container/AlertHistory/index.tsx create mode 100644 frontend/src/container/AlertHistory/types.ts create mode 100644 frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts create mode 100644 frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts create mode 100644 frontend/src/pages/AlertDetails/AlertDetails.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertDetails.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx create mode 100644 frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts create mode 100644 frontend/src/pages/AlertDetails/hooks.tsx create mode 100644 frontend/src/pages/AlertDetails/index.tsx create mode 100644 frontend/src/pages/AlertDetails/types.ts create mode 100644 frontend/src/pages/AlertHistory/index.tsx create mode 100644 frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss create mode 100644 frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx create mode 100644 frontend/src/periscope/components/CopyToClipboard/index.tsx create mode 100644 frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx create mode 100644 frontend/src/periscope/components/DataStateRenderer/index.tsx create mode 100644 frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss create mode 100644 frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx create mode 100644 frontend/src/periscope/components/KeyValueLabel/index.tsx create mode 100644 frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx create mode 100644 frontend/src/periscope/components/SeeMore/SeeMore.styles.scss create mode 100644 frontend/src/periscope/components/SeeMore/SeeMore.tsx create mode 100644 frontend/src/periscope/components/SeeMore/index.tsx create mode 100644 frontend/src/periscope/components/Tabs2/Tabs2.styles.scss create mode 100644 frontend/src/periscope/components/Tabs2/Tabs2.tsx create mode 100644 frontend/src/periscope/components/Tabs2/index.tsx create mode 100644 frontend/src/providers/Alert.tsx create mode 100644 frontend/src/types/api/alerts/ruleStats.ts create mode 100644 frontend/src/types/api/alerts/timelineGraph.ts create mode 100644 frontend/src/types/api/alerts/timelineTable.ts create mode 100644 frontend/src/types/api/alerts/topContributors.ts create mode 100644 frontend/src/utils/calculateChange.ts diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index 0eb98e9960..6cfe6e0238 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -38,5 +38,7 @@ "LIST_LICENSES": "SigNoz | List of Licenses", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", "SUPPORT": "SigNoz | Support", - "DEFAULT": "Open source Observability Platform | SigNoz" + "DEFAULT": "Open source Observability Platform | SigNoz", + "ALERT_HISTORY": "SigNoz | Alert Rule History", + "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview" } diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 4aa2b65dc0..126b8a7ac1 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -50,5 +50,7 @@ "DEFAULT": "Open source Observability Platform | SigNoz", "SHORTCUTS": "SigNoz | Shortcuts", "INTEGRATIONS": "SigNoz | Integrations", + "ALERT_HISTORY": "SigNoz | Alert Rule History", + "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview", "MESSAGING_QUEUES": "SigNoz | Messaging Queues" } diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index 23e7ea9644..b900255172 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -19,6 +19,7 @@ import { ResourceProvider } from 'hooks/useResourceAttribute'; import history from 'lib/history'; import { identity, pick, pickBy } from 'lodash-es'; import posthog from 'posthog-js'; +import AlertRuleProvider from 'providers/Alert'; import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { Suspense, useEffect, useState } from 'react'; @@ -236,22 +237,24 @@ function App(): JSX.Element { - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} + + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} - - - - + + + + + diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index bce075cef3..0a7764149b 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -92,6 +92,14 @@ export const CreateNewAlerts = Loadable( () => import(/* webpackChunkName: "Create Alerts" */ 'pages/CreateAlert'), ); +export const AlertHistory = Loadable( + () => import(/* webpackChunkName: "Alert History" */ 'pages/AlertList'), +); + +export const AlertOverview = Loadable( + () => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'), +); + export const CreateAlertChannelAlerts = Loadable( () => import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'), diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 98fdbed392..42ce00c0fb 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -2,6 +2,8 @@ import ROUTES from 'constants/routes'; import { RouteProps } from 'react-router-dom'; import { + AlertHistory, + AlertOverview, AllAlertChannels, AllErrors, APIKeys, @@ -171,6 +173,20 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'ALERTS_NEW', }, + { + path: ROUTES.ALERT_HISTORY, + exact: true, + component: AlertHistory, + isPrivate: true, + key: 'ALERT_HISTORY', + }, + { + path: ROUTES.ALERT_OVERVIEW, + exact: true, + component: AlertOverview, + isPrivate: true, + key: 'ALERT_OVERVIEW', + }, { path: ROUTES.TRACE, exact: true, diff --git a/frontend/src/api/alerts/create.ts b/frontend/src/api/alerts/create.ts index cad7917815..744183fa4b 100644 --- a/frontend/src/api/alerts/create.ts +++ b/frontend/src/api/alerts/create.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/create'; const create = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.post('/rules', { - ...props.data, - }); + const response = await axios.post('/rules', { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default create; diff --git a/frontend/src/api/alerts/delete.ts b/frontend/src/api/alerts/delete.ts index 278e3e2935..56407f3c40 100644 --- a/frontend/src/api/alerts/delete.ts +++ b/frontend/src/api/alerts/delete.ts @@ -1,24 +1,18 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/delete'; const deleteAlerts = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.delete(`/rules/${props.id}`); + const response = await axios.delete(`/rules/${props.id}`); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data.rules, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data.rules, + }; }; export default deleteAlerts; diff --git a/frontend/src/api/alerts/get.ts b/frontend/src/api/alerts/get.ts index 0437f8d1d8..15a741287e 100644 --- a/frontend/src/api/alerts/get.ts +++ b/frontend/src/api/alerts/get.ts @@ -1,24 +1,16 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/get'; const get = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.get(`/rules/${props.id}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + const response = await axios.get(`/rules/${props.id}`); + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; }; - export default get; diff --git a/frontend/src/api/alerts/patch.ts b/frontend/src/api/alerts/patch.ts index 920b53ae9f..cb64a1046f 100644 --- a/frontend/src/api/alerts/patch.ts +++ b/frontend/src/api/alerts/patch.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/patch'; const patch = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.patch(`/rules/${props.id}`, { - ...props.data, - }); + const response = await axios.patch(`/rules/${props.id}`, { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default patch; diff --git a/frontend/src/api/alerts/put.ts b/frontend/src/api/alerts/put.ts index b8c34e96bd..77d98d3c49 100644 --- a/frontend/src/api/alerts/put.ts +++ b/frontend/src/api/alerts/put.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/save'; const put = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.put(`/rules/${props.id}`, { - ...props.data, - }); + const response = await axios.put(`/rules/${props.id}`, { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default put; diff --git a/frontend/src/api/alerts/ruleStats.ts b/frontend/src/api/alerts/ruleStats.ts new file mode 100644 index 0000000000..2e09751e0f --- /dev/null +++ b/frontend/src/api/alerts/ruleStats.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleStatsPayload } from 'types/api/alerts/def'; +import { RuleStatsProps } from 'types/api/alerts/ruleStats'; + +const ruleStats = async ( + props: RuleStatsProps, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/rules/${props.id}/history/stats`, { + start: props.start, + end: props.end, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default ruleStats; diff --git a/frontend/src/api/alerts/timelineGraph.ts b/frontend/src/api/alerts/timelineGraph.ts new file mode 100644 index 0000000000..8073943d72 --- /dev/null +++ b/frontend/src/api/alerts/timelineGraph.ts @@ -0,0 +1,33 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def'; +import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph'; + +const timelineGraph = async ( + props: GetTimelineGraphRequestProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post( + `/rules/${props.id}/history/overall_status`, + { + start: props.start, + end: props.end, + }, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default timelineGraph; diff --git a/frontend/src/api/alerts/timelineTable.ts b/frontend/src/api/alerts/timelineTable.ts new file mode 100644 index 0000000000..8d7f3edee7 --- /dev/null +++ b/frontend/src/api/alerts/timelineTable.ts @@ -0,0 +1,36 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def'; +import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable'; + +const timelineTable = async ( + props: GetTimelineTableRequestProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post(`/rules/${props.id}/history/timeline`, { + start: props.start, + end: props.end, + offset: props.offset, + limit: props.limit, + order: props.order, + state: props.state, + // TODO(shaheer): implement filters + filters: props.filters, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default timelineTable; diff --git a/frontend/src/api/alerts/topContributors.ts b/frontend/src/api/alerts/topContributors.ts new file mode 100644 index 0000000000..7d3f2baec1 --- /dev/null +++ b/frontend/src/api/alerts/topContributors.ts @@ -0,0 +1,33 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def'; +import { TopContributorsProps } from 'types/api/alerts/topContributors'; + +const topContributors = async ( + props: TopContributorsProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post( + `/rules/${props.id}/history/top_contributors`, + { + start: props.start, + end: props.end, + }, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default topContributors; diff --git a/frontend/src/assets/AlertHistory/ConfigureIcon.tsx b/frontend/src/assets/AlertHistory/ConfigureIcon.tsx new file mode 100644 index 0000000000..05268b8f5f --- /dev/null +++ b/frontend/src/assets/AlertHistory/ConfigureIcon.tsx @@ -0,0 +1,41 @@ +interface ConfigureIconProps { + width?: number; + height?: number; + fill?: string; +} + +function ConfigureIcon({ + width, + height, + fill, +}: ConfigureIconProps): JSX.Element { + return ( + + + + + ); +} + +ConfigureIcon.defaultProps = { + width: 16, + height: 16, + fill: 'none', +}; +export default ConfigureIcon; diff --git a/frontend/src/assets/AlertHistory/LogsIcon.tsx b/frontend/src/assets/AlertHistory/LogsIcon.tsx new file mode 100644 index 0000000000..8ffcaaa90b --- /dev/null +++ b/frontend/src/assets/AlertHistory/LogsIcon.tsx @@ -0,0 +1,65 @@ +interface LogsIconProps { + width?: number; + height?: number; + fill?: string; + strokeColor?: string; + strokeWidth?: number; +} + +function LogsIcon({ + width, + height, + fill, + strokeColor, + strokeWidth, +}: LogsIconProps): JSX.Element { + return ( + + + + + + + + + ); +} + +LogsIcon.defaultProps = { + width: 14, + height: 14, + fill: 'none', + strokeColor: '#C0C1C3', + strokeWidth: 1.167, +}; + +export default LogsIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx b/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx new file mode 100644 index 0000000000..67d0977fe8 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx @@ -0,0 +1,39 @@ +interface SeverityCriticalIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; +} + +function SeverityCriticalIcon({ + width, + height, + fill, + stroke, +}: SeverityCriticalIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityCriticalIcon.defaultProps = { + width: 6, + height: 6, + fill: 'none', + stroke: '#F56C87', +}; + +export default SeverityCriticalIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx b/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx new file mode 100644 index 0000000000..a402289a62 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx @@ -0,0 +1,42 @@ +interface SeverityErrorIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; + strokeWidth?: string; +} + +function SeverityErrorIcon({ + width, + height, + fill, + stroke, + strokeWidth, +}: SeverityErrorIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityErrorIcon.defaultProps = { + width: 2, + height: 6, + fill: 'none', + stroke: '#F56C87', + strokeWidth: '1.02083', +}; + +export default SeverityErrorIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx b/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx new file mode 100644 index 0000000000..72316b2244 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx @@ -0,0 +1,46 @@ +interface SeverityInfoIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; +} + +function SeverityInfoIcon({ + width, + height, + fill, + stroke, +}: SeverityInfoIconProps): JSX.Element { + return ( + + + + + ); +} + +SeverityInfoIcon.defaultProps = { + width: 14, + height: 14, + fill: 'none', + stroke: '#7190F9', +}; + +export default SeverityInfoIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx b/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx new file mode 100644 index 0000000000..204d615a21 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx @@ -0,0 +1,42 @@ +interface SeverityWarningIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; + strokeWidth?: string; +} + +function SeverityWarningIcon({ + width, + height, + fill, + stroke, + strokeWidth, +}: SeverityWarningIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityWarningIcon.defaultProps = { + width: 2, + height: 6, + fill: 'none', + stroke: '#FFD778', + strokeWidth: '0.978299', +}; + +export default SeverityWarningIcon; diff --git a/frontend/src/components/AlertDetailsFilters/Filters.styles.scss b/frontend/src/components/AlertDetailsFilters/Filters.styles.scss new file mode 100644 index 0000000000..6869dd4366 --- /dev/null +++ b/frontend/src/components/AlertDetailsFilters/Filters.styles.scss @@ -0,0 +1,14 @@ +.reset-button { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-ink-300); + border: 1px solid var(--bg-slate-400); +} + +.lightMode { + .reset-button { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } +} diff --git a/frontend/src/components/AlertDetailsFilters/Filters.tsx b/frontend/src/components/AlertDetailsFilters/Filters.tsx new file mode 100644 index 0000000000..baf109bf1d --- /dev/null +++ b/frontend/src/components/AlertDetailsFilters/Filters.tsx @@ -0,0 +1,11 @@ +import './Filters.styles.scss'; + +import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2'; + +export function Filters(): JSX.Element { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss b/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss new file mode 100644 index 0000000000..f3c2ea622a --- /dev/null +++ b/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss @@ -0,0 +1,5 @@ +.tab-title { + display: flex; + gap: 4px; + align-items: center; +} diff --git a/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx b/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx new file mode 100644 index 0000000000..981c291146 --- /dev/null +++ b/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx @@ -0,0 +1,41 @@ +import './Tabs.styles.scss'; + +import { Radio } from 'antd'; +import { RadioChangeEvent } from 'antd/lib'; +import { History, Table } from 'lucide-react'; +import { useState } from 'react'; + +import { ALERT_TABS } from '../constants'; + +export function Tabs(): JSX.Element { + const [selectedTab, setSelectedTab] = useState('overview'); + + const handleTabChange = (e: RadioChangeEvent): void => { + setSelectedTab(e.target.value); + }; + + return ( + + +
+ + Overview + + + +
+ + History +
+
+ + ); +} diff --git a/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss b/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss new file mode 100644 index 0000000000..5115eabe2e --- /dev/null +++ b/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss @@ -0,0 +1,18 @@ +@mixin flex-center { + display: flex; + justify-content: space-between; + align-items: center; +} + +.tabs-and-filters { + @include flex-center; + margin-top: 1rem; + margin-bottom: 1rem; + .filters { + @include flex-center; + gap: 16px; + .reset-button { + @include flex-center; + } + } +} diff --git a/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx b/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx new file mode 100644 index 0000000000..ac6738d491 --- /dev/null +++ b/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx @@ -0,0 +1,16 @@ +import './TabsAndFilters.styles.scss'; + +import { Filters } from 'components/AlertDetailsFilters/Filters'; + +import { Tabs } from './Tabs/Tabs'; + +function TabsAndFilters(): JSX.Element { + return ( +
+ + +
+ ); +} + +export default TabsAndFilters; diff --git a/frontend/src/components/TabsAndFilters/constants.ts b/frontend/src/components/TabsAndFilters/constants.ts new file mode 100644 index 0000000000..b052c0e4cf --- /dev/null +++ b/frontend/src/components/TabsAndFilters/constants.ts @@ -0,0 +1,5 @@ +export const ALERT_TABS = { + OVERVIEW: 'OVERVIEW', + HISTORY: 'HISTORY', + ACTIVITY: 'ACTIVITY', +} as const; diff --git a/frontend/src/constants/global.ts b/frontend/src/constants/global.ts index 42fb29720b..dfa096470d 100644 --- a/frontend/src/constants/global.ts +++ b/frontend/src/constants/global.ts @@ -1,4 +1,17 @@ +import { ManipulateType } from 'dayjs'; + const MAX_RPS_LIMIT = 100; export { MAX_RPS_LIMIT }; export const LEGEND = 'legend'; + +export const DAYJS_MANIPULATE_TYPES: { [key: string]: ManipulateType } = { + DAY: 'day', + WEEK: 'week', + MONTH: 'month', + YEAR: 'year', + HOUR: 'hour', + MINUTE: 'minute', + SECOND: 'second', + MILLISECOND: 'millisecond', +}; diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index 52ae235ef6..ec2353abbf 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -8,5 +8,14 @@ export const REACT_QUERY_KEY = { GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS', DELETE_DASHBOARD: 'DELETE_DASHBOARD', LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW', + ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS', + ALERT_RULE_STATS: 'ALERT_RULE_STATS', + ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS', + ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE', + ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH', GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS', + TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE', + GET_ALL_ALLERTS: 'GET_ALL_ALLERTS', + REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE', + DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE', }; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 8f76cd0386..b4f43ee684 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -22,6 +22,8 @@ const ROUTES = { EDIT_ALERTS: '/alerts/edit', LIST_ALL_ALERT: '/alerts', ALERTS_NEW: '/alerts/new', + ALERT_HISTORY: '/alerts/history', + ALERT_OVERVIEW: '/alerts/overview', ALL_CHANNELS: '/settings/channels', CHANNELS_NEW: '/settings/channels/new', CHANNELS_EDIT: '/settings/channels/:id', diff --git a/frontend/src/container/AlertHistory/AlertHistory.styles.scss b/frontend/src/container/AlertHistory/AlertHistory.styles.scss new file mode 100644 index 0000000000..39fce3ca29 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertHistory.styles.scss @@ -0,0 +1,5 @@ +.alert-history { + display: flex; + flex-direction: column; + gap: 24px; +} diff --git a/frontend/src/container/AlertHistory/AlertHistory.tsx b/frontend/src/container/AlertHistory/AlertHistory.tsx new file mode 100644 index 0000000000..0776cfcebb --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertHistory.tsx @@ -0,0 +1,22 @@ +import './AlertHistory.styles.scss'; + +import { useState } from 'react'; + +import Statistics from './Statistics/Statistics'; +import Timeline from './Timeline/Timeline'; + +function AlertHistory(): JSX.Element { + const [totalCurrentTriggers, setTotalCurrentTriggers] = useState(0); + + return ( +
+ + +
+ ); +} + +export default AlertHistory; diff --git a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss new file mode 100644 index 0000000000..43d645efa5 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss @@ -0,0 +1,3 @@ +.alert-popover { + cursor: pointer; +} diff --git a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx new file mode 100644 index 0000000000..83605a61d3 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx @@ -0,0 +1,114 @@ +import './AlertPopover.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Popover } from 'antd'; +import LogsIcon from 'assets/AlertHistory/LogsIcon'; +import ROUTES from 'constants/routes'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { DraftingCompass } from 'lucide-react'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +type Props = { + children: React.ReactNode; + relatedTracesLink?: string; + relatedLogsLink?: string; +}; + +function PopoverContent({ + relatedTracesLink, + relatedLogsLink, +}: { + relatedTracesLink?: Props['relatedTracesLink']; + relatedLogsLink?: Props['relatedLogsLink']; +}): JSX.Element { + const isDarkMode = useIsDarkMode(); + return ( +
+ {!!relatedLogsLink && ( + +
+ +
+
View Logs
+ + )} + {!!relatedTracesLink && ( + +
+ +
+
View Traces
+ + )} +
+ ); +} +PopoverContent.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +function AlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: Props): JSX.Element { + return ( +
+ + } + trigger="click" + > + {children} + +
+ ); +} + +AlertPopover.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +type ConditionalAlertPopoverProps = { + relatedTracesLink: string; + relatedLogsLink: string; + children: React.ReactNode; +}; +export function ConditionalAlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: ConditionalAlertPopoverProps): JSX.Element { + if (relatedTracesLink || relatedLogsLink) { + return ( + + {children} + + ); + } + return
{children}
; +} +export default AlertPopover; diff --git a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx new file mode 100644 index 0000000000..f55c4385ce --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx @@ -0,0 +1,28 @@ +import { AlertRuleStats } from 'types/api/alerts/def'; +import { formatTime } from 'utils/timeUtils'; + +import StatsCard from '../StatsCard/StatsCard'; + +type TotalTriggeredCardProps = { + currentAvgResolutionTime: AlertRuleStats['currentAvgResolutionTime']; + pastAvgResolutionTime: AlertRuleStats['pastAvgResolutionTime']; + timeSeries: AlertRuleStats['currentAvgResolutionTimeSeries']['values']; +}; + +function AverageResolutionCard({ + currentAvgResolutionTime, + pastAvgResolutionTime, + timeSeries, +}: TotalTriggeredCardProps): JSX.Element { + return ( + + ); +} + +export default AverageResolutionCard; diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss b/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss new file mode 100644 index 0000000000..cc0a5b1b43 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss @@ -0,0 +1,14 @@ +.statistics { + display: flex; + justify-content: space-between; + height: 280px; + border: 1px solid var(--bg-slate-500); + border-radius: 4px; + margin: 0 16px; +} + +.lightMode { + .statistics { + border: 1px solid var(--bg-vanilla-300); + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx new file mode 100644 index 0000000000..7158e0c069 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx @@ -0,0 +1,23 @@ +import './Statistics.styles.scss'; + +import { AlertRuleStats } from 'types/api/alerts/def'; + +import StatsCardsRenderer from './StatsCardsRenderer/StatsCardsRenderer'; +import TopContributorsRenderer from './TopContributorsRenderer/TopContributorsRenderer'; + +function Statistics({ + setTotalCurrentTriggers, + totalCurrentTriggers, +}: { + setTotalCurrentTriggers: (value: number) => void; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + return ( +
+ + +
+ ); +} + +export default Statistics; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss new file mode 100644 index 0000000000..bb9d3c3e72 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss @@ -0,0 +1,112 @@ +.stats-card { + width: 21.7%; + border-right: 1px solid var(--bg-slate-500); + padding: 9px 12px 13px; + + &--empty { + justify-content: normal; + } + + &__title-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + + .title { + text-transform: uppercase; + font-size: 13px; + line-height: 22px; + color: var(--bg-vanilla-400); + font-weight: 500; + } + .duration-indicator { + display: flex; + align-items: center; + gap: 4px; + .icon { + display: flex; + align-self: center; + } + .text { + text-transform: uppercase; + color: var(--text-slate-200); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.48px; + } + } + } + &__stats { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 4px; + .count-label { + color: var(--text-vanilla-100); + font-family: 'Geist Mono'; + font-size: 24px; + line-height: 36px; + } + } + &__graph { + margin-top: 80px; + + .graph { + width: 100%; + height: 72px; + } + } +} + +.change-percentage { + width: max-content; + display: flex; + padding: 4px 8px; + border-radius: 20px; + align-items: center; + gap: 4px; + + &--success { + background: rgba(37, 225, 146, 0.1); + color: var(--bg-forest-500); + } + &--error { + background: rgba(229, 72, 77, 0.1); + color: var(--bg-cherry-500); + } + &--no-previous-data { + color: var(--text-robin-500); + background: rgba(78, 116, 248, 0.1); + padding: 4px 16px; + } + &__icon { + display: flex; + align-self: center; + } + &__label { + font-size: 12px; + font-weight: 500; + line-height: 16px; + } +} + +.lightMode { + .stats-card { + border-color: var(--bg-vanilla-300); + &__title-wrapper { + .title { + color: var(--text-ink-400); + } + .duration-indicator { + .text { + color: var(--text-ink-200); + } + } + } + &__stats { + .count-label { + color: var(--text-ink-100); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx new file mode 100644 index 0000000000..f204579f93 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx @@ -0,0 +1,158 @@ +import './StatsCard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Tooltip } from 'antd'; +import { QueryParams } from 'constants/query'; +import useUrlQuery from 'hooks/useUrlQuery'; +import { ArrowDownLeft, ArrowUpRight, Calendar } from 'lucide-react'; +import { AlertRuleStats } from 'types/api/alerts/def'; +import { calculateChange } from 'utils/calculateChange'; + +import StatsGraph from './StatsGraph/StatsGraph'; +import { + convertTimestampToLocaleDateString, + extractDayFromTimestamp, +} from './utils'; + +type ChangePercentageProps = { + percentage: number; + direction: number; + duration: string | null; +}; +function ChangePercentage({ + percentage, + direction, + duration, +}: ChangePercentageProps): JSX.Element { + if (direction > 0) { + return ( +
+
+ +
+
+ {percentage}% vs Last {duration} +
+
+ ); + } + if (direction < 0) { + return ( +
+
+ +
+
+ {percentage}% vs Last {duration} +
+
+ ); + } + + return ( +
+
no previous data
+
+ ); +} + +type StatsCardProps = { + totalCurrentCount?: number; + totalPastCount?: number; + title: string; + isEmpty?: boolean; + emptyMessage?: string; + displayValue?: string | number; + timeSeries?: AlertRuleStats['currentTriggersSeries']['values']; +}; + +function StatsCard({ + displayValue, + totalCurrentCount, + totalPastCount, + title, + isEmpty, + emptyMessage, + timeSeries = [], +}: StatsCardProps): JSX.Element { + const urlQuery = useUrlQuery(); + + const relativeTime = urlQuery.get('relativeTime'); + + const { changePercentage, changeDirection } = calculateChange( + totalCurrentCount, + totalPastCount, + ); + + const startTime = urlQuery.get(QueryParams.startTime); + const endTime = urlQuery.get(QueryParams.endTime); + + let displayTime = relativeTime; + + if (!displayTime && startTime && endTime) { + const formattedStartDate = extractDayFromTimestamp(startTime); + const formattedEndDate = extractDayFromTimestamp(endTime); + displayTime = `${formattedStartDate} to ${formattedEndDate}`; + } + + if (!displayTime) { + displayTime = ''; + } + const formattedStartTimeForTooltip = convertTimestampToLocaleDateString( + startTime, + ); + const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime); + + return ( +
+
+
{title}
+
+
+ +
+ {relativeTime ? ( +
{displayTime}
+ ) : ( + +
{displayTime}
+
+ )} +
+
+ +
+
+ {isEmpty ? emptyMessage : displayValue || totalCurrentCount} +
+ + +
+ +
+
+ {!isEmpty && timeSeries.length > 1 && ( + + )} +
+
+
+ ); +} + +StatsCard.defaultProps = { + totalCurrentCount: 0, + totalPastCount: 0, + isEmpty: false, + emptyMessage: 'No Data', + displayValue: '', + timeSeries: [], +}; + +export default StatsCard; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx new file mode 100644 index 0000000000..26c381d706 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx @@ -0,0 +1,90 @@ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useResizeObserver } from 'hooks/useDimensions'; +import { useMemo, useRef } from 'react'; +import { AlertRuleStats } from 'types/api/alerts/def'; + +type Props = { + timeSeries: AlertRuleStats['currentTriggersSeries']['values']; + changeDirection: number; +}; + +const getStyle = ( + changeDirection: number, +): { stroke: string; fill: string } => { + if (changeDirection === 0) { + return { + stroke: Color.BG_ROBIN_500, + fill: 'rgba(78, 116, 248, 0.20)', + }; + } + if (changeDirection > 0) { + return { + stroke: Color.BG_FOREST_500, + fill: 'rgba(37, 225, 146, 0.20)', + }; + } + return { + stroke: Color.BG_CHERRY_500, + fill: ' rgba(229, 72, 77, 0.20)', + }; +}; + +function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element { + const { xData, yData } = useMemo( + () => ({ + xData: timeSeries.map((item) => item.timestamp), + yData: timeSeries.map((item) => Number(item.value)), + }), + [timeSeries], + ); + + const graphRef = useRef(null); + + const containerDimensions = useResizeObserver(graphRef); + + const options: uPlot.Options = useMemo( + () => ({ + width: containerDimensions.width, + height: containerDimensions.height, + + legend: { + show: false, + }, + cursor: { + x: false, + y: false, + drag: { + x: false, + y: false, + }, + }, + padding: [0, 0, 2, 0], + series: [ + {}, + { + ...getStyle(changeDirection), + points: { + show: false, + }, + width: 1.4, + }, + ], + axes: [ + { show: false }, + { + show: false, + }, + ], + }), + [changeDirection, containerDimensions.height, containerDimensions.width], + ); + + return ( +
+ +
+ ); +} + +export default StatsGraph; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts b/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts new file mode 100644 index 0000000000..a2584aad37 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts @@ -0,0 +1,12 @@ +export const extractDayFromTimestamp = (timestamp: string | null): string => { + if (!timestamp) return ''; + const date = new Date(parseInt(timestamp, 10)); + return date.getDate().toString(); +}; + +export const convertTimestampToLocaleDateString = ( + timestamp: string | null, +): string => { + if (!timestamp) return ''; + return new Date(parseInt(timestamp, 10)).toLocaleString(); +}; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx new file mode 100644 index 0000000000..e8859131df --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx @@ -0,0 +1,102 @@ +import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; +import { useEffect } from 'react'; + +import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard'; +import StatsCard from '../StatsCard/StatsCard'; +import TotalTriggeredCard from '../TotalTriggeredCard/TotalTriggeredCard'; + +const hasTotalTriggeredStats = ( + totalCurrentTriggers: number | string, + totalPastTriggers: number | string, +): boolean => + (Number(totalCurrentTriggers) > 0 && Number(totalPastTriggers) > 0) || + Number(totalCurrentTriggers) > 0; + +const hasAvgResolutionTimeStats = ( + currentAvgResolutionTime: number | string, + pastAvgResolutionTime: number | string, +): boolean => + (Number(currentAvgResolutionTime) > 0 && Number(pastAvgResolutionTime) > 0) || + Number(currentAvgResolutionTime) > 0; + +type StatsCardsRendererProps = { + setTotalCurrentTriggers: (value: number) => void; +}; + +// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title +function StatsCardsRenderer({ + setTotalCurrentTriggers, +}: StatsCardsRendererProps): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsStats(); + + useEffect(() => { + if (data?.payload?.data?.totalCurrentTriggers !== undefined) { + setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers); + } + }, [data, setTotalCurrentTriggers]); + + return ( + + {(data): JSX.Element => { + const { + currentAvgResolutionTime, + pastAvgResolutionTime, + totalCurrentTriggers, + totalPastTriggers, + currentAvgResolutionTimeSeries, + currentTriggersSeries, + } = data; + + return ( + <> + {hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) ? ( + + ) : ( + + )} + + {hasAvgResolutionTimeStats( + currentAvgResolutionTime, + pastAvgResolutionTime, + ) ? ( + + ) : ( + + )} + + ); + }} + + ); +} + +export default StatsCardsRenderer; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss new file mode 100644 index 0000000000..4b3c0a6069 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss @@ -0,0 +1,191 @@ +.top-contributors-card { + width: 56.6%; + overflow: hidden; + + &--view-all { + width: auto; + } + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + + border-bottom: 1px solid var(--bg-slate-500); + .title { + color: var(--text-vanilla-400); + font-size: 13px; + font-weight: 500; + line-height: 22px; + letter-spacing: 0.52px; + text-transform: uppercase; + } + .view-all { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 0; + height: 20px; + &:hover { + background-color: transparent !important; + } + + .label { + color: var(--text-vanilla-400); + font-size: 14px; + line-height: 20px; + letter-spacing: -0.07px; + } + .icon { + display: flex; + } + } + } + .contributors-row { + height: 80px; + } + &__content { + .ant-table { + &-cell { + padding: 12px !important; + } + } + .contributors-row { + background: var(--bg-ink-500); + + td { + border: none !important; + } + &:not(:last-of-type) td { + border-bottom: 1px solid var(--bg-slate-500) !important; + } + } + .total-contribution { + color: var(--text-robin-500); + font-family: 'Geist Mono'; + font-size: 12px; + font-weight: 500; + letter-spacing: -0.06px; + padding: 4px 8px; + background: rgba(78, 116, 248, 0.1); + border-radius: 50px; + width: max-content; + } + } + .empty-content { + margin: 16px 12px; + padding: 40px 45px; + display: flex; + flex-direction: column; + gap: 12px; + border: 1px dashed var(--bg-slate-500); + border-radius: 6px; + + &__icon { + font-family: Inter; + font-size: 20px; + line-height: 26px; + letter-spacing: -0.103px; + } + &__text { + color: var(--text-vanilla-400); + line-height: 18px; + .bold-text { + color: var(--text-vanilla-100); + font-weight: 500; + } + } + &__button-wrapper { + margin-top: 12px; + .configure-alert-rule-button { + padding: 8px 16px; + border-radius: 2px; + background: var(--bg-slate-400); + border-width: 0; + color: var(--text-vanilla-100); + line-height: 24px; + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + } + } + } +} + +.ant-popover-inner:has(.contributor-row-popover-buttons) { + padding: 0 !important; +} +.contributor-row-popover-buttons { + display: flex; + flex-direction: column; + border: 1px solid var(--bg-slate-400); + + &__button { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 15px; + color: var(--text-vanilla-400); + font-size: 14px; + letter-spacing: 0.14px; + width: 160px; + cursor: pointer; + + &:hover { + background: var(--bg-slate-400); + } + + .icon { + display: flex; + } + } +} + +.view-all-drawer { + border-radius: 4px; +} + +.lightMode { + .ant-table { + background: inherit; + } + + .top-contributors-card { + &__header { + border-color: var(--bg-vanilla-300); + .title { + color: var(--text-ink-400); + } + .view-all { + .label { + color: var(--text-ink-400); + } + } + } + &__content { + .contributors-row { + background: inherit; + &:not(:last-of-type) td { + border-bottom: 1px solid var(--bg-vanilla-300) !important; + } + } + } + .empty-content { + border-color: var(--bg-vanilla-300); + &__text { + color: var(--text-ink-400); + .bold-text { + color: var(--text-ink-500); + } + } + &__button-wrapper { + .configure-alert-rule-button { + background: var(--bg-vanilla-300); + color: var(--text-ink-500); + } + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx new file mode 100644 index 0000000000..d3cd0bb756 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx @@ -0,0 +1,84 @@ +import './TopContributorsCard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import history from 'lib/history'; +import { ArrowRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +import TopContributorsContent from './TopContributorsContent'; +import { TopContributorsCardProps } from './types'; +import ViewAllDrawer from './ViewAllDrawer'; + +function TopContributorsCard({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const { search } = useLocation(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + const viewAllTopContributorsParam = searchParams.get('viewAllTopContributors'); + + const [isViewAllVisible, setIsViewAllVisible] = useState( + !!viewAllTopContributorsParam ?? false, + ); + + const isDarkMode = useIsDarkMode(); + + const toggleViewAllParam = (isOpen: boolean): void => { + if (isOpen) { + searchParams.set('viewAllTopContributors', 'true'); + } else { + searchParams.delete('viewAllTopContributors'); + } + }; + + const toggleViewAllDrawer = (): void => { + setIsViewAllVisible((prev) => { + const newState = !prev; + + toggleViewAllParam(newState); + + return newState; + }); + history.push({ search: searchParams.toString() }); + }; + + return ( + <> +
+
+
top contributors
+ {topContributorsData.length > 3 && ( + + )} +
+ + +
+ {isViewAllVisible && ( + + )} + + ); +} + +export default TopContributorsCard; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx new file mode 100644 index 0000000000..b458871f71 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx @@ -0,0 +1,32 @@ +import TopContributorsRows from './TopContributorsRows'; +import { TopContributorsCardProps } from './types'; + +function TopContributorsContent({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const isEmpty = !topContributorsData.length; + + if (isEmpty) { + return ( +
+
ℹ️
+
+ Top contributors highlight the most frequently triggering group-by + attributes in multi-dimensional alerts +
+
+ ); + } + + return ( +
+ +
+ ); +} + +export default TopContributorsContent; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx new file mode 100644 index 0000000000..85857605f8 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx @@ -0,0 +1,87 @@ +import { Color } from '@signozhq/design-tokens'; +import { Progress, Table } from 'antd'; +import { ColumnsType } from 'antd/es/table'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +function TopContributorsRows({ + topContributors, + totalCurrentTriggers, +}: { + topContributors: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + const columns: ColumnsType = [ + { + title: 'labels', + dataIndex: 'labels', + key: 'labels', + width: '51%', + render: ( + labels: AlertRuleTopContributors['labels'], + record, + ): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'progressBar', + dataIndex: 'count', + key: 'progressBar', + width: '39%', + render: (count: AlertRuleTopContributors['count'], record): JSX.Element => ( + + + + ), + }, + { + title: 'count', + dataIndex: 'count', + key: 'count', + width: '10%', + render: (count: AlertRuleTopContributors['count'], record): JSX.Element => ( + +
+ {count}/{totalCurrentTriggers} +
+
+ ), + }, + ]; + + return ( +
`top-contributor-${row.fingerprint}`} + columns={columns} + showHeader={false} + dataSource={topContributors} + pagination={ + topContributors.length > 10 ? { showTotal: PaginationInfoText } : false + } + /> + ); +} + +export default TopContributorsRows; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx new file mode 100644 index 0000000000..1d49c87afd --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx @@ -0,0 +1,46 @@ +import { Color } from '@signozhq/design-tokens'; +import { Drawer } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +import TopContributorsRows from './TopContributorsRows'; + +function ViewAllDrawer({ + isViewAllVisible, + toggleViewAllDrawer, + totalCurrentTriggers, + topContributorsData, +}: { + isViewAllVisible: boolean; + toggleViewAllDrawer: () => void; + topContributorsData: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + const isDarkMode = useIsDarkMode(); + return ( + +
+
+ +
+
+
+ ); +} + +export default ViewAllDrawer; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts new file mode 100644 index 0000000000..f44d2ded99 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts @@ -0,0 +1,6 @@ +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +export type TopContributorsCardProps = { + topContributorsData: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx new file mode 100644 index 0000000000..b773579ca0 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx @@ -0,0 +1,42 @@ +import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; +import { AlertRuleStats } from 'types/api/alerts/def'; + +import TopContributorsCard from '../TopContributorsCard/TopContributorsCard'; + +type TopContributorsRendererProps = { + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}; + +function TopContributorsRenderer({ + totalCurrentTriggers, +}: TopContributorsRendererProps): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTopContributors(); + const response = data?.payload?.data; + + // TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all + return ( + + {(topContributorsData): JSX.Element => ( + + )} + + ); +} + +export default TopContributorsRenderer; diff --git a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx new file mode 100644 index 0000000000..0e4f412894 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx @@ -0,0 +1,26 @@ +import { AlertRuleStats } from 'types/api/alerts/def'; + +import StatsCard from '../StatsCard/StatsCard'; + +type TotalTriggeredCardProps = { + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; + totalPastTriggers: AlertRuleStats['totalPastTriggers']; + timeSeries: AlertRuleStats['currentTriggersSeries']['values']; +}; + +function TotalTriggeredCard({ + totalCurrentTriggers, + totalPastTriggers, + timeSeries, +}: TotalTriggeredCardProps): JSX.Element { + return ( + + ); +} + +export default TotalTriggeredCard; diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss new file mode 100644 index 0000000000..3ea30fe25a --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss @@ -0,0 +1,52 @@ +.timeline-graph { + display: flex; + flex-direction: column; + gap: 24px; + background: var(--bg-ink-400); + padding: 12px; + border-radius: 4px; + border: 1px solid var(--bg-slate-500); + height: 150px; + + &__title { + width: max-content; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid #1d212d; + background: rgba(29, 33, 45, 0.5); + color: #ebebeb; + font-size: 12px; + line-height: 18px; + letter-spacing: -0.06px; + } + &__chart { + .chart-placeholder { + width: 100%; + height: 52px; + background: rgba(255, 255, 255, 0.1215686275); + display: flex; + align-items: center; + justify-content: center; + .chart-icon { + font-size: 2rem; + } + } + } +} + +.lightMode { + .timeline-graph { + background: var(--bg-vanilla-200); + border-color: var(--bg-vanilla-300); + &__title { + background: var(--bg-vanilla-100); + color: var(--text-ink-400); + border-color: var(--bg-vanilla-300); + } + &__chart { + .chart-placeholder { + background: var(--bg-vanilla-300); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx new file mode 100644 index 0000000000..a0534691df --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx @@ -0,0 +1,184 @@ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin'; +import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin'; +import { useMemo, useRef } from 'react'; +import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def'; +import uPlot, { AlignedData } from 'uplot'; + +import { ALERT_STATUS, TIMELINE_OPTIONS } from './constants'; + +type Props = { type: string; data: AlertRuleTimelineGraphResponse[] }; + +function HorizontalTimelineGraph({ + width, + isDarkMode, + data, +}: { + width: number; + isDarkMode: boolean; + data: AlertRuleTimelineGraphResponse[]; +}): JSX.Element { + const transformedData: AlignedData = useMemo(() => { + if (!data?.length) { + return [[], []]; + } + + // add a first and last entry to make sure the graph displays all the data + const FIVE_MINUTES_IN_SECONDS = 300; + + const timestamps = [ + data[0].start / 1000 - FIVE_MINUTES_IN_SECONDS, // 5 minutes before the first entry + ...data.map((item) => item.start / 1000), + data[data.length - 1].end / 1000, // end value of last entry + ]; + + const states = [ + ALERT_STATUS[data[0].state], // Same state as the first entry + ...data.map((item) => ALERT_STATUS[item.state]), + ALERT_STATUS[data[data.length - 1].state], // Same state as the last entry + ]; + + return [timestamps, states]; + }, [data]); + + const options: uPlot.Options = useMemo( + () => ({ + width, + height: 85, + cursor: { show: false }, + + axes: [ + { + gap: 10, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + { show: false }, + ], + legend: { + show: false, + }, + padding: [null, 0, null, 0], + series: [ + { + label: 'Time', + }, + { + label: 'States', + }, + ], + plugins: + transformedData?.length > 1 + ? [ + timelinePlugin({ + count: transformedData.length - 1, + ...TIMELINE_OPTIONS, + }), + ] + : [], + }), + [width, isDarkMode, transformedData], + ); + return ; +} + +const transformVerticalTimelineGraph = (data: any[]): any => [ + data.map((item: { timestamp: any }) => item.timestamp), + Array(data.length).fill(0), + Array(data.length).fill(10), + Array(data.length).fill([0, 1, 2, 3, 4, 5]), + data.map((item: { value: number }) => { + const count = Math.floor(item.value / 10); + return [...Array(count).fill(1), 2]; + }), +]; + +const datatest: any[] = []; +const now = Math.floor(Date.now() / 1000); // current timestamp in seconds +const oneDay = 24 * 60 * 60; // one day in seconds + +for (let i = 0; i < 90; i++) { + const timestamp = now - i * oneDay; + const startOfDay = timestamp - (timestamp % oneDay); + datatest.push({ + timestamp: startOfDay, + value: Math.floor(Math.random() * 30) + 1, + }); +} + +function VerticalTimelineGraph({ + isDarkMode, + width, +}: { + width: number; + isDarkMode: boolean; +}): JSX.Element { + const transformedData = useMemo( + () => transformVerticalTimelineGraph(datatest), + [], + ); + + const options: uPlot.Options = useMemo( + () => ({ + width, + height: 90, + plugins: [heatmapPlugin()], + cursor: { show: false }, + legend: { + show: false, + }, + axes: [ + { + gap: 10, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + { show: false }, + ], + series: [ + {}, + { + paths: (): null => null, + points: { show: false }, + }, + { + paths: (): null => null, + points: { show: false }, + }, + ], + }), + [isDarkMode, width], + ); + return ; +} + +function Graph({ type, data }: Props): JSX.Element | null { + const graphRef = useRef(null); + + const isDarkMode = useIsDarkMode(); + + const containerDimensions = useResizeObserver(graphRef); + + if (type === 'horizontal') { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); +} + +export default Graph; diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts new file mode 100644 index 0000000000..b56499a0d0 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts @@ -0,0 +1,33 @@ +import { Color } from '@signozhq/design-tokens'; + +export const ALERT_STATUS: { [key: string]: number } = { + firing: 0, + inactive: 1, + normal: 1, + 'no-data': 2, + disabled: 3, + muted: 4, +}; + +export const STATE_VS_COLOR: { + [key: string]: { stroke: string; fill: string }; +}[] = [ + {}, + { + 0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 }, + 1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 }, + 2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 }, + 3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 }, + 4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 }, + }, +]; + +export const TIMELINE_OPTIONS = { + mode: 1, + fill: (seriesIdx: any, _: any, value: any): any => + STATE_VS_COLOR[seriesIdx][value].fill, + stroke: (seriesIdx: any, _: any, value: any): any => + STATE_VS_COLOR[seriesIdx][value].stroke, + laneWidthOption: 0.3, + showGrid: false, +}; diff --git a/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx new file mode 100644 index 0000000000..05690a9041 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx @@ -0,0 +1,67 @@ +import '../Graph/Graph.styles.scss'; + +import useUrlQuery from 'hooks/useUrlQuery'; +import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; + +import Graph from '../Graph/Graph'; + +function GraphWrapper({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { + const urlQuery = useUrlQuery(); + + const relativeTime = urlQuery.get('relativeTime'); + + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineGraphData(); + + // TODO(shaheer): uncomment when the API is ready for + // const { startTime } = useAlertHistoryQueryParams(); + + // const [isVerticalGraph, setIsVerticalGraph] = useState(false); + + // useEffect(() => { + // const checkVerticalGraph = (): void => { + // if (startTime) { + // const startTimeDate = dayjs(Number(startTime)); + // const twentyFourHoursAgo = dayjs().subtract( + // HORIZONTAL_GRAPH_HOURS_THRESHOLD, + // DAYJS_MANIPULATE_TYPES.HOUR, + // ); + + // setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo)); + // } + // }; + + // checkVerticalGraph(); + // }, [startTime]); + + return ( +
+
+ {totalCurrentTriggers} triggers in {relativeTime} +
+
+ + {(data): JSX.Element => } + +
+
+ ); +} + +export default GraphWrapper; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss b/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss new file mode 100644 index 0000000000..9d31e0b0ea --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss @@ -0,0 +1,134 @@ +.timeline-table { + border-top: 1px solid var(--text-slate-500); + border-radius: 6px; + overflow: hidden; + margin-top: 4px; + min-height: 600px; + .ant-table { + background: var(--bg-ink-500); + &-cell { + padding: 12px 16px !important; + vertical-align: baseline; + &::before { + display: none; + } + } + &-thead > tr > th { + border-color: var(--bg-slate-500); + background: var(--bg-ink-500); + font-size: 12px; + font-weight: 500; + padding: 12px 16px 8px !important; + &:last-of-type + // TODO(shaheer): uncomment when we display value column + // , + // &:nth-last-of-type(2) + { + text-align: right; + } + } + &-tbody > tr > td { + border: none; + &:last-of-type, + &:nth-last-of-type(2) { + text-align: right; + } + } + } + + .label-filter { + padding: 6px 8px; + border-radius: 4px; + background: var(--text-ink-400); + border-width: 0; + line-height: 18px; + & ::placeholder { + color: var(--text-vanilla-400); + font-size: 12px; + letter-spacing: 0.6px; + text-transform: uppercase; + font-weight: 500; + } + } + .alert-rule { + &-value, + &-created-at { + font-size: 14px; + color: var(--text-vanilla-400); + } + &-value { + font-weight: 500; + line-height: 20px; + } + &-created-at { + line-height: 18px; + letter-spacing: -0.07px; + } + } + .ant-table.ant-table-middle { + border-bottom: 1px solid var(--bg-slate-500); + border-left: 1px solid var(--bg-slate-500); + border-right: 1px solid var(--bg-slate-500); + + border-radius: 6px; + } + .ant-pagination-item { + &-active { + display: flex; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + padding: 1px 8px; + border-radius: 2px; + background: var(--bg-robin-500); + & > a { + color: var(--text-ink-500); + line-height: 20px; + font-weight: 500; + } + } + } + .alert-history-label-search { + .ant-select-selector { + border: none; + } + } +} + +.lightMode { + .timeline-table { + border-color: var(--bg-vanilla-300); + + .ant-table { + background: var(--bg-vanilla-100); + &-thead { + & > tr > th { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } + } + &.ant-table-middle { + border-color: var(--bg-vanilla-300); + } + } + .alert-history-label-search { + .ant-select-selector { + background: var(--bg-vanilla-200); + } + } + + .alert-rule { + &-value, + &-created-at { + color: var(--text-ink-400); + } + } + .ant-pagination-item { + &-active > a { + color: var(--text-vanilla-100); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx new file mode 100644 index 0000000000..f3144b88e6 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx @@ -0,0 +1,56 @@ +import './Table.styles.scss'; + +import { Table } from 'antd'; +import { + useGetAlertRuleDetailsTimelineTable, + useTimelineTable, +} from 'pages/AlertDetails/hooks'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { timelineTableColumns } from './useTimelineTable'; + +function TimelineTable(): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineTable(); + + const { timelineData, totalItems } = useMemo(() => { + const response = data?.payload?.data; + return { + timelineData: response?.items, + totalItems: response?.total, + }; + }, [data?.payload?.data]); + + const { paginationConfig, onChangeHandler } = useTimelineTable({ + totalItems: totalItems ?? 0, + }); + + const { t } = useTranslation('common'); + + if (isError || !isValidRuleId || !ruleId) { + return
{t('something_went_wrong')}
; + } + + return ( +
+
`${row.fingerprint}-${row.value}-${row.unixMilli}`} + columns={timelineTableColumns()} + dataSource={timelineData} + pagination={paginationConfig} + size="middle" + onChange={onChangeHandler} + loading={isLoading || isRefetching} + /> + + ); +} + +export default TimelineTable; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/types.ts b/frontend/src/container/AlertHistory/Timeline/Table/types.ts new file mode 100644 index 0000000000..badf649867 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/types.ts @@ -0,0 +1,9 @@ +import { + AlertRuleTimelineTableResponse, + AlertRuleTimelineTableResponsePayload, +} from 'types/api/alerts/def'; + +export type TimelineTableProps = { + timelineData: AlertRuleTimelineTableResponse[]; + totalItems: AlertRuleTimelineTableResponsePayload['data']['total']; +}; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx new file mode 100644 index 0000000000..5a42fcd5bd --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx @@ -0,0 +1,53 @@ +import { ColumnsType } from 'antd/es/table'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState'; +import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; +import { formatEpochTimestamp } from 'utils/timeUtils'; + +export const timelineTableColumns = (): ColumnsType => [ + { + title: 'STATE', + dataIndex: 'state', + sorter: true, + width: '12.5%', + render: (value, record): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'LABELS', + dataIndex: 'labels', + width: '54.5%', + render: (labels, record): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'CREATED AT', + dataIndex: 'unixMilli', + width: '32.5%', + render: (value, record): JSX.Element => ( + +
{formatEpochTimestamp(value)}
+
+ ), + }, +]; diff --git a/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss new file mode 100644 index 0000000000..c153ba65fc --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss @@ -0,0 +1,32 @@ +.timeline-tabs-and-filters { + display: flex; + justify-content: space-between; + align-items: center; + .reset-button, + .top-5-contributors { + display: flex; + align-items: center; + gap: 10px; + } + .coming-soon { + display: inline-flex; + padding: 4px 8px; + border-radius: 20px; + border: 1px solid rgba(173, 127, 88, 0.2); + background: rgba(173, 127, 88, 0.1); + justify-content: center; + align-items: center; + gap: 5px; + + &__text { + color: var(--text-sienna-400); + font-size: 10px; + font-weight: 500; + letter-spacing: -0.05px; + line-height: normal; + } + &__icon { + display: flex; + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx new file mode 100644 index 0000000000..515cef1616 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx @@ -0,0 +1,90 @@ +import './TabsAndFilters.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { TimelineFilter, TimelineTab } from 'container/AlertHistory/types'; +import history from 'lib/history'; +import { Info } from 'lucide-react'; +import Tabs2 from 'periscope/components/Tabs2'; +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +function ComingSoon(): JSX.Element { + return ( +
+
Coming Soon
+
+ +
+
+ ); +} +function TimelineTabs(): JSX.Element { + const tabs = [ + { + value: TimelineTab.OVERALL_STATUS, + label: 'Overall Status', + }, + { + value: TimelineTab.TOP_5_CONTRIBUTORS, + label: ( +
+ Top 5 Contributors + +
+ ), + disabled: true, + }, + ]; + + return ; +} + +function TimelineFilters(): JSX.Element { + const { search } = useLocation(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + const initialSelectedTab = useMemo( + () => searchParams.get('timelineFilter') ?? TimelineFilter.ALL, + [searchParams], + ); + + const handleFilter = (value: TimelineFilter): void => { + searchParams.set('timelineFilter', value); + history.push({ search: searchParams.toString() }); + }; + + const tabs = [ + { + value: TimelineFilter.ALL, + label: 'All', + }, + { + value: TimelineFilter.FIRED, + label: 'Fired', + }, + { + value: TimelineFilter.RESOLVED, + label: 'Resolved', + }, + ]; + + return ( + + ); +} + +function TabsAndFilters(): JSX.Element { + return ( +
+ + +
+ ); +} + +export default TabsAndFilters; diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss b/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss new file mode 100644 index 0000000000..1d6b4d7990 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss @@ -0,0 +1,22 @@ +.timeline { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 16px; + + &__title { + color: var(--text-vanilla-100); + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: -0.07px; + } +} + +.lightMode { + .timeline { + &__title { + color: var(--text-ink-400); + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.tsx b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx new file mode 100644 index 0000000000..18430f7144 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx @@ -0,0 +1,32 @@ +import './Timeline.styles.scss'; + +import GraphWrapper from './GraphWrapper/GraphWrapper'; +import TimelineTable from './Table/Table'; +import TabsAndFilters from './TabsAndFilters/TabsAndFilters'; + +function TimelineTableRenderer(): JSX.Element { + return ; +} + +function Timeline({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { + return ( +
+
Timeline
+
+ +
+
+ +
+
+ +
+
+ ); +} + +export default Timeline; diff --git a/frontend/src/container/AlertHistory/Timeline/constants.ts b/frontend/src/container/AlertHistory/Timeline/constants.ts new file mode 100644 index 0000000000..2f1652437f --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/constants.ts @@ -0,0 +1,2 @@ +// setting to 25 hours because we want to display the horizontal graph when the user selects 'Last 1 day' from date and time selector +export const HORIZONTAL_GRAPH_HOURS_THRESHOLD = 25; diff --git a/frontend/src/container/AlertHistory/constants.ts b/frontend/src/container/AlertHistory/constants.ts new file mode 100644 index 0000000000..2253a27677 --- /dev/null +++ b/frontend/src/container/AlertHistory/constants.ts @@ -0,0 +1 @@ +export const TIMELINE_TABLE_PAGE_SIZE = 20; diff --git a/frontend/src/container/AlertHistory/index.tsx b/frontend/src/container/AlertHistory/index.tsx new file mode 100644 index 0000000000..3a99a130a6 --- /dev/null +++ b/frontend/src/container/AlertHistory/index.tsx @@ -0,0 +1,3 @@ +import AlertHistory from './AlertHistory'; + +export default AlertHistory; diff --git a/frontend/src/container/AlertHistory/types.ts b/frontend/src/container/AlertHistory/types.ts new file mode 100644 index 0000000000..797a557eed --- /dev/null +++ b/frontend/src/container/AlertHistory/types.ts @@ -0,0 +1,15 @@ +export enum AlertDetailsTab { + OVERVIEW = 'OVERVIEW', + HISTORY = 'HISTORY', +} + +export enum TimelineTab { + OVERALL_STATUS = 'OVERALL_STATUS', + TOP_5_CONTRIBUTORS = 'TOP_5_CONTRIBUTORS', +} + +export enum TimelineFilter { + ALL = 'ALL', + FIRED = 'FIRED', + RESOLVED = 'RESOLVED', +} diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index e821e67104..85a58aac99 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -253,6 +253,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { routeKey === 'MESSAGING_QUEUES' || routeKey === 'MESSAGING_QUEUES_DETAIL'; const isDashboardListView = (): boolean => routeKey === 'ALL_DASHBOARD'; + const isAlertHistory = (): boolean => routeKey === 'ALERT_HISTORY'; + const isAlertOverview = (): boolean => routeKey === 'ALERT_OVERVIEW'; const isDashboardView = (): boolean => { /** * need to match using regex here as the getRoute function will not work for @@ -341,6 +343,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { isDashboardView() || isDashboardWidgetView() || isDashboardListView() || + isAlertHistory() || + isAlertOverview() || isMessagingQueues() ? 0 : '0 1rem', diff --git a/frontend/src/container/FormAlertRules/QuerySection.styles.scss b/frontend/src/container/FormAlertRules/QuerySection.styles.scss index ee3f4892af..303f6d45d8 100644 --- a/frontend/src/container/FormAlertRules/QuerySection.styles.scss +++ b/frontend/src/container/FormAlertRules/QuerySection.styles.scss @@ -42,6 +42,10 @@ display: flex; align-items: center; } + + .ant-tabs-tab-btn { + padding: 0 !important; + } } .lightMode { diff --git a/frontend/src/container/FormAlertRules/index.tsx b/frontend/src/container/FormAlertRules/index.tsx index 965b21aa5a..f53a6b2cfe 100644 --- a/frontend/src/container/FormAlertRules/index.tsx +++ b/frontend/src/container/FormAlertRules/index.tsx @@ -19,6 +19,7 @@ import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts'; import { FeatureKeys } from 'constants/features'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag'; import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag'; @@ -369,7 +370,7 @@ function FormAlertRules({ }); // invalidate rule in cache - ruleCache.invalidateQueries(['ruleId', ruleId]); + ruleCache.invalidateQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]); // eslint-disable-next-line sonarjs/no-identical-functions setTimeout(() => { diff --git a/frontend/src/container/ListAlertRules/ListAlert.tsx b/frontend/src/container/ListAlertRules/ListAlert.tsx index 3ba953be73..f446d55f90 100644 --- a/frontend/src/container/ListAlertRules/ListAlert.tsx +++ b/frontend/src/container/ListAlertRules/ListAlert.tsx @@ -139,7 +139,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { params.set(QueryParams.ruleId, record.id.toString()); setEditLoader(false); - history.push(`${ROUTES.EDIT_ALERTS}?${params.toString()}`); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); }) .catch(handleError) .finally(() => setEditLoader(false)); diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss b/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss index 22efca5009..601b513f8f 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss @@ -62,6 +62,14 @@ .shareable-link-popover { margin-left: 8px; } + .reset-button { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-ink-300); + border: 1px solid var(--bg-slate-400); + margin-right: 16px; + } } .share-modal-content { @@ -296,4 +304,8 @@ } } } + .reset-button { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } } diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts index b652a68202..e0b73deb33 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts @@ -208,6 +208,8 @@ export const routesToSkip = [ ROUTES.DASHBOARD, ROUTES.DASHBOARD_WIDGET, ROUTES.SERVICE_TOP_LEVEL_OPERATIONS, + ROUTES.ALERT_HISTORY, + ROUTES.ALERT_OVERVIEW, ROUTES.MESSAGING_QUEUES, ROUTES.MESSAGING_QUEUES_DETAIL, ]; diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx index 3895d8b38c..cd4fb97d4f 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx @@ -27,7 +27,7 @@ import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax'; import getTimeString from 'lib/getTimeString'; import history from 'lib/history'; import { isObject } from 'lodash-es'; -import { Check, Copy, Info, Send } from 'lucide-react'; +import { Check, Copy, Info, Send, Undo } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useQueryClient } from 'react-query'; import { connect, useSelector } from 'react-redux'; @@ -44,6 +44,7 @@ import { GlobalReducer } from 'types/reducer/globalTime'; import AutoRefresh from '../AutoRefreshV2'; import { DateTimeRangeType } from '../CustomDateTimeModal'; +import { RelativeTimeMap } from '../DateTimeSelection/config'; import { convertOldTimeToNewValidCustomTimeFormat, CustomTimeType, @@ -63,7 +64,9 @@ function DateTimeSelection({ location, updateTimeInterval, globalTimeLoading, + showResetButton = false, showOldExplorerCTA = false, + defaultRelativeTime = RelativeTimeMap['6hr'] as Time, }: Props): JSX.Element { const [formSelector] = Form.useForm(); @@ -242,22 +245,25 @@ function DateTimeSelection({ return defaultSelectedOption; }; - const updateLocalStorageForRoutes = (value: Time | string): void => { - const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION); - if (preRoutes !== null) { - const preRoutesObject = JSON.parse(preRoutes); + const updateLocalStorageForRoutes = useCallback( + (value: Time | string): void => { + const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION); + if (preRoutes !== null) { + const preRoutesObject = JSON.parse(preRoutes); - const preRoute = { - ...preRoutesObject, - }; - preRoute[location.pathname] = value; + const preRoute = { + ...preRoutesObject, + }; + preRoute[location.pathname] = value; - setLocalStorageKey( - LOCALSTORAGE.METRICS_TIME_IN_DURATION, - JSON.stringify(preRoute), - ); - } - }; + setLocalStorageKey( + LOCALSTORAGE.METRICS_TIME_IN_DURATION, + JSON.stringify(preRoute), + ); + } + }, + [location.pathname], + ); const onLastRefreshHandler = useCallback(() => { const currentTime = dayjs(); @@ -297,48 +303,65 @@ function DateTimeSelection({ [location.pathname], ); - const onSelectHandler = (value: Time | CustomTimeType): void => { - if (value !== 'custom') { - setIsOpen(false); - updateTimeInterval(value); - updateLocalStorageForRoutes(value); - setIsValidteRelativeTime(true); - if (refreshButtonHidden) { - setRefreshButtonHidden(false); + const onSelectHandler = useCallback( + (value: Time | CustomTimeType): void => { + if (value !== 'custom') { + setIsOpen(false); + updateTimeInterval(value); + updateLocalStorageForRoutes(value); + setIsValidteRelativeTime(true); + if (refreshButtonHidden) { + setRefreshButtonHidden(false); + } + } else { + setRefreshButtonHidden(true); + setCustomDTPickerVisible(true); + setIsValidteRelativeTime(false); + setEnableAbsoluteTime(false); + + return; } - } else { - setRefreshButtonHidden(true); - setCustomDTPickerVisible(true); - setIsValidteRelativeTime(false); - setEnableAbsoluteTime(false); - return; - } + if (!isLogsExplorerPage) { + urlQuery.delete('startTime'); + urlQuery.delete('endTime'); - if (!isLogsExplorerPage) { - urlQuery.delete('startTime'); - urlQuery.delete('endTime'); + urlQuery.set(QueryParams.relativeTime, value); - urlQuery.set(QueryParams.relativeTime, value); + const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; + history.replace(generatedUrl); + } - const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; - history.replace(generatedUrl); - } + // For logs explorer - time range handling is managed in useCopyLogLink.ts:52 - // For logs explorer - time range handling is managed in useCopyLogLink.ts:52 - - if (!stagedQuery) { - return; - } - // the second boolean param directs the qb about the time change so to merge the query and retain the current state - // we removed update step interval to stop auto updating the value on time change - initQueryBuilderData(stagedQuery, true); - }; + if (!stagedQuery) { + return; + } + // the second boolean param directs the qb about the time change so to merge the query and retain the current state + // we removed update step interval to stop auto updating the value on time change + initQueryBuilderData(stagedQuery, true); + }, + [ + initQueryBuilderData, + isLogsExplorerPage, + location.pathname, + refreshButtonHidden, + stagedQuery, + updateLocalStorageForRoutes, + updateTimeInterval, + urlQuery, + ], + ); const onRefreshHandler = (): void => { onSelectHandler(selectedTime); onLastRefreshHandler(); }; + const handleReset = useCallback(() => { + if (defaultRelativeTime) { + onSelectHandler(defaultRelativeTime); + } + }, [defaultRelativeTime, onSelectHandler]); const onCustomDateHandler = (dateTimeRange: DateTimeRangeType): void => { if (dateTimeRange !== null) { @@ -446,6 +469,22 @@ function DateTimeSelection({ } const currentRoute = location.pathname; + + // set the default relative time for alert history and overview pages if relative time is not specified + if ( + (!urlQuery.has(QueryParams.startTime) || + !urlQuery.has(QueryParams.endTime)) && + !urlQuery.has(QueryParams.relativeTime) && + (currentRoute === ROUTES.ALERT_OVERVIEW || + currentRoute === ROUTES.ALERT_HISTORY) + ) { + updateTimeInterval(defaultRelativeTime); + urlQuery.set(QueryParams.relativeTime, defaultRelativeTime); + const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; + history.replace(generatedUrl); + return; + } + const time = getDefaultTime(currentRoute); const currentOptions = getOptions(currentRoute); @@ -575,6 +614,19 @@ function DateTimeSelection({ return (
+ {showResetButton && selectedTime !== defaultRelativeTime && ( + + + + )} {showOldExplorerCTA && (
@@ -666,11 +718,15 @@ interface DateTimeSelectionV2Props { showAutoRefresh: boolean; hideShareModal?: boolean; showOldExplorerCTA?: boolean; + showResetButton?: boolean; + defaultRelativeTime?: Time; } DateTimeSelection.defaultProps = { hideShareModal: false, showOldExplorerCTA: false, + showResetButton: false, + defaultRelativeTime: RelativeTimeMap['6hr'] as Time, }; interface DispatchProps { updateTimeInterval: ( diff --git a/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts b/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts new file mode 100644 index 0000000000..d2eb2c09e0 --- /dev/null +++ b/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts @@ -0,0 +1,49 @@ +import { Color } from '@signozhq/design-tokens'; +import uPlot from 'uplot'; + +const bucketIncr = 5; + +function heatmapPlugin(): uPlot.Plugin { + function fillStyle(count: number): string { + const colors = [Color.BG_CHERRY_500, Color.BG_SLATE_400]; + return colors[count - 1]; + } + + return { + hooks: { + draw: (u: uPlot): void => { + const { ctx, data } = u; + + const yData = (data[3] as unknown) as number[][]; + const yQtys = (data[4] as unknown) as number[][]; + const yHgt = Math.floor( + u.valToPos(bucketIncr, 'y', true) - u.valToPos(0, 'y', true), + ); + + ctx.save(); + ctx.beginPath(); + ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + ctx.clip(); + + yData.forEach((yVals, xi) => { + const xPos = Math.floor(u.valToPos(data[0][xi], 'x', true)); + + // const maxCount = yQtys[xi].reduce( + // (acc, val) => Math.max(val, acc), + // -Infinity, + // ); + + yVals.forEach((yVal, yi) => { + const yPos = Math.floor(u.valToPos(yVal, 'y', true)); + + ctx.fillStyle = fillStyle(yQtys[xi][yi]); + ctx.fillRect(xPos - 4, yPos, 30, yHgt); + }); + }); + + ctx.restore(); + }, + }, + }; +} +export default heatmapPlugin; diff --git a/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts new file mode 100644 index 0000000000..b740fb2b2c --- /dev/null +++ b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts @@ -0,0 +1,632 @@ +import uPlot from 'uplot'; + +export function pointWithin( + px: number, + py: number, + rlft: number, + rtop: number, + rrgt: number, + rbtm: number, +): boolean { + return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm; +} +const MAX_OBJECTS = 10; +const MAX_LEVELS = 4; + +export class Quadtree { + x: number; + + y: number; + + w: number; + + h: number; + + l: number; + + o: any[]; + + q: Quadtree[] | null; + + constructor(x: number, y: number, w: number, h: number, l?: number) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + this.l = l || 0; + this.o = []; + this.q = null; + } + + split(): void { + const w = this.w / 2; + const h = this.h / 2; + const l = this.l + 1; + + this.q = [ + // top right + new Quadtree(this.x + w, this.y, w, h, l), + // top left + new Quadtree(this.x, this.y, w, h, l), + // bottom left + new Quadtree(this.x, this.y + h, w, h, l), + // bottom right + new Quadtree(this.x + w, this.y + h, w, h, l), + ]; + } + + quads( + x: number, + y: number, + w: number, + h: number, + cb: (quad: Quadtree) => void, + ): void { + const { q } = this; + const hzMid = this.x + this.w / 2; + const vtMid = this.y + this.h / 2; + const startIsNorth = y < vtMid; + const startIsWest = x < hzMid; + const endIsEast = x + w > hzMid; + const endIsSouth = y + h > vtMid; + if (q) { + // top-right quad + if (startIsNorth && endIsEast) { + cb(q[0]); + } + // top-left quad + if (startIsWest && startIsNorth) { + cb(q[1]); + } + // bottom-left quad + if (startIsWest && endIsSouth) { + cb(q[2]); + } + // bottom-right quad + if (endIsEast && endIsSouth) { + cb(q[3]); + } + } + } + + add(o: any): void { + if (this.q != null) { + this.quads(o.x, o.y, o.w, o.h, (q) => { + q.add(o); + }); + } else { + const os = this.o; + + os.push(o); + + if (os.length > MAX_OBJECTS && this.l < MAX_LEVELS) { + this.split(); + + for (let i = 0; i < os.length; i++) { + const oi = os[i]; + + this.quads(oi.x, oi.y, oi.w, oi.h, (q) => { + q.add(oi); + }); + } + + this.o.length = 0; + } + } + } + + get(x: number, y: number, w: number, h: number, cb: (o: any) => void): void { + const os = this.o; + + for (let i = 0; i < os.length; i++) { + cb(os[i]); + } + + if (this.q != null) { + this.quads(x, y, w, h, (q) => { + q.get(x, y, w, h, cb); + }); + } + } + + clear(): void { + this.o.length = 0; + this.q = null; + } +} + +Object.assign(Quadtree.prototype, { + split: Quadtree.prototype.split, + quads: Quadtree.prototype.quads, + add: Quadtree.prototype.add, + get: Quadtree.prototype.get, + clear: Quadtree.prototype.clear, +}); + +const { round, min, ceil } = Math; + +function roundDec(val: number, dec: number): number { + return Math.round(val * 10 ** dec) / 10 ** dec; +} + +export const SPACE_BETWEEN = 1; +export const SPACE_AROUND = 2; +export const SPACE_EVENLY = 3; +export const inf = Infinity; + +const coord = (i: number, offs: number, iwid: number, gap: number): number => + roundDec(offs + i * (iwid + gap), 6); + +export function distr( + numItems: number, + sizeFactor: number, + justify: number, + onlyIdx: number | null, + each: (i: number, offPct: number, dimPct: number) => void, +): void { + const space = 1 - sizeFactor; + + let gap = 0; + if (justify === SPACE_BETWEEN) { + gap = space / (numItems - 1); + } else if (justify === SPACE_AROUND) { + gap = space / numItems; + } else if (justify === SPACE_EVENLY) { + gap = space / (numItems + 1); + } + + if (Number.isNaN(gap) || gap === Infinity) gap = 0; + + let offs = 0; + if (justify === SPACE_AROUND) { + offs = gap / 2; + } else if (justify === SPACE_EVENLY) { + offs = gap; + } + + const iwid = sizeFactor / numItems; + const iwidRounded = roundDec(iwid, 6); + + if (onlyIdx == null) { + for (let i = 0; i < numItems; i++) + each(i, coord(i, offs, iwid, gap), iwidRounded); + } else each(onlyIdx, coord(onlyIdx, offs, iwid, gap), iwidRounded); +} + +function timelinePlugin(opts: any): any { + const { mode, count, fill, stroke, laneWidthOption, showGrid } = opts; + + const pxRatio = devicePixelRatio; + + const laneWidth = laneWidthOption ?? 0.9; + + const laneDistr = SPACE_BETWEEN; + + const font = `${round(14 * pxRatio)}px Geist Mono`; + + function walk( + yIdx: number | null, + count: number, + dim: number, + draw: (iy: number, y0: number, hgt: number) => void, + ): void { + distr( + count, + laneWidth, + laneDistr, + yIdx, + (i: number, offPct: number, dimPct: number) => { + const laneOffPx = dim * offPct; + const laneWidPx = dim * dimPct; + + draw(i, laneOffPx, laneWidPx); + }, + ); + } + + const size = opts.size ?? [0.6, Infinity]; + const align = opts.align ?? 0; + + const gapFactor = 1 - size[0]; + const maxWidth = (size[1] ?? inf) * pxRatio; + + const fillPaths = new Map(); + const strokePaths = new Map(); + + function drawBoxes(ctx: CanvasRenderingContext2D): void { + fillPaths.forEach((fillPath, fillStyle) => { + ctx.fillStyle = fillStyle; + ctx.fill(fillPath); + }); + + strokePaths.forEach((strokePath, strokeStyle) => { + ctx.strokeStyle = strokeStyle; + ctx.stroke(strokePath); + }); + + fillPaths.clear(); + strokePaths.clear(); + } + let qt: Quadtree; + + function putBox( + ctx: CanvasRenderingContext2D, + rect: (path: Path2D, x: number, y: number, w: number, h: number) => void, + xOff: number, + yOff: number, + lft: number, + top: number, + wid: number, + hgt: number, + strokeWidth: number, + iy: number, + ix: number, + value: number | null, + ): void { + const fillStyle = fill(iy + 1, ix, value); + let fillPath = fillPaths.get(fillStyle); + + if (fillPath == null) fillPaths.set(fillStyle, (fillPath = new Path2D())); + + rect(fillPath, lft, top, wid, hgt); + + if (strokeWidth) { + const strokeStyle = stroke(iy + 1, ix, value); + let strokePath = strokePaths.get(strokeStyle); + + if (strokePath == null) + strokePaths.set(strokeStyle, (strokePath = new Path2D())); + + rect( + strokePath, + lft + strokeWidth / 2, + top + strokeWidth / 2, + wid - strokeWidth, + hgt - strokeWidth, + ); + } + + qt.add({ + x: round(lft - xOff), + y: round(top - yOff), + w: wid, + h: hgt, + sidx: iy + 1, + didx: ix, + }); + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + function drawPaths(u: uPlot, sidx: number, idx0: number, idx1: number): null { + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect, + ) => { + const strokeWidth = round((series.width || 0) * pxRatio); + + u.ctx.save(); + rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + walk(sidx - 1, count, yDim, (iy: number, y0: number, hgt: number) => { + // draw spans + if (mode === 1) { + for (let ix = 0; ix < dataY.length; ix++) { + if (dataY[ix] != null) { + const lft = round(valToPosX(dataX[ix], scaleX, xDim, xOff)); + + let nextIx = ix; + // eslint-disable-next-line no-empty + while (dataY[++nextIx] === undefined && nextIx < dataY.length) {} + + // to now (not to end of chart) + const rgt = + nextIx === dataY.length + ? xOff + xDim + strokeWidth + : round(valToPosX(dataX[nextIx], scaleX, xDim, xOff)); + + putBox( + u.ctx, + rect, + xOff, + yOff, + lft, + round(yOff + y0), + rgt - lft, + round(hgt), + strokeWidth, + iy, + ix, + dataY[ix], + ); + + ix = nextIx - 1; + } + } + } + // draw matrix + else { + const colWid = + valToPosX(dataX[1], scaleX, xDim, xOff) - + valToPosX(dataX[0], scaleX, xDim, xOff); + const gapWid = colWid * gapFactor; + const barWid = round(min(maxWidth, colWid - gapWid) - strokeWidth); + let xShift; + if (align === 1) { + xShift = 0; + } else if (align === -1) { + xShift = barWid; + } else { + xShift = barWid / 2; + } + + for (let ix = idx0; ix <= idx1; ix++) { + if (dataY[ix] != null) { + // TODO: all xPos can be pre-computed once for all series in aligned set + const lft = valToPosX(dataX[ix], scaleX, xDim, xOff); + + putBox( + u.ctx, + rect, + xOff, + yOff, + round(lft - xShift), + round(yOff + y0), + barWid, + round(hgt), + strokeWidth, + iy, + ix, + dataY[ix], + ); + } + } + } + }); + + // eslint-disable-next-line no-param-reassign + u.ctx.lineWidth = strokeWidth; + drawBoxes(u.ctx); + + u.ctx.restore(); + }, + ); + + return null; + } + const yMids = Array(count).fill(0); + function drawPoints(u: uPlot, sidx: number): boolean { + u.ctx.save(); + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + const { ctx } = u; + ctx.font = font; + ctx.fillStyle = 'black'; + ctx.textAlign = mode === 1 ? 'left' : 'center'; + ctx.textBaseline = 'middle'; + + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + ) => { + const strokeWidth = round((series.width || 0) * pxRatio); + const textOffset = mode === 1 ? strokeWidth + 2 : 0; + + const y = round(yOff + yMids[sidx - 1]); + if (opts.displayTimelineValue) { + for (let ix = 0; ix < dataY.length; ix++) { + if (dataY[ix] != null) { + const x = valToPosX(dataX[ix], scaleX, xDim, xOff) + textOffset; + u.ctx.fillText(String(dataY[ix]), x, y); + } + } + } + }, + ); + + u.ctx.restore(); + + return false; + } + + const hovered = Array(count).fill(null); + + const ySplits = Array(count).fill(0); + + const fmtDate = uPlot.fmtDate('{YYYY}-{MM}-{DD} {HH}:{mm}:{ss}'); + let legendTimeValueEl: HTMLElement | null = null; + + return { + hooks: { + init: (u: uPlot): void => { + legendTimeValueEl = u.root.querySelector('.u-series:first-child .u-value'); + }, + drawClear: (u: uPlot): void => { + qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + + qt.clear(); + + // force-clear the path cache to cause drawBars() to rebuild new quadtree + u.series.forEach((s: any) => { + // eslint-disable-next-line no-param-reassign + s._paths = null; + }); + }, + setCursor: (u: { + posToVal: (arg0: any, arg1: string) => any; + cursor: { left: any }; + scales: { x: { time: any } }; + }): any => { + if (mode === 1 && legendTimeValueEl) { + const val = u.posToVal(u.cursor.left, 'x'); + legendTimeValueEl.textContent = u.scales.x.time + ? fmtDate(new Date(val * 1e3)) + : val.toFixed(2); + } + }, + }, + // eslint-disable-next-line sonarjs/cognitive-complexity + opts: (u: { series: { label: any }[] }, opts: any): any => { + uPlot.assign(opts, { + cursor: { + // x: false, + y: false, + dataIdx: ( + u: { cursor: { left: number } }, + seriesIdx: number, + closestIdx: any, + ) => { + if (seriesIdx === 0) return closestIdx; + + const cx = round(u.cursor.left * pxRatio); + + if (cx >= 0) { + const cy = yMids[seriesIdx - 1]; + + hovered[seriesIdx - 1] = null; + + qt.get(cx, cy, 1, 1, (o: { x: any; y: any; w: any; h: any }) => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) + hovered[seriesIdx - 1] = o; + }); + } + + return hovered[seriesIdx - 1]?.didx; + }, + points: { + fill: 'rgba(0,0,0,0.3)', + bbox: (u: any, seriesIdx: number) => { + const hRect = hovered[seriesIdx - 1]; + + return { + left: hRect ? round(hRect.x / devicePixelRatio) : -10, + top: hRect ? round(hRect.y / devicePixelRatio) : -10, + width: hRect ? round(hRect.w / devicePixelRatio) : 0, + height: hRect ? round(hRect.h / devicePixelRatio) : 0, + }; + }, + }, + }, + scales: { + x: { + range(u: { data: number[][] }, min: number, max: number) { + if (mode === 2) { + const colWid = u.data[0][1] - u.data[0][0]; + const scalePad = colWid / 2; + + // eslint-disable-next-line no-param-reassign + if (min <= u.data[0][0]) min = u.data[0][0] - scalePad; + + const lastIdx = u.data[0].length - 1; + + // eslint-disable-next-line no-param-reassign + if (max >= u.data[0][lastIdx]) max = u.data[0][lastIdx] + scalePad; + } + + return [min, max]; + }, + }, + y: { + range: [0, 1], + }, + }, + }); + + uPlot.assign(opts.axes[0], { + splits: + mode === 2 + ? ( + u: { data: any[][] }, + scaleMin: number, + scaleMax: number, + foundIncr: number, + ): any => { + const splits = []; + + const dataIncr = u.data[0][1] - u.data[0][0]; + const skipFactor = ceil(foundIncr / dataIncr); + + for (let i = 0; i < u.data[0].length; i += skipFactor) { + const v = u.data[0][i]; + + if (v >= scaleMin && v <= scaleMax) splits.push(v); + } + + return splits; + } + : null, + grid: { + show: showGrid ?? mode !== 2, + }, + }); + + uPlot.assign(opts.axes[1], { + splits: (u: { + bbox: { height: any }; + posToVal: (arg0: number, arg1: string) => any; + }) => { + walk(null, count, u.bbox.height, (iy: any, y0: number, hgt: number) => { + // vertical midpoints of each series' timeline (stored relative to .u-over) + yMids[iy] = round(y0 + hgt / 2); + ySplits[iy] = u.posToVal(yMids[iy] / pxRatio, 'y'); + }); + + return ySplits; + }, + values: () => + Array(count) + .fill(null) + .map((v, i) => u.series[i + 1].label), + gap: 15, + size: 70, + grid: { show: false }, + ticks: { show: false }, + + side: 3, + }); + + opts.series.forEach((s: any, i: number) => { + if (i > 0) { + uPlot.assign(s, { + // width: 0, + // pxAlign: false, + // stroke: "rgba(255,0,0,0.5)", + paths: drawPaths, + points: { + show: drawPoints, + }, + }); + } + }); + }, + }; +} + +export default timelinePlugin; diff --git a/frontend/src/pages/AlertDetails/AlertDetails.styles.scss b/frontend/src/pages/AlertDetails/AlertDetails.styles.scss new file mode 100644 index 0000000000..62eeb96ae0 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertDetails.styles.scss @@ -0,0 +1,189 @@ +@mixin flex-center { + display: flex; + justify-content: space-between; + align-items: center; +} + +.alert-details-tabs { + .top-level-tab.periscope-tab { + padding: 2px 0; + } + .ant-tabs { + &-nav { + margin-bottom: 0 !important; + &::before { + border-bottom: 1px solid var(--bg-slate-500) !important; + } + } + &-tab { + &[data-node-key='TriggeredAlerts'] { + margin-left: 16px; + } + &:not(:first-of-type) { + margin-left: 24px !important; + } + .periscope-tab { + font-size: 14px; + color: var(--text-vanilla-100); + line-height: 20px; + letter-spacing: -0.07px; + gap: 10px; + } + [aria-selected='false'] { + .periscope-tab { + color: var(--text-vanilla-400); + } + } + } + } +} + +.alert-details { + margin-top: 10px; + .divider { + border-color: var(--bg-slate-500); + margin: 16px 0; + } + .breadcrumb-divider { + margin-top: 10px; + } + &__breadcrumb { + ol { + align-items: center; + } + padding-left: 16px; + .breadcrumb-item { + color: var(--text-vanilla-400); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + padding: 0; + } + + .ant-breadcrumb-separator, + .breadcrumb-item--last { + color: var(--text-vanilla-500); + font-family: 'Geist Mono'; + } + } + .tabs-and-filters { + margin: 1rem 0; + + .ant-tabs { + &-ink-bar { + background-color: transparent; + } + &-nav { + &-wrap { + padding: 0 16px 16px 16px; + } + &::before { + border-bottom: none !important; + } + } + &-tab { + margin-left: 0 !important; + padding: 0; + + &-btn { + padding: 6px 17px; + color: var(--text-vanilla-400) !important; + letter-spacing: -0.07px; + font-size: 14px; + + &[aria-selected='true'] { + color: var(--text-vanilla-100) !important; + } + } + &-active { + background: var(--bg-slate-400, #1d212d); + } + } + &-extra-content { + padding: 0 16px 16px; + } + &-nav-list { + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-400); + border-radius: 2px; + } + } + + .tab-item { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + } + .filters { + @include flex-center; + gap: 16px; + .reset-button { + @include flex-center; + } + } + } +} + +.lightMode { + .alert-details { + &-tabs { + .ant-tabs-nav { + &::before { + border-bottom: 1px solid var(--bg-vanilla-300) !important; + } + } + } + &__breadcrumb { + .ant-breadcrumb-link { + color: var(--text-ink-400); + } + .ant-breadcrumb-separator, + span.ant-breadcrumb-link { + color: var(--text-ink-500); + } + } + .tabs-and-filters { + .ant-tabs { + &-nav-list { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-300); + } + &-tab { + &-btn { + &[aria-selected='true'] { + color: var(--text-robin-500) !important; + } + color: var(--text-ink-400) !important; + } + &-active { + background: var(--bg-vanilla-100); + } + } + } + } + .divider { + border-color: var(--bg-vanilla-300); + } + } + + .alert-details-tabs { + .ant-tabs { + &-nav { + &::before { + border: none !important; + } + } + &-tab { + .periscope-tab { + color: var(--text-ink-300); + } + [aria-selected='true'] { + .periscope-tab { + color: var(--text-ink-400); + } + } + } + } + } +} diff --git a/frontend/src/pages/AlertDetails/AlertDetails.tsx b/frontend/src/pages/AlertDetails/AlertDetails.tsx new file mode 100644 index 0000000000..c79478fb77 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertDetails.tsx @@ -0,0 +1,123 @@ +import './AlertDetails.styles.scss'; + +import { Breadcrumb, Button, Divider } from 'antd'; +import { Filters } from 'components/AlertDetailsFilters/Filters'; +import NotFound from 'components/NotFound'; +import RouteTab from 'components/RouteTab'; +import Spinner from 'components/Spinner'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation } from 'react-router-dom'; + +import AlertHeader from './AlertHeader/AlertHeader'; +import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks'; +import { AlertDetailsStatusRendererProps } from './types'; + +function AlertDetailsStatusRenderer({ + isLoading, + isError, + isRefetching, + data, +}: AlertDetailsStatusRendererProps): JSX.Element { + const alertRuleDetails = useMemo(() => data?.payload?.data, [data]); + const { t } = useTranslation('common'); + + if (isLoading || isRefetching) { + return ; + } + + if (isError) { + return
{data?.error || t('something_went_wrong')}
; + } + + return ; +} + +function BreadCrumbItem({ + title, + isLast, + route, +}: { + title: string | null; + isLast?: boolean; + route?: string; +}): JSX.Element { + if (isLast) { + return
{title}
; + } + const handleNavigate = (): void => { + if (!route) { + return; + } + history.push(ROUTES.LIST_ALL_ALERT); + }; + + return ( + + ); +} + +BreadCrumbItem.defaultProps = { + isLast: false, + route: '', +}; + +function AlertDetails(): JSX.Element { + const { pathname } = useLocation(); + const { routes } = useRouteTabUtils(); + + const { + isLoading, + isRefetching, + isError, + ruleId, + isValidRuleId, + alertDetailsResponse, + } = useGetAlertRuleDetails(); + + if ( + isError || + !isValidRuleId || + (alertDetailsResponse && alertDetailsResponse.statusCode !== 200) + ) { + return ; + } + + return ( +
+ + ), + }, + { + title: , + }, + ]} + /> + + + + +
+ } + /> +
+
+ ); +} + +export default AlertDetails; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss new file mode 100644 index 0000000000..edd94a5bcd --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss @@ -0,0 +1,63 @@ +.alert-action-buttons { + display: flex; + align-items: center; + gap: 12px; + color: var(--bg-slate-400); + .ant-divider-vertical { + height: 16px; + border-color: var(--bg-slate-400); + margin: 0; + } + .dropdown-icon { + margin-right: 4px; + } +} +.dropdown-menu { + border-radius: 4px; + box-shadow: none; + background: linear-gradient( + 138.7deg, + rgba(18, 19, 23, 0.8) 0%, + rgba(18, 19, 23, 0.9) 98.68% + ); + + .dropdown-divider { + margin: 0; + } + + .delete-button { + border: none; + display: flex; + align-items: center; + width: 100%; + + &, + & span { + &:hover { + background: var(--bg-slate-400); + color: var(--bg-cherry-400); + } + color: var(--bg-cherry-400); + font-size: 14px; + } + } +} + +.lightMode { + .alert-action-buttons { + .ant-divider-vertical { + border-color: var(--bg-vanilla-300); + } + } + .dropdown-menu { + background: inherit; + .delete-button { + &, + &span { + &:hover { + background: var(--bg-vanilla-300); + } + } + } + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx new file mode 100644 index 0000000000..186a34676b --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx @@ -0,0 +1,111 @@ +import './ActionButtons.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd'; +import { QueryParams } from 'constants/query'; +import ROUTES from 'constants/routes'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import useUrlQuery from 'hooks/useUrlQuery'; +import history from 'lib/history'; +import { Copy, Ellipsis, PenLine, Trash2 } from 'lucide-react'; +import { + useAlertRuleDelete, + useAlertRuleDuplicate, + useAlertRuleStatusToggle, +} from 'pages/AlertDetails/hooks'; +import CopyToClipboard from 'periscope/components/CopyToClipboard'; +import { useAlertRule } from 'providers/Alert'; +import React from 'react'; +import { CSSProperties } from 'styled-components'; +import { AlertDef } from 'types/api/alerts/def'; + +import { AlertHeaderProps } from '../AlertHeader'; + +const menuItemStyle: CSSProperties = { + fontSize: '14px', + letterSpacing: '0.14px', +}; +function AlertActionButtons({ + ruleId, + alertDetails, +}: { + ruleId: string; + alertDetails: AlertHeaderProps['alertDetails']; +}): JSX.Element { + const { isAlertRuleDisabled } = useAlertRule(); + const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId }); + + const { handleAlertDuplicate } = useAlertRuleDuplicate({ + alertDetails: (alertDetails as unknown) as AlertDef, + }); + const { handleAlertDelete } = useAlertRuleDelete({ ruleId: Number(ruleId) }); + + const params = useUrlQuery(); + + const handleRename = React.useCallback(() => { + params.set(QueryParams.ruleId, String(ruleId)); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); + }, [params, ruleId]); + + const menu: MenuProps['items'] = React.useMemo( + () => [ + { + key: 'rename-rule', + label: 'Rename', + icon: , + onClick: (): void => handleRename(), + style: menuItemStyle, + }, + { + key: 'duplicate-rule', + label: 'Duplicate', + icon: , + onClick: (): void => handleAlertDuplicate(), + style: menuItemStyle, + }, + { type: 'divider' }, + { + key: 'delete-rule', + label: 'Delete', + icon: , + onClick: (): void => handleAlertDelete(), + style: { + ...menuItemStyle, + color: Color.BG_CHERRY_400, + }, + }, + ], + [handleAlertDelete, handleAlertDuplicate, handleRename], + ); + const isDarkMode = useIsDarkMode(); + + return ( +
+ + {isAlertRuleDisabled !== undefined && ( + + )} + + + + + + + + + + +
+ ); +} + +export default AlertActionButtons; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss new file mode 100644 index 0000000000..10a05f2258 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss @@ -0,0 +1,50 @@ +.alert-info { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0 16px; + + &__info-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + height: 54px; + + .top-section { + display: flex; + align-items: center; + justify-content: space-between; + .alert-title-wrapper { + display: flex; + align-items: center; + gap: 8px; + .alert-title { + font-size: 16px; + font-weight: 500; + color: var(--text-vanilla-100); + line-height: 24px; + letter-spacing: -0.08px; + } + } + } + .bottom-section { + display: flex; + align-items: center; + gap: 24px; + } + } +} + +.lightMode { + .alert-info { + &__info-wrapper { + .top-section { + .alert-title-wrapper { + .alert-title { + color: var(--text-ink-100); + } + } + } + } + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx new file mode 100644 index 0000000000..f4ff7b933b --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx @@ -0,0 +1,66 @@ +import './AlertHeader.styles.scss'; + +import { useAlertRule } from 'providers/Alert'; +import { useEffect, useMemo } from 'react'; + +import AlertActionButtons from './ActionButtons/ActionButtons'; +import AlertLabels from './AlertLabels/AlertLabels'; +import AlertSeverity from './AlertSeverity/AlertSeverity'; +import AlertState from './AlertState/AlertState'; + +export type AlertHeaderProps = { + alertDetails: { + state: string; + alert: string; + id: string; + labels: Record; + disabled: boolean; + }; +}; +function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element { + const { state, alert, labels, disabled } = alertDetails; + + const labelsWithoutSeverity = useMemo( + () => + Object.fromEntries( + Object.entries(labels).filter(([key]) => key !== 'severity'), + ), + [labels], + ); + + const { isAlertRuleDisabled, setIsAlertRuleDisabled } = useAlertRule(); + + useEffect(() => { + if (isAlertRuleDisabled === undefined) { + setIsAlertRuleDisabled(disabled); + } + }, [disabled, setIsAlertRuleDisabled, isAlertRuleDisabled]); + + return ( +
+
+
+
+ +
{alert}
+
+
+
+ + + {/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */} + {/* */} + +
+
+
+ +
+
+ ); +} + +export default AlertHeader; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss new file mode 100644 index 0000000000..3468bad7ec --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss @@ -0,0 +1,5 @@ +.alert-labels { + display: flex; + flex-wrap: wrap; + gap: 4px 6px; +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx new file mode 100644 index 0000000000..bdc5eaa019 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx @@ -0,0 +1,31 @@ +import './AlertLabels.styles.scss'; + +import KeyValueLabel from 'periscope/components/KeyValueLabel'; +import SeeMore from 'periscope/components/SeeMore'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AlertLabelsProps = { + labels: Record; + initialCount?: number; +}; + +function AlertLabels({ + labels, + initialCount = 2, +}: AlertLabelsProps): JSX.Element { + return ( +
+ + {Object.entries(labels).map(([key, value]) => ( + + ))} + +
+ ); +} + +AlertLabels.defaultProps = { + initialCount: 2, +}; + +export default AlertLabels; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss new file mode 100644 index 0000000000..ba0226a11d --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss @@ -0,0 +1,40 @@ +@mixin severity-styles($background, $text-color) { + .alert-severity__icon { + background: $background; + } + .alert-severity__text { + color: $text-color; + } +} + +.alert-severity { + display: flex; + align-items: center; + gap: 8px; + + overflow: hidden; + &__icon { + display: flex; + align-items: center; + justify-content: center; + height: 14px; + width: 14px; + border-radius: 3.5px; + } + &__text { + color: var(--text-sakura-400); + font-size: 14px; + line-height: 18px; + } + + &--critical, + &--error { + @include severity-styles(rgba(245, 108, 135, 0.2), var(--text-sakura-400)); + } + &--warning { + @include severity-styles(rgba(255, 215, 120, 0.2), var(--text-amber-400)); + } + &--info { + @include severity-styles(rgba(113, 144, 249, 0.2), var(--text-robin-400)); + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx new file mode 100644 index 0000000000..90e7c14de4 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx @@ -0,0 +1,42 @@ +import './AlertSeverity.styles.scss'; + +import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon'; +import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon'; +import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon'; +import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon'; + +export default function AlertSeverity({ + severity, +}: { + severity: string; +}): JSX.Element { + const severityConfig: Record> = { + critical: { + text: 'Critical', + className: 'alert-severity--critical', + icon: , + }, + error: { + text: 'Error', + className: 'alert-severity--error', + icon: , + }, + warning: { + text: 'Warning', + className: 'alert-severity--warning', + icon: , + }, + info: { + text: 'Info', + className: 'alert-severity--info', + icon: , + }, + }; + const severityDetails = severityConfig[severity]; + return ( +
+
{severityDetails.icon}
+
{severityDetails.text}
+
+ ); +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss new file mode 100644 index 0000000000..582494e54a --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss @@ -0,0 +1,10 @@ +.alert-state { + display: flex; + align-items: center; + gap: 6px; + &__label { + font-size: 14px; + line-height: 18px; + letter-spacing: -0.07px; + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx new file mode 100644 index 0000000000..d2be316d8a --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx @@ -0,0 +1,73 @@ +import './AlertState.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { BellOff, CircleCheck, CircleOff, Flame } from 'lucide-react'; + +type AlertStateProps = { + state: string; + showLabel?: boolean; +}; + +export default function AlertState({ + state, + showLabel, +}: AlertStateProps): JSX.Element { + let icon; + let label; + const isDarkMode = useIsDarkMode(); + switch (state) { + case 'no-data': + icon = ( + + ); + label = No Data; + break; + + case 'disabled': + icon = ( + + ); + label = Muted; + break; + case 'firing': + icon = ( + + ); + label = Firing; + break; + + case 'normal': + case 'inactive': + icon = ( + + ); + label = Resolved; + break; + + default: + icon = null; + } + + return ( +
+ {icon} {showLabel &&
{label}
} +
+ ); +} + +AlertState.defaultProps = { + showLabel: false, +}; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss new file mode 100644 index 0000000000..97549bf21d --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss @@ -0,0 +1,22 @@ +.alert-status-info { + gap: 6px; + color: var(--text-vanilla-400); + &__icon { + display: flex; + align-items: baseline; + } + &, + &__details { + display: flex; + align-items: center; + } + &__details { + gap: 3px; + } +} + +.lightMode { + .alert-status-info { + color: var(--text-ink-400); + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx new file mode 100644 index 0000000000..dd06d107bb --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx @@ -0,0 +1,54 @@ +import './AlertStatus.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { CircleCheck, Siren } from 'lucide-react'; +import { useMemo } from 'react'; +import { getDurationFromNow } from 'utils/timeUtils'; + +import { AlertStatusProps, StatusConfig } from './types'; + +export default function AlertStatus({ + status, + timestamp, +}: AlertStatusProps): JSX.Element { + const statusConfig: StatusConfig = useMemo( + () => ({ + firing: { + icon: , + text: 'Firing since', + extraInfo: timestamp ? ( + <> +
+
{getDurationFromNow(timestamp)}
+ + ) : null, + className: 'alert-status-info--firing', + }, + resolved: { + icon: ( + + ), + text: 'Resolved', + extraInfo: null, + className: 'alert-status-info--resolved', + }, + }), + [timestamp], + ); + + const currentStatus = statusConfig[status]; + + return ( +
+
{currentStatus.icon}
+
+
{currentStatus.text}
+ {currentStatus.extraInfo} +
+
+ ); +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts new file mode 100644 index 0000000000..c297480f38 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts @@ -0,0 +1,18 @@ +export type AlertStatusProps = + | { status: 'firing'; timestamp: number } + | { status: 'resolved'; timestamp?: number }; + +export type StatusConfig = { + firing: { + icon: JSX.Element; + text: string; + extraInfo: JSX.Element | null; + className: string; + }; + resolved: { + icon: JSX.Element; + text: string; + extraInfo: JSX.Element | null; + className: string; + }; +}; diff --git a/frontend/src/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx new file mode 100644 index 0000000000..fc6219b195 --- /dev/null +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -0,0 +1,525 @@ +import { FilterValue, SorterResult } from 'antd/es/table/interface'; +import { TablePaginationConfig, TableProps } from 'antd/lib'; +import deleteAlerts from 'api/alerts/delete'; +import get from 'api/alerts/get'; +import getAll from 'api/alerts/getAll'; +import patchAlert from 'api/alerts/patch'; +import ruleStats from 'api/alerts/ruleStats'; +import save from 'api/alerts/save'; +import timelineGraph from 'api/alerts/timelineGraph'; +import timelineTable from 'api/alerts/timelineTable'; +import topContributors from 'api/alerts/topContributors'; +import { TabRoutes } from 'components/RouteTab/types'; +import { QueryParams } from 'constants/query'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import ROUTES from 'constants/routes'; +import AlertHistory from 'container/AlertHistory'; +import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants'; +import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types'; +import { urlKey } from 'container/AllError/utils'; +import useAxiosError from 'hooks/useAxiosError'; +import { useNotifications } from 'hooks/useNotifications'; +import useUrlQuery from 'hooks/useUrlQuery'; +import createQueryParams from 'lib/createQueryParams'; +import GetMinMax from 'lib/getMinMax'; +import history from 'lib/history'; +import { History, Table } from 'lucide-react'; +import EditRules from 'pages/EditRules'; +import { OrderPreferenceItems } from 'pages/Logs/config'; +import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; +import { useAlertRule } from 'providers/Alert'; +import { useCallback, useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { useSelector } from 'react-redux'; +import { generatePath, useLocation } from 'react-router-dom'; +import { AppState } from 'store/reducers'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + AlertDef, + AlertRuleStatsPayload, + AlertRuleTimelineGraphResponsePayload, + AlertRuleTimelineTableResponse, + AlertRuleTimelineTableResponsePayload, + AlertRuleTopContributorsPayload, +} from 'types/api/alerts/def'; +import { PayloadProps } from 'types/api/alerts/get'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { nanoToMilli } from 'utils/timeUtils'; + +export const useAlertHistoryQueryParams = (): { + ruleId: string | null; + startTime: number; + endTime: number; + hasStartAndEndParams: boolean; + params: URLSearchParams; +} => { + const params = useUrlQuery(); + + const globalTime = useSelector( + (state) => state.globalTime, + ); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); + + const intStartTime = parseInt(startTime || '0', 10); + const intEndTime = parseInt(endTime || '0', 10); + const hasStartAndEndParams = !!intStartTime && !!intEndTime; + + const { maxTime, minTime } = useMemo(() => { + if (hasStartAndEndParams) + return GetMinMax('custom', [intStartTime, intEndTime]); + return GetMinMax(globalTime.selectedTime); + }, [hasStartAndEndParams, intStartTime, intEndTime, globalTime.selectedTime]); + + const ruleId = params.get(QueryParams.ruleId); + + return { + ruleId, + startTime: Math.floor(nanoToMilli(minTime)), + endTime: Math.floor(nanoToMilli(maxTime)), + hasStartAndEndParams, + params, + }; +}; +export const useRouteTabUtils = (): { routes: TabRoutes[] } => { + const urlQuery = useUrlQuery(); + + const getRouteUrl = (tab: AlertDetailsTab): string => { + let route = ''; + let params = urlQuery.toString(); + const ruleIdKey = QueryParams.ruleId; + const relativeTimeKey = QueryParams.relativeTime; + + switch (tab) { + case AlertDetailsTab.OVERVIEW: + route = ROUTES.ALERT_OVERVIEW; + break; + case AlertDetailsTab.HISTORY: + params = `${ruleIdKey}=${urlQuery.get( + ruleIdKey, + )}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`; + route = ROUTES.ALERT_HISTORY; + break; + default: + return ''; + } + + return `${generatePath(route)}?${params}`; + }; + + const routes = [ + { + Component: EditRules, + name: ( +
+
+ Overview + + ), + route: getRouteUrl(AlertDetailsTab.OVERVIEW), + key: ROUTES.ALERT_OVERVIEW, + }, + { + Component: AlertHistory, + name: ( +
+ + History +
+ ), + route: getRouteUrl(AlertDetailsTab.HISTORY), + key: ROUTES.ALERT_HISTORY, + }, + ]; + + return { routes }; +}; +type Props = { + ruleId: string | null; + isValidRuleId: boolean; + alertDetailsResponse: + | SuccessResponse + | ErrorResponse + | undefined; + isLoading: boolean; + isRefetching: boolean; + isError: boolean; +}; + +export const useGetAlertRuleDetails = (): Props => { + const { ruleId } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { + isLoading, + data: alertDetailsResponse, + isRefetching, + isError, + } = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { + queryFn: () => + get({ + id: parseInt(ruleId || '', 10), + }), + enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + return { + ruleId, + isLoading, + alertDetailsResponse, + isRefetching, + isError, + isValidRuleId, + }; +}; + +type GetAlertRuleDetailsApiProps = { + isLoading: boolean; + isRefetching: boolean; + isError: boolean; + isValidRuleId: boolean; + ruleId: string | null; +}; + +type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsStats = (): GetAlertRuleDetailsStatsProps => { + const { ruleId, startTime, endTime } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime], + { + queryFn: () => + ruleStats({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + }), + enabled: isValidRuleId && !!startTime && !!endTime, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTopContributors = (): GetAlertRuleDetailsTopContributorsProps => { + const { ruleId, startTime, endTime } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime], + { + queryFn: () => + topContributors({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + }), + enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimelineTableProps => { + const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams(); + const { updatedOrder, offset } = useMemo( + () => ({ + updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC, + offset: parseInt(params.get(urlKey.offset) ?? '1', 10), + }), + [params], + ); + + const timelineFilter = params.get('timelineFilter'); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + const hasStartAndEnd = startTime !== null && endTime !== null; + + const { isLoading, isRefetching, isError, data } = useQuery( + [ + REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE, + ruleId, + startTime, + endTime, + timelineFilter, + updatedOrder, + offset, + ], + { + queryFn: () => + timelineTable({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + limit: TIMELINE_TABLE_PAGE_SIZE, + order: updatedOrder, + offset, + + ...(timelineFilter && timelineFilter !== TimelineFilter.ALL + ? { + state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal', + } + : {}), + }), + enabled: isValidRuleId && hasStartAndEnd, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +export const useTimelineTable = ({ + totalItems, +}: { + totalItems: number; +}): { + paginationConfig: TablePaginationConfig; + onChangeHandler: ( + pagination: TablePaginationConfig, + sorter: any, + filters: any, + extra: any, + ) => void; +} => { + const { pathname } = useLocation(); + + const { search } = useLocation(); + + const params = useMemo(() => new URLSearchParams(search), [search]); + + const offset = params.get('offset') ?? '0'; + + const onChangeHandler: TableProps['onChange'] = useCallback( + ( + pagination: TablePaginationConfig, + filters: Record, + sorter: + | SorterResult[] + | SorterResult, + ) => { + if (!Array.isArray(sorter)) { + const { pageSize = 0, current = 0 } = pagination; + const { order } = sorter; + const updatedOrder = order === 'ascend' ? 'asc' : 'desc'; + const params = new URLSearchParams(window.location.search); + + history.replace( + `${pathname}?${createQueryParams({ + ...Object.fromEntries(params), + order: updatedOrder, + offset: current * TIMELINE_TABLE_PAGE_SIZE - TIMELINE_TABLE_PAGE_SIZE, + pageSize, + })}`, + ); + } + }, + [pathname], + ); + + const offsetInt = parseInt(offset, 10); + const pageSize = params.get('pageSize') ?? String(TIMELINE_TABLE_PAGE_SIZE); + const pageSizeInt = parseInt(pageSize, 10); + + const paginationConfig: TablePaginationConfig = { + pageSize: pageSizeInt, + showTotal: PaginationInfoText, + current: offsetInt / TIMELINE_TABLE_PAGE_SIZE + 1, + showSizeChanger: false, + hideOnSinglePage: true, + total: totalItems, + }; + + return { paginationConfig, onChangeHandler }; +}; + +export const useAlertRuleStatusToggle = ({ + ruleId, +}: { + ruleId: string; +}): { + handleAlertStateToggle: (state: boolean) => void; +} => { + const { isAlertRuleDisabled, setIsAlertRuleDisabled } = useAlertRule(); + const { notifications } = useNotifications(); + + const queryClient = useQueryClient(); + const handleError = useAxiosError(); + + const { mutate: toggleAlertState } = useMutation( + [REACT_QUERY_KEY.TOGGLE_ALERT_STATE, ruleId], + patchAlert, + { + onMutate: () => { + setIsAlertRuleDisabled((prev) => !prev); + }, + onSuccess: () => { + notifications.success({ + message: `Alert has been ${isAlertRuleDisabled ? 'enabled' : 'disabled'}.`, + }); + }, + onError: (error) => { + queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS]); + handleError(error); + }, + }, + ); + + const handleAlertStateToggle = (): void => { + const args = { + id: parseInt(ruleId, 10), + data: { disabled: !isAlertRuleDisabled }, + }; + toggleAlertState(args); + }; + + return { handleAlertStateToggle }; +}; + +export const useAlertRuleDuplicate = ({ + alertDetails, +}: { + alertDetails: AlertDef; +}): { + handleAlertDuplicate: () => void; +} => { + const { notifications } = useNotifications(); + + const params = useUrlQuery(); + + const { refetch } = useQuery(REACT_QUERY_KEY.GET_ALL_ALLERTS, { + queryFn: getAll, + cacheTime: 0, + }); + const handleError = useAxiosError(); + const { mutate: duplicateAlert } = useMutation( + [REACT_QUERY_KEY.DUPLICATE_ALERT_RULE], + save, + { + onSuccess: async () => { + notifications.success({ + message: `Success`, + }); + + const { data: allAlertsData } = await refetch(); + + if ( + allAlertsData && + allAlertsData.payload && + allAlertsData.payload.length > 0 + ) { + const clonedAlert = + allAlertsData.payload[allAlertsData.payload.length - 1]; + params.set(QueryParams.ruleId, String(clonedAlert.id)); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); + } + }, + onError: handleError, + }, + ); + + const handleAlertDuplicate = (): void => { + const args = { + data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') }, + }; + duplicateAlert(args); + }; + + return { handleAlertDuplicate }; +}; + +export const useAlertRuleDelete = ({ + ruleId, +}: { + ruleId: number; +}): { + handleAlertDelete: () => void; +} => { + const { notifications } = useNotifications(); + const handleError = useAxiosError(); + + const { mutate: deleteAlert } = useMutation( + [REACT_QUERY_KEY.REMOVE_ALERT_RULE, ruleId], + deleteAlerts, + { + onSuccess: async () => { + notifications.success({ + message: `Success`, + }); + + history.push(ROUTES.LIST_ALL_ALERT); + }, + onError: handleError, + }, + ); + + const handleAlertDelete = (): void => { + const args = { id: ruleId }; + deleteAlert(args); + }; + + return { handleAlertDelete }; +}; + +type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTimelineGraphData = (): GetAlertRuleDetailsTimelineGraphProps => { + const { ruleId, startTime, endTime } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + const hasStartAndEnd = startTime !== null && endTime !== null; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime], + { + queryFn: () => + timelineGraph({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + }), + enabled: isValidRuleId && hasStartAndEnd, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; diff --git a/frontend/src/pages/AlertDetails/index.tsx b/frontend/src/pages/AlertDetails/index.tsx new file mode 100644 index 0000000000..aa6eb0b819 --- /dev/null +++ b/frontend/src/pages/AlertDetails/index.tsx @@ -0,0 +1,3 @@ +import AlertDetails from './AlertDetails'; + +export default AlertDetails; diff --git a/frontend/src/pages/AlertDetails/types.ts b/frontend/src/pages/AlertDetails/types.ts new file mode 100644 index 0000000000..f68fa9c512 --- /dev/null +++ b/frontend/src/pages/AlertDetails/types.ts @@ -0,0 +1,6 @@ +export type AlertDetailsStatusRendererProps = { + isLoading: boolean; + isError: boolean; + isRefetching: boolean; + data: any; +}; diff --git a/frontend/src/pages/AlertHistory/index.tsx b/frontend/src/pages/AlertHistory/index.tsx new file mode 100644 index 0000000000..7a7b0d01d8 --- /dev/null +++ b/frontend/src/pages/AlertHistory/index.tsx @@ -0,0 +1,3 @@ +import AlertHistory from 'container/AlertHistory'; + +export default AlertHistory; diff --git a/frontend/src/pages/AlertList/index.tsx b/frontend/src/pages/AlertList/index.tsx index 1bf3d9a6ea..19d746e8f0 100644 --- a/frontend/src/pages/AlertList/index.tsx +++ b/frontend/src/pages/AlertList/index.tsx @@ -1,10 +1,14 @@ import { Tabs } from 'antd'; import { TabsProps } from 'antd/lib'; +import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon'; +import ROUTES from 'constants/routes'; import AllAlertRules from 'container/ListAlertRules'; import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime'; import TriggeredAlerts from 'container/TriggeredAlerts'; import useUrlQuery from 'hooks/useUrlQuery'; import history from 'lib/history'; +import { GalleryVerticalEnd, Pyramid } from 'lucide-react'; +import AlertDetails from 'pages/AlertDetails'; import { useLocation } from 'react-router-dom'; function AllAlertList(): JSX.Element { @@ -12,15 +16,40 @@ function AllAlertList(): JSX.Element { const location = useLocation(); const tab = urlQuery.get('tab'); + const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY; + const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW; + + const search = urlQuery.get('search'); + const items: TabsProps['items'] = [ - { label: 'Alert Rules', key: 'AlertRules', children: }, { - label: 'Triggered Alerts', + label: ( +
+ + Triggered Alerts +
+ ), key: 'TriggeredAlerts', children: , }, { - label: 'Configuration', + label: ( +
+ + Alert Rules +
+ ), + key: 'AlertRules', + children: + isAlertHistory || isAlertOverview ? : , + }, + { + label: ( +
+ + Configuration +
+ ), key: 'Configuration', children: , }, @@ -33,8 +62,16 @@ function AllAlertList(): JSX.Element { activeKey={tab || 'AlertRules'} onChange={(tab): void => { urlQuery.set('tab', tab); - history.replace(`${location.pathname}?${urlQuery.toString()}`); + let params = `tab=${tab}`; + + if (search) { + params += `&search=${search}`; + } + history.replace(`/alerts?${params}`); }} + className={`${ + isAlertHistory || isAlertOverview ? 'alert-details-tabs' : '' + }`} /> ); } diff --git a/frontend/src/pages/EditRules/EditRules.styles.scss b/frontend/src/pages/EditRules/EditRules.styles.scss index 412cddd1ad..a01a6e7ab7 100644 --- a/frontend/src/pages/EditRules/EditRules.styles.scss +++ b/frontend/src/pages/EditRules/EditRules.styles.scss @@ -1,32 +1,33 @@ .edit-rules-container { - display: flex; - justify-content: center; - align-items: center; - margin-top: 5rem; + padding: 0 16px; + &--error { + display: flex; + justify-content: center; + align-items: center; + margin-top: 5rem; + } } - .edit-rules-card { - width: 20rem; - padding: 1rem; + width: 20rem; + padding: 1rem; } .content { - font-style: normal; + font-style: normal; font-weight: 300; font-size: 18px; line-height: 20px; display: flex; align-items: center; - justify-content: center; - text-align: center; + justify-content: center; + text-align: center; margin: 0; } .btn-container { - display: flex; - justify-content: center; - align-items: center; - margin-top: 2rem; + display: flex; + justify-content: center; + align-items: center; + margin-top: 2rem; } - diff --git a/frontend/src/pages/EditRules/index.tsx b/frontend/src/pages/EditRules/index.tsx index cccfc6aee2..372a8a199e 100644 --- a/frontend/src/pages/EditRules/index.tsx +++ b/frontend/src/pages/EditRules/index.tsx @@ -4,6 +4,7 @@ import { Button, Card } from 'antd'; import get from 'api/alerts/get'; import Spinner from 'components/Spinner'; import { QueryParams } from 'constants/query'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; import EditRulesContainer from 'container/EditRules'; import { useNotifications } from 'hooks/useNotifications'; @@ -21,19 +22,21 @@ import { function EditRules(): JSX.Element { const params = useUrlQuery(); - const ruleId = params.get('ruleId'); + const ruleId = params.get(QueryParams.ruleId); const { t } = useTranslation('common'); const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; const { isLoading, data, isRefetching, isError } = useQuery( - ['ruleId', ruleId], + [REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { queryFn: () => get({ id: parseInt(ruleId || '', 10), }), enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, }, ); @@ -62,7 +65,7 @@ function EditRules(): JSX.Element { (data?.payload?.data === undefined && !isLoading) ) { return ( -
+

{data?.message === errorMessageReceivedFromBackend @@ -84,10 +87,12 @@ function EditRules(): JSX.Element { } return ( - +

+ +
); } diff --git a/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss new file mode 100644 index 0000000000..7a55632ae6 --- /dev/null +++ b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss @@ -0,0 +1,39 @@ +.copy-to-clipboard { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + padding: 4px 6px; + width: 100px; + + &:hover { + background-color: transparent !important; + } + + .ant-btn-icon { + margin: 0 !important; + } + & > * { + color: var(--text-vanilla-400); + font-weight: 400; + line-height: 20px; + letter-spacing: -0.07px; + } + + &--success { + & span, + &:hover { + color: var(--bg-forest-400); + } + } +} + +.lightMode { + .copy-to-clipboard { + &:not(&--success) { + & > * { + color: var(--text-ink-400); + } + } + } +} diff --git a/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx new file mode 100644 index 0000000000..598f6e5a3f --- /dev/null +++ b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx @@ -0,0 +1,54 @@ +import './CopyToClipboard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { CircleCheck, Link2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useCopyToClipboard } from 'react-use'; + +function CopyToClipboard({ textToCopy }: { textToCopy: string }): JSX.Element { + const [state, copyToClipboard] = useCopyToClipboard(); + const [success, setSuccess] = useState(false); + const isDarkMode = useIsDarkMode(); + + useEffect(() => { + let timer: string | number | NodeJS.Timeout | undefined; + if (state.value) { + setSuccess(true); + timer = setTimeout(() => setSuccess(false), 1000); + } + + return (): void => clearTimeout(timer); + }, [state]); + + if (success) { + return ( + + ); + } + + return ( + + ); +} + +export default CopyToClipboard; diff --git a/frontend/src/periscope/components/CopyToClipboard/index.tsx b/frontend/src/periscope/components/CopyToClipboard/index.tsx new file mode 100644 index 0000000000..7b6b62c1b5 --- /dev/null +++ b/frontend/src/periscope/components/CopyToClipboard/index.tsx @@ -0,0 +1,3 @@ +import CopyToClipboard from './CopyToClipboard'; + +export default CopyToClipboard; diff --git a/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx new file mode 100644 index 0000000000..7d6c6eb5a1 --- /dev/null +++ b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx @@ -0,0 +1,46 @@ +import Spinner from 'components/Spinner'; +import { useTranslation } from 'react-i18next'; + +interface DataStateRendererProps { + isLoading: boolean; + isRefetching: boolean; + isError: boolean; + data: T | null; + errorMessage?: string; + loadingMessage?: string; + children: (data: T) => React.ReactNode; +} + +/** + * TODO(shaheer): add empty state and optionally accept empty state custom component + * TODO(shaheer): optionally accept custom error state component + * TODO(shaheer): optionally accept custom loading state component + */ +function DataStateRenderer({ + isLoading, + isRefetching, + isError, + data, + errorMessage, + loadingMessage, + children, +}: DataStateRendererProps): JSX.Element { + const { t } = useTranslation('common'); + + if (isLoading || isRefetching || !data) { + return ; + } + + if (isError || data === null) { + return
{errorMessage ?? t('something_went_wrong')}
; + } + + return <>{children(data)}; +} + +DataStateRenderer.defaultProps = { + errorMessage: '', + loadingMessage: 'Loading...', +}; + +export default DataStateRenderer; diff --git a/frontend/src/periscope/components/DataStateRenderer/index.tsx b/frontend/src/periscope/components/DataStateRenderer/index.tsx new file mode 100644 index 0000000000..e4afdfa3bd --- /dev/null +++ b/frontend/src/periscope/components/DataStateRenderer/index.tsx @@ -0,0 +1,3 @@ +import DataStateRenderer from './DataStateRenderer'; + +export default DataStateRenderer; diff --git a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss new file mode 100644 index 0000000000..88ae57f4e8 --- /dev/null +++ b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss @@ -0,0 +1,37 @@ +.key-value-label { + display: flex; + align-items: center; + border: 1px solid var(--bg-slate-400); + border-radius: 2px; + flex-wrap: wrap; + + &__key, + &__value { + padding: 1px 6px; + font-size: 14px; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.005em; + } + &__key { + background: var(--bg-ink-400); + border-radius: 2px 0 0 2px; + } + &__value { + background: var(--bg-slate-400); + } + color: var(--text-vanilla-400); +} + +.lightMode { + .key-value-label { + border-color: var(--bg-vanilla-400); + color: var(--text-ink-400); + &__key { + background: var(--bg-vanilla-300); + } + &__value { + background: var(--bg-vanilla-200); + } + } +} diff --git a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx new file mode 100644 index 0000000000..aa14dd6380 --- /dev/null +++ b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx @@ -0,0 +1,18 @@ +import './KeyValueLabel.styles.scss'; + +type KeyValueLabelProps = { badgeKey: string; badgeValue: string }; + +export default function KeyValueLabel({ + badgeKey, + badgeValue, +}: KeyValueLabelProps): JSX.Element | null { + if (!badgeKey || !badgeValue) { + return null; + } + return ( +
+
{badgeKey}
+
{badgeValue}
+
+ ); +} diff --git a/frontend/src/periscope/components/KeyValueLabel/index.tsx b/frontend/src/periscope/components/KeyValueLabel/index.tsx new file mode 100644 index 0000000000..7341e057e8 --- /dev/null +++ b/frontend/src/periscope/components/KeyValueLabel/index.tsx @@ -0,0 +1,3 @@ +import KeyValueLabel from './KeyValueLabel'; + +export default KeyValueLabel; diff --git a/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx b/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx new file mode 100644 index 0000000000..205e1d3db8 --- /dev/null +++ b/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx @@ -0,0 +1,24 @@ +import { Typography } from 'antd'; + +function PaginationInfoText( + total: number, + [start, end]: number[], +): JSX.Element { + return ( + + + {start} — {end} + + of {total} + + ); +} + +export default PaginationInfoText; diff --git a/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss b/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss new file mode 100644 index 0000000000..002b04294b --- /dev/null +++ b/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss @@ -0,0 +1,26 @@ +.see-more-button { + background: none; + padding: 2px; + font-size: 14px; + line-height: 18px; + letter-spacing: -0.005em; + color: var(--text-vanilla-400); + border: none; + cursor: pointer; +} + +.see-more-popover-content { + display: flex; + gap: 6px; + flex-wrap: wrap; + width: 300px; +} + +.lightMode { + .see-more-button { + color: var(--text-ink-400); + } + .see-more-popover-content { + background: var(--bg-vanilla-100); + } +} diff --git a/frontend/src/periscope/components/SeeMore/SeeMore.tsx b/frontend/src/periscope/components/SeeMore/SeeMore.tsx new file mode 100644 index 0000000000..f94da8a564 --- /dev/null +++ b/frontend/src/periscope/components/SeeMore/SeeMore.tsx @@ -0,0 +1,48 @@ +import './SeeMore.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Popover } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; + +type SeeMoreProps = { + children: JSX.Element[]; + initialCount?: number; + moreLabel: string; +}; + +function SeeMore({ + children, + initialCount = 2, + moreLabel, +}: SeeMoreProps): JSX.Element { + const remainingCount = children.length - initialCount; + const isDarkMode = useIsDarkMode(); + + return ( + <> + {children.slice(0, initialCount)} + {remainingCount > 0 && ( + + {children.slice(initialCount)} +
+ } + > + + + )} + + ); +} + +SeeMore.defaultProps = { + initialCount: 2, +}; + +export default SeeMore; diff --git a/frontend/src/periscope/components/SeeMore/index.tsx b/frontend/src/periscope/components/SeeMore/index.tsx new file mode 100644 index 0000000000..9ee14a54c9 --- /dev/null +++ b/frontend/src/periscope/components/SeeMore/index.tsx @@ -0,0 +1,3 @@ +import SeeMore from './SeeMore'; + +export default SeeMore; diff --git a/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss b/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss new file mode 100644 index 0000000000..59b5156cdd --- /dev/null +++ b/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss @@ -0,0 +1,48 @@ +.tabs-wrapper { + display: flex; + align-items: center; + gap: 12px; + + .tab { + &.ant-btn-default { + box-shadow: none; + display: flex; + align-items: center; + gap: 10px; + color: var(--text-vanilla-400); + background: var(--bg-ink-400); + font-size: 14px; + line-height: 20px; + letter-spacing: -0.07px; + padding: 6px 24px; + border-color: var(--bg-slate-400); + justify-content: center; + } + &.reset-button { + .ant-btn-icon { + margin: 0; + } + padding: 6px 12px; + } + &.selected { + color: var(--text-vanilla-100); + background: var(--bg-slate-400); + } + } +} + +.lightMode { + .tabs-wrapper { + .tab { + &.ant-btn-default { + color: var(--text-ink-400); + background: var(--bg-vanilla-300); + border-color: var(--bg-vanilla-300); + } + &.selected { + color: var(--text-robin-500); + background: var(--bg-vanilla-100); + } + } + } +} diff --git a/frontend/src/periscope/components/Tabs2/Tabs2.tsx b/frontend/src/periscope/components/Tabs2/Tabs2.tsx new file mode 100644 index 0000000000..051d80365e --- /dev/null +++ b/frontend/src/periscope/components/Tabs2/Tabs2.tsx @@ -0,0 +1,80 @@ +import './Tabs2.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import { TimelineFilter } from 'container/AlertHistory/types'; +import { Undo } from 'lucide-react'; +import { useState } from 'react'; + +interface Tab { + value: string; + label: string | JSX.Element; + disabled?: boolean; + icon?: string | JSX.Element; +} + +interface TimelineTabsProps { + tabs: Tab[]; + onSelectTab?: (selectedTab: TimelineFilter) => void; + initialSelectedTab?: string; + hasResetButton?: boolean; + buttonMinWidth?: string; +} + +function Tabs2({ + tabs, + onSelectTab, + initialSelectedTab, + hasResetButton, + buttonMinWidth = '114px', +}: TimelineTabsProps): JSX.Element { + const [selectedTab, setSelectedTab] = useState( + initialSelectedTab || tabs[0].value, + ); + + const handleTabClick = (tabValue: string): void => { + setSelectedTab(tabValue); + if (onSelectTab) { + onSelectTab(tabValue as TimelineFilter); + } + }; + + return ( +
+ {hasResetButton && selectedTab !== tabs[0].value && ( + + )} + + {tabs.map((tab) => ( + + ))} + +
+ ); +} + +Tabs2.defaultProps = { + initialSelectedTab: '', + onSelectTab: (): void => {}, + hasResetButton: false, + buttonMinWidth: '114px', +}; + +export default Tabs2; diff --git a/frontend/src/periscope/components/Tabs2/index.tsx b/frontend/src/periscope/components/Tabs2/index.tsx new file mode 100644 index 0000000000..0338314a3a --- /dev/null +++ b/frontend/src/periscope/components/Tabs2/index.tsx @@ -0,0 +1,3 @@ +import Tabs2 from './Tabs2'; + +export default Tabs2; diff --git a/frontend/src/providers/Alert.tsx b/frontend/src/providers/Alert.tsx new file mode 100644 index 0000000000..337eec9ba5 --- /dev/null +++ b/frontend/src/providers/Alert.tsx @@ -0,0 +1,43 @@ +import React, { createContext, useContext, useState } from 'react'; + +interface AlertRuleContextType { + isAlertRuleDisabled: boolean | undefined; + setIsAlertRuleDisabled: React.Dispatch< + React.SetStateAction + >; +} + +const AlertRuleContext = createContext( + undefined, +); + +function AlertRuleProvider({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + const [isAlertRuleDisabled, setIsAlertRuleDisabled] = useState< + boolean | undefined + >(undefined); + + const value = React.useMemo( + () => ({ isAlertRuleDisabled, setIsAlertRuleDisabled }), + [isAlertRuleDisabled], + ); + + return ( + + {children} + + ); +} + +export const useAlertRule = (): AlertRuleContextType => { + const context = useContext(AlertRuleContext); + if (context === undefined) { + throw new Error('useAlertRule must be used within an AlertRuleProvider'); + } + return context; +}; + +export default AlertRuleProvider; diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts index c773cb78a2..9393ccd5a0 100644 --- a/frontend/src/types/api/alerts/def.ts +++ b/frontend/src/types/api/alerts/def.ts @@ -38,7 +38,71 @@ export interface RuleCondition { alertOnAbsent?: boolean | undefined; absentFor?: number | undefined; } - export interface Labels { [key: string]: string; } + +export interface AlertRuleStats { + totalCurrentTriggers: number; + totalPastTriggers: number; + currentTriggersSeries: CurrentTriggersSeries; + pastTriggersSeries: CurrentTriggersSeries | null; + currentAvgResolutionTime: number; + pastAvgResolutionTime: number; + currentAvgResolutionTimeSeries: CurrentTriggersSeries; + pastAvgResolutionTimeSeries: any | null; +} + +interface CurrentTriggersSeries { + labels: Labels; + labelsArray: any | null; + values: StatsTimeSeriesItem[]; +} + +export interface StatsTimeSeriesItem { + timestamp: number; + value: string; +} + +export type AlertRuleStatsPayload = { + data: AlertRuleStats; +}; + +export interface AlertRuleTopContributors { + fingerprint: number; + labels: Labels; + count: number; + relatedLogsLink: string; + relatedTracesLink: string; +} +export type AlertRuleTopContributorsPayload = { + data: AlertRuleTopContributors[]; +}; + +export interface AlertRuleTimelineTableResponse { + ruleID: string; + ruleName: string; + overallState: string; + overallStateChanged: boolean; + state: string; + stateChanged: boolean; + unixMilli: number; + labels: Labels; + fingerprint: number; + value: number; + relatedTracesLink: string; + relatedLogsLink: string; +} +export type AlertRuleTimelineTableResponsePayload = { + data: { items: AlertRuleTimelineTableResponse[]; total: number }; +}; +type AlertState = 'firing' | 'normal' | 'no-data' | 'muted'; + +export interface AlertRuleTimelineGraphResponse { + start: number; + end: number; + state: AlertState; +} +export type AlertRuleTimelineGraphResponsePayload = { + data: AlertRuleTimelineGraphResponse[]; +}; diff --git a/frontend/src/types/api/alerts/ruleStats.ts b/frontend/src/types/api/alerts/ruleStats.ts new file mode 100644 index 0000000000..2669a4c6be --- /dev/null +++ b/frontend/src/types/api/alerts/ruleStats.ts @@ -0,0 +1,7 @@ +import { AlertDef } from './def'; + +export interface RuleStatsProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/types/api/alerts/timelineGraph.ts b/frontend/src/types/api/alerts/timelineGraph.ts new file mode 100644 index 0000000000..99e9601f1e --- /dev/null +++ b/frontend/src/types/api/alerts/timelineGraph.ts @@ -0,0 +1,7 @@ +import { AlertDef } from './def'; + +export interface GetTimelineGraphRequestProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/types/api/alerts/timelineTable.ts b/frontend/src/types/api/alerts/timelineTable.ts new file mode 100644 index 0000000000..b2e27a4d1c --- /dev/null +++ b/frontend/src/types/api/alerts/timelineTable.ts @@ -0,0 +1,13 @@ +import { TagFilter } from '../queryBuilder/queryBuilderData'; +import { AlertDef } from './def'; + +export interface GetTimelineTableRequestProps { + id: AlertDef['id']; + start: number; + end: number; + offset: number; + limit: number; + order: string; + filters?: TagFilter; + state?: string; +} diff --git a/frontend/src/types/api/alerts/topContributors.ts b/frontend/src/types/api/alerts/topContributors.ts new file mode 100644 index 0000000000..74acb4b871 --- /dev/null +++ b/frontend/src/types/api/alerts/topContributors.ts @@ -0,0 +1,7 @@ +import { AlertDef } from './def'; + +export interface TopContributorsProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/utils/calculateChange.ts b/frontend/src/utils/calculateChange.ts new file mode 100644 index 0000000000..4e3d912f0d --- /dev/null +++ b/frontend/src/utils/calculateChange.ts @@ -0,0 +1,31 @@ +export function calculateChange( + totalCurrentTriggers: number | undefined, + totalPastTriggers: number | undefined, +): { changePercentage: number; changeDirection: number } { + if ( + totalCurrentTriggers === undefined || + totalPastTriggers === undefined || + [0, '0'].includes(totalPastTriggers) + ) { + return { changePercentage: 0, changeDirection: 0 }; + } + + let changePercentage = + ((totalCurrentTriggers - totalPastTriggers) / totalPastTriggers) * 100; + + let changeDirection = 0; + + if (changePercentage < 0) { + changeDirection = -1; + } else if (changePercentage > 0) { + changeDirection = 1; + } + + changePercentage = Math.abs(changePercentage); + changePercentage = Math.round(changePercentage); + + return { + changePercentage, + changeDirection, + }; +} diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index 1845e77941..8a35121f57 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -64,6 +64,8 @@ export const routePermission: Record = { ERROR_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'], HOME_PAGE: ['ADMIN', 'EDITOR', 'VIEWER'], LIST_ALL_ALERT: ['ADMIN', 'EDITOR', 'VIEWER'], + ALERT_HISTORY: ['ADMIN', 'EDITOR', 'VIEWER'], + ALERT_OVERVIEW: ['ADMIN'], LOGIN: ['ADMIN', 'EDITOR', 'VIEWER'], NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR'], PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'], diff --git a/frontend/src/utils/timeUtils.ts b/frontend/src/utils/timeUtils.ts index 277c0c04af..5eb795bf45 100644 --- a/frontend/src/utils/timeUtils.ts +++ b/frontend/src/utils/timeUtils.ts @@ -1,8 +1,11 @@ import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; +import duration from 'dayjs/plugin/duration'; dayjs.extend(customParseFormat); +dayjs.extend(duration); + export function toUTCEpoch(time: number): number { const x = new Date(); return time + x.getTimezoneOffset() * 60 * 1000; @@ -28,3 +31,97 @@ export const getRemainingDays = (billingEndDate: number): number => { return Math.ceil(timeDifference / (1000 * 60 * 60 * 24)); }; + +/** + * Calculates the duration from the given epoch timestamp to the current time. + * + * + * @param {number} epochTimestamp + * @returns {string} - human readable string representing the duration from the given epoch timestamp to the current time e.g. "3d 14h" + */ +export const getDurationFromNow = (epochTimestamp: number): string => { + const now = dayjs(); + const inputTime = dayjs(epochTimestamp); + const duration = dayjs.duration(now.diff(inputTime)); + + const days = duration.days(); + const hours = duration.hours(); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + + let result = ''; + if (days > 0) result += `${days}d `; + if (hours > 0) result += `${hours}h `; + if (minutes > 0) result += `${minutes}m `; + if (seconds > 0) result += `${seconds}s`; + + return result.trim(); +}; + +/** + * Formats an epoch timestamp into a human-readable date and time string. + * + * @param {number} epoch - The epoch timestamp to format. + * @returns {string} - The formatted date and time string in the format "MMM D, YYYY ⎯ HH:MM:SS". + */ +export function formatEpochTimestamp(epoch: number): string { + const date = new Date(epoch); + + const optionsDate: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + }; + + const optionsTime: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }; + + const formattedDate = date.toLocaleDateString('en-US', optionsDate); + const formattedTime = date.toLocaleTimeString('en-US', optionsTime); + + return `${formattedDate} ⎯ ${formattedTime}`; +} + +/** + * Converts a given number of seconds into a human-readable format. + * @param {number} seconds The number of seconds to convert. + * @returns {string} The formatted time string, either in days (e.g., "1.2d"), hours (e.g., "1.2h"), minutes (e.g., "~7m"), or seconds (e.g., "~45s"). + */ + +export function formatTime(seconds: number): string { + const days = seconds / 86400; + + if (days >= 1) { + return `${days.toFixed(1)}d`; + } + + const hours = seconds / 3600; + if (hours >= 1) { + return `${hours.toFixed(1)}h`; + } + + const minutes = seconds / 60; + if (minutes >= 1) { + return `${minutes.toFixed(1)}m`; + } + + return `${seconds.toFixed(1)}s`; +} + +export const nanoToMilli = (nanoseconds: number): number => + nanoseconds / 1_000_000; + +export const epochToTimeString = (epochMs: number): string => { + console.log({ epochMs }); + const date = new Date(epochMs); + const options: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }; + return date.toLocaleTimeString('en-US', options); +}; From 5942c758f0ed79299513ea59221bacc7e780926e Mon Sep 17 00:00:00 2001 From: Vishal Sharma Date: Thu, 5 Sep 2024 17:00:18 +0530 Subject: [PATCH 12/43] fix: broken links (#5867) * fix: broken links * Update eks-monitorUsingDashboard.md * Update eks-monitorUsingDashboard.md --------- Co-authored-by: CheetoDa <31571545+Calm-Rock@users.noreply.github.com> --- .../Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md index 77bd5cb87c..bbdba36523 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md @@ -1,9 +1,8 @@ ## Monitor using Dashboards -To visualize the Kubernetes Metrics, you can use one of the following pre-built Dashboards: +To visualize the Kubernetes Metrics, you can use following pre-built Dashboards: -- [K8s Node-Level Metrics](https://github.com/SigNoz/dashboards/blob/main/k8s-node-%26-pod-metrics/k8s-node-level-metrics.json) -- [K8s Pod_level Metrics](https://github.com/SigNoz/dashboards/blob/main/k8s-node-%26-pod-metrics/k8s-pod-level-metrics.json) +- [K8s Infra Metrics](https://github.com/SigNoz/dashboards/tree/main/k8s-infra-metrics) You should copy the JSON data in these files and create a New Dashboard in the Dashboard Tab of SigNoz. @@ -13,4 +12,4 @@ By following the previous step, you should also be able to see Kubernetes Pod lo   -To send traces for your application deployed on your Kubernetes cluster, checkout the Application monitoring section of onboarding. \ No newline at end of file +To send traces for your application deployed on your Kubernetes cluster, checkout the Application monitoring section of onboarding. From ba95ca682b12eb86b482fdfa12033f131804396f Mon Sep 17 00:00:00 2001 From: Vishal Sharma Date: Thu, 5 Sep 2024 17:00:33 +0530 Subject: [PATCH 13/43] chore: update posthog-js to 1.160.3 (#5869) --- frontend/package.json | 2 +- frontend/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 34e08ea263..26ffd31d5c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -88,7 +88,7 @@ "lucide-react": "0.379.0", "mini-css-extract-plugin": "2.4.5", "papaparse": "5.4.1", - "posthog-js": "1.142.1", + "posthog-js": "1.160.3", "rc-tween-one": "3.0.6", "react": "18.2.0", "react-addons-update": "15.6.3", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d12754da95..a9186bea9a 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -13715,10 +13715,10 @@ postcss@8.4.38, postcss@^8.0.0, postcss@^8.1.1, postcss@^8.3.7, postcss@^8.4.21, picocolors "^1.0.0" source-map-js "^1.2.0" -posthog-js@1.142.1: - version "1.142.1" - resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.142.1.tgz#3b91229732938c5c76b5ee6d410698a267e073e9" - integrity sha512-yqeWTWitlb0sCaH5v6s7UJ+pPspzf/lkzPaSE5pMMXRM2i2KNsMoZEAZqbPCW8fQ8QL6lHs6d8PLjHrvbR288w== +posthog-js@1.160.3: + version "1.160.3" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.160.3.tgz#17c8af4c9ffa2d795d925ca1e7146e61cd5ccabd" + integrity sha512-mGvxOIlWPtdPx8EI0MQ81wNKlnH2K0n4RqwQOl044b34BCKiFVzZ7Hc7geMuZNaRAvCi5/5zyGeWHcAYZQxiMQ== dependencies: fflate "^0.4.8" preact "^10.19.3" From 4a9847abdd4cc02d0bac89c1215200203a2133d9 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Fri, 6 Sep 2024 10:24:47 +0530 Subject: [PATCH 14/43] feat: implement quick filters for the new logs explorer page (#5799) * feat: logs quick filter * feat: added open button in the closed state * fix: build issues * chore: minor css * feat: handle changes for last used query,states and reset * feat: refactor some code * feat: handle on change functionality * fix: handle only and all * chore: handle empty edge cases * feat: added necessary tooltips * feat: use tag instead of tooltip icon * feat: handle light mode designs * feat: added correct facets * feat: added resize observer for the graph resize * chore: added local storage state for the toggle * chore: make refresh text configurable * feat: added environment and fix build * feat: handle the cases for = and != operators * feat: design changes and zoom out * feat: minor css issue * fix: light mode designs * fix: handle the case for state initialization * fix: onDelete query the last used index should be set to 0 --- .../Checkbox/Checkbox.styles.scss | 145 +++++ .../FilterRenderers/Checkbox/Checkbox.tsx | 503 ++++++++++++++++++ .../FilterRenderers/Slider/Slider.styles.scss | 0 .../FilterRenderers/Slider/Slider.tsx | 14 + .../QuickFilters/QuickFilters.styles.scss | 93 ++++ .../components/QuickFilters/QuickFilters.tsx | 124 +++++ frontend/src/constants/localStorage.ts | 1 + .../QueryBuilder/QueryBuilder.styles.scss | 6 + .../container/QueryBuilder/QueryBuilder.tsx | 17 +- .../QBEntityOptions.styles.scss | 6 + .../QBEntityOptions/QBEntityOptions.tsx | 13 + .../QueryBuilder/components/Query/Query.tsx | 1 + .../ToolbarActions/LeftToolbarActions.tsx | 12 + .../ToolbarActions/ToolbarActions.styles.scss | 11 + .../tests/ToolbarActions.test.tsx | 4 + .../TimeSeriesView/TimeSeriesView.tsx | 14 +- frontend/src/container/Toolbar/Toolbar.tsx | 13 +- .../TopNav/DateTimeSelectionV2/index.tsx | 5 +- .../queryBuilder/useQueryBuilderOperations.ts | 9 +- .../LogsExplorer/LogsExplorer.styles.scss | 44 +- .../__tests__/LogsExplorer.test.tsx | 2 + frontend/src/pages/LogsExplorer/index.tsx | 117 ++-- frontend/src/pages/LogsExplorer/utils.ts | 19 - frontend/src/pages/LogsExplorer/utils.tsx | 113 ++++ .../__test__/TracesExplorer.test.tsx | 8 + frontend/src/providers/QueryBuilder.tsx | 8 + frontend/src/types/common/queryBuilder.ts | 2 + 27 files changed, 1220 insertions(+), 84 deletions(-) create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss create mode 100644 frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx create mode 100644 frontend/src/components/QuickFilters/QuickFilters.styles.scss create mode 100644 frontend/src/components/QuickFilters/QuickFilters.tsx delete mode 100644 frontend/src/pages/LogsExplorer/utils.ts create mode 100644 frontend/src/pages/LogsExplorer/utils.tsx diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss new file mode 100644 index 0000000000..c46d9975f4 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss @@ -0,0 +1,145 @@ +.checkbox-filter { + display: flex; + flex-direction: column; + padding: 12px; + gap: 12px; + border-bottom: 1px solid var(--bg-slate-400); + .filter-header-checkbox { + display: flex; + align-items: center; + justify-content: space-between; + + .left-action { + display: flex; + align-items: center; + gap: 6px; + + .title { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.07px; + text-transform: capitalize; + } + } + + .right-action { + display: flex; + align-items: center; + + .clear-all { + font-size: 12px; + color: var(--bg-robin-500); + cursor: pointer; + } + } + } + + .values { + display: flex; + flex-direction: column; + gap: 8px; + + .value { + display: flex; + align-items: center; + gap: 8px; + + .checkbox-value-section { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + cursor: pointer; + + &.filter-disabled { + cursor: not-allowed; + + .value-string { + color: var(--bg-slate-200); + } + + .only-btn { + cursor: not-allowed; + color: var(--bg-slate-200); + } + + .toggle-btn { + cursor: not-allowed; + color: var(--bg-slate-200); + } + } + + .value-string { + } + + .only-btn { + display: none; + } + .toggle-btn { + display: none; + } + + .toggle-btn:hover { + background-color: unset; + } + + .only-btn:hover { + background-color: unset; + } + } + + .checkbox-value-section:hover { + .toggle-btn { + display: none; + } + .only-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + } + } + + .value:hover { + .toggle-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + } + } + + .no-data { + align-self: center; + } + + .show-more { + display: flex; + align-items: center; + justify-content: center; + + .show-more-text { + color: var(--bg-robin-500); + cursor: pointer; + } + } +} + +.lightMode { + .checkbox-filter { + border-bottom: 1px solid var(--bg-vanilla-300); + .filter-header-checkbox { + .left-action { + .title { + color: var(--bg-ink-400); + } + } + } + } +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx new file mode 100644 index 0000000000..fc9a71a7b1 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx @@ -0,0 +1,503 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import './Checkbox.styles.scss'; + +import { Button, Checkbox, Input, Skeleton, Typography } from 'antd'; +import cx from 'classnames'; +import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters'; +import { OPERATORS } from 'constants/queryBuilder'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { cloneDeep, isArray, isEmpty, isEqual } from 'lodash-es'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { v4 as uuid } from 'uuid'; + +const SELECTED_OPERATORS = [OPERATORS['='], 'in']; +const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'nin']; + +function setDefaultValues( + values: string[], + trueOrFalse: boolean, +): Record { + const defaultState: Record = {}; + values.forEach((val) => { + defaultState[val] = trueOrFalse; + }); + return defaultState; +} +interface ICheckboxProps { + filter: IQuickFiltersConfig; +} + +export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { + const { filter } = props; + const [searchText, setSearchText] = useState(''); + const [isOpen, setIsOpen] = useState(filter.defaultOpen); + const [visibleItemsCount, setVisibleItemsCount] = useState(10); + + const { + lastUsedQuery, + currentQuery, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + + const { data, isLoading } = useGetAggregateValues( + { + aggregateOperator: 'noop', + dataSource: DataSource.LOGS, + aggregateAttribute: '', + attributeKey: filter.attributeKey.key, + filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY, + tagType: filter.attributeKey.type || '', + searchText: searchText ?? '', + }, + { + enabled: isOpen, + keepPreviousData: true, + }, + ); + + const attributeValues: string[] = useMemo( + () => + ((Object.values(data?.payload || {}).find((el) => !!el) || + []) as string[]).filter((val) => !isEmpty(val)), + [data?.payload], + ); + const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount); + + // derive the state of each filter key here in the renderer itself and keep it in sync with staged query + // also we need to keep a note of last focussed query. + // eslint-disable-next-line sonarjs/cognitive-complexity + const currentFilterState = useMemo(() => { + let filterState: Record = setDefaultValues( + attributeValues, + false, + ); + const filterSync = currentQuery?.builder.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items.find((item) => isEqual(item.key, filter.attributeKey)); + + if (filterSync) { + if (SELECTED_OPERATORS.includes(filterSync.op)) { + if (isArray(filterSync.value)) { + filterSync.value.forEach((val) => { + filterState[val] = true; + }); + } else if (typeof filterSync.value === 'string') { + filterState[filterSync.value] = true; + } else if (typeof filterSync.value === 'boolean') { + filterState[String(filterSync.value)] = true; + } else if (typeof filterSync.value === 'number') { + filterState[String(filterSync.value)] = true; + } + } else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) { + filterState = setDefaultValues(attributeValues, true); + if (isArray(filterSync.value)) { + filterSync.value.forEach((val) => { + filterState[val] = false; + }); + } else if (typeof filterSync.value === 'string') { + filterState[filterSync.value] = false; + } else if (typeof filterSync.value === 'boolean') { + filterState[String(filterSync.value)] = false; + } else if (typeof filterSync.value === 'number') { + filterState[String(filterSync.value)] = false; + } + } + } else { + filterState = setDefaultValues(attributeValues, true); + } + return filterState; + }, [ + attributeValues, + currentQuery?.builder.queryData, + filter.attributeKey, + lastUsedQuery, + ]); + + // disable the filter when there are multiple entries of the same attribute key present in the filter bar + const isFilterDisabled = useMemo( + () => + (currentQuery?.builder?.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items?.filter((item) => isEqual(item.key, filter.attributeKey)) + ?.length || 0) > 1, + + [currentQuery?.builder?.queryData, lastUsedQuery, filter.attributeKey], + ); + + // variable to check if the current filter has multiple values to its name in the key op value section + const isMultipleValuesTrueForTheKey = + Object.values(currentFilterState).filter((val) => val).length > 1; + + const handleClearFilterAttribute = (): void => { + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item, idx) => ({ + ...item, + filters: { + ...item.filters, + items: + idx === lastUsedQuery + ? item.filters.items.filter( + (fil) => !isEqual(fil.key, filter.attributeKey), + ) + : [...item.filters.items], + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }; + + const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey)); + + const onChange = ( + value: string, + checked: boolean, + isOnlyOrAllClicked: boolean, + // eslint-disable-next-line sonarjs/cognitive-complexity + ): void => { + const query = cloneDeep(currentQuery.builder.queryData?.[lastUsedQuery || 0]); + + // if only or all are clicked we do not need to worry about anything just override whatever we have + // by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL. + if (isOnlyOrAllClicked && query?.filters?.items) { + const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute + ? currentFilterState[value] && !isMultipleValuesTrueForTheKey + ? 'All' + : 'Only' + : 'Only'; + query.filters.items = query.filters.items.filter( + (q) => !isEqual(q.key, filter.attributeKey), + ); + if (isOnlyOrAll === 'Only') { + const newFilterItem: TagFilterItem = { + id: uuid(), + op: getOperatorValue(OPERATORS.IN), + key: filter.attributeKey, + value, + }; + query.filters.items = [...query.filters.items, newFilterItem]; + } + } else if (query?.filters?.items) { + if ( + query.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey)) + ) { + // if there is already a running filter for the current attribute key then + // we split the cases by which particular operator is present right now! + const currentFilter = query.filters?.items?.find((q) => + isEqual(q.key, filter.attributeKey), + ); + if (currentFilter) { + const runningOperator = currentFilter?.op; + switch (runningOperator) { + case 'in': + if (checked) { + // if it's an IN operator then if we are checking another value it get's added to the + // filter clause. example - key IN [value1, currentSelectedValue] + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: [...currentFilter.value, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else { + // if the current state wasn't an array we make it one and add our value + const newFilter = { + ...currentFilter, + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else if (!checked) { + // if we are removing some value when the running operator is IN we filter. + // example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: currentFilter.value.filter((val) => val !== value), + }; + + if (newFilter.value.length === 0) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } else { + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else { + // if not an array remove the whole thing altogether! + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + } + break; + case 'nin': + // if the current running operator is NIN then when unchecking the value it gets + // added to the clause like key NIN [value1 , currentUnselectedValue] + if (!checked) { + // in case of array add the currentUnselectedValue to the list. + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: [...currentFilter.value, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else { + // in case of not an array make it one! + const newFilter = { + ...currentFilter, + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else if (checked) { + // opposite of above! + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: currentFilter.value.filter((val) => val !== value), + }; + + if (newFilter.value.length === 0) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } else { + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + } + break; + case '=': + if (checked) { + const newFilter = { + ...currentFilter, + op: getOperatorValue(OPERATORS.IN), + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else if (!checked) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + break; + case '!=': + if (!checked) { + const newFilter = { + ...currentFilter, + op: getOperatorValue(OPERATORS.NIN), + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else if (checked) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + break; + default: + break; + } + } + } else { + // case - when there is no filter for the current key that means all are selected right now. + const newFilterItem: TagFilterItem = { + id: uuid(), + op: getOperatorValue(OPERATORS.NIN), + key: filter.attributeKey, + value, + }; + query.filters.items = [...query.filters.items, newFilterItem]; + } + } + const finalQuery = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: [ + ...currentQuery.builder.queryData.map((q, idx) => { + if (idx === lastUsedQuery) { + return query; + } + return q; + }), + ], + }, + }; + + redirectWithQueryBuilderData(finalQuery); + }; + + return ( +
+
+
+ {isOpen ? ( + { + setIsOpen(false); + setVisibleItemsCount(10); + }} + /> + ) : ( + setIsOpen(true)} + cursor="pointer" + /> + )} + {filter.title} +
+
+ {isOpen && ( + + Clear All + + )} +
+
+ {isOpen && isLoading && !attributeValues.length && ( +
+ +
+ )} + {isOpen && !isLoading && ( + <> +
+ setSearchText(e.target.value)} + disabled={isFilterDisabled} + /> +
+ {attributeValues.length > 0 ? ( +
+ {currentAttributeKeys.map((value: string) => ( +
+ onChange(value, e.target.checked, false)} + checked={currentFilterState[value]} + disabled={isFilterDisabled} + rootClassName="check-box" + /> + +
{ + if (isFilterDisabled) { + return; + } + onChange(value, currentFilterState[value], true); + }} + > + {filter.customRendererForValue ? ( + filter.customRendererForValue(value) + ) : ( + + {value} + + )} + + +
+
+ ))} +
+ ) : ( +
+ No values found{' '} +
+ )} + {visibleItemsCount < attributeValues?.length && ( +
+ setVisibleItemsCount((prev) => prev + 10)} + > + Show More... + +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx new file mode 100644 index 0000000000..f7cd9547e8 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx @@ -0,0 +1,14 @@ +import './Slider.styles.scss'; + +import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters'; + +interface ISliderProps { + filter: IQuickFiltersConfig; +} + +// not needed for now build when required +export default function Slider(props: ISliderProps): JSX.Element { + const { filter } = props; + console.log(filter); + return
Slider
; +} diff --git a/frontend/src/components/QuickFilters/QuickFilters.styles.scss b/frontend/src/components/QuickFilters/QuickFilters.styles.scss new file mode 100644 index 0000000000..d5c3460891 --- /dev/null +++ b/frontend/src/components/QuickFilters/QuickFilters.styles.scss @@ -0,0 +1,93 @@ +.quick-filters { + display: flex; + flex-direction: column; + height: 100%; + border-right: 1px solid var(--bg-slate-400); + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10.5px; + border-bottom: 1px solid var(--bg-slate-400); + + .left-actions { + display: flex; + align-items: center; + gap: 6px; + + .text { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.07px; + } + + .sync-tag { + display: flex; + padding: 5px 9px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 2px; + border: 1px solid rgba(78, 116, 248, 0.2); + background: rgba(78, 116, 248, 0.1); + color: var(--bg-robin-500); + font-family: 'Geist Mono'; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 128.571% */ + text-transform: uppercase; + } + } + + .right-actions { + display: flex; + align-items: center; + gap: 12px; + + .divider-filter { + width: 1px; + height: 14px; + background: #161922; + } + + .sync-icon { + background-color: var(--bg-ink-500); + border: 0; + box-shadow: none; + } + } + } +} + +.lightMode { + .quick-filters { + background-color: var(--bg-vanilla-100); + border-right: 1px solid var(--bg-vanilla-300); + + .header { + border-bottom: 1px solid var(--bg-vanilla-300); + + .left-actions { + .text { + color: var(--bg-ink-400); + } + + .sync-icon { + background-color: var(--bg-vanilla-100); + } + } + .right-actions { + .sync-icon { + background-color: var(--bg-vanilla-100); + } + } + } + } +} diff --git a/frontend/src/components/QuickFilters/QuickFilters.tsx b/frontend/src/components/QuickFilters/QuickFilters.tsx new file mode 100644 index 0000000000..a706e35aef --- /dev/null +++ b/frontend/src/components/QuickFilters/QuickFilters.tsx @@ -0,0 +1,124 @@ +import './QuickFilters.styles.scss'; + +import { + FilterOutlined, + SyncOutlined, + VerticalAlignTopOutlined, +} from '@ant-design/icons'; +import { Tooltip, Typography } from 'antd'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { cloneDeep } from 'lodash-es'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +import Checkbox from './FilterRenderers/Checkbox/Checkbox'; +import Slider from './FilterRenderers/Slider/Slider'; + +export enum FiltersType { + SLIDER = 'SLIDER', + CHECKBOX = 'CHECKBOX', +} + +export enum MinMax { + MIN = 'MIN', + MAX = 'MAX', +} + +export enum SpecficFilterOperations { + ALL = 'ALL', + ONLY = 'ONLY', +} + +export interface IQuickFiltersConfig { + type: FiltersType; + title: string; + attributeKey: BaseAutocompleteData; + customRendererForValue?: (value: string) => JSX.Element; + defaultOpen: boolean; +} + +interface IQuickFiltersProps { + config: IQuickFiltersConfig[]; + handleFilterVisibilityChange: () => void; +} + +export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { + const { config, handleFilterVisibilityChange } = props; + + const { + currentQuery, + lastUsedQuery, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + + // clear all the filters for the query which is in sync with filters + const handleReset = (): void => { + const updatedQuery = cloneDeep( + currentQuery?.builder.queryData?.[lastUsedQuery || 0], + ); + + if (!updatedQuery) { + return; + } + + if (updatedQuery?.filters?.items) { + updatedQuery.filters.items = []; + } + + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item, idx) => ({ + ...item, + filters: { + ...item.filters, + items: idx === lastUsedQuery ? [] : [...item.filters.items], + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }; + + const lastQueryName = + currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName; + return ( +
+
+
+ + Filters for + + {lastQueryName} + +
+
+ + + +
+ + + +
+
+ +
+ {config.map((filter) => { + switch (filter.type) { + case FiltersType.CHECKBOX: + return ; + case FiltersType.SLIDER: + return ; + default: + return ; + } + })} +
+
+ ); +} diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index c7e8b81179..bab93a7ff1 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -19,4 +19,5 @@ export enum LOCALSTORAGE { SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR', PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES', THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1', + SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS', } diff --git a/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss b/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss index dbb7a962ef..7cac6794c5 100644 --- a/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss +++ b/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss @@ -77,6 +77,12 @@ border: 1px solid rgba(242, 71, 105, 0.4); color: var(--bg-sakura-400); } + + &.sync-btn { + border: 1px solid rgba(78, 116, 248, 0.2); + background: rgba(78, 116, 248, 0.1); + color: var(--bg-robin-500); + } } &.formula-btn { diff --git a/frontend/src/container/QueryBuilder/QueryBuilder.tsx b/frontend/src/container/QueryBuilder/QueryBuilder.tsx index 844f9e3ab3..5726087e6d 100644 --- a/frontend/src/container/QueryBuilder/QueryBuilder.tsx +++ b/frontend/src/container/QueryBuilder/QueryBuilder.tsx @@ -1,17 +1,20 @@ import './QueryBuilder.styles.scss'; import { Button, Col, Divider, Row, Tooltip, Typography } from 'antd'; +import cx from 'classnames'; import { MAX_FORMULAS, MAX_QUERIES, OPERATORS, PANEL_TYPES, } from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; // ** Hooks import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { DatabaseZap, Sigma } from 'lucide-react'; // ** Constants import { memo, useEffect, useMemo, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; import { DataSource } from 'types/common/queryBuilder'; // ** Components @@ -35,6 +38,8 @@ export const QueryBuilder = memo(function QueryBuilder({ handleSetConfig, panelType, initialDataSource, + setLastUsedQuery, + lastUsedQuery, } = useQueryBuilder(); const containerRef = useRef(null); @@ -46,6 +51,10 @@ export const QueryBuilder = memo(function QueryBuilder({ [config], ); + const { pathname } = useLocation(); + + const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER; + useEffect(() => { if (currentDataSource !== initialDataSource || newPanelType !== panelType) { if (newPanelType === PANEL_TYPES.BAR) { @@ -212,6 +221,7 @@ export const QueryBuilder = memo(function QueryBuilder({
setLastUsedQuery(index)} className="query" id={`qb-query-${query.queryName}`} > @@ -265,10 +275,13 @@ export const QueryBuilder = memo(function QueryBuilder({ {!isListViewPanel && ( - {currentQuery.builder.queryData.map((query) => ( + {currentQuery.builder.queryData.map((query, index) => ( + + )}
)} - {!hasSelectedTimeError && !refreshButtonHidden && ( + {!hasSelectedTimeError && !refreshButtonHidden && showRefreshText && ( 1) { removeQueryBuilderEntityByIndex('queryData', index); } - }, [removeQueryBuilderEntityByIndex, index, currentQuery]); + setLastUsedQuery(0); + }, [ + currentQuery.builder.queryData.length, + setLastUsedQuery, + removeQueryBuilderEntityByIndex, + index, + ]); const handleChangeQueryData: HandleChangeQueryData = useCallback( (key, value) => { diff --git a/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss b/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss index 95d53fe9a4..82d3f5bffc 100644 --- a/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss +++ b/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss @@ -1,11 +1,35 @@ -.log-explorer-query-container { - display: flex; - flex-direction: column; - flex: 1; +.logs-module-page { + display: flex; + height: 100%; + .log-quick-filter-left-section { + width: 0%; + flex-shrink: 0; + } - .logs-explorer-views { - flex: 1; - display: flex; - flex-direction: column; - } -} \ No newline at end of file + .log-module-right-section { + display: flex; + flex-direction: column; + width: 100%; + .log-explorer-query-container { + display: flex; + flex-direction: column; + flex: 1; + + .logs-explorer-views { + flex: 1; + display: flex; + flex-direction: column; + } + } + } + + &.filter-visible { + .log-quick-filter-left-section { + width: 260px; + } + + .log-module-right-section { + width: calc(100% - 260px); + } + } +} diff --git a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx index fab08d51a8..4970d6cf17 100644 --- a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx +++ b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx @@ -189,6 +189,8 @@ describe('Logs Explorer Tests', () => { initialDataSource: null, panelType: PANEL_TYPES.TIME_SERIES, isEnabledQuery: false, + lastUsedQuery: 0, + setLastUsedQuery: noop, handleSetQueryData: noop, handleSetFormulaData: noop, handleSetQueryItemData: noop, diff --git a/frontend/src/pages/LogsExplorer/index.tsx b/frontend/src/pages/LogsExplorer/index.tsx index 8873d04e39..9e23b34c2c 100644 --- a/frontend/src/pages/LogsExplorer/index.tsx +++ b/frontend/src/pages/LogsExplorer/index.tsx @@ -1,25 +1,40 @@ import './LogsExplorer.styles.scss'; import * as Sentry from '@sentry/react'; +import getLocalStorageKey from 'api/browser/localstorage/get'; +import setLocalStorageApi from 'api/browser/localstorage/set'; +import cx from 'classnames'; import ExplorerCard from 'components/ExplorerCard/ExplorerCard'; +import QuickFilters from 'components/QuickFilters/QuickFilters'; +import { LOCALSTORAGE } from 'constants/localStorage'; import LogExplorerQuerySection from 'container/LogExplorerQuerySection'; import LogsExplorerViews from 'container/LogsExplorerViews'; import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions'; import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions'; import Toolbar from 'container/Toolbar/Toolbar'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { isNull } from 'lodash-es'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import { useEffect, useMemo, useRef, useState } from 'react'; import { DataSource } from 'types/common/queryBuilder'; import { WrapperStyled } from './styles'; -import { SELECTED_VIEWS } from './utils'; +import { LogsQuickFiltersConfig, SELECTED_VIEWS } from './utils'; function LogsExplorer(): JSX.Element { const [showFrequencyChart, setShowFrequencyChart] = useState(true); const [selectedView, setSelectedView] = useState( SELECTED_VIEWS.SEARCH, ); + const [showFilters, setShowFilters] = useState(() => { + const localStorageValue = getLocalStorageKey( + LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, + ); + if (!isNull(localStorageValue)) { + return localStorageValue === 'true'; + } + return true; + }); const { handleRunQuery, currentQuery } = useQueryBuilder(); @@ -37,6 +52,14 @@ function LogsExplorer(): JSX.Element { setSelectedView(view); }; + const handleFilterVisibilityChange = (): void => { + setLocalStorageApi( + LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, + String(!showFilters), + ); + setShowFilters((prev) => !prev); + }; + // Switch to query builder view if there are more than 1 queries useEffect(() => { if (currentQuery.builder.queryData.length > 1) { @@ -90,46 +113,60 @@ function LogsExplorer(): JSX.Element { return ( }> - - } - rightActions={ - - } - showOldCTA - /> - - -
-
- - - -
-
- + {showFilters && ( +
+ -
-
-
+ + )} +
+ + } + rightActions={ + + } + showOldCTA + /> + + +
+
+ + + +
+
+ +
+
+
+
+
); } diff --git a/frontend/src/pages/LogsExplorer/utils.ts b/frontend/src/pages/LogsExplorer/utils.ts deleted file mode 100644 index 0fedaaece4..0000000000 --- a/frontend/src/pages/LogsExplorer/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Query } from 'types/api/queryBuilder/queryBuilderData'; - -export const prepareQueryWithDefaultTimestamp = (query: Query): Query => ({ - ...query, - builder: { - ...query.builder, - queryData: query.builder.queryData?.map((item) => ({ - ...item, - orderBy: [{ columnName: 'timestamp', order: 'desc' }], - })), - }, -}); - -// eslint-disable-next-line @typescript-eslint/naming-convention -export enum SELECTED_VIEWS { - SEARCH = 'search', - QUERY_BUILDER = 'query-builder', - CLICKHOUSE = 'clickhouse', -} diff --git a/frontend/src/pages/LogsExplorer/utils.tsx b/frontend/src/pages/LogsExplorer/utils.tsx new file mode 100644 index 0000000000..7a197bd467 --- /dev/null +++ b/frontend/src/pages/LogsExplorer/utils.tsx @@ -0,0 +1,113 @@ +import { + FiltersType, + IQuickFiltersConfig, +} from 'components/QuickFilters/QuickFilters'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +export const prepareQueryWithDefaultTimestamp = (query: Query): Query => ({ + ...query, + builder: { + ...query.builder, + queryData: query.builder.queryData?.map((item) => ({ + ...item, + orderBy: [{ columnName: 'timestamp', order: 'desc' }], + })), + }, +}); + +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum SELECTED_VIEWS { + SEARCH = 'search', + QUERY_BUILDER = 'query-builder', + CLICKHOUSE = 'clickhouse', +} + +export const LogsQuickFiltersConfig: IQuickFiltersConfig[] = [ + { + type: FiltersType.CHECKBOX, + title: 'Severity Text', + attributeKey: { + key: 'severity_text', + dataType: DataTypes.String, + type: '', + isColumn: true, + isJSON: false, + id: 'severity_text--string----true', + }, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Environment', + attributeKey: { + key: 'deployment.environment', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Service Name', + attributeKey: { + key: 'service.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: true, + isJSON: false, + id: 'service.name--string--resource--true', + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Hostname', + attributeKey: { + key: 'hostname', + dataType: DataTypes.String, + type: 'tag', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Cluster Name', + attributeKey: { + key: 'k8s.cluster.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Deployment Name', + attributeKey: { + key: 'k8s.deployment.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Namespace Name', + attributeKey: { + key: 'k8s.namespace.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: true, + isJSON: false, + }, + defaultOpen: false, + }, +]; diff --git a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx index a28776f0d0..4a3fa8018e 100644 --- a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx +++ b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx @@ -77,6 +77,14 @@ jest.mock( }, ); +window.ResizeObserver = + window.ResizeObserver || + jest.fn().mockImplementation(() => ({ + disconnect: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + })); + const successNotification = jest.fn(); jest.mock('hooks/useNotifications', () => ({ __esModule: true, diff --git a/frontend/src/providers/QueryBuilder.tsx b/frontend/src/providers/QueryBuilder.tsx index c3b50bbc7e..305372eea6 100644 --- a/frontend/src/providers/QueryBuilder.tsx +++ b/frontend/src/providers/QueryBuilder.tsx @@ -62,6 +62,8 @@ import { v4 as uuid } from 'uuid'; export const QueryBuilderContext = createContext({ currentQuery: initialQueriesMap.metrics, supersetQuery: initialQueriesMap.metrics, + lastUsedQuery: null, + setLastUsedQuery: () => {}, setSupersetQuery: () => {}, stagedQuery: initialQueriesMap.metrics, initialDataSource: null, @@ -117,6 +119,7 @@ export function QueryBuilderProvider({ const [currentQuery, setCurrentQuery] = useState(queryState); const [supersetQuery, setSupersetQuery] = useState(queryState); + const [lastUsedQuery, setLastUsedQuery] = useState(0); const [stagedQuery, setStagedQuery] = useState(null); const [queryType, setQueryType] = useState(queryTypeParam); @@ -230,6 +233,8 @@ export function QueryBuilderProvider({ timeUpdated ? merge(currentQuery, newQueryState) : newQueryState, ); setQueryType(type); + // this is required to reset the last used query when navigating or initializing the query builder + setLastUsedQuery(0); }, [prepareQueryBuilderData, currentQuery], ); @@ -857,6 +862,8 @@ export function QueryBuilderProvider({ () => ({ currentQuery: query, supersetQuery: superQuery, + lastUsedQuery, + setLastUsedQuery, setSupersetQuery, stagedQuery, initialDataSource, @@ -884,6 +891,7 @@ export function QueryBuilderProvider({ [ query, superQuery, + lastUsedQuery, stagedQuery, initialDataSource, panelType, diff --git a/frontend/src/types/common/queryBuilder.ts b/frontend/src/types/common/queryBuilder.ts index 4a67619a61..fd3b4c0530 100644 --- a/frontend/src/types/common/queryBuilder.ts +++ b/frontend/src/types/common/queryBuilder.ts @@ -189,6 +189,8 @@ export type QueryBuilderData = { export type QueryBuilderContextType = { currentQuery: Query; stagedQuery: Query | null; + lastUsedQuery: number | null; + setLastUsedQuery: Dispatch>; supersetQuery: Query; setSupersetQuery: Dispatch>; initialDataSource: DataSource | null; From 266894b0f836e8fd26a933e099af500b35fa1cbf Mon Sep 17 00:00:00 2001 From: Yunus M Date: Fri, 6 Sep 2024 11:17:56 +0530 Subject: [PATCH 15/43] fix: strip starting and ending quotes from field value on copy to clipboard (#5831) --- .../LogDetailedView/TableView/TableViewActions.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx b/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx index 63912ffa82..74b30bf6de 100644 --- a/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx +++ b/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx @@ -67,7 +67,6 @@ export function TableViewActions( ); const [isOpen, setIsOpen] = useState(false); - const textToCopy = fieldData.value; if (record.field === 'body') { const parsedBody = recursiveParseJSON(fieldData.value); @@ -89,6 +88,17 @@ export function TableViewActions( : { __html: '' }; const fieldFilterKey = filterKeyForField(fieldData.field); + let textToCopy = fieldData.value; + + // remove starting and ending quotes from the value + try { + textToCopy = textToCopy.replace(/^"|"$/g, ''); + } catch (error) { + console.error( + 'Failed to remove starting and ending quotes from the value', + error, + ); + } return (
From 23704b00ce1b427d1cec701e89f21edad2ec9cd3 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Fri, 6 Sep 2024 11:20:47 +0530 Subject: [PATCH 16/43] feat: show RPS message only if user is on trail and trail is not converted to sub (#5860) * feat: show rps message only if user is on trail and trail is not converted to sub * feat: show rps message only if user is on trail and trail is not converted to sub --- .../ServiceMetrics/ServiceMetricTable.tsx | 7 ++++++- .../ServiceTraces/ServiceTracesTable.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx index 33a4dd729b..6430cc9c8f 100644 --- a/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx +++ b/frontend/src/container/ServiceApplication/ServiceMetrics/ServiceMetricTable.tsx @@ -69,7 +69,12 @@ function ServiceMetricTable({ const [RPS, setRPS] = useState(0); useEffect(() => { - if (!isFetching && licenseData?.payload?.onTrial && isCloudUserVal) { + if ( + !isFetching && + licenseData?.payload?.onTrial && + !licenseData?.payload?.trialConvertedToSubscription && + isCloudUserVal + ) { if (services.length > 0) { const rps = getTotalRPS(services); setRPS(rps); diff --git a/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx b/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx index 8b03027a16..6633b7a1aa 100644 --- a/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx +++ b/frontend/src/container/ServiceApplication/ServiceTraces/ServiceTracesTable.tsx @@ -26,7 +26,12 @@ function ServiceTraceTable({ const tableColumns = useMemo(() => getColumns(search, false), [search]); useEffect(() => { - if (!isFetching && licenseData?.payload?.onTrial && isCloudUserVal) { + if ( + !isFetching && + licenseData?.payload?.onTrial && + !licenseData?.payload?.trialConvertedToSubscription && + isCloudUserVal + ) { if (services.length > 0) { const rps = getTotalRPS(services); setRPS(rps); From b9ab6d3fd4a67da00e03320a3be2dd99f83b7bd5 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Fri, 6 Sep 2024 11:21:07 +0530 Subject: [PATCH 17/43] feat: show add credit card chat icon only to logged in users (#5863) --- frontend/src/container/AppLayout/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 85a58aac99..60b26b8db2 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -78,6 +78,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const isCloudUserVal = isCloudUser(); const showAddCreditCardModal = + isLoggedIn && isChatSupportEnabled && isCloudUserVal && !isPremiumChatSupportEnabled && From 4214e36d22f632557b4d4af40f687993f56199df Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:22:54 +0530 Subject: [PATCH 18/43] fix: added default fallback for selectedColumns, when the attributeKeys call gives empty (#5847) --- frontend/src/container/OptionsMenu/useOptionsMenu.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/container/OptionsMenu/useOptionsMenu.ts b/frontend/src/container/OptionsMenu/useOptionsMenu.ts index 7b3cfce035..a4a91d82f4 100644 --- a/frontend/src/container/OptionsMenu/useOptionsMenu.ts +++ b/frontend/src/container/OptionsMenu/useOptionsMenu.ts @@ -140,6 +140,11 @@ const useOptionsMenu = ({ return col; }) .filter(Boolean) as BaseAutocompleteData[]; + + // this is the last point where we can set the default columns and if uptil now also we have an empty array then we will set the default columns + if (!initialSelected || !initialSelected?.length) { + initialSelected = defaultTraceSelectedColumns; + } } return initialSelected || []; From 7a10fe2b8c871575bb6c8ee5f064132f59296c83 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:23:28 +0530 Subject: [PATCH 19/43] chore: hide old trace explorer cta btn from trace explorer page (#5850) --- frontend/src/pages/TracesExplorer/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/TracesExplorer/index.tsx b/frontend/src/pages/TracesExplorer/index.tsx index bb25a37f86..b865fd02bd 100644 --- a/frontend/src/pages/TracesExplorer/index.tsx +++ b/frontend/src/pages/TracesExplorer/index.tsx @@ -259,7 +259,7 @@ function TracesExplorer(): JSX.Element { )}
- +
From 4eb533fff879140474ac16ebce8fdb35b908c78c Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:50:02 +0530 Subject: [PATCH 20/43] fix: added start and end time info text to educate user better around the schedule timelines (#5837) * fix: added start and end time info text to educate user better around the schedule timelines * fix: changed the start and endtime info text * fix: changed the start and endtime info text * fix: comment resolved --- .../PlannedDowntime.styles.scss | 20 +++ .../PlannedDowntime/PlannedDowntimeForm.tsx | 139 +++++++++++++++++- .../PlannedDowntime/PlannedDowntimeutils.ts | 2 +- 3 files changed, 153 insertions(+), 8 deletions(-) diff --git a/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss b/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss index 41949142fa..b81e4d1e51 100644 --- a/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss +++ b/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss @@ -77,6 +77,18 @@ color: var(--bg-vanilla-400); } } + + .formItemWithBullet { + margin-bottom: 0; + } + + .scheduleTimeInfoText { + margin-top: 8px; + margin-bottom: 20px; + font-size: 12px; + font-weight: 400; + color: var(--bg-vanilla-400); + } } .alert-rule-tags { @@ -543,5 +555,13 @@ background: var(--bg-vanilla-100); } } + + .scheduleTimeInfoText { + color: var(--bg-slate-300); + } + + .alert-rule-info { + color: var(--bg-slate-300); + } } } diff --git a/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx b/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx index 76b0507558..94d1a5d6eb 100644 --- a/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx +++ b/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx @@ -41,7 +41,7 @@ import { getAlertOptionsFromIds, getDurationInfo, getEndTime, - handleTimeConvertion, + handleTimeConversion, isScheduleRecurring, recurrenceOptions, recurrenceOptionWithSubmenu, @@ -52,6 +52,10 @@ dayjs.locale('en'); dayjs.extend(utc); dayjs.extend(timezone); +const TIME_FORMAT = 'HH:mm'; +const DATE_FORMAT = 'Do MMM YYYY'; +const ORDINAL_FORMAT = 'Do'; + interface PlannedDowntimeFormData { name: string; startTime: dayjs.Dayjs | string; @@ -105,6 +109,10 @@ export function PlannedDowntimeForm( ?.unit || 'm', ); + const [formData, setFormData] = useState( + initialValues?.schedule as PlannedDowntimeFormData, + ); + const [recurrenceType, setRecurrenceType] = useState( (initialValues.schedule?.recurrence?.repeatType as string) || recurrenceOptions.doesNotRepeat.value, @@ -131,7 +139,7 @@ export function PlannedDowntimeForm( .filter((alert) => alert !== undefined) as string[], name: values.name, schedule: { - startTime: handleTimeConvertion( + startTime: handleTimeConversion( values.startTime, timezoneInitialValue, values.timezone, @@ -139,7 +147,7 @@ export function PlannedDowntimeForm( ), timezone: values.timezone, endTime: values.endTime - ? handleTimeConvertion( + ? handleTimeConversion( values.endTime, timezoneInitialValue, values.timezone, @@ -196,14 +204,14 @@ export function PlannedDowntimeForm( ? `${values.recurrence?.duration}${durationUnit}` : undefined, endTime: !isEmpty(values.endTime) - ? handleTimeConvertion( + ? handleTimeConversion( values.endTime, timezoneInitialValue, values.timezone, !isEditMode, ) : undefined, - startTime: handleTimeConvertion( + startTime: handleTimeConversion( values.startTime, timezoneInitialValue, values.timezone, @@ -300,6 +308,116 @@ export function PlannedDowntimeForm( }), ); + const getTimezoneFormattedTime = ( + time: string | dayjs.Dayjs, + timeZone?: string, + isEditMode?: boolean, + format?: string, + ): string => { + if (!time) { + return ''; + } + if (!timeZone) { + return dayjs(time).format(format); + } + return dayjs(time).tz(timeZone, isEditMode).format(format); + }; + + const startTimeText = useMemo((): string => { + let startTime = formData?.startTime; + if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) { + startTime = formData?.recurrence?.startTime || formData?.startTime || ''; + } + + if (!startTime) { + return ''; + } + + if (formData.timezone) { + startTime = handleTimeConversion( + startTime, + timezoneInitialValue, + formData?.timezone, + !isEditMode, + ); + } + const daysOfWeek = formData?.recurrence?.repeatOn; + + const formattedStartTime = getTimezoneFormattedTime( + startTime, + formData.timezone, + !isEditMode, + TIME_FORMAT, + ); + + const formattedStartDate = getTimezoneFormattedTime( + startTime, + formData.timezone, + !isEditMode, + DATE_FORMAT, + ); + + const ordinalFormat = getTimezoneFormattedTime( + startTime, + formData.timezone, + !isEditMode, + ORDINAL_FORMAT, + ); + + const formattedDaysOfWeek = daysOfWeek?.join(', '); + switch (recurrenceType) { + case 'daily': + return `Scheduled from ${formattedStartDate}, daily starting at ${formattedStartTime}.`; + case 'monthly': + return `Scheduled from ${formattedStartDate}, monthly on the ${ordinalFormat} starting at ${formattedStartTime}.`; + case 'weekly': + return `Scheduled from ${formattedStartDate}, weekly ${ + formattedDaysOfWeek ? `on [${formattedDaysOfWeek}]` : '' + } starting at ${formattedStartTime}`; + default: + return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`; + } + }, [formData, recurrenceType, isEditMode, timezoneInitialValue]); + + const endTimeText = useMemo((): string => { + let endTime = formData?.endTime; + if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) { + endTime = formData?.recurrence?.endTime || ''; + + if (!isEditMode && !endTime) { + endTime = formData?.endTime || ''; + } + } + + if (!endTime) { + return ''; + } + + if (formData.timezone) { + endTime = handleTimeConversion( + endTime, + timezoneInitialValue, + formData?.timezone, + !isEditMode, + ); + } + + const formattedEndTime = getTimezoneFormattedTime( + endTime, + formData.timezone, + !isEditMode, + TIME_FORMAT, + ); + + const formattedEndDate = getTimezoneFormattedTime( + endTime, + formData.timezone, + !isEditMode, + DATE_FORMAT, + ); + return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`; + }, [formData, recurrenceType, isEditMode, timezoneInitialValue]); + return ( { setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string); + setFormData(form.getFieldsValue()); }} autoComplete="off" > @@ -333,7 +452,7 @@ export function PlannedDowntimeForm( label="Starts from" name="startTime" rules={formValidationRules} - className="formItemWithBullet" + className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''} getValueProps={(value): any => ({ value: value ? dayjs(value).tz(timezoneInitialValue) : undefined, })} @@ -348,6 +467,9 @@ export function PlannedDowntimeForm( popupClassName="datePicker" /> + {!isEmpty(startTimeText) && ( +
{startTimeText}
+ )} ({ value: value ? dayjs(value).tz(timezoneInitialValue) : undefined, })} @@ -426,6 +548,9 @@ export function PlannedDowntimeForm( popupClassName="datePicker" /> + {!isEmpty(endTimeText) && ( +
{endTimeText}
+ )}
Silence Alerts diff --git a/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts b/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts index 7d0745dc5e..feba0cb13e 100644 --- a/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts +++ b/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts @@ -262,7 +262,7 @@ export function formatWithTimezone( return `${parsedDate?.substring(0, 19)}${targetOffset}`; } -export function handleTimeConvertion( +export function handleTimeConversion( dateValue: string | dayjs.Dayjs, timezoneInit?: string, timezone?: string, From 292b3f418eaca688bfab78de2a57f0649ed76c6f Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:53:05 +0530 Subject: [PATCH 21/43] chore: dashboard detail - panel data fetched - telemetry (#5871) * chore: dashboard detail - panel data fetched - telemetry * chore: dashboard detail - code refactor --- .../GridCardLayout/GridCard/index.tsx | 5 ++++- .../GridCardLayout/GridCard/utils.ts | 20 +++++++++++++++++++ .../GridCardLayout/GridCardLayout.tsx | 17 ++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/frontend/src/container/GridCardLayout/GridCard/index.tsx b/frontend/src/container/GridCardLayout/GridCard/index.tsx index 444978f61d..a618f807a5 100644 --- a/frontend/src/container/GridCardLayout/GridCard/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/index.tsx @@ -22,6 +22,7 @@ import { getSortedSeriesData } from 'utils/getSortedSeriesData'; import EmptyWidget from '../EmptyWidget'; import { MenuItemKeys } from '../WidgetHeader/contants'; import { GridCardGraphProps } from './types'; +import { isDataAvailableByPanelType } from './utils'; import WidgetGraphComponent from './WidgetGraphComponent'; function GridCardGraph({ @@ -182,7 +183,9 @@ function GridCardGraph({ setErrorMessage(error.message); }, onSettled: (data) => { - dataAvailable?.(Boolean(data?.payload?.data?.result?.length)); + dataAvailable?.( + isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes), + ); }, }, ); diff --git a/frontend/src/container/GridCardLayout/GridCard/utils.ts b/frontend/src/container/GridCardLayout/GridCard/utils.ts index e14903c33d..ec60e662fa 100644 --- a/frontend/src/container/GridCardLayout/GridCard/utils.ts +++ b/frontend/src/container/GridCardLayout/GridCard/utils.ts @@ -1,6 +1,8 @@ /* eslint-disable sonarjs/cognitive-complexity */ import { LOCALSTORAGE } from 'constants/localStorage'; +import { PANEL_TYPES } from 'constants/queryBuilder'; import getLabelName from 'lib/getLabelName'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { QueryData } from 'types/api/widgets/getQuery'; import { LegendEntryProps } from './FullView/types'; @@ -131,3 +133,21 @@ export const toggleGraphsVisibilityInChart = ({ lineChartRef?.current?.toggleGraph(index, showLegendData); }); }; + +export const isDataAvailableByPanelType = ( + data?: MetricRangePayloadProps['data'], + panelType?: string, +): boolean => { + const getPanelData = (): any[] | undefined => { + switch (panelType) { + case PANEL_TYPES.TABLE: + return (data?.result?.[0] as any)?.table?.rows; + case PANEL_TYPES.LIST: + return data?.newResult?.data?.result?.[0]?.list as any[]; + default: + return data?.result; + } + }; + + return Boolean(getPanelData()?.length); +}; diff --git a/frontend/src/container/GridCardLayout/GridCardLayout.tsx b/frontend/src/container/GridCardLayout/GridCardLayout.tsx index a96599b127..4600b46dd1 100644 --- a/frontend/src/container/GridCardLayout/GridCardLayout.tsx +++ b/frontend/src/container/GridCardLayout/GridCardLayout.tsx @@ -438,6 +438,10 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element { : true, [selectedDashboard], ); + + let isDataAvailableInAnyWidget = false; + const isLogEventCalled = useRef(false); + return isDashboardEmpty ? ( ) : ( @@ -516,6 +520,18 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element { ); } + const checkIfDataExists = (isDataAvailable: boolean): void => { + if (!isDataAvailableInAnyWidget && isDataAvailable) { + isDataAvailableInAnyWidget = true; + } + if (!isLogEventCalled.current && isDataAvailableInAnyWidget) { + isLogEventCalled.current = true; + logEvent('Dashboard Detail: Panel data fetched', { + isDataAvailableInAnyWidget, + }); + } + }; + return ( From 47d1caf078df721b0df51000c2aaa6724b2d1601 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:57:24 +0530 Subject: [PATCH 22/43] chore(deps): bump axios from 1.6.4 to 1.7.4 in /frontend (#5734) Bumps [axios](https://github.com/axios/axios) from 1.6.4 to 1.7.4. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.6.4...v1.7.4) --- updated-dependencies: - dependency-name: axios dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package.json | 2 +- frontend/yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 26ffd31d5c..51097f7696 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "ansi-to-html": "0.7.2", "antd": "5.11.0", "antd-table-saveas-excel": "2.2.1", - "axios": "1.6.4", + "axios": "1.7.4", "babel-eslint": "^10.1.0", "babel-jest": "^29.6.4", "babel-loader": "9.1.3", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index a9186bea9a..2ef8b540e0 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5626,12 +5626,12 @@ axe-core@^4.6.2: resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz" integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ== -axios@1.6.4: - version "1.6.4" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.4.tgz#184ee1f63d412caffcf30d2c50982253c3ee86e0" - integrity sha512-heJnIs6N4aa1eSthhN9M5ioILu8Wi8vmQW9iHQ9NUvfkJb0lEEDUiIdQNAuBtfUt3FxReaKdpQA5DbmMOqzF/A== +axios@1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.4.tgz#4c8ded1b43683c8dd362973c393f3ede24052aa2" + integrity sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw== dependencies: - follow-redirects "^1.15.4" + follow-redirects "^1.15.6" form-data "^4.0.0" proxy-from-env "^1.1.0" @@ -8925,7 +8925,7 @@ flubber@^0.4.2: svgpath "^2.2.1" topojson-client "^3.0.0" -follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.4, follow-redirects@^1.15.6: +follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.6: version "1.15.6" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== From 0db2784d6b0403e78d815d8119ff3661337726ec Mon Sep 17 00:00:00 2001 From: Vishal Sharma Date: Fri, 6 Sep 2024 14:46:18 +0530 Subject: [PATCH 23/43] =?UTF-8?q?chore:=20calculate=20user=20count=20dynam?= =?UTF-8?q?ically=20and=20set=20user=20role=20in=20identity=E2=80=A6=20(#5?= =?UTF-8?q?870)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: calculate user count dynamically and set user role in identity event * chore: move to callbacks --------- Co-authored-by: Srikanth Chekuri --- pkg/query-service/dao/sqlite/connection.go | 4 ++- pkg/query-service/dao/sqlite/rbac.go | 16 +++++++++ pkg/query-service/telemetry/telemetry.go | 38 ++++++++++++++++------ 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/pkg/query-service/dao/sqlite/connection.go b/pkg/query-service/dao/sqlite/connection.go index d7e5ad5de9..a4373d5ecd 100644 --- a/pkg/query-service/dao/sqlite/connection.go +++ b/pkg/query-service/dao/sqlite/connection.go @@ -103,6 +103,9 @@ func InitDB(dataSourceName string) (*ModelDaoSqlite, error) { return nil, err } + telemetry.GetInstance().SetUserCountCallback(mds.GetUserCount) + telemetry.GetInstance().SetUserRoleCallback(mds.GetUserRole) + return mds, nil } @@ -140,7 +143,6 @@ func (mds *ModelDaoSqlite) initializeOrgPreferences(ctx context.Context) error { users, _ := mds.GetUsers(ctx) countUsers := len(users) - telemetry.GetInstance().SetCountUsers(int8(countUsers)) if countUsers > 0 { telemetry.GetInstance().SetCompanyDomain(users[countUsers-1].Email) telemetry.GetInstance().SetUserEmail(users[countUsers-1].Email) diff --git a/pkg/query-service/dao/sqlite/rbac.go b/pkg/query-service/dao/sqlite/rbac.go index aba9beb065..bb594ac463 100644 --- a/pkg/query-service/dao/sqlite/rbac.go +++ b/pkg/query-service/dao/sqlite/rbac.go @@ -612,3 +612,19 @@ func (mds *ModelDaoSqlite) PrecheckLogin(ctx context.Context, email, sourceUrl s return resp, nil } + +func (mds *ModelDaoSqlite) GetUserRole(ctx context.Context, groupId string) (string, error) { + role, err := mds.GetGroup(ctx, groupId) + if err != nil || role == nil { + return "", err + } + return role.Name, nil +} + +func (mds *ModelDaoSqlite) GetUserCount(ctx context.Context) (int, error) { + users, err := mds.GetUsers(ctx) + if err != nil { + return 0, err + } + return len(users), nil +} diff --git a/pkg/query-service/telemetry/telemetry.go b/pkg/query-service/telemetry/telemetry.go index c916135f4e..88f3a09542 100644 --- a/pkg/query-service/telemetry/telemetry.go +++ b/pkg/query-service/telemetry/telemetry.go @@ -176,16 +176,25 @@ type Telemetry struct { rateLimits map[string]int8 activeUser map[string]int8 patTokenUser bool - countUsers int8 mutex sync.RWMutex alertsInfoCallback func(ctx context.Context) (*model.AlertsInfo, error) + userCountCallback func(ctx context.Context) (int, error) + userRoleCallback func(ctx context.Context, groupId string) (string, error) } func (a *Telemetry) SetAlertsInfoCallback(callback func(ctx context.Context) (*model.AlertsInfo, error)) { a.alertsInfoCallback = callback } +func (a *Telemetry) SetUserCountCallback(callback func(ctx context.Context) (int, error)) { + a.userCountCallback = callback +} + +func (a *Telemetry) SetUserRoleCallback(callback func(ctx context.Context, groupId string) (string, error)) { + a.userRoleCallback = callback +} + func createTelemetry() { // Do not do anything in CI (not even resolving the outbound IP address) if testing.Testing() { @@ -259,6 +268,8 @@ func createTelemetry() { metricsTTL, _ := telemetry.reader.GetTTL(ctx, &model.GetTTLParams{Type: constants.MetricsTTL}) logsTTL, _ := telemetry.reader.GetTTL(ctx, &model.GetTTLParams{Type: constants.LogsTTL}) + userCount, _ := telemetry.userCountCallback(ctx) + data := map[string]interface{}{ "totalSpans": totalSpans, "spansInLastHeartBeatInterval": spansInLastHeartBeatInterval, @@ -266,7 +277,7 @@ func createTelemetry() { "getSamplesInfoInLastHeartBeatInterval": getSamplesInfoInLastHeartBeatInterval, "totalLogs": totalLogs, "getLogsInfoInLastHeartBeatInterval": getLogsInfoInLastHeartBeatInterval, - "countUsers": telemetry.countUsers, + "countUsers": userCount, "metricsTTLStatus": metricsTTL.Status, "tracesTTLStatus": traceTTL.Status, "logsTTLStatus": logsTTL.Status, @@ -450,11 +461,22 @@ func (a *Telemetry) IdentifyUser(user *model.User) { if !a.isTelemetryEnabled() || a.isTelemetryAnonymous() { return } + // extract user group from user.groupId + role, _ := a.userRoleCallback(context.Background(), user.GroupId) + if a.saasOperator != nil { - a.saasOperator.Enqueue(analytics.Identify{ - UserId: a.userEmail, - Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email), - }) + if role != "" { + a.saasOperator.Enqueue(analytics.Identify{ + UserId: a.userEmail, + Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email).Set("role", role), + }) + } else { + a.saasOperator.Enqueue(analytics.Identify{ + UserId: a.userEmail, + Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email), + }) + } + a.saasOperator.Enqueue(analytics.Group{ UserId: a.userEmail, GroupId: a.getCompanyDomain(), @@ -474,10 +496,6 @@ func (a *Telemetry) IdentifyUser(user *model.User) { }) } -func (a *Telemetry) SetCountUsers(countUsers int8) { - a.countUsers = countUsers -} - func (a *Telemetry) SetUserEmail(email string) { a.userEmail = email } From ae857d3fcd11f74b702ac882b829518e5e0abae2 Mon Sep 17 00:00:00 2001 From: Sudeep MP Date: Fri, 6 Sep 2024 14:26:13 +0100 Subject: [PATCH 24/43] feat(paywall blocker): improvements for trial end blocker screen (#5756) * feat: add view templates option to dashboard menu * feat: increase dropdown overlay width Set the dropdown overlay width to 200px to provide breathing space for the dropdown button. Added flex to wrap the dropdown button to create space between the right icon and the left elements. * feat(paywall blocker): improvements for trial end blocker screen - added new components locally for rendering static contents - fixed SCSS code for better readablity - seperated data to specific file - added alert info style for the non admin users message * chore: fixed few conditions * feat(paywall title): added contact us to modal title * feat: non admin users communication styles * chore: added useState for the sidebar collapse state to be false * test(WorkspaceLocked): update Jest test to sync with recent UX copy changes * feat(workspaceLocked): added locale added English and English-GB translations for workspace locked messages * feat: reverted the translation for and sidebar collapse fix - I have removed the scope for unitest having locale support - remove the useEffect way to set sidebar collapse, instead added it in app layout - removed the opacity effect on tabs * refactor(workspaceLocked): refactor appLayout component to simplify the isWorkspaceLocked function * refactor(workspaceLocked): simplify isWorkspaceLocked by converting it to a constant expression * refactor(workspaceLocked): refactor modal classname and variable --------- Co-authored-by: Pranay Prateek --- .../public/locales/en-GB/workspaceLocked.json | 22 ++ .../public/locales/en/workspaceLocked.json | 22 ++ frontend/src/container/AppLayout/index.tsx | 11 +- .../WorkspaceLocked/CustomerStoryCard.tsx | 35 ++ .../src/pages/WorkspaceLocked/InfoBlocks.tsx | 30 ++ .../WorkspaceLocked.styles.scss | 159 ++++++++- .../WorkspaceLocked/WorkspaceLocked.test.tsx | 16 +- .../pages/WorkspaceLocked/WorkspaceLocked.tsx | 301 ++++++++++++++---- .../customerStoryCard.styles.scss | 33 ++ .../WorkspaceLocked/workspaceLocked.data.ts | 156 +++++++++ 10 files changed, 714 insertions(+), 71 deletions(-) create mode 100644 frontend/public/locales/en-GB/workspaceLocked.json create mode 100644 frontend/public/locales/en/workspaceLocked.json create mode 100644 frontend/src/pages/WorkspaceLocked/CustomerStoryCard.tsx create mode 100644 frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx create mode 100644 frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss create mode 100644 frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts diff --git a/frontend/public/locales/en-GB/workspaceLocked.json b/frontend/public/locales/en-GB/workspaceLocked.json new file mode 100644 index 0000000000..1eb6a0da1c --- /dev/null +++ b/frontend/public/locales/en-GB/workspaceLocked.json @@ -0,0 +1,22 @@ +{ + "trialPlanExpired": "Trial Plan Expired", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "upgradeToContinue": "Upgrade to Continue", + "upgradeNow": "Upgrade now to keep enjoying all the great features you’ve been using.", + "yourDataIsSafe": "Your data is safe with us until", + "actNow": "Act now to avoid any disruptions and continue where you left off.", + "contactAdmin": "Contact your admin to proceed with the upgrade.", + "continueMyJourney": "Continue My Journey", + "needMoreTime": "Need More Time?", + "extendTrial": "Extend Trial", + "extendTrialMsgPart1": "If you have a specific reason why you were not able to finish your PoC in the trial period, please write to us on", + "extendTrialMsgPart2": "with the reason. Sometimes we can extend trial by a few days on a case by case basis", + "whyChooseSignoz": "Why choose Signoz", + "enterpriseGradeObservability": "Enterprise-grade Observability", + "observabilityDescription": "Get access to observability at any scale with advanced security and compliance.", + "continueToUpgrade": "Continue to Upgrade", + "youAreInGoodCompany": "You are in good company", + "faqs": "FAQs", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/public/locales/en/workspaceLocked.json b/frontend/public/locales/en/workspaceLocked.json new file mode 100644 index 0000000000..1eb6a0da1c --- /dev/null +++ b/frontend/public/locales/en/workspaceLocked.json @@ -0,0 +1,22 @@ +{ + "trialPlanExpired": "Trial Plan Expired", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "upgradeToContinue": "Upgrade to Continue", + "upgradeNow": "Upgrade now to keep enjoying all the great features you’ve been using.", + "yourDataIsSafe": "Your data is safe with us until", + "actNow": "Act now to avoid any disruptions and continue where you left off.", + "contactAdmin": "Contact your admin to proceed with the upgrade.", + "continueMyJourney": "Continue My Journey", + "needMoreTime": "Need More Time?", + "extendTrial": "Extend Trial", + "extendTrialMsgPart1": "If you have a specific reason why you were not able to finish your PoC in the trial period, please write to us on", + "extendTrialMsgPart2": "with the reason. Sometimes we can extend trial by a few days on a case by case basis", + "whyChooseSignoz": "Why choose Signoz", + "enterpriseGradeObservability": "Enterprise-grade Observability", + "observabilityDescription": "Get access to observability at any scale with advanced security and compliance.", + "continueToUpgrade": "Continue to Upgrade", + "youAreInGoodCompany": "You are in good company", + "faqs": "FAQs", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 60b26b8db2..4cf2e0f5bb 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -214,7 +214,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const pageTitle = t(routeKey); const renderFullScreen = pathname === ROUTES.GET_STARTED || - pathname === ROUTES.WORKSPACE_LOCKED || pathname === ROUTES.GET_STARTED_APPLICATION_MONITORING || pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING || pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT || @@ -282,6 +281,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const isSideNavCollapsed = getLocalStorageKey(IS_SIDEBAR_COLLAPSED); + /** + * Note: Right now we don't have a page-level method to pass the sidebar collapse state. + * Since the use case for overriding is not widely needed, we are setting it here + * so that the workspace locked page will have an expanded sidebar regardless of how users + * have set it or what is stored in localStorage. This will not affect the localStorage config. + */ + const isWorkspaceLocked = pathname === ROUTES.WORKSPACE_LOCKED; + return ( )}
+ + + } + title={personName} + description={role} + /> + {message} + + + + ); +} +export default CustomerStoryCard; diff --git a/frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx b/frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx new file mode 100644 index 0000000000..90bec521d7 --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx @@ -0,0 +1,30 @@ +import { Col, Row, Space, Typography } from 'antd'; + +interface InfoItem { + title: string; + description: string; + id: string; // Add a unique identifier +} + +interface InfoBlocksProps { + items: InfoItem[]; +} + +function InfoBlocks({ items }: InfoBlocksProps): JSX.Element { + return ( + + {items.map((item) => ( + +
+ {item.title} + + + {item.description} + + + ))} + + ); +} + +export default InfoBlocks; diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss index c35284241a..131601bfb0 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss @@ -1,16 +1,161 @@ -.workspace-locked-container { - text-align: center; - padding: 48px; - margin: 24px; +$light-theme: 'lightMode'; + +@keyframes gradientFlow { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } } -.workpace-locked-details { - width: 50%; - margin: 0 auto; +.workspace-locked { + &__modal { + .ant-modal-mask { + backdrop-filter: blur(2px); + } + } + + &__tabs { + margin-top: 148px; + + .ant-tabs { + &-nav { + &::before { + border-color: var(--bg-slate-500); + + .#{$light-theme} & { + border-color: var(--bg-vanilla-300); + } + } + } + &-nav-wrap { + justify-content: center; + } + } + } + + &__modal { + &__header { + display: flex; + justify-content: space-between; + align-items: center; + + &__actions { + display: flex; + align-items: center; + gap: 16px; + } + } + .ant-modal-content { + border-radius: 4px; + border: 1px solid var(--bg-slate-400); + background: linear-gradient( + 139deg, + rgba(18, 19, 23, 0.8) 0%, + rgba(18, 19, 23, 0.9) 98.68% + ); + box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(20px); + + .#{$light-theme} & { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + } + } + + .ant-modal-header { + background: transparent; + } + + .ant-list { + &-item { + border-color: var(--bg-slate-500); + + .#{$light-theme} & { + border-color: var(--bg-vanilla-300); + } + + &-meta { + align-items: center !important; + + &-title { + margin-bottom: 0 !important; + } + + &-avatar { + display: flex; + } + } + } + } + &__title { + font-weight: 400; + color: var(--text-vanilla-400); + + .#{$light-theme} & { + color: var(--text-ink-200); + } + } + &__cta { + margin-top: 54px; + } + } + &__container { + padding-top: 64px; + } + &__details { + width: 80%; + margin: 0 auto; + color: var(--text-vanilla-400, #c0c1c3); + text-align: center; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; /* 150% */ + + .#{$light-theme} & { + color: var(--text-ink-200); + } + + &__highlight { + color: var(--text-vanilla-100, #fff); + font-style: normal; + font-weight: 700; + line-height: 24px; + + .#{$light-theme} & { + color: var(--text-ink-100); + } + } + } + &__title { + background: linear-gradient( + 99deg, + #ead8fd 0%, + #7a97fa 33%, + #fd5ab2 66%, + #ead8fd 100% + ); + background-size: 300% 300%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: gradientFlow 24s ease infinite; + margin-bottom: 18px; + } } .contact-us { margin-top: 48px; + color: var(--text-vanilla-400); + + .#{$light-theme} & { + color: var(--text-ink-200); + } } .cta { diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx index bc6885ae65..e459003665 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx @@ -20,17 +20,17 @@ describe('WorkspaceLocked', () => { }); const workspaceLocked = await screen.findByRole('heading', { - name: /workspace locked/i, + name: /upgrade to continue/i, }); expect(workspaceLocked).toBeInTheDocument(); const gotQuestionText = await screen.findByText(/got question?/i); expect(gotQuestionText).toBeInTheDocument(); - const contactUsLink = await screen.findByRole('link', { - name: /contact us/i, + const contactUsBtn = await screen.findByRole('button', { + name: /Contact Us/i, }); - expect(contactUsLink).toBeInTheDocument(); + expect(contactUsBtn).toBeInTheDocument(); }); test('Render for Admin', async () => { @@ -42,11 +42,11 @@ describe('WorkspaceLocked', () => { render(); const contactAdminMessage = await screen.queryByText( - /please contact your administrator for further help/i, + /contact your admin to proceed with the upgrade./i, ); expect(contactAdminMessage).not.toBeInTheDocument(); const updateCreditCardBtn = await screen.findByRole('button', { - name: /update credit card/i, + name: /continue my journey/i, }); expect(updateCreditCardBtn).toBeInTheDocument(); }); @@ -60,12 +60,12 @@ describe('WorkspaceLocked', () => { render(, {}, 'VIEWER'); const updateCreditCardBtn = await screen.queryByRole('button', { - name: /update credit card/i, + name: /Continue My Journey/i, }); expect(updateCreditCardBtn).not.toBeInTheDocument(); const contactAdminMessage = await screen.findByText( - /please contact your administrator for further help/i, + /contact your admin to proceed with the upgrade./i, ); expect(contactAdminMessage).toBeInTheDocument(); }); diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx index 0cc3990af7..84d977ae81 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx @@ -1,21 +1,30 @@ /* eslint-disable react/no-unescaped-entities */ import './WorkspaceLocked.styles.scss'; +import type { TabsProps } from 'antd'; import { - CreditCardOutlined, - LockOutlined, - SendOutlined, -} from '@ant-design/icons'; -import { Button, Card, Skeleton, Typography } from 'antd'; + Alert, + Button, + Col, + Collapse, + Flex, + List, + Modal, + Row, + Skeleton, + Space, + Tabs, + Typography, +} from 'antd'; import updateCreditCardApi from 'api/billing/checkout'; import logEvent from 'api/common/logEvent'; -import { SOMETHING_WENT_WRONG } from 'constants/api'; import ROUTES from 'constants/routes'; -import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader'; import useLicense from 'hooks/useLicense'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; +import { CircleArrowRight } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useMutation } from 'react-query'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; @@ -23,13 +32,22 @@ import { License } from 'types/api/licenses/def'; import AppReducer from 'types/reducer/app'; import { getFormattedDate } from 'utils/timeUtils'; +import CustomerStoryCard from './CustomerStoryCard'; +import InfoBlocks from './InfoBlocks'; +import { + customerStoriesData, + enterpriseGradeValuesData, + faqData, + infoData, +} from './workspaceLocked.data'; + export default function WorkspaceBlocked(): JSX.Element { const { role } = useSelector((state) => state.app); const isAdmin = role === 'ADMIN'; const [activeLicense, setActiveLicense] = useState(null); - const { notifications } = useNotifications(); + const { t } = useTranslation(['workspaceLocked']); const { isFetching: isFetchingLicenseData, isLoading: isLoadingLicenseData, @@ -67,7 +85,7 @@ export default function WorkspaceBlocked(): JSX.Element { }, onError: () => notifications.error({ - message: SOMETHING_WENT_WRONG, + message: t('somethingWentWrong'), }), }, ); @@ -87,73 +105,248 @@ export default function WorkspaceBlocked(): JSX.Element { logEvent('Workspace Blocked: User Clicked Extend Trial', {}); notifications.info({ - message: 'Extend Trial', + message: t('extendTrial'), + duration: 0, description: ( - If you have a specific reason why you were not able to finish your PoC in - the trial period, please write to us on - cloud-support@signoz.io - with the reason. Sometimes we can extend trial by a few days on a case by - case basis + {t('extendTrialMsgPart1')}{' '} + cloud-support@signoz.io{' '} + {t('extendTrialMsgPart2')} ), }); }; - return ( - <> - + const renderCustomerStories = ( + filterCondition: (index: number) => boolean, + ): JSX.Element[] => + customerStoriesData + .filter((_, index) => filterCondition(index)) + .map((story) => ( + + )); - - {isLoadingLicenseData || !licensesData?.payload?.workSpaceBlock ? ( - - ) : ( - <> - - Workspace Locked - - You have been locked out of your workspace because your trial ended - without an upgrade to a paid plan. Your data will continue to be ingested - till{' '} - {getFormattedDate(licensesData?.payload?.gracePeriodEnd || Date.now())} , - at which point we will drop all the ingested data and terminate the - account. - {!isAdmin && 'Please contact your administrator for further help'} - - -
+ const tabItems: TabsProps['items'] = [ + { + key: '1', + label: t('whyChooseSignoz'), + children: ( + +
+ + + + + + + + + {t('enterpriseGradeObservability')} + + {t('observabilityDescription')} + + ( + + } title={item.title} /> + + )} + /> + + {isAdmin && ( + + + + )} + + + + ), + }, + { + key: '2', + label: t('youAreInGoodCompany'), + children: ( + + {/* #FIXME: please suggest if there is any better way to loop in different columns to get the masonry layout */} + {renderCustomerStories((index) => index % 2 === 0)} + {renderCustomerStories((index) => index % 2 !== 0)} + {isAdmin && ( + + + + + )} + + ), + }, + // #TODO: comming soon + // { + // key: '3', + // label: 'Our Pricing', + // children: 'Our Pricing', + // }, + { + key: '4', + label: t('faqs'), + children: ( + + + + + {isAdmin && ( + )} + + + + ), + }, + ]; + return ( +
+ + + {t('trialPlanExpired')} + + + + Got Questions? + -
-
- Got Questions? - - Contact Us - -
- - )} - - + + + } + open + closable={false} + footer={null} + width="65%" + > +
+ {isLoadingLicenseData || !licensesData ? ( + + ) : ( + <> + +
+ + +
Upgrade to Continue
+
+ + {t('upgradeNow')} +
+ {t('yourDataIsSafe')}{' '} + + {getFormattedDate( + licensesData.payload?.gracePeriodEnd || Date.now(), + )} + {' '} + {t('actNow')} +
+
+ + + {!isAdmin && ( + + + + + + )} + {isAdmin && ( + + + + + + + + + )} + + + + + + )} + + + ); } diff --git a/frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss b/frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss new file mode 100644 index 0000000000..7abddada3a --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss @@ -0,0 +1,33 @@ +$component-name: 'customer-story-card'; +$ant-card-override: 'ant-card'; +$light-theme: 'lightMode'; + +.#{$component-name} { + max-width: 385px; + margin: 0 auto; // Center the card within the column + margin-bottom: 24px; + border-radius: 6px; + transition: transform 0.3s ease, box-shadow 0.3s ease; + background-color: var(--bg-ink-400); + border: 1px solid var(--bg-ink-300); + + .#{$light-theme} & { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + } + + .#{$ant-card-override}-meta-title { + margin-bottom: 2px !important; + } + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + background-color: var(--bg-ink-300); + + .#{$light-theme} & { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + background-color: var(--bg-vanilla-100); + } + } +} diff --git a/frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts b/frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts new file mode 100644 index 0000000000..0f4d07b96e --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts @@ -0,0 +1,156 @@ +export const infoData = [ + { + id: 'infoBlock-1', + title: 'Built for scale', + description: + 'Our powerful ingestion engine has a proven track record of handling 10TB+ data ingestion per day.', + }, + { + id: 'infoBlock-2', + title: 'Trusted across the globe', + description: + 'Used by teams in all 5 continents ⎯ across the mountains, rivers, and the high seas.', + }, + { + id: 'infoBlock-3', + title: 'Powering observability for teams of all sizes', + description: + 'Hundreds of companies ⎯from early-stage start-ups to public enterprises use SigNoz to build more reliable products.', + }, +]; + +export const enterpriseGradeValuesData = [ + { + title: 'SSO and SAML support', + }, + { + title: 'Query API keys', + }, + { + title: 'Advanced security with SOC 2 Type I certification', + }, + { + title: 'AWS Private Link', + }, + { + title: 'VPC peering', + }, + { + title: 'Custom integrations', + }, +]; + +export const customerStoriesData = [ + { + key: 'c-story-1', + avatar: 'https://signoz.io/img/users/subomi-oluwalana.webp', + personName: 'Subomi Oluwalana', + role: 'Founder & CEO at Convoy', + customerName: 'Convoy', + message: + "We use OTel with SigNoz to spot redundant database connect calls. For example, we found that our database driver wasn't using the connection pool even though the documentation claimed otherwise.", + link: + 'https://www.linkedin.com/feed/update/urn:li:activity:7212117589068591105/', + }, + { + key: 'c-story-2', + avatar: 'https://signoz.io/img/users/dhruv-garg.webp', + personName: 'Dhruv Garg', + role: 'Tech Lead at Nudge', + customerName: 'Nudge', + message: + 'SigNoz is one of the best observability tools you can self-host hands down. And they are always there to help on their slack channel when needed.', + link: + 'https://www.linkedin.com/posts/dhruv-garg79_signoz-docker-kubernetes-activity-7205163679028240384-Otlb/', + }, + { + key: 'c-story-3', + avatar: 'https://signoz.io/img/users/vivek-bhakta.webp', + personName: 'Vivek Bhakta', + role: 'CTO at Wombo AI', + customerName: 'Wombo AI', + message: + 'We use SigNoz and have been loving it - can definitely handle scale.', + link: 'https://x.com/notorious_VB/status/1701773119696904242', + }, + { + key: 'c-story-4', + avatar: 'https://signoz.io/img/users/pranay-narang.webp', + personName: 'Pranay Narang', + role: 'Engineering at Azodha', + customerName: 'Azodha', + message: + 'Recently moved metrics and logging to SigNoz. Gotta say, absolutely loving the tool.', + link: 'https://x.com/PranayNarang/status/1676247073396752387', + }, + { + key: 'c-story-4', + avatar: 'https://signoz.io/img/users/shey.webp', + personName: 'Sheheryar Sewani', + role: 'Seasoned Rails Dev & Founder', + customerName: '', + message: + "But wow, I'm glad I tried SigNoz. Setting up SigNoz was easy—they provide super helpful instructions along with a docker-compose file.", + link: + 'https://www.linkedin.com/feed/update/urn:li:activity:7181011853915926528/', + }, + { + key: 'c-story-5', + avatar: 'https://signoz.io/img/users/daniel.webp', + personName: 'Daniel Schell', + role: 'Founder & CTO at Airlockdigital', + customerName: 'Airlockdigital', + message: + 'Have been deep diving Signoz. Seems like the new hotness for an "all-in-one".', + link: 'https://x.com/danonit/status/1749256583157284919', + }, + { + key: 'c-story-6', + avatar: 'https://signoz.io/img/users/go-frendi.webp', + personName: 'Go Frendi Gunawan', + role: 'Data Engineer at Ctlyst.id', + customerName: 'Ctlyst.id', + message: + 'Monitoring done. Thanks to SigNoz, I don’t have to deal with Grafana, Loki, Prometheus, and Jaeger separately.', + link: 'https://x.com/gofrendiasgard/status/1680139003658641408', + }, + { + key: 'c-story-7', + avatar: 'https://signoz.io/img/users/anselm.jpg', + personName: 'Anselm Eickhoff', + role: 'Software Architect', + customerName: '', + message: + 'NewRelic: receiving OpenTelemetry at all takes me 1/2 day to grok, docs are a mess. Traces show up after 5min. I burn the free 100GB/mo in 1 day of light testing. @SignozHQ: can run it locally (∞GB), has a special tutorial for OpenTelemetry + Rust! Traces show up immediately.', + link: + 'https://twitter.com/ae_play/status/1572993932094472195?s=20&t=LWWrW5EP_k5q6_mwbFN4jQ', + }, +]; + +export const faqData = [ + { + key: '1', + label: + 'What is the difference between SigNoz Cloud(Teams) and Community Edition?', + children: + 'You can self-host and manage the community edition yourself. You should choose SigNoz Cloud if you don’t want to worry about managing the SigNoz cluster. There are some exclusive features like SSO & SAML support, which come with SigNoz cloud offering. Our team also offers support on the initial configuration of dashboards & alerts and advises on best practices for setting up your observability stack in the SigNoz cloud offering.', + }, + { + key: '2', + label: 'How are number of samples calculated for metrics pricing?', + children: + "If a timeseries sends data every 30s, then it will generate 2 samples per min. So, if you have 10,000 time series sending data every 30s then you will be sending 20,000 samples per min to SigNoz. This will be around 864 mn samples per month and would cost 86.4 USD/month. Here's an explainer video on how metrics pricing is calculated - Link: https://vimeo.com/973012522", + }, + { + key: '3', + label: 'Do you offer enterprise support plans?', + children: + 'Yes, feel free to reach out to us on hello@signoz.io if you need a dedicated support plan or paid support for setting up your initial SigNoz setup.', + }, + { + key: '4', + label: 'Who should use Enterprise plans?', + children: + 'Teams which need enterprise support or features like SSO, Audit logs, etc. may find our enterprise plans valuable.', + }, +]; From afc97511af366bb6f78408a30c420ade573b6610 Mon Sep 17 00:00:00 2001 From: Abhishek Mehandiratta <36722596+abhi12299@users.noreply.github.com> Date: Sat, 7 Sep 2024 02:22:32 +0530 Subject: [PATCH 25/43] feat(dashboard): add widget count to collapsed section rows (#5822) --- .../src/container/GridCardLayout/GridCardLayout.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/container/GridCardLayout/GridCardLayout.tsx b/frontend/src/container/GridCardLayout/GridCardLayout.tsx index 4600b46dd1..c4e4279f9f 100644 --- a/frontend/src/container/GridCardLayout/GridCardLayout.tsx +++ b/frontend/src/container/GridCardLayout/GridCardLayout.tsx @@ -472,6 +472,15 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element { if (currentWidget?.panelTypes === PANEL_GROUP_TYPES.ROW) { const rowWidgetProperties = currentPanelMap[id] || {}; + let { title } = currentWidget; + if (rowWidgetProperties.collapsed) { + const widgetCount = rowWidgetProperties.widgets?.length || 0; + const collapsedText = `(${widgetCount} widget${ + widgetCount > 1 ? 's' : '' + })`; + title += ` ${collapsedText}`; + } + return ( )} - - {currentWidget.title} - + {title} {rowWidgetProperties.collapsed ? ( Date: Sat, 7 Sep 2024 18:34:35 +0100 Subject: [PATCH 26/43] refactor(alert timeline): update TopContributorsCard and Table styles (#5881) * refactor(alert timeline): update TopContributorsCard and Table styles - Update hover styles for collapsed section rows in TopContributorsCard - Update text and icon colors on hover in TopContributorsCard - Remove unnecessary styles for value column in Table - Update font size and alignment for table headers in Table - Update font size and alignment for created at column in Table - Add actions column with ellipsis button in Table * feat(alert history styles): update alert popover and top contributors card styles --- .../AlertPopover/AlertPopover.styles.scss | 20 +++++++- .../AlertPopover/AlertPopover.tsx | 3 +- .../TopContributorsCard.styles.scss | 24 +++++++++- .../Timeline/Table/Table.styles.scss | 15 +----- .../Timeline/Table/useTimelineTable.tsx | 47 ++++++++++--------- 5 files changed, 70 insertions(+), 39 deletions(-) diff --git a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss index 43d645efa5..144996ba38 100644 --- a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss +++ b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss @@ -1,3 +1,21 @@ -.alert-popover { +.alert-popover-trigger-action { cursor: pointer; } + +.alert-history-popover { + .ant-popover-inner { + border: 1px solid var(--bg-slate-400); + + .lightMode & { + background: var(--bg-vanilla-100) !important; + border: 1px solid var(--bg-vanilla-300); + } + } + .ant-popover-arrow { + &::before { + .lightMode & { + background: var(--bg-vanilla-100); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx index 83605a61d3..d70da07903 100644 --- a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx +++ b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx @@ -64,12 +64,13 @@ function AlertPopover({ relatedLogsLink, }: Props): JSX.Element { return ( -
+
tr > td { border: none; - &:last-of-type, - &:nth-last-of-type(2) { - text-align: right; - } } } @@ -52,7 +41,7 @@ } .alert-rule { &-value, - &-created-at { + &__created-at { font-size: 14px; color: var(--text-vanilla-400); } @@ -60,7 +49,7 @@ font-weight: 500; line-height: 20px; } - &-created-at { + &__created-at { line-height: 18px; letter-spacing: -0.07px; } diff --git a/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx index 5a42fcd5bd..1eb43fc417 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx @@ -1,3 +1,5 @@ +import { EllipsisOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; import { ColumnsType } from 'antd/es/table'; import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; @@ -10,43 +12,42 @@ export const timelineTableColumns = (): ColumnsType ( - -
- -
-
+ width: 140, + render: (value): JSX.Element => ( +
+ +
), }, { title: 'LABELS', dataIndex: 'labels', - width: '54.5%', - render: (labels, record): JSX.Element => ( - -
- -
-
+ render: (labels): JSX.Element => ( +
+ +
), }, { title: 'CREATED AT', dataIndex: 'unixMilli', - width: '32.5%', - render: (value, record): JSX.Element => ( + width: 200, + render: (value): JSX.Element => ( +
{formatEpochTimestamp(value)}
+ ), + }, + { + title: 'ACTIONS', + width: 140, + align: 'right', + render: (record): JSX.Element => ( -
{formatEpochTimestamp(value)}
+
), }, From 12f2f8095849925bb5dbfb101f9e1e6281e017af Mon Sep 17 00:00:00 2001 From: Nityananda Gohain Date: Sun, 8 Sep 2024 14:14:13 +0530 Subject: [PATCH 27/43] feat: logsV4 resource table query builder (#5872) * feat: logsV4 resource table query builder * fix: address pr comments * fix: escape %, _ for contains queries * fix: resource attribute filtering case sensitive --------- Co-authored-by: Srikanth Chekuri --- .../app/logs/v4/query_builder.go | 31 ++ .../app/logs/v4/resource_query_builder.go | 201 ++++++++ .../logs/v4/resource_query_builder_test.go | 482 ++++++++++++++++++ pkg/query-service/utils/format.go | 8 + 4 files changed, 722 insertions(+) create mode 100644 pkg/query-service/app/logs/v4/query_builder.go create mode 100644 pkg/query-service/app/logs/v4/resource_query_builder.go create mode 100644 pkg/query-service/app/logs/v4/resource_query_builder_test.go diff --git a/pkg/query-service/app/logs/v4/query_builder.go b/pkg/query-service/app/logs/v4/query_builder.go new file mode 100644 index 0000000000..08024756bd --- /dev/null +++ b/pkg/query-service/app/logs/v4/query_builder.go @@ -0,0 +1,31 @@ +package v4 + +import ( + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" +) + +var logOperators = map[v3.FilterOperator]string{ + v3.FilterOperatorEqual: "=", + v3.FilterOperatorNotEqual: "!=", + v3.FilterOperatorLessThan: "<", + v3.FilterOperatorLessThanOrEq: "<=", + v3.FilterOperatorGreaterThan: ">", + v3.FilterOperatorGreaterThanOrEq: ">=", + v3.FilterOperatorLike: "LIKE", + v3.FilterOperatorNotLike: "NOT LIKE", + v3.FilterOperatorContains: "LIKE", + v3.FilterOperatorNotContains: "NOT LIKE", + v3.FilterOperatorRegex: "match(%s, %s)", + v3.FilterOperatorNotRegex: "NOT match(%s, %s)", + v3.FilterOperatorIn: "IN", + v3.FilterOperatorNotIn: "NOT IN", + v3.FilterOperatorExists: "mapContains(%s_%s, '%s')", + v3.FilterOperatorNotExists: "not mapContains(%s_%s, '%s')", +} + +const ( + BODY = "body" + DISTRIBUTED_LOGS_V2 = "distributed_logs_v2" + DISTRIBUTED_LOGS_V2_RESOURCE = "distributed_logs_v2_resource" + NANOSECOND = 1000000000 +) diff --git a/pkg/query-service/app/logs/v4/resource_query_builder.go b/pkg/query-service/app/logs/v4/resource_query_builder.go new file mode 100644 index 0000000000..004c9269fb --- /dev/null +++ b/pkg/query-service/app/logs/v4/resource_query_builder.go @@ -0,0 +1,201 @@ +package v4 + +import ( + "fmt" + "strings" + + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" + "go.signoz.io/signoz/pkg/query-service/utils" +) + +// buildResourceFilter builds a clickhouse filter string for resource labels +func buildResourceFilter(logsOp string, key string, op v3.FilterOperator, value interface{}) string { + searchKey := fmt.Sprintf("simpleJSONExtractString(labels, '%s')", key) + + chFmtVal := utils.ClickHouseFormattedValue(value) + + switch op { + case v3.FilterOperatorExists: + return fmt.Sprintf("simpleJSONHas(labels, '%s')", key) + case v3.FilterOperatorNotExists: + return fmt.Sprintf("not simpleJSONHas(labels, '%s')", key) + case v3.FilterOperatorRegex, v3.FilterOperatorNotRegex: + return fmt.Sprintf(logsOp, searchKey, chFmtVal) + case v3.FilterOperatorContains, v3.FilterOperatorNotContains: + // this is required as clickhouseFormattedValue add's quotes to the string + escapedStringValue := utils.QuoteEscapedStringForContains(fmt.Sprintf("%s", value)) + return fmt.Sprintf("%s %s '%%%s%%'", searchKey, logsOp, escapedStringValue) + default: + return fmt.Sprintf("%s %s %s", searchKey, logsOp, chFmtVal) + } +} + +// buildIndexFilterForInOperator builds a clickhouse filter string for in operator +// example:= x in a,b,c = (labels like '%x%a%' or labels like '%"x":"b"%' or labels like '%"x"="c"%') +// example:= x nin a,b,c = (labels nlike '%x%a%' AND labels nlike '%"x"="b"' AND labels nlike '%"x"="c"%') +func buildIndexFilterForInOperator(key string, op v3.FilterOperator, value interface{}) string { + conditions := []string{} + separator := " OR " + sqlOp := "like" + if op == v3.FilterOperatorNotIn { + separator = " AND " + sqlOp = "not like" + } + + // values is a slice of strings, we need to convert value to this type + // value can be string or []interface{} + values := []string{} + switch value.(type) { + case string: + values = append(values, value.(string)) + case []interface{}: + for _, v := range (value).([]interface{}) { + // also resources attributes are always string values + strV, ok := v.(string) + if !ok { + continue + } + values = append(values, strV) + } + } + + // if there are no values to filter on, return an empty string + if len(values) > 0 { + for _, v := range values { + value := utils.QuoteEscapedStringForContains(v) + conditions = append(conditions, fmt.Sprintf("labels %s '%%\"%s\":\"%s\"%%'", sqlOp, key, value)) + } + return "(" + strings.Join(conditions, separator) + ")" + } + return "" +} + +// buildResourceIndexFilter builds a clickhouse filter string for resource labels +// example:= x like '%john%' = labels like '%x%john%' +func buildResourceIndexFilter(key string, op v3.FilterOperator, value interface{}) string { + // not using clickhouseFormattedValue as we don't wan't the quotes + formattedValueEscaped := utils.QuoteEscapedStringForContains(fmt.Sprintf("%s", value)) + + // add index filters + switch op { + case v3.FilterOperatorContains, v3.FilterOperatorEqual, v3.FilterOperatorLike: + return fmt.Sprintf("labels like '%%%s%%%s%%'", key, formattedValueEscaped) + case v3.FilterOperatorNotContains, v3.FilterOperatorNotEqual, v3.FilterOperatorNotLike: + return fmt.Sprintf("labels not like '%%%s%%%s%%'", key, formattedValueEscaped) + case v3.FilterOperatorNotRegex: + return fmt.Sprintf("labels not like '%%%s%%'", key) + case v3.FilterOperatorIn, v3.FilterOperatorNotIn: + return buildIndexFilterForInOperator(key, op, value) + default: + return fmt.Sprintf("labels like '%%%s%%'", key) + } +} + +// buildResourceFiltersFromFilterItems builds a list of clickhouse filter strings for resource labels from a FilterSet. +// It skips any filter items that are not resource attributes and checks that the operator is supported and the data type is correct. +func buildResourceFiltersFromFilterItems(fs *v3.FilterSet) ([]string, error) { + var conditions []string + if fs == nil || len(fs.Items) == 0 { + return nil, nil + } + for _, item := range fs.Items { + // skip anything other than resource attribute + if item.Key.Type != v3.AttributeKeyTypeResource { + continue + } + + // since out map is in lower case we are converting it to lowercase + operatorLower := strings.ToLower(string(item.Operator)) + op := v3.FilterOperator(operatorLower) + keyName := item.Key.Key + + // resource filter value data type will always be string + // will be an interface if the operator is IN or NOT IN + if item.Key.DataType != v3.AttributeKeyDataTypeString && + (op != v3.FilterOperatorIn && op != v3.FilterOperatorNotIn) { + return nil, fmt.Errorf("invalid data type for resource attribute: %s", item.Key.Key) + } + + var value interface{} + var err error + if op != v3.FilterOperatorExists && op != v3.FilterOperatorNotExists { + // make sure to cast the value regardless of the actual type + value, err = utils.ValidateAndCastValue(item.Value, item.Key.DataType) + if err != nil { + return nil, fmt.Errorf("failed to validate and cast value for %s: %v", item.Key.Key, err) + } + } + + if logsOp, ok := logOperators[op]; ok { + // the filter + if resourceFilter := buildResourceFilter(logsOp, keyName, op, value); resourceFilter != "" { + conditions = append(conditions, resourceFilter) + } + // the additional filter for better usage of the index + if resourceIndexFilter := buildResourceIndexFilter(keyName, op, value); resourceIndexFilter != "" { + conditions = append(conditions, resourceIndexFilter) + } + } else { + return nil, fmt.Errorf("unsupported operator: %s", op) + } + + } + + return conditions, nil +} + +func buildResourceFiltersFromGroupBy(groupBy []v3.AttributeKey) []string { + var conditions []string + + for _, attr := range groupBy { + if attr.Type != v3.AttributeKeyTypeResource { + continue + } + conditions = append(conditions, fmt.Sprintf("(simpleJSONHas(labels, '%s') AND labels like '%%%s%%')", attr.Key, attr.Key)) + } + return conditions +} + +func buildResourceFiltersFromAggregateAttribute(aggregateAttribute v3.AttributeKey) string { + if aggregateAttribute.Key != "" && aggregateAttribute.Type == v3.AttributeKeyTypeResource { + return fmt.Sprintf("(simpleJSONHas(labels, '%s') AND labels like '%%%s%%')", aggregateAttribute.Key, aggregateAttribute.Key) + } + + return "" +} + +func buildResourceSubQuery(bucketStart, bucketEnd int64, fs *v3.FilterSet, groupBy []v3.AttributeKey, aggregateAttribute v3.AttributeKey) (string, error) { + + // BUILD THE WHERE CLAUSE + var conditions []string + // only add the resource attributes to the filters here + rs, err := buildResourceFiltersFromFilterItems(fs) + if err != nil { + return "", err + } + conditions = append(conditions, rs...) + + // for aggregate attribute add exists check in resources + aggregateAttributeResourceFilter := buildResourceFiltersFromAggregateAttribute(aggregateAttribute) + if aggregateAttributeResourceFilter != "" { + conditions = append(conditions, aggregateAttributeResourceFilter) + } + + groupByResourceFilters := buildResourceFiltersFromGroupBy(groupBy) + if len(groupByResourceFilters) > 0 { + // TODO: change AND to OR once we know how to solve for group by ( i.e show values if one is not present) + groupByStr := "( " + strings.Join(groupByResourceFilters, " AND ") + " )" + conditions = append(conditions, groupByStr) + } + if len(conditions) == 0 { + return "", nil + } + conditionStr := strings.Join(conditions, " AND ") + + // BUILD THE FINAL QUERY + query := fmt.Sprintf("SELECT fingerprint FROM signoz_logs.%s WHERE (seen_at_ts_bucket_start >= %d) AND (seen_at_ts_bucket_start <= %d) AND ", DISTRIBUTED_LOGS_V2_RESOURCE, bucketStart, bucketEnd) + + query = "(" + query + conditionStr + ")" + + return query, nil +} diff --git a/pkg/query-service/app/logs/v4/resource_query_builder_test.go b/pkg/query-service/app/logs/v4/resource_query_builder_test.go new file mode 100644 index 0000000000..1616c29e08 --- /dev/null +++ b/pkg/query-service/app/logs/v4/resource_query_builder_test.go @@ -0,0 +1,482 @@ +package v4 + +import ( + "reflect" + "testing" + + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" +) + +func Test_buildResourceFilter(t *testing.T) { + type args struct { + logsOp string + key string + op v3.FilterOperator + value interface{} + } + tests := []struct { + name string + args args + want string + }{ + { + name: "test exists", + args: args{ + key: "service.name", + op: v3.FilterOperatorExists, + }, + want: `simpleJSONHas(labels, 'service.name')`, + }, + { + name: "test nexists", + args: args{ + key: "service.name", + op: v3.FilterOperatorNotExists, + }, + want: `not simpleJSONHas(labels, 'service.name')`, + }, + { + name: "test regex", + args: args{ + logsOp: "match(%s, %s)", + key: "service.name", + op: v3.FilterOperatorRegex, + value: ".*", + }, + want: `match(simpleJSONExtractString(labels, 'service.name'), '.*')`, + }, + { + name: "test contains", + args: args{ + logsOp: "LIKE", + key: "service.name", + op: v3.FilterOperatorContains, + value: "Application%_", + }, + want: `simpleJSONExtractString(labels, 'service.name') LIKE '%Application\%\_%'`, + }, + { + name: "test eq", + args: args{ + logsOp: "=", + key: "service.name", + op: v3.FilterOperatorEqual, + value: "Application", + }, + want: `simpleJSONExtractString(labels, 'service.name') = 'Application'`, + }, + { + name: "test value with quotes", + args: args{ + logsOp: "=", + key: "service.name", + op: v3.FilterOperatorEqual, + value: "Application's", + }, + want: `simpleJSONExtractString(labels, 'service.name') = 'Application\'s'`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildResourceFilter(tt.args.logsOp, tt.args.key, tt.args.op, tt.args.value); got != tt.want { + t.Errorf("buildResourceFilter() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_buildIndexFilterForInOperator(t *testing.T) { + type args struct { + key string + op v3.FilterOperator + value interface{} + } + tests := []struct { + name string + args args + want string + }{ + { + name: "test in array", + args: args{ + key: "service.name", + op: v3.FilterOperatorIn, + value: []interface{}{"Application", "Test"}, + }, + want: `(labels like '%"service.name":"Application"%' OR labels like '%"service.name":"Test"%')`, + }, + { + name: "test nin array", + args: args{ + key: "service.name", + op: v3.FilterOperatorNotIn, + value: []interface{}{"Application", "Test"}, + }, + want: `(labels not like '%"service.name":"Application"%' AND labels not like '%"service.name":"Test"%')`, + }, + { + name: "test in string", + args: args{ + key: "service.name", + op: v3.FilterOperatorIn, + value: "application", + }, + want: `(labels like '%"service.name":"application"%')`, + }, + { + name: "test nin string", + args: args{ + key: "service.name", + op: v3.FilterOperatorNotIn, + value: "application'\"_s", + }, + want: `(labels not like '%"service.name":"application\'"\_s"%')`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildIndexFilterForInOperator(tt.args.key, tt.args.op, tt.args.value); got != tt.want { + t.Errorf("buildIndexFilterForInOperator() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_buildResourceIndexFilter(t *testing.T) { + type args struct { + key string + op v3.FilterOperator + value interface{} + } + tests := []struct { + name string + args args + want string + }{ + { + name: "test contains", + args: args{ + key: "service.name", + op: v3.FilterOperatorContains, + value: "application", + }, + want: `labels like '%service.name%application%'`, + }, + { + name: "test not contains", + args: args{ + key: "service.name", + op: v3.FilterOperatorNotContains, + value: "application", + }, + want: `labels not like '%service.name%application%'`, + }, + { + name: "test contains with % and _", + args: args{ + key: "service.name", + op: v3.FilterOperatorNotContains, + value: "application%_test", + }, + want: `labels not like '%service.name%application\%\_test%'`, + }, + { + name: "test not regex", + args: args{ + key: "service.name", + op: v3.FilterOperatorNotRegex, + value: ".*", + }, + want: `labels not like '%service.name%'`, + }, + { + name: "test in", + args: args{ + key: "service.name", + op: v3.FilterOperatorNotIn, + value: []interface{}{"Application", "Test"}, + }, + want: `(labels not like '%"service.name":"Application"%' AND labels not like '%"service.name":"Test"%')`, + }, + { + name: "test eq", + args: args{ + key: "service.name", + op: v3.FilterOperatorEqual, + value: "Application", + }, + want: `labels like '%service.name%Application%'`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildResourceIndexFilter(tt.args.key, tt.args.op, tt.args.value); got != tt.want { + t.Errorf("buildResourceIndexFilter() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_buildResourceFiltersFromFilterItems(t *testing.T) { + type args struct { + fs *v3.FilterSet + } + tests := []struct { + name string + args args + want []string + wantErr bool + }{ + { + name: "ignore attribute", + args: args{ + fs: &v3.FilterSet{ + Items: []v3.FilterItem{ + { + Key: v3.AttributeKey{ + Key: "service.name", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeTag, + }, + Operator: v3.FilterOperatorEqual, + Value: "test", + }, + }, + }, + }, + want: nil, + wantErr: false, + }, + { + name: "build filter", + args: args{ + fs: &v3.FilterSet{ + Items: []v3.FilterItem{ + { + Key: v3.AttributeKey{ + Key: "service.name", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + Operator: v3.FilterOperatorEqual, + Value: "test", + }, + }, + }, + }, + want: []string{ + "simpleJSONExtractString(labels, 'service.name') = 'test'", + "labels like '%service.name%test%'", + }, + wantErr: false, + }, + { + name: "build filter with multiple items", + args: args{ + fs: &v3.FilterSet{ + Items: []v3.FilterItem{ + { + Key: v3.AttributeKey{ + Key: "service.name", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + Operator: v3.FilterOperatorEqual, + Value: "test", + }, + { + Key: v3.AttributeKey{ + Key: "namespace", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + Operator: v3.FilterOperatorContains, + Value: "test1", + }, + }, + }, + }, + want: []string{ + "simpleJSONExtractString(labels, 'service.name') = 'test'", + "labels like '%service.name%test%'", + "simpleJSONExtractString(labels, 'namespace') LIKE '%test1%'", + "labels like '%namespace%test1%'", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildResourceFiltersFromFilterItems(tt.args.fs) + if (err != nil) != tt.wantErr { + t.Errorf("buildResourceFiltersFromFilterItems() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("buildResourceFiltersFromFilterItems() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_buildResourceFiltersFromGroupBy(t *testing.T) { + type args struct { + groupBy []v3.AttributeKey + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "build filter", + args: args{ + groupBy: []v3.AttributeKey{ + { + Key: "service.name", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + }, + }, + want: []string{ + "(simpleJSONHas(labels, 'service.name') AND labels like '%service.name%')", + }, + }, + { + name: "build filter multiple group by", + args: args{ + groupBy: []v3.AttributeKey{ + { + Key: "service.name", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + { + Key: "namespace", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + }, + }, + want: []string{ + "(simpleJSONHas(labels, 'service.name') AND labels like '%service.name%')", + "(simpleJSONHas(labels, 'namespace') AND labels like '%namespace%')", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildResourceFiltersFromGroupBy(tt.args.groupBy); !reflect.DeepEqual(got, tt.want) { + t.Errorf("buildResourceFiltersFromGroupBy() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_buildResourceFiltersFromAggregateAttribute(t *testing.T) { + type args struct { + aggregateAttribute v3.AttributeKey + } + tests := []struct { + name string + args args + want string + }{ + { + name: "build filter", + args: args{ + aggregateAttribute: v3.AttributeKey{ + Key: "service.name", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + }, + want: "(simpleJSONHas(labels, 'service.name') AND labels like '%service.name%')", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildResourceFiltersFromAggregateAttribute(tt.args.aggregateAttribute); got != tt.want { + t.Errorf("buildResourceFiltersFromAggregateAttribute() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_buildResourceSubQuery(t *testing.T) { + type args struct { + bucketStart int64 + bucketEnd int64 + fs *v3.FilterSet + groupBy []v3.AttributeKey + aggregateAttribute v3.AttributeKey + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "build sub query", + args: args{ + bucketStart: 1680064560, + bucketEnd: 1680066458, + fs: &v3.FilterSet{ + Items: []v3.FilterItem{ + { + Key: v3.AttributeKey{ + Key: "service.name", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + Operator: v3.FilterOperatorEqual, + Value: "test", + }, + { + Key: v3.AttributeKey{ + Key: "namespace", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + Operator: v3.FilterOperatorContains, + Value: "test1", + }, + }, + }, + groupBy: []v3.AttributeKey{ + { + Key: "host.name", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + }, + aggregateAttribute: v3.AttributeKey{ + Key: "cluster.name", + DataType: v3.AttributeKeyDataTypeString, + Type: v3.AttributeKeyTypeResource, + }, + }, + want: "(SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE " + + "(seen_at_ts_bucket_start >= 1680064560) AND (seen_at_ts_bucket_start <= 1680066458) AND " + + "simpleJSONExtractString(labels, 'service.name') = 'test' AND labels like '%service.name%test%' " + + "AND simpleJSONExtractString(labels, 'namespace') LIKE '%test1%' AND labels like '%namespace%test1%' " + + "AND (simpleJSONHas(labels, 'cluster.name') AND labels like '%cluster.name%') AND " + + "( (simpleJSONHas(labels, 'host.name') AND labels like '%host.name%') ))", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildResourceSubQuery(tt.args.bucketStart, tt.args.bucketEnd, tt.args.fs, tt.args.groupBy, tt.args.aggregateAttribute) + if (err != nil) != tt.wantErr { + t.Errorf("buildResourceSubQuery() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("buildResourceSubQuery() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/query-service/utils/format.go b/pkg/query-service/utils/format.go index 4de081940d..c623d3e8e0 100644 --- a/pkg/query-service/utils/format.go +++ b/pkg/query-service/utils/format.go @@ -154,6 +154,14 @@ func QuoteEscapedString(str string) string { return str } +func QuoteEscapedStringForContains(str string) string { + // https: //clickhouse.com/docs/en/sql-reference/functions/string-search-functions#like + str = QuoteEscapedString(str) + str = strings.ReplaceAll(str, `%`, `\%`) + str = strings.ReplaceAll(str, `_`, `\_`) + return str +} + // ClickHouseFormattedValue formats the value to be used in clickhouse query func ClickHouseFormattedValue(v interface{}) string { // if it's pointer convert it to a value From 784452269196392eb4de674b218dffb12951c7f7 Mon Sep 17 00:00:00 2001 From: Raj Kamal Singh <1133322+raj-k-singh@users.noreply.github.com> Date: Mon, 9 Sep 2024 10:12:36 +0530 Subject: [PATCH 28/43] Chore: qs filter suggestions: example queries for multiple top attributes (#5703) * chore: put together helper for fetching values for multiple attributes * chore: poc: use helper for filter suggestions * chore: add a working impl for getting attrib values for multiple attributes * chore: start updating integration test to account for new approach for getting log attrib values * chore: use a global zap logger in filter suggestion tests * chore: fix attrib values clickhouse query expectation * chore: only query values for actual attributes when generating example queries * chore: update clickhouse-go-mock * chore: cleanup: separate params for attributesLimit and examplesLimit for filter suggestions * chore: some test cleanup * chore: some more cleanup * chore: some more cleanup --------- Co-authored-by: Srikanth Chekuri --- go.mod | 2 +- go.sum | 2 + .../app/clickhouseReader/reader.go | 178 ++++++++++++++---- pkg/query-service/app/parser.go | 51 +++-- pkg/query-service/constants/constants.go | 5 +- pkg/query-service/model/v3/v3.go | 9 +- .../integration/filter_suggestions_test.go | 50 +++-- 7 files changed, 231 insertions(+), 66 deletions(-) diff --git a/go.mod b/go.mod index c7cafd89cc..9d61916d42 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,7 @@ require ( github.com/sethvargo/go-password v0.2.0 github.com/smartystreets/goconvey v1.8.1 github.com/soheilhy/cmux v0.1.5 - github.com/srikanthccv/ClickHouse-go-mock v0.8.0 + github.com/srikanthccv/ClickHouse-go-mock v0.9.0 github.com/stretchr/testify v1.9.0 go.opentelemetry.io/collector/component v0.103.0 go.opentelemetry.io/collector/confmap v0.103.0 diff --git a/go.sum b/go.sum index a98137d6db..a442200b0e 100644 --- a/go.sum +++ b/go.sum @@ -716,6 +716,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/srikanthccv/ClickHouse-go-mock v0.8.0 h1:DeeM8XLbTFl6sjYPPwazPEXx7kmRV8TgPFVkt1SqT0Y= github.com/srikanthccv/ClickHouse-go-mock v0.8.0/go.mod h1:pgJm+apjvi7FHxEdgw1Bt4MRbUYpVxyhKQ/59Wkig24= +github.com/srikanthccv/ClickHouse-go-mock v0.9.0 h1:XKr1Tb7GL1HlifKH874QGR3R6l0e6takXasROUiZawU= +github.com/srikanthccv/ClickHouse-go-mock v0.9.0/go.mod h1:pgJm+apjvi7FHxEdgw1Bt4MRbUYpVxyhKQ/59Wkig24= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= diff --git a/pkg/query-service/app/clickhouseReader/reader.go b/pkg/query-service/app/clickhouseReader/reader.go index 5d8f7ece82..bac50ca157 100644 --- a/pkg/query-service/app/clickhouseReader/reader.go +++ b/pkg/query-service/app/clickhouseReader/reader.go @@ -4414,7 +4414,7 @@ func (r *ClickHouseReader) GetQBFilterSuggestionsForLogs( ctx, &v3.FilterAttributeKeyRequest{ SearchText: req.SearchText, DataSource: v3.DataSourceLogs, - Limit: req.Limit, + Limit: int(req.AttributesLimit), }) if err != nil { return nil, model.InternalError(fmt.Errorf("couldn't get attribute keys: %w", err)) @@ -4458,53 +4458,61 @@ func (r *ClickHouseReader) GetQBFilterSuggestionsForLogs( } } - // Suggest example query for top suggested attribute using existing - // autocomplete logic for recommending attrib values - // - // Example queries for multiple top attributes using a batch version of - // GetLogAttributeValues is expected to come in a follow up change - if len(suggestions.AttributeKeys) > 0 { - topAttrib := suggestions.AttributeKeys[0] + // Suggest example queries for top suggested log attributes and resource attributes + exampleAttribs := []v3.AttributeKey{} + for _, attrib := range suggestions.AttributeKeys { + isAttributeOrResource := slices.Contains([]v3.AttributeKeyType{ + v3.AttributeKeyTypeResource, v3.AttributeKeyTypeTag, + }, attrib.Type) - resp, err := r.GetLogAttributeValues(ctx, &v3.FilterAttributeValueRequest{ - DataSource: v3.DataSourceLogs, - FilterAttributeKey: topAttrib.Key, - FilterAttributeKeyDataType: topAttrib.DataType, - TagType: v3.TagType(topAttrib.Type), - Limit: 1, - }) + isNumOrStringType := slices.Contains([]v3.AttributeKeyDataType{ + v3.AttributeKeyDataTypeInt64, v3.AttributeKeyDataTypeFloat64, v3.AttributeKeyDataTypeString, + }, attrib.DataType) + if isAttributeOrResource && isNumOrStringType { + exampleAttribs = append(exampleAttribs, attrib) + } + + if len(exampleAttribs) >= int(req.ExamplesLimit) { + break + } + } + + if len(exampleAttribs) > 0 { + exampleAttribValues, err := r.getValuesForLogAttributes( + ctx, exampleAttribs, req.ExamplesLimit, + ) if err != nil { // Do not fail the entire request if only example query generation fails zap.L().Error("could not find attribute values for creating example query", zap.Error(err)) - } else { - addExampleQuerySuggestion := func(value any) { - exampleQuery := newExampleQuery() - exampleQuery.Items = append(exampleQuery.Items, v3.FilterItem{ - Key: topAttrib, - Operator: "=", - Value: value, - }) + // add example queries for as many attributes as possible. + // suggest 1st value for 1st attrib, followed by 1st value for second attrib and so on + // and if there is still room, suggest 2nd value for 1st attrib, 2nd value for 2nd attrib and so on + for valueIdx := 0; valueIdx < int(req.ExamplesLimit); valueIdx++ { + for attrIdx, attr := range exampleAttribs { + needMoreExamples := len(suggestions.ExampleQueries) < int(req.ExamplesLimit) - suggestions.ExampleQueries = append( - suggestions.ExampleQueries, exampleQuery, - ) - } + if needMoreExamples && valueIdx < len(exampleAttribValues[attrIdx]) { + exampleQuery := newExampleQuery() + exampleQuery.Items = append(exampleQuery.Items, v3.FilterItem{ + Key: attr, + Operator: "=", + Value: exampleAttribValues[attrIdx][valueIdx], + }) - if len(resp.StringAttributeValues) > 0 { - addExampleQuerySuggestion(resp.StringAttributeValues[0]) - } else if len(resp.NumberAttributeValues) > 0 { - addExampleQuerySuggestion(resp.NumberAttributeValues[0]) - } else if len(resp.BoolAttributeValues) > 0 { - addExampleQuerySuggestion(resp.BoolAttributeValues[0]) + suggestions.ExampleQueries = append( + suggestions.ExampleQueries, exampleQuery, + ) + } + } } } } // Suggest static example queries for standard log attributes if needed. - if len(suggestions.ExampleQueries) < req.Limit { + if len(suggestions.ExampleQueries) < int(req.ExamplesLimit) { exampleQuery := newExampleQuery() exampleQuery.Items = append(exampleQuery.Items, v3.FilterItem{ Key: v3.AttributeKey{ @@ -4522,6 +4530,108 @@ func (r *ClickHouseReader) GetQBFilterSuggestionsForLogs( return &suggestions, nil } +// Get up to `limit` values seen for each attribute in `attributes` +// Returns a slice of slices where the ith slice has values for ith entry in `attributes` +func (r *ClickHouseReader) getValuesForLogAttributes( + ctx context.Context, attributes []v3.AttributeKey, limit uint64, +) ([][]any, *model.ApiError) { + // query top `limit` distinct values seen for `tagKey`s of interest + // ordered by timestamp when the value was seen + query := fmt.Sprintf( + ` + select tagKey, stringTagValue, int64TagValue, float64TagValue + from ( + select + tagKey, + stringTagValue, + int64TagValue, + float64TagValue, + row_number() over (partition by tagKey order by ts desc) as rank + from ( + select + tagKey, + stringTagValue, + int64TagValue, + float64TagValue, + max(timestamp) as ts + from %s.%s + where tagKey in $1 + group by (tagKey, stringTagValue, int64TagValue, float64TagValue) + ) + ) + where rank <= %d + `, + r.logsDB, r.logsTagAttributeTable, limit, + ) + + attribNames := []string{} + for _, attrib := range attributes { + attribNames = append(attribNames, attrib.Key) + } + + rows, err := r.db.Query(ctx, query, attribNames) + if err != nil { + zap.L().Error("couldn't query attrib values for suggestions", zap.Error(err)) + return nil, model.InternalError(fmt.Errorf( + "couldn't query attrib values for suggestions: %w", err, + )) + } + defer rows.Close() + + result := make([][]any, len(attributes)) + + // Helper for getting hold of the result slice to append to for each scanned row + resultIdxForAttrib := func(key string, dataType v3.AttributeKeyDataType) int { + return slices.IndexFunc(attributes, func(attrib v3.AttributeKey) bool { + return attrib.Key == key && attrib.DataType == dataType + }) + } + + // Scan rows and append to result + for rows.Next() { + var tagKey string + var stringValue string + var float64Value sql.NullFloat64 + var int64Value sql.NullInt64 + + err := rows.Scan( + &tagKey, &stringValue, &int64Value, &float64Value, + ) + if err != nil { + return nil, model.InternalError(fmt.Errorf( + "couldn't scan attrib value rows: %w", err, + )) + } + + if len(stringValue) > 0 { + attrResultIdx := resultIdxForAttrib(tagKey, v3.AttributeKeyDataTypeString) + if attrResultIdx >= 0 { + result[attrResultIdx] = append(result[attrResultIdx], stringValue) + } + + } else if int64Value.Valid { + attrResultIdx := resultIdxForAttrib(tagKey, v3.AttributeKeyDataTypeInt64) + if attrResultIdx >= 0 { + result[attrResultIdx] = append(result[attrResultIdx], int64Value.Int64) + } + + } else if float64Value.Valid { + attrResultIdx := resultIdxForAttrib(tagKey, v3.AttributeKeyDataTypeFloat64) + if attrResultIdx >= 0 { + result[attrResultIdx] = append(result[attrResultIdx], float64Value.Float64) + } + } + } + + if err := rows.Err(); err != nil { + return nil, model.InternalError(fmt.Errorf( + "couldn't scan attrib value rows: %w", err, + )) + } + + return result, nil +} + func readRow(vars []interface{}, columnNames []string, countOfNumberCols int) ([]string, map[string]string, []map[string]string, *v3.Point) { // Each row will have a value and a timestamp, and an optional list of label values // example: {Timestamp: ..., Value: ...} diff --git a/pkg/query-service/app/parser.go b/pkg/query-service/app/parser.go index 9bdde09be6..bbf97c7adf 100644 --- a/pkg/query-service/app/parser.go +++ b/pkg/query-service/app/parser.go @@ -846,15 +846,41 @@ func parseQBFilterSuggestionsRequest(r *http.Request) ( return nil, model.BadRequest(err) } - limit := baseconstants.DefaultFilterSuggestionsLimit - limitStr := r.URL.Query().Get("limit") - if len(limitStr) > 0 { - limit, err := strconv.Atoi(limitStr) - if err != nil || limit < 1 { - return nil, model.BadRequest(fmt.Errorf( - "invalid limit: %s", limitStr, - )) + parsePositiveIntQP := func( + queryParam string, defaultValue uint64, maxValue uint64, + ) (uint64, *model.ApiError) { + value := defaultValue + + qpValue := r.URL.Query().Get(queryParam) + if len(qpValue) > 0 { + value, err := strconv.Atoi(qpValue) + + if err != nil || value < 1 || value > int(maxValue) { + return 0, model.BadRequest(fmt.Errorf( + "invalid %s: %s", queryParam, qpValue, + )) + } } + + return value, nil + } + + attributesLimit, err := parsePositiveIntQP( + "attributesLimit", + baseconstants.DefaultFilterSuggestionsAttributesLimit, + baseconstants.MaxFilterSuggestionsAttributesLimit, + ) + if err != nil { + return nil, err + } + + examplesLimit, err := parsePositiveIntQP( + "examplesLimit", + baseconstants.DefaultFilterSuggestionsExamplesLimit, + baseconstants.MaxFilterSuggestionsExamplesLimit, + ) + if err != nil { + return nil, err } var existingFilter *v3.FilterSet @@ -875,10 +901,11 @@ func parseQBFilterSuggestionsRequest(r *http.Request) ( searchText := r.URL.Query().Get("searchText") return &v3.QBFilterSuggestionsRequest{ - DataSource: dataSource, - Limit: limit, - SearchText: searchText, - ExistingFilter: existingFilter, + DataSource: dataSource, + SearchText: searchText, + ExistingFilter: existingFilter, + AttributesLimit: attributesLimit, + ExamplesLimit: examplesLimit, }, nil } diff --git a/pkg/query-service/constants/constants.go b/pkg/query-service/constants/constants.go index 4b5134c6ee..8c8a038f2f 100644 --- a/pkg/query-service/constants/constants.go +++ b/pkg/query-service/constants/constants.go @@ -417,4 +417,7 @@ var TracesListViewDefaultSelectedColumns = []v3.AttributeKey{ }, } -const DefaultFilterSuggestionsLimit = 100 +const DefaultFilterSuggestionsAttributesLimit = 50 +const MaxFilterSuggestionsAttributesLimit = 100 +const DefaultFilterSuggestionsExamplesLimit = 2 +const MaxFilterSuggestionsExamplesLimit = 10 diff --git a/pkg/query-service/model/v3/v3.go b/pkg/query-service/model/v3/v3.go index b0e786a6d6..0f04375198 100644 --- a/pkg/query-service/model/v3/v3.go +++ b/pkg/query-service/model/v3/v3.go @@ -253,10 +253,11 @@ type FilterAttributeKeyRequest struct { } type QBFilterSuggestionsRequest struct { - DataSource DataSource `json:"dataSource"` - SearchText string `json:"searchText"` - Limit int `json:"limit"` - ExistingFilter *FilterSet `json:"existing_filter"` + DataSource DataSource `json:"dataSource"` + SearchText string `json:"searchText"` + ExistingFilter *FilterSet `json:"existingFilter"` + AttributesLimit uint64 `json:"attributesLimit"` + ExamplesLimit uint64 `json:"examplesLimit"` } type QBFilterSuggestionsResponse struct { diff --git a/pkg/query-service/tests/integration/filter_suggestions_test.go b/pkg/query-service/tests/integration/filter_suggestions_test.go index 8c379b1c10..6859a6ac2f 100644 --- a/pkg/query-service/tests/integration/filter_suggestions_test.go +++ b/pkg/query-service/tests/integration/filter_suggestions_test.go @@ -19,6 +19,7 @@ import ( "go.signoz.io/signoz/pkg/query-service/model" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" "go.signoz.io/signoz/pkg/query-service/utils" + "go.uber.org/zap" ) // If no data has been received yet, filter suggestions should contain @@ -59,7 +60,9 @@ func TestLogsFilterSuggestionsWithoutExistingFilter(t *testing.T) { testAttribValue := "test-container" tb.mockAttribKeysQueryResponse([]v3.AttributeKey{testAttrib}) - tb.mockAttribValuesQueryResponse(testAttrib, []string{testAttribValue}) + tb.mockAttribValuesQueryResponse( + []v3.AttributeKey{testAttrib}, [][]string{{testAttribValue}}, + ) suggestionsQueryParams := map[string]string{} suggestionsResp := tb.GetQBFilterSuggestionsForLogs(suggestionsQueryParams) @@ -71,6 +74,7 @@ func TestLogsFilterSuggestionsWithoutExistingFilter(t *testing.T) { )) require.Greater(len(suggestionsResp.ExampleQueries), 0) + require.True(slices.ContainsFunc( suggestionsResp.ExampleQueries, func(q v3.FilterSet) bool { return slices.ContainsFunc(q.Items, func(i v3.FilterItem) bool { @@ -113,7 +117,10 @@ func TestLogsFilterSuggestionsWithExistingFilter(t *testing.T) { } tb.mockAttribKeysQueryResponse([]v3.AttributeKey{testAttrib, testFilterAttrib}) - tb.mockAttribValuesQueryResponse(testAttrib, []string{testAttribValue}) + tb.mockAttribValuesQueryResponse( + []v3.AttributeKey{testAttrib, testFilterAttrib}, + [][]string{{testAttribValue}, {testFilterAttribValue}}, + ) testFilterJson, err := json.Marshal(testFilter) require.Nil(err, "couldn't serialize existing filter to JSON") @@ -152,7 +159,7 @@ func (tb *FilterSuggestionsTestBed) mockAttribKeysQueryResponse( tb.mockClickhouse.ExpectQuery( "select.*from.*signoz_logs.distributed_tag_attributes.*", ).WithArgs( - constants.DefaultFilterSuggestionsLimit, + constants.DefaultFilterSuggestionsAttributesLimit, ).WillReturnRows( mockhouse.NewRows(cols, values), ) @@ -169,22 +176,30 @@ func (tb *FilterSuggestionsTestBed) mockAttribKeysQueryResponse( // Mocks response for CH queries made by reader.GetLogAttributeValues func (tb *FilterSuggestionsTestBed) mockAttribValuesQueryResponse( - expectedAttrib v3.AttributeKey, - stringValuesToReturn []string, + expectedAttribs []v3.AttributeKey, + stringValuesToReturn [][]string, ) { - cols := []mockhouse.ColumnType{} - cols = append(cols, mockhouse.ColumnType{Type: "String", Name: "stringTagValue"}) + resultCols := []mockhouse.ColumnType{ + {Type: "String", Name: "tagKey"}, + {Type: "String", Name: "stringTagValue"}, + {Type: "Nullable(Int64)", Name: "int64TagValue"}, + {Type: "Nullable(Float64)", Name: "float64TagValue"}, + } - values := [][]any{} - for _, v := range stringValuesToReturn { - rowValues := []any{} - rowValues = append(rowValues, v) - values = append(values, rowValues) + expectedAttribKeysInQuery := []string{} + mockResultRows := [][]any{} + for idx, attrib := range expectedAttribs { + expectedAttribKeysInQuery = append(expectedAttribKeysInQuery, attrib.Key) + for _, stringTagValue := range stringValuesToReturn[idx] { + mockResultRows = append(mockResultRows, []any{ + attrib.Key, stringTagValue, nil, nil, + }) + } } tb.mockClickhouse.ExpectQuery( - "select distinct.*stringTagValue.*from.*signoz_logs.distributed_tag_attributes.*", - ).WithArgs(string(expectedAttrib.Key), v3.TagType(expectedAttrib.Type), 1).WillReturnRows(mockhouse.NewRows(cols, values)) + "select.*tagKey.*stringTagValue.*int64TagValue.*float64TagValue.*distributed_tag_attributes.*tagKey.*in.*", + ).WithArgs(expectedAttribKeysInQuery).WillReturnRows(mockhouse.NewRows(resultCols, mockResultRows)) } type FilterSuggestionsTestBed struct { @@ -244,6 +259,13 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed { t.Fatalf("could not create a test user: %v", apiErr) } + logger := zap.NewExample() + originalLogger := zap.L() + zap.ReplaceGlobals(logger) + t.Cleanup(func() { + zap.ReplaceGlobals(originalLogger) + }) + return &FilterSuggestionsTestBed{ t: t, testUser: user, From 74c994fbab000538a9e403a98a61c602b18aec2f Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Mon, 9 Sep 2024 10:28:54 +0530 Subject: [PATCH 29/43] chore: make ee init rule manager with it's own prepareTask func (#5807) --- ee/query-service/app/server.go | 15 +++-- ee/query-service/rules/manager.go | 71 +++++++++++++++++++++++ pkg/query-service/rules/manager.go | 6 +- pkg/query-service/rules/prom_rule_task.go | 2 +- pkg/query-service/rules/rule_task.go | 4 +- pkg/query-service/rules/task.go | 4 +- 6 files changed, 88 insertions(+), 14 deletions(-) create mode 100644 ee/query-service/rules/manager.go diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index 5d645673e4..7fb9317946 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -28,6 +28,7 @@ import ( "go.signoz.io/signoz/ee/query-service/dao" "go.signoz.io/signoz/ee/query-service/integrations/gateway" "go.signoz.io/signoz/ee/query-service/interfaces" + "go.signoz.io/signoz/ee/query-service/rules" baseauth "go.signoz.io/signoz/pkg/query-service/auth" "go.signoz.io/signoz/pkg/query-service/migrate" "go.signoz.io/signoz/pkg/query-service/model" @@ -52,7 +53,7 @@ import ( baseint "go.signoz.io/signoz/pkg/query-service/interfaces" basemodel "go.signoz.io/signoz/pkg/query-service/model" pqle "go.signoz.io/signoz/pkg/query-service/pqlEngine" - rules "go.signoz.io/signoz/pkg/query-service/rules" + baserules "go.signoz.io/signoz/pkg/query-service/rules" "go.signoz.io/signoz/pkg/query-service/telemetry" "go.signoz.io/signoz/pkg/query-service/utils" "go.uber.org/zap" @@ -81,7 +82,7 @@ type ServerOptions struct { // Server runs HTTP api service type Server struct { serverOptions *ServerOptions - ruleManager *rules.Manager + ruleManager *baserules.Manager // public http router httpConn net.Listener @@ -727,7 +728,7 @@ func makeRulesManager( db *sqlx.DB, ch baseint.Reader, disableRules bool, - fm baseint.FeatureLookup) (*rules.Manager, error) { + fm baseint.FeatureLookup) (*baserules.Manager, error) { // create engine pqle, err := pqle.FromConfigPath(promConfigPath) @@ -743,9 +744,9 @@ func makeRulesManager( } // create manager opts - managerOpts := &rules.ManagerOptions{ + managerOpts := &baserules.ManagerOptions{ NotifierOpts: notifierOpts, - Queriers: &rules.Queriers{ + Queriers: &baserules.Queriers{ PqlEngine: pqle, Ch: ch.GetConn(), }, @@ -757,10 +758,12 @@ func makeRulesManager( FeatureFlags: fm, Reader: ch, EvalDelay: baseconst.GetEvalDelay(), + + PrepareTaskFunc: rules.PrepareTaskFunc, } // create Manager - manager, err := rules.NewManager(managerOpts) + manager, err := baserules.NewManager(managerOpts) if err != nil { return nil, fmt.Errorf("rule manager error: %v", err) } diff --git a/ee/query-service/rules/manager.go b/ee/query-service/rules/manager.go new file mode 100644 index 0000000000..831fb52793 --- /dev/null +++ b/ee/query-service/rules/manager.go @@ -0,0 +1,71 @@ +package rules + +import ( + "fmt" + "time" + + baserules "go.signoz.io/signoz/pkg/query-service/rules" +) + +func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) { + + rules := make([]baserules.Rule, 0) + var task baserules.Task + + ruleId := baserules.RuleIdFromTaskName(opts.TaskName) + if opts.Rule.RuleType == baserules.RuleTypeThreshold { + // create a threshold rule + tr, err := baserules.NewThresholdRule( + ruleId, + opts.Rule, + baserules.ThresholdRuleOpts{ + EvalDelay: opts.ManagerOpts.EvalDelay, + }, + opts.FF, + opts.Reader, + ) + + if err != nil { + return task, err + } + + rules = append(rules, tr) + + // create ch rule task for evalution + task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB) + + } else if opts.Rule.RuleType == baserules.RuleTypeProm { + + // create promql rule + pr, err := baserules.NewPromRule( + ruleId, + opts.Rule, + opts.Logger, + baserules.PromRuleOpts{}, + opts.Reader, + ) + + if err != nil { + return task, err + } + + rules = append(rules, pr) + + // create promql rule task for evalution + task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB) + + } else { + return nil, fmt.Errorf("unsupported rule type. Supported types: %s, %s", baserules.RuleTypeProm, baserules.RuleTypeThreshold) + } + + return task, nil +} + +// newTask returns an appropriate group for +// rule type +func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, ruleDB baserules.RuleDB) baserules.Task { + if taskType == baserules.TaskTypeCh { + return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, ruleDB) + } + return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, ruleDB) +} diff --git a/pkg/query-service/rules/manager.go b/pkg/query-service/rules/manager.go index 768c753cb8..5de680c184 100644 --- a/pkg/query-service/rules/manager.go +++ b/pkg/query-service/rules/manager.go @@ -38,7 +38,7 @@ type PrepareTaskOptions struct { const taskNamesuffix = "webAppEditor" -func ruleIdFromTaskName(n string) string { +func RuleIdFromTaskName(n string) string { return strings.Split(n, "-groupname")[0] } @@ -121,7 +121,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) { rules := make([]Rule, 0) var task Task - ruleId := ruleIdFromTaskName(opts.TaskName) + ruleId := RuleIdFromTaskName(opts.TaskName) if opts.Rule.RuleType == RuleTypeThreshold { // create a threshold rule tr, err := NewThresholdRule( @@ -400,7 +400,7 @@ func (m *Manager) deleteTask(taskName string) { if ok { oldg.Stop() delete(m.tasks, taskName) - delete(m.rules, ruleIdFromTaskName(taskName)) + delete(m.rules, RuleIdFromTaskName(taskName)) zap.L().Debug("rule task deleted", zap.String("name", taskName)) } else { zap.L().Info("rule not found for deletion", zap.String("name", taskName)) diff --git a/pkg/query-service/rules/prom_rule_task.go b/pkg/query-service/rules/prom_rule_task.go index f2f11cd494..aa29e90187 100644 --- a/pkg/query-service/rules/prom_rule_task.go +++ b/pkg/query-service/rules/prom_rule_task.go @@ -40,7 +40,7 @@ type PromRuleTask struct { // newPromRuleTask holds rules that have promql condition // and evalutes the rule at a given frequency -func newPromRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, ruleDB RuleDB) *PromRuleTask { +func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, ruleDB RuleDB) *PromRuleTask { zap.L().Info("Initiating a new rule group", zap.String("name", name), zap.Duration("frequency", frequency)) if time.Now() == time.Now().Add(frequency) { diff --git a/pkg/query-service/rules/rule_task.go b/pkg/query-service/rules/rule_task.go index eb657c9f7c..d6aa09ce2f 100644 --- a/pkg/query-service/rules/rule_task.go +++ b/pkg/query-service/rules/rule_task.go @@ -37,8 +37,8 @@ type RuleTask struct { const DefaultFrequency = 1 * time.Minute -// newRuleTask makes a new RuleTask with the given name, options, and rules. -func newRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, ruleDB RuleDB) *RuleTask { +// NewRuleTask makes a new RuleTask with the given name, options, and rules. +func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, ruleDB RuleDB) *RuleTask { if time.Now() == time.Now().Add(frequency) { frequency = DefaultFrequency diff --git a/pkg/query-service/rules/task.go b/pkg/query-service/rules/task.go index 64acf6c76e..08d6d911c6 100644 --- a/pkg/query-service/rules/task.go +++ b/pkg/query-service/rules/task.go @@ -31,7 +31,7 @@ type Task interface { // rule type func newTask(taskType TaskType, name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, ruleDB RuleDB) Task { if taskType == TaskTypeCh { - return newRuleTask(name, file, frequency, rules, opts, notify, ruleDB) + return NewRuleTask(name, file, frequency, rules, opts, notify, ruleDB) } - return newPromRuleTask(name, file, frequency, rules, opts, notify, ruleDB) + return NewPromRuleTask(name, file, frequency, rules, opts, notify, ruleDB) } From 3e32dabf46271558494e7afb419c08131854666c Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Mon, 9 Sep 2024 13:06:09 +0530 Subject: [PATCH 30/43] chore: alert state change and overall status (#5845) --- .../app/clickhouseReader/reader.go | 154 ++++++++++++++++-- pkg/query-service/app/http_handler.go | 24 +-- pkg/query-service/interfaces/interface.go | 4 +- pkg/query-service/migrate/migate.go | 10 +- pkg/query-service/model/alerting.go | 90 ++++++++++ pkg/query-service/model/v3/v3.go | 35 ++-- pkg/query-service/rules/alerting.go | 58 +------ pkg/query-service/rules/api_params.go | 4 +- pkg/query-service/rules/manager.go | 6 +- pkg/query-service/rules/prom_rule.go | 146 +++++++++++++---- pkg/query-service/rules/prom_rule_task.go | 7 +- pkg/query-service/rules/rule.go | 3 +- pkg/query-service/rules/rule_task.go | 1 + pkg/query-service/rules/threshold_rule.go | 151 +++++++++++++---- 14 files changed, 506 insertions(+), 187 deletions(-) create mode 100644 pkg/query-service/model/alerting.go diff --git a/pkg/query-service/app/clickhouseReader/reader.go b/pkg/query-service/app/clickhouseReader/reader.go index bac50ca157..2984fa0fa5 100644 --- a/pkg/query-service/app/clickhouseReader/reader.go +++ b/pkg/query-service/app/clickhouseReader/reader.go @@ -64,7 +64,7 @@ const ( archiveNamespace = "clickhouse-archive" signozTraceDBName = "signoz_traces" signozHistoryDBName = "signoz_analytics" - ruleStateHistoryTableName = "distributed_rule_state_history" + ruleStateHistoryTableName = "distributed_rule_state_history_v0" signozDurationMVTable = "distributed_durationSort" signozUsageExplorerTable = "distributed_usage_explorer" signozSpansTable = "distributed_signoz_spans" @@ -5332,6 +5332,18 @@ func (r *ClickHouseReader) AddRuleStateHistory(ctx context.Context, ruleStateHis return nil } +func (r *ClickHouseReader) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]v3.RuleStateHistory, error) { + query := fmt.Sprintf("SELECT * FROM %s.%s WHERE rule_id = '%s' AND state_changed = true ORDER BY unix_milli DESC LIMIT 1 BY fingerprint", + signozHistoryDBName, ruleStateHistoryTableName, ruleID) + + history := []v3.RuleStateHistory{} + err := r.db.Select(ctx, &history, query) + if err != nil { + return nil, err + } + return history, nil +} + func (r *ClickHouseReader) ReadRuleStateHistoryByRuleID( ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) (*v3.RuleStateTimeline, error) { @@ -5397,6 +5409,7 @@ func (r *ClickHouseReader) ReadRuleStateHistoryByRuleID( signozHistoryDBName, ruleStateHistoryTableName, whereClause, params.Order, params.Limit, params.Offset) history := []v3.RuleStateHistory{} + zap.L().Debug("rule state history query", zap.String("query", query)) err := r.db.Select(ctx, &history, query) if err != nil { zap.L().Error("Error while reading rule state history", zap.Error(err)) @@ -5404,15 +5417,43 @@ func (r *ClickHouseReader) ReadRuleStateHistoryByRuleID( } var total uint64 + zap.L().Debug("rule state history total query", zap.String("query", fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE %s", + signozHistoryDBName, ruleStateHistoryTableName, whereClause))) err = r.db.QueryRow(ctx, fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE %s", signozHistoryDBName, ruleStateHistoryTableName, whereClause)).Scan(&total) if err != nil { return nil, err } + labelsQuery := fmt.Sprintf("SELECT DISTINCT labels FROM %s.%s WHERE rule_id = $1", + signozHistoryDBName, ruleStateHistoryTableName) + rows, err := r.db.Query(ctx, labelsQuery, ruleID) + if err != nil { + return nil, err + } + defer rows.Close() + + labelsMap := make(map[string][]string) + for rows.Next() { + var rawLabel string + err = rows.Scan(&rawLabel) + if err != nil { + return nil, err + } + label := map[string]string{} + err = json.Unmarshal([]byte(rawLabel), &label) + if err != nil { + return nil, err + } + for k, v := range label { + labelsMap[k] = append(labelsMap[k], v) + } + } + timeline := &v3.RuleStateTimeline{ - Items: history, - Total: total, + Items: history, + Total: total, + Labels: labelsMap, } return timeline, nil @@ -5425,11 +5466,13 @@ func (r *ClickHouseReader) ReadRuleStateHistoryTopContributorsByRuleID( any(labels) as labels, count(*) as count FROM %s.%s - WHERE rule_id = '%s' AND (state_changed = true) AND (state = 'firing') AND unix_milli >= %d AND unix_milli <= %d + WHERE rule_id = '%s' AND (state_changed = true) AND (state = '%s') AND unix_milli >= %d AND unix_milli <= %d GROUP BY fingerprint + HAVING labels != '{}' ORDER BY count DESC`, - signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End) + signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.Start, params.End) + zap.L().Debug("rule state history top contributors query", zap.String("query", query)) contributors := []v3.RuleStateHistoryContributor{} err := r.db.Select(ctx, &contributors, query) if err != nil { @@ -5440,7 +5483,7 @@ func (r *ClickHouseReader) ReadRuleStateHistoryTopContributorsByRuleID( return contributors, nil } -func (r *ClickHouseReader) GetOverallStateTransitions(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) ([]v3.RuleStateTransition, error) { +func (r *ClickHouseReader) GetOverallStateTransitions(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) ([]v3.ReleStateItem, error) { tmpl := `WITH firing_events AS ( SELECT @@ -5448,7 +5491,7 @@ func (r *ClickHouseReader) GetOverallStateTransitions(ctx context.Context, ruleI state, unix_milli AS firing_time FROM %s.%s - WHERE overall_state = 'firing' + WHERE overall_state = '` + model.StateFiring.String() + `' AND overall_state_changed = true AND rule_id IN ('%s') AND unix_milli >= %d AND unix_milli <= %d @@ -5459,7 +5502,7 @@ resolution_events AS ( state, unix_milli AS resolution_time FROM %s.%s - WHERE overall_state = 'normal' + WHERE overall_state = '` + model.StateInactive.String() + `' AND overall_state_changed = true AND rule_id IN ('%s') AND unix_milli >= %d AND unix_milli <= %d @@ -5484,13 +5527,87 @@ ORDER BY firing_time ASC;` signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End, signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End) + zap.L().Debug("overall state transitions query", zap.String("query", query)) + transitions := []v3.RuleStateTransition{} err := r.db.Select(ctx, &transitions, query) if err != nil { return nil, err } - return transitions, nil + stateItems := []v3.ReleStateItem{} + + for idx, item := range transitions { + start := item.FiringTime + end := item.ResolutionTime + stateItems = append(stateItems, v3.ReleStateItem{ + State: item.State, + Start: start, + End: end, + }) + if idx < len(transitions)-1 { + nextStart := transitions[idx+1].FiringTime + if nextStart > end { + stateItems = append(stateItems, v3.ReleStateItem{ + State: model.StateInactive, + Start: end, + End: nextStart, + }) + } + } + } + + // fetch the most recent overall_state from the table + var state model.AlertState + stateQuery := fmt.Sprintf("SELECT state FROM %s.%s WHERE rule_id = '%s' AND unix_milli <= %d ORDER BY unix_milli DESC LIMIT 1", + signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.End) + if err := r.db.QueryRow(ctx, stateQuery).Scan(&state); err != nil { + if err != sql.ErrNoRows { + return nil, err + } + state = model.StateInactive + } + + if len(transitions) == 0 { + // no transitions found, it is either firing or inactive for whole time range + stateItems = append(stateItems, v3.ReleStateItem{ + State: state, + Start: params.Start, + End: params.End, + }) + } else { + // there were some transitions, we need to add the last state at the end + if state == model.StateInactive { + stateItems = append(stateItems, v3.ReleStateItem{ + State: model.StateInactive, + Start: transitions[len(transitions)-1].ResolutionTime, + End: params.End, + }) + } else { + // fetch the most recent firing event from the table in the given time range + var firingTime int64 + firingQuery := fmt.Sprintf(` + SELECT + unix_milli + FROM %s.%s + WHERE rule_id = '%s' AND overall_state_changed = true AND overall_state = '%s' AND unix_milli <= %d + ORDER BY unix_milli DESC LIMIT 1`, signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.End) + if err := r.db.QueryRow(ctx, firingQuery).Scan(&firingTime); err != nil { + return nil, err + } + stateItems = append(stateItems, v3.ReleStateItem{ + State: model.StateInactive, + Start: transitions[len(transitions)-1].ResolutionTime, + End: firingTime, + }) + stateItems = append(stateItems, v3.ReleStateItem{ + State: model.StateFiring, + Start: firingTime, + End: params.End, + }) + } + } + return stateItems, nil } func (r *ClickHouseReader) GetAvgResolutionTime(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) (float64, error) { @@ -5502,7 +5619,7 @@ WITH firing_events AS ( state, unix_milli AS firing_time FROM %s.%s - WHERE overall_state = 'firing' + WHERE overall_state = '` + model.StateFiring.String() + `' AND overall_state_changed = true AND rule_id IN ('%s') AND unix_milli >= %d AND unix_milli <= %d @@ -5513,7 +5630,7 @@ resolution_events AS ( state, unix_milli AS resolution_time FROM %s.%s - WHERE overall_state = 'normal' + WHERE overall_state = '` + model.StateInactive.String() + `' AND overall_state_changed = true AND rule_id IN ('%s') AND unix_milli >= %d AND unix_milli <= %d @@ -5538,6 +5655,7 @@ FROM matched_events; signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End, signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End) + zap.L().Debug("avg resolution time query", zap.String("query", query)) var avgResolutionTime float64 err := r.db.QueryRow(ctx, query).Scan(&avgResolutionTime) if err != nil { @@ -5558,7 +5676,7 @@ WITH firing_events AS ( state, unix_milli AS firing_time FROM %s.%s - WHERE overall_state = 'firing' + WHERE overall_state = '` + model.StateFiring.String() + `' AND overall_state_changed = true AND rule_id IN ('%s') AND unix_milli >= %d AND unix_milli <= %d @@ -5569,7 +5687,7 @@ resolution_events AS ( state, unix_milli AS resolution_time FROM %s.%s - WHERE overall_state = 'normal' + WHERE overall_state = '` + model.StateInactive.String() + `' AND overall_state_changed = true AND rule_id IN ('%s') AND unix_milli >= %d AND unix_milli <= %d @@ -5595,6 +5713,7 @@ ORDER BY ts ASC;` signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End, signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End, step) + zap.L().Debug("avg resolution time by interval query", zap.String("query", query)) result, err := r.GetTimeSeriesResultV3(ctx, query) if err != nil || len(result) == 0 { return nil, err @@ -5604,10 +5723,11 @@ ORDER BY ts ASC;` } func (r *ClickHouseReader) GetTotalTriggers(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) (uint64, error) { - query := fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE rule_id = '%s' AND (state_changed = true) AND (state = 'firing') AND unix_milli >= %d AND unix_milli <= %d", - signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End) + query := fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE rule_id = '%s' AND (state_changed = true) AND (state = '%s') AND unix_milli >= %d AND unix_milli <= %d", + signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.Start, params.End) var totalTriggers uint64 + err := r.db.QueryRow(ctx, query).Scan(&totalTriggers) if err != nil { return 0, err @@ -5619,8 +5739,8 @@ func (r *ClickHouseReader) GetTotalTriggers(ctx context.Context, ruleID string, func (r *ClickHouseReader) GetTriggersByInterval(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) (*v3.Series, error) { step := common.MinAllowedStepInterval(params.Start, params.End) - query := fmt.Sprintf("SELECT count(*), toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), INTERVAL %d SECOND) as ts FROM %s.%s WHERE rule_id = '%s' AND (state_changed = true) AND (state = 'firing') AND unix_milli >= %d AND unix_milli <= %d GROUP BY ts ORDER BY ts ASC", - step, signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End) + query := fmt.Sprintf("SELECT count(*), toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), INTERVAL %d SECOND) as ts FROM %s.%s WHERE rule_id = '%s' AND (state_changed = true) AND (state = '%s') AND unix_milli >= %d AND unix_milli <= %d GROUP BY ts ORDER BY ts ASC", + step, signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.Start, params.End) result, err := r.GetTimeSeriesResultV3(ctx, query) if err != nil || len(result) == 0 { diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 1210cd4f67..957ea5aaff 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -746,34 +746,12 @@ func (aH *APIHandler) getOverallStateTransitions(w http.ResponseWriter, r *http. return } - res, err := aH.reader.GetOverallStateTransitions(r.Context(), ruleID, ¶ms) + stateItems, err := aH.reader.GetOverallStateTransitions(r.Context(), ruleID, ¶ms) if err != nil { RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil) return } - stateItems := []v3.ReleStateItem{} - - for idx, item := range res { - start := item.FiringTime - end := item.ResolutionTime - stateItems = append(stateItems, v3.ReleStateItem{ - State: item.State, - Start: start, - End: end, - }) - if idx < len(res)-1 { - nextStart := res[idx+1].FiringTime - if nextStart > end { - stateItems = append(stateItems, v3.ReleStateItem{ - State: "normal", - Start: end, - End: nextStart, - }) - } - } - } - aH.Respond(w, stateItems) } diff --git a/pkg/query-service/interfaces/interface.go b/pkg/query-service/interfaces/interface.go index f275579104..cfb4f9159e 100644 --- a/pkg/query-service/interfaces/interface.go +++ b/pkg/query-service/interfaces/interface.go @@ -109,13 +109,15 @@ type Reader interface { GetMetricMetadata(context.Context, string, string) (*v3.MetricMetadataResponse, error) AddRuleStateHistory(ctx context.Context, ruleStateHistory []v3.RuleStateHistory) error - GetOverallStateTransitions(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) ([]v3.RuleStateTransition, error) + GetOverallStateTransitions(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) ([]v3.ReleStateItem, error) ReadRuleStateHistoryByRuleID(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) (*v3.RuleStateTimeline, error) GetTotalTriggers(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) (uint64, error) GetTriggersByInterval(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) (*v3.Series, error) GetAvgResolutionTime(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) (float64, error) GetAvgResolutionTimeByInterval(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) (*v3.Series, error) ReadRuleStateHistoryTopContributorsByRuleID(ctx context.Context, ruleID string, params *v3.QueryRuleStateHistory) ([]v3.RuleStateHistoryContributor, error) + GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]v3.RuleStateHistory, error) + GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error) // Query Progress tracking helpers. diff --git a/pkg/query-service/migrate/migate.go b/pkg/query-service/migrate/migate.go index 60e65d6d72..2db7243f58 100644 --- a/pkg/query-service/migrate/migate.go +++ b/pkg/query-service/migrate/migate.go @@ -60,7 +60,7 @@ func ClickHouseMigrate(conn driver.Conn, cluster string) error { database := "CREATE DATABASE IF NOT EXISTS signoz_analytics ON CLUSTER %s" - localTable := `CREATE TABLE IF NOT EXISTS signoz_analytics.rule_state_history ON CLUSTER %s + localTable := `CREATE TABLE IF NOT EXISTS signoz_analytics.rule_state_history_v0 ON CLUSTER %s ( _retention_days UInt32 DEFAULT 180, rule_id LowCardinality(String), @@ -80,7 +80,7 @@ ORDER BY (rule_id, unix_milli) TTL toDateTime(unix_milli / 1000) + toIntervalDay(_retention_days) SETTINGS ttl_only_drop_parts = 1, index_granularity = 8192` - distributedTable := `CREATE TABLE IF NOT EXISTS signoz_analytics.distributed_rule_state_history ON CLUSTER %s + distributedTable := `CREATE TABLE IF NOT EXISTS signoz_analytics.distributed_rule_state_history_v0 ON CLUSTER %s ( rule_id LowCardinality(String), rule_name LowCardinality(String), @@ -93,7 +93,7 @@ SETTINGS ttl_only_drop_parts = 1, index_granularity = 8192` value Float64 CODEC(Gorilla, ZSTD(1)), labels String CODEC(ZSTD(5)), ) -ENGINE = Distributed(%s, signoz_analytics, rule_state_history, cityHash64(rule_id, rule_name, fingerprint))` +ENGINE = Distributed(%s, signoz_analytics, rule_state_history_v0, cityHash64(rule_id, rule_name, fingerprint))` // check if db exists dbExists := `SELECT count(*) FROM system.databases WHERE name = 'signoz_analytics'` @@ -111,7 +111,7 @@ ENGINE = Distributed(%s, signoz_analytics, rule_state_history, cityHash64(rule_i } // check if table exists - tableExists := `SELECT count(*) FROM system.tables WHERE name = 'rule_state_history' AND database = 'signoz_analytics'` + tableExists := `SELECT count(*) FROM system.tables WHERE name = 'rule_state_history_v0' AND database = 'signoz_analytics'` var tableCount uint64 err = conn.QueryRow(context.Background(), tableExists).Scan(&tableCount) if err != nil { @@ -126,7 +126,7 @@ ENGINE = Distributed(%s, signoz_analytics, rule_state_history, cityHash64(rule_i } // check if distributed table exists - distributedTableExists := `SELECT count(*) FROM system.tables WHERE name = 'distributed_rule_state_history' AND database = 'signoz_analytics'` + distributedTableExists := `SELECT count(*) FROM system.tables WHERE name = 'distributed_rule_state_history_v0' AND database = 'signoz_analytics'` var distributedTableCount uint64 err = conn.QueryRow(context.Background(), distributedTableExists).Scan(&distributedTableCount) if err != nil { diff --git a/pkg/query-service/model/alerting.go b/pkg/query-service/model/alerting.go new file mode 100644 index 0000000000..c065fdfae6 --- /dev/null +++ b/pkg/query-service/model/alerting.go @@ -0,0 +1,90 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + + "github.com/pkg/errors" +) + +// AlertState denotes the state of an active alert. +type AlertState int + +const ( + StateInactive AlertState = iota + StatePending + StateFiring + StateNoData + StateDisabled +) + +func (s AlertState) String() string { + switch s { + case StateInactive: + return "inactive" + case StatePending: + return "pending" + case StateFiring: + return "firing" + case StateNoData: + return "nodata" + case StateDisabled: + return "disabled" + } + panic(errors.Errorf("unknown alert state: %d", s)) +} + +func (s AlertState) MarshalJSON() ([]byte, error) { + return json.Marshal(s.String()) +} + +func (s *AlertState) UnmarshalJSON(b []byte) error { + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return err + } + switch value := v.(type) { + case string: + switch value { + case "inactive": + *s = StateInactive + case "pending": + *s = StatePending + case "firing": + *s = StateFiring + case "nodata": + *s = StateNoData + case "disabled": + *s = StateDisabled + default: + return errors.New("invalid alert state") + } + return nil + default: + return errors.New("invalid alert state") + } +} + +func (s *AlertState) Scan(value interface{}) error { + v, ok := value.(string) + if !ok { + return errors.New("invalid alert state") + } + switch v { + case "inactive": + *s = StateInactive + case "pending": + *s = StatePending + case "firing": + *s = StateFiring + case "nodata": + *s = StateNoData + case "disabled": + *s = StateDisabled + } + return nil +} + +func (s *AlertState) Value() (driver.Value, error) { + return s.String(), nil +} diff --git a/pkg/query-service/model/v3/v3.go b/pkg/query-service/model/v3/v3.go index 0f04375198..0128536ac2 100644 --- a/pkg/query-service/model/v3/v3.go +++ b/pkg/query-service/model/v3/v3.go @@ -1183,23 +1183,24 @@ func (l LabelsString) String() string { } type RuleStateTimeline struct { - Items []RuleStateHistory `json:"items"` - Total uint64 `json:"total"` + Items []RuleStateHistory `json:"items"` + Total uint64 `json:"total"` + Labels map[string][]string `json:"labels"` } type RuleStateHistory struct { RuleID string `json:"ruleID" ch:"rule_id"` RuleName string `json:"ruleName" ch:"rule_name"` // One of ["normal", "firing"] - OverallState string `json:"overallState" ch:"overall_state"` - OverallStateChanged bool `json:"overallStateChanged" ch:"overall_state_changed"` + OverallState model.AlertState `json:"overallState" ch:"overall_state"` + OverallStateChanged bool `json:"overallStateChanged" ch:"overall_state_changed"` // One of ["normal", "firing", "no_data", "muted"] - State string `json:"state" ch:"state"` - StateChanged bool `json:"stateChanged" ch:"state_changed"` - UnixMilli int64 `json:"unixMilli" ch:"unix_milli"` - Labels LabelsString `json:"labels" ch:"labels"` - Fingerprint uint64 `json:"fingerprint" ch:"fingerprint"` - Value float64 `json:"value" ch:"value"` + State model.AlertState `json:"state" ch:"state"` + StateChanged bool `json:"stateChanged" ch:"state_changed"` + UnixMilli int64 `json:"unixMilli" ch:"unix_milli"` + Labels LabelsString `json:"labels" ch:"labels"` + Fingerprint uint64 `json:"fingerprint" ch:"fingerprint"` + Value float64 `json:"value" ch:"value"` RelatedTracesLink string `json:"relatedTracesLink"` RelatedLogsLink string `json:"relatedLogsLink"` @@ -1237,16 +1238,16 @@ type RuleStateHistoryContributor struct { } type RuleStateTransition struct { - RuleID string `json:"ruleID" ch:"rule_id"` - State string `json:"state" ch:"state"` - FiringTime int64 `json:"firingTime" ch:"firing_time"` - ResolutionTime int64 `json:"resolutionTime" ch:"resolution_time"` + RuleID string `json:"ruleID" ch:"rule_id"` + State model.AlertState `json:"state" ch:"state"` + FiringTime int64 `json:"firingTime" ch:"firing_time"` + ResolutionTime int64 `json:"resolutionTime" ch:"resolution_time"` } type ReleStateItem struct { - State string `json:"state"` - Start int64 `json:"start"` - End int64 `json:"end"` + State model.AlertState `json:"state"` + Start int64 `json:"start"` + End int64 `json:"end"` } type Stats struct { diff --git a/pkg/query-service/rules/alerting.go b/pkg/query-service/rules/alerting.go index 7c7fb40ed6..f6826ed3d8 100644 --- a/pkg/query-service/rules/alerting.go +++ b/pkg/query-service/rules/alerting.go @@ -8,6 +8,7 @@ import ( "time" "github.com/pkg/errors" + "go.signoz.io/signoz/pkg/query-service/model" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" "go.signoz.io/signoz/pkg/query-service/utils/labels" ) @@ -37,61 +38,8 @@ const ( HealthBad RuleHealth = "err" ) -// AlertState denotes the state of an active alert. -type AlertState int - -const ( - StateInactive AlertState = iota - StatePending - StateFiring - StateDisabled -) - -func (s AlertState) String() string { - switch s { - case StateInactive: - return "inactive" - case StatePending: - return "pending" - case StateFiring: - return "firing" - case StateDisabled: - return "disabled" - } - panic(errors.Errorf("unknown alert state: %d", s)) -} - -func (s AlertState) MarshalJSON() ([]byte, error) { - return json.Marshal(s.String()) -} - -func (s *AlertState) UnmarshalJSON(b []byte) error { - var v interface{} - if err := json.Unmarshal(b, &v); err != nil { - return err - } - switch value := v.(type) { - case string: - switch value { - case "inactive": - *s = StateInactive - case "pending": - *s = StatePending - case "firing": - *s = StateFiring - case "disabled": - *s = StateDisabled - default: - return errors.New("invalid alert state") - } - return nil - default: - return errors.New("invalid alert state") - } -} - type Alert struct { - State AlertState + State model.AlertState Labels labels.BaseLabels Annotations labels.BaseLabels @@ -114,7 +62,7 @@ type Alert struct { } func (a *Alert) needsSending(ts time.Time, resendDelay time.Duration) bool { - if a.State == StatePending { + if a.State == model.StatePending { return false } diff --git a/pkg/query-service/rules/api_params.go b/pkg/query-service/rules/api_params.go index d460d39973..6d3288ece1 100644 --- a/pkg/query-service/rules/api_params.go +++ b/pkg/query-service/rules/api_params.go @@ -259,8 +259,8 @@ type GettableRules struct { // GettableRule has info for an alerting rules. type GettableRule struct { - Id string `json:"id"` - State AlertState `json:"state"` + Id string `json:"id"` + State model.AlertState `json:"state"` PostableRule CreatedAt *time.Time `json:"createAt"` CreatedBy *string `json:"createBy"` diff --git a/pkg/query-service/rules/manager.go b/pkg/query-service/rules/manager.go index 5de680c184..fe309334b1 100644 --- a/pkg/query-service/rules/manager.go +++ b/pkg/query-service/rules/manager.go @@ -617,7 +617,7 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*GettableRules, error) { // fetch state of rule from memory if rm, ok := m.rules[ruleResponse.Id]; !ok { - ruleResponse.State = StateDisabled + ruleResponse.State = model.StateDisabled ruleResponse.Disabled = true } else { ruleResponse.State = rm.State() @@ -644,7 +644,7 @@ func (m *Manager) GetRule(ctx context.Context, id string) (*GettableRule, error) r.Id = fmt.Sprintf("%d", s.Id) // fetch state of rule from memory if rm, ok := m.rules[r.Id]; !ok { - r.State = StateDisabled + r.State = model.StateDisabled r.Disabled = true } else { r.State = rm.State() @@ -751,7 +751,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, ruleId string) // fetch state of rule from memory if rm, ok := m.rules[ruleId]; !ok { - response.State = StateDisabled + response.State = model.StateDisabled response.Disabled = true } else { response.State = rm.State() diff --git a/pkg/query-service/rules/prom_rule.go b/pkg/query-service/rules/prom_rule.go index a9890a9503..2241d32a4b 100644 --- a/pkg/query-service/rules/prom_rule.go +++ b/pkg/query-service/rules/prom_rule.go @@ -15,6 +15,7 @@ import ( "go.signoz.io/signoz/pkg/query-service/converter" "go.signoz.io/signoz/pkg/query-service/formatter" "go.signoz.io/signoz/pkg/query-service/interfaces" + "go.signoz.io/signoz/pkg/query-service/model" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" qslabels "go.signoz.io/signoz/pkg/query-service/utils/labels" "go.signoz.io/signoz/pkg/query-service/utils/times" @@ -56,6 +57,8 @@ type PromRule struct { opts PromRuleOpts reader interfaces.Reader + + handledRestart bool } func NewPromRule( @@ -218,9 +221,9 @@ func (r *PromRule) GetEvaluationTimestamp() time.Time { // State returns the maximum state of alert instances for this rule. // StateFiring > StatePending > StateInactive -func (r *PromRule) State() AlertState { +func (r *PromRule) State() model.AlertState { - maxState := StateInactive + maxState := model.StateInactive for _, a := range r.active { if a.State > maxState { maxState = a.State @@ -338,6 +341,102 @@ func (r *PromRule) compareOp() CompareOp { return r.ruleCondition.CompareOp } +// TODO(srikanthccv): implement base rule and use for all types of rules +func (r *PromRule) recordRuleStateHistory(ctx context.Context, prevState, currentState model.AlertState, itemsToAdd []v3.RuleStateHistory) error { + zap.L().Debug("recording rule state history", zap.String("ruleid", r.ID()), zap.Any("prevState", prevState), zap.Any("currentState", currentState), zap.Any("itemsToAdd", itemsToAdd)) + revisedItemsToAdd := map[uint64]v3.RuleStateHistory{} + + lastSavedState, err := r.reader.GetLastSavedRuleStateHistory(ctx, r.ID()) + if err != nil { + return err + } + // if the query-service has been restarted, or the rule has been modified (which re-initializes the rule), + // the state would reset so we need to add the corresponding state changes to previously saved states + if !r.handledRestart && len(lastSavedState) > 0 { + zap.L().Debug("handling restart", zap.String("ruleid", r.ID()), zap.Any("lastSavedState", lastSavedState)) + l := map[uint64]v3.RuleStateHistory{} + for _, item := range itemsToAdd { + l[item.Fingerprint] = item + } + + shouldSkip := map[uint64]bool{} + + for _, item := range lastSavedState { + // for the last saved item with fingerprint, check if there is a corresponding entry in the current state + currentState, ok := l[item.Fingerprint] + if !ok { + // there was a state change in the past, but not in the current state + // if the state was firing, then we should add a resolved state change + if item.State == model.StateFiring || item.State == model.StateNoData { + item.State = model.StateInactive + item.StateChanged = true + item.UnixMilli = time.Now().UnixMilli() + revisedItemsToAdd[item.Fingerprint] = item + } + // there is nothing to do if the prev state was normal + } else { + if item.State != currentState.State { + item.State = currentState.State + item.StateChanged = true + item.UnixMilli = time.Now().UnixMilli() + revisedItemsToAdd[item.Fingerprint] = item + } + } + // do not add this item to revisedItemsToAdd as it is already processed + shouldSkip[item.Fingerprint] = true + } + zap.L().Debug("after lastSavedState loop", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + // if there are any new state changes that were not saved, add them to the revised items + for _, item := range itemsToAdd { + if _, ok := revisedItemsToAdd[item.Fingerprint]; !ok && !shouldSkip[item.Fingerprint] { + revisedItemsToAdd[item.Fingerprint] = item + } + } + zap.L().Debug("after itemsToAdd loop", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + newState := model.StateInactive + for _, item := range revisedItemsToAdd { + if item.State == model.StateFiring || item.State == model.StateNoData { + newState = model.StateFiring + break + } + } + zap.L().Debug("newState", zap.String("ruleid", r.ID()), zap.Any("newState", newState)) + + // if there is a change in the overall state, update the overall state + if lastSavedState[0].OverallState != newState { + for fingerprint, item := range revisedItemsToAdd { + item.OverallState = newState + item.OverallStateChanged = true + revisedItemsToAdd[fingerprint] = item + } + } + zap.L().Debug("revisedItemsToAdd after newState", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + } else { + for _, item := range itemsToAdd { + revisedItemsToAdd[item.Fingerprint] = item + } + } + + if len(revisedItemsToAdd) > 0 && r.reader != nil { + zap.L().Debug("writing rule state history", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + entries := make([]v3.RuleStateHistory, 0, len(revisedItemsToAdd)) + for _, item := range revisedItemsToAdd { + entries = append(entries, item) + } + err := r.reader.AddRuleStateHistory(ctx, entries) + if err != nil { + zap.L().Error("error while inserting rule state history", zap.Error(err), zap.Any("itemsToAdd", itemsToAdd)) + } + } + r.handledRestart = true + + return nil +} + func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) (interface{}, error) { prevState := r.State() @@ -442,7 +541,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( QueryResultLables: resultLabels, Annotations: annotations, ActiveAt: ts, - State: StatePending, + State: model.StatePending, Value: alertSmpl.F, GeneratorURL: r.GeneratorURL(), Receivers: r.preferredChannels, @@ -454,7 +553,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( for h, a := range alerts { // Check whether we already have alerting state for the identifying label set. // Update the last value and annotations if so, create a new alert entry otherwise. - if alert, ok := r.active[h]; ok && alert.State != StateInactive { + if alert, ok := r.active[h]; ok && alert.State != model.StateInactive { alert.Value = a.Value alert.Annotations = a.Annotations alert.Receivers = r.preferredChannels @@ -469,23 +568,23 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( // Check if any pending alerts should be removed or fire now. Write out alert timeseries. for fp, a := range r.active { - labelsJSON, err := json.Marshal(a.Labels) + labelsJSON, err := json.Marshal(a.QueryResultLables) if err != nil { zap.L().Error("error marshaling labels", zap.Error(err), zap.String("name", r.Name())) } if _, ok := resultFPs[fp]; !ok { // If the alert was previously firing, keep it around for a given // retention time so it is reported as resolved to the AlertManager. - if a.State == StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > resolvedRetention) { + if a.State == model.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > resolvedRetention) { delete(r.active, fp) } - if a.State != StateInactive { - a.State = StateInactive + if a.State != model.StateInactive { + a.State = model.StateInactive a.ResolvedAt = ts itemsToAdd = append(itemsToAdd, v3.RuleStateHistory{ RuleID: r.ID(), RuleName: r.Name(), - State: "normal", + State: model.StateInactive, StateChanged: true, UnixMilli: ts.UnixMilli(), Labels: v3.LabelsString(labelsJSON), @@ -495,12 +594,12 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( continue } - if a.State == StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration { - a.State = StateFiring + if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration { + a.State = model.StateFiring a.FiredAt = ts - state := "firing" + state := model.StateFiring if a.Missing { - state = "no_data" + state = model.StateNoData } itemsToAdd = append(itemsToAdd, v3.RuleStateHistory{ RuleID: r.ID(), @@ -520,23 +619,14 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( currentState := r.State() - if currentState != prevState { - for idx := range itemsToAdd { - if currentState == StateInactive { - itemsToAdd[idx].OverallState = "normal" - } else { - itemsToAdd[idx].OverallState = currentState.String() - } - itemsToAdd[idx].OverallStateChanged = true - } + overallStateChanged := currentState != prevState + for idx, item := range itemsToAdd { + item.OverallStateChanged = overallStateChanged + item.OverallState = currentState + itemsToAdd[idx] = item } - if len(itemsToAdd) > 0 && r.reader != nil { - err := r.reader.AddRuleStateHistory(ctx, itemsToAdd) - if err != nil { - zap.L().Error("error while inserting rule state history", zap.Error(err), zap.Any("itemsToAdd", itemsToAdd)) - } - } + r.recordRuleStateHistory(ctx, prevState, currentState, itemsToAdd) return len(r.active), nil } diff --git a/pkg/query-service/rules/prom_rule_task.go b/pkg/query-service/rules/prom_rule_task.go index aa29e90187..032fc227f2 100644 --- a/pkg/query-service/rules/prom_rule_task.go +++ b/pkg/query-service/rules/prom_rule_task.go @@ -192,7 +192,7 @@ func (g *PromRuleTask) HasAlertingRules() bool { defer g.mtx.Unlock() for _, rule := range g.rules { - if _, ok := rule.(*ThresholdRule); ok { + if _, ok := rule.(*PromRule); ok { return true } } @@ -284,11 +284,11 @@ func (g *PromRuleTask) CopyState(fromTask Task) error { g.seriesInPreviousEval[i] = from.seriesInPreviousEval[fi] ruleMap[nameAndLabels] = indexes[1:] - ar, ok := rule.(*ThresholdRule) + ar, ok := rule.(*PromRule) if !ok { continue } - far, ok := from.rules[fi].(*ThresholdRule) + far, ok := from.rules[fi].(*PromRule) if !ok { continue } @@ -296,6 +296,7 @@ func (g *PromRuleTask) CopyState(fromTask Task) error { for fp, a := range far.active { ar.active[fp] = a } + ar.handledRestart = far.handledRestart } // Handle deleted and unmatched duplicate rules. diff --git a/pkg/query-service/rules/rule.go b/pkg/query-service/rules/rule.go index 8228f70c8f..eeb7de9066 100644 --- a/pkg/query-service/rules/rule.go +++ b/pkg/query-service/rules/rule.go @@ -4,6 +4,7 @@ import ( "context" "time" + "go.signoz.io/signoz/pkg/query-service/model" "go.signoz.io/signoz/pkg/query-service/utils/labels" ) @@ -17,7 +18,7 @@ type Rule interface { Labels() labels.BaseLabels Annotations() labels.BaseLabels Condition() *RuleCondition - State() AlertState + State() model.AlertState ActiveAlerts() []*Alert PreferredChannels() []string diff --git a/pkg/query-service/rules/rule_task.go b/pkg/query-service/rules/rule_task.go index d6aa09ce2f..61e154d74d 100644 --- a/pkg/query-service/rules/rule_task.go +++ b/pkg/query-service/rules/rule_task.go @@ -288,6 +288,7 @@ func (g *RuleTask) CopyState(fromTask Task) error { for fp, a := range far.active { ar.active[fp] = a } + ar.handledRestart = far.handledRestart } return nil diff --git a/pkg/query-service/rules/threshold_rule.go b/pkg/query-service/rules/threshold_rule.go index e657af9288..e50fb4b761 100644 --- a/pkg/query-service/rules/threshold_rule.go +++ b/pkg/query-service/rules/threshold_rule.go @@ -20,6 +20,7 @@ import ( "github.com/ClickHouse/clickhouse-go/v2/lib/driver" "go.signoz.io/signoz/pkg/query-service/common" "go.signoz.io/signoz/pkg/query-service/converter" + "go.signoz.io/signoz/pkg/query-service/model" "go.signoz.io/signoz/pkg/query-service/postprocess" "go.signoz.io/signoz/pkg/query-service/app/querier" @@ -100,6 +101,8 @@ type ThresholdRule struct { reader interfaces.Reader evalDelay time.Duration + + handledRestart bool } type ThresholdRuleOpts struct { @@ -309,9 +312,9 @@ func (r *ThresholdRule) GetEvaluationTimestamp() time.Time { // State returns the maximum state of alert instances for this rule. // StateFiring > StatePending > StateInactive -func (r *ThresholdRule) State() AlertState { +func (r *ThresholdRule) State() model.AlertState { - maxState := StateInactive + maxState := model.StateInactive for _, a := range r.active { if a.State > maxState { maxState = a.State @@ -898,6 +901,102 @@ func normalizeLabelName(name string) string { return normalized } +// TODO(srikanthccv): implement base rule and use for all types of rules +func (r *ThresholdRule) recordRuleStateHistory(ctx context.Context, prevState, currentState model.AlertState, itemsToAdd []v3.RuleStateHistory) error { + zap.L().Debug("recording rule state history", zap.String("ruleid", r.ID()), zap.Any("prevState", prevState), zap.Any("currentState", currentState), zap.Any("itemsToAdd", itemsToAdd)) + revisedItemsToAdd := map[uint64]v3.RuleStateHistory{} + + lastSavedState, err := r.reader.GetLastSavedRuleStateHistory(ctx, r.ID()) + if err != nil { + return err + } + // if the query-service has been restarted, or the rule has been modified (which re-initializes the rule), + // the state would reset so we need to add the corresponding state changes to previously saved states + if !r.handledRestart && len(lastSavedState) > 0 { + zap.L().Debug("handling restart", zap.String("ruleid", r.ID()), zap.Any("lastSavedState", lastSavedState)) + l := map[uint64]v3.RuleStateHistory{} + for _, item := range itemsToAdd { + l[item.Fingerprint] = item + } + + shouldSkip := map[uint64]bool{} + + for _, item := range lastSavedState { + // for the last saved item with fingerprint, check if there is a corresponding entry in the current state + currentState, ok := l[item.Fingerprint] + if !ok { + // there was a state change in the past, but not in the current state + // if the state was firing, then we should add a resolved state change + if item.State == model.StateFiring || item.State == model.StateNoData { + item.State = model.StateInactive + item.StateChanged = true + item.UnixMilli = time.Now().UnixMilli() + revisedItemsToAdd[item.Fingerprint] = item + } + // there is nothing to do if the prev state was normal + } else { + if item.State != currentState.State { + item.State = currentState.State + item.StateChanged = true + item.UnixMilli = time.Now().UnixMilli() + revisedItemsToAdd[item.Fingerprint] = item + } + } + // do not add this item to revisedItemsToAdd as it is already processed + shouldSkip[item.Fingerprint] = true + } + zap.L().Debug("after lastSavedState loop", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + // if there are any new state changes that were not saved, add them to the revised items + for _, item := range itemsToAdd { + if _, ok := revisedItemsToAdd[item.Fingerprint]; !ok && !shouldSkip[item.Fingerprint] { + revisedItemsToAdd[item.Fingerprint] = item + } + } + zap.L().Debug("after itemsToAdd loop", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + newState := model.StateInactive + for _, item := range revisedItemsToAdd { + if item.State == model.StateFiring || item.State == model.StateNoData { + newState = model.StateFiring + break + } + } + zap.L().Debug("newState", zap.String("ruleid", r.ID()), zap.Any("newState", newState)) + + // if there is a change in the overall state, update the overall state + if lastSavedState[0].OverallState != newState { + for fingerprint, item := range revisedItemsToAdd { + item.OverallState = newState + item.OverallStateChanged = true + revisedItemsToAdd[fingerprint] = item + } + } + zap.L().Debug("revisedItemsToAdd after newState", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + } else { + for _, item := range itemsToAdd { + revisedItemsToAdd[item.Fingerprint] = item + } + } + + if len(revisedItemsToAdd) > 0 && r.reader != nil { + zap.L().Debug("writing rule state history", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + entries := make([]v3.RuleStateHistory, 0, len(revisedItemsToAdd)) + for _, item := range revisedItemsToAdd { + entries = append(entries, item) + } + err := r.reader.AddRuleStateHistory(ctx, entries) + if err != nil { + zap.L().Error("error while inserting rule state history", zap.Error(err), zap.Any("itemsToAdd", itemsToAdd)) + } + } + r.handledRestart = true + + return nil +} + func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) (interface{}, error) { prevState := r.State() @@ -1005,7 +1104,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Querie QueryResultLables: resultLabels, Annotations: annotations, ActiveAt: ts, - State: StatePending, + State: model.StatePending, Value: smpl.V, GeneratorURL: r.GeneratorURL(), Receivers: r.preferredChannels, @@ -1019,7 +1118,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Querie for h, a := range alerts { // Check whether we already have alerting state for the identifying label set. // Update the last value and annotations if so, create a new alert entry otherwise. - if alert, ok := r.active[h]; ok && alert.State != StateInactive { + if alert, ok := r.active[h]; ok && alert.State != model.StateInactive { alert.Value = a.Value alert.Annotations = a.Annotations @@ -1042,31 +1141,32 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Querie if _, ok := resultFPs[fp]; !ok { // If the alert was previously firing, keep it around for a given // retention time so it is reported as resolved to the AlertManager. - if a.State == StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > resolvedRetention) { + if a.State == model.StatePending || (!a.ResolvedAt.IsZero() && ts.Sub(a.ResolvedAt) > resolvedRetention) { delete(r.active, fp) } - if a.State != StateInactive { - a.State = StateInactive + if a.State != model.StateInactive { + a.State = model.StateInactive a.ResolvedAt = ts itemsToAdd = append(itemsToAdd, v3.RuleStateHistory{ RuleID: r.ID(), RuleName: r.Name(), - State: "normal", + State: model.StateInactive, StateChanged: true, UnixMilli: ts.UnixMilli(), Labels: v3.LabelsString(labelsJSON), Fingerprint: a.QueryResultLables.Hash(), + Value: a.Value, }) } continue } - if a.State == StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration { - a.State = StateFiring + if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.holdDuration { + a.State = model.StateFiring a.FiredAt = ts - state := "firing" + state := model.StateFiring if a.Missing { - state = "no_data" + state = model.StateNoData } itemsToAdd = append(itemsToAdd, v3.RuleStateHistory{ RuleID: r.ID(), @@ -1083,28 +1183,15 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Querie currentState := r.State() - if currentState != prevState { - for idx := range itemsToAdd { - if currentState == StateInactive { - itemsToAdd[idx].OverallState = "normal" - } else { - itemsToAdd[idx].OverallState = currentState.String() - } - itemsToAdd[idx].OverallStateChanged = true - } - } else { - for idx := range itemsToAdd { - itemsToAdd[idx].OverallState = currentState.String() - itemsToAdd[idx].OverallStateChanged = false - } + overallStateChanged := currentState != prevState + for idx, item := range itemsToAdd { + item.OverallStateChanged = overallStateChanged + item.OverallState = currentState + itemsToAdd[idx] = item } - if len(itemsToAdd) > 0 && r.reader != nil { - err := r.reader.AddRuleStateHistory(ctx, itemsToAdd) - if err != nil { - zap.L().Error("error while inserting rule state history", zap.Error(err), zap.Any("itemsToAdd", itemsToAdd)) - } - } + r.recordRuleStateHistory(ctx, prevState, currentState, itemsToAdd) + r.health = HealthGood r.lastError = err From 36adc17a34e5273ea74c83038c19eb6fd3ffddb1 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Mon, 9 Sep 2024 15:39:55 +0530 Subject: [PATCH 31/43] fix: make the config isColumn false for all the filters (#5896) --- frontend/src/pages/LogsExplorer/utils.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/LogsExplorer/utils.tsx b/frontend/src/pages/LogsExplorer/utils.tsx index 7a197bd467..5b5ef631b0 100644 --- a/frontend/src/pages/LogsExplorer/utils.tsx +++ b/frontend/src/pages/LogsExplorer/utils.tsx @@ -31,7 +31,7 @@ export const LogsQuickFiltersConfig: IQuickFiltersConfig[] = [ key: 'severity_text', dataType: DataTypes.String, type: '', - isColumn: true, + isColumn: false, isJSON: false, id: 'severity_text--string----true', }, @@ -56,7 +56,7 @@ export const LogsQuickFiltersConfig: IQuickFiltersConfig[] = [ key: 'service.name', dataType: DataTypes.String, type: 'resource', - isColumn: true, + isColumn: false, isJSON: false, id: 'service.name--string--resource--true', }, @@ -105,7 +105,7 @@ export const LogsQuickFiltersConfig: IQuickFiltersConfig[] = [ key: 'k8s.namespace.name', dataType: DataTypes.String, type: 'resource', - isColumn: true, + isColumn: false, isJSON: false, }, defaultOpen: false, From c6ba2b4598d564fbbafe4ceaccb087bc70b53996 Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Mon, 9 Sep 2024 21:47:07 +0530 Subject: [PATCH 32/43] fix: use inactive for empty alert state (#5902) --- pkg/query-service/model/alerting.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/query-service/model/alerting.go b/pkg/query-service/model/alerting.go index c065fdfae6..4d54f6ae34 100644 --- a/pkg/query-service/model/alerting.go +++ b/pkg/query-service/model/alerting.go @@ -57,7 +57,7 @@ func (s *AlertState) UnmarshalJSON(b []byte) error { case "disabled": *s = StateDisabled default: - return errors.New("invalid alert state") + *s = StateInactive } return nil default: From b8d228a339ed0c34007fd5f24dad306b25bba568 Mon Sep 17 00:00:00 2001 From: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com> Date: Mon, 9 Sep 2024 22:05:05 +0530 Subject: [PATCH 33/43] fix: make header sticky for table panel (#5892) * fix: make header sticky for table panel * fix: added sticky prop conditionally and updated test cases * fix: added a smaller scrollbar --------- Co-authored-by: Srikanth Chekuri --- .../src/container/GridPanelSwitch/index.tsx | 1 + .../container/GridTableComponent/index.tsx | 2 ++ .../src/container/GridTableComponent/types.ts | 1 + .../src/container/LogsExplorerTable/index.tsx | 1 + .../Tabs/Overview/TopOperationMetrics.tsx | 1 + .../PanelWrapper/TablePanelWrapper.tsx | 2 ++ .../TablePanelWrapper.test.tsx.snap | 32 ++++++++++++------- .../QueryTable/QueryTable.intefaces.ts | 1 + .../QueryTable/QueryTable.styles.scss | 24 ++++++++------ .../src/container/QueryTable/QueryTable.tsx | 2 ++ .../TracesExplorer/TableView/index.tsx | 1 + 11 files changed, 48 insertions(+), 20 deletions(-) diff --git a/frontend/src/container/GridPanelSwitch/index.tsx b/frontend/src/container/GridPanelSwitch/index.tsx index 641f06e885..24fbde3c85 100644 --- a/frontend/src/container/GridPanelSwitch/index.tsx +++ b/frontend/src/container/GridPanelSwitch/index.tsx @@ -40,6 +40,7 @@ const GridPanelSwitch = forwardRef< data: panelData, query, thresholds, + sticky: true, }, [PANEL_TYPES.LIST]: null, [PANEL_TYPES.PIE]: null, diff --git a/frontend/src/container/GridTableComponent/index.tsx b/frontend/src/container/GridTableComponent/index.tsx index fab4d85e8b..676a745b65 100644 --- a/frontend/src/container/GridTableComponent/index.tsx +++ b/frontend/src/container/GridTableComponent/index.tsx @@ -23,6 +23,7 @@ function GridTableComponent({ thresholds, columnUnits, tableProcessedDataRef, + sticky, ...props }: GridTableComponentProps): JSX.Element { const { t } = useTranslation(['valueGraph']); @@ -146,6 +147,7 @@ function GridTableComponent({ loading={false} columns={newColumnData} dataSource={dataSource} + sticky={sticky} // eslint-disable-next-line react/jsx-props-no-spreading {...props} /> diff --git a/frontend/src/container/GridTableComponent/types.ts b/frontend/src/container/GridTableComponent/types.ts index 25ca647933..6088f9dcb8 100644 --- a/frontend/src/container/GridTableComponent/types.ts +++ b/frontend/src/container/GridTableComponent/types.ts @@ -13,6 +13,7 @@ export type GridTableComponentProps = { thresholds?: ThresholdProps[]; columnUnits?: ColumnUnit; tableProcessedDataRef?: React.MutableRefObject; + sticky?: TableProps['sticky']; } & Pick & Omit, 'columns' | 'dataSource'>; diff --git a/frontend/src/container/LogsExplorerTable/index.tsx b/frontend/src/container/LogsExplorerTable/index.tsx index 13883d3a62..e65bd61464 100644 --- a/frontend/src/container/LogsExplorerTable/index.tsx +++ b/frontend/src/container/LogsExplorerTable/index.tsx @@ -30,6 +30,7 @@ function LogsExplorerTable({ queryTableData={data} loading={isLoading} rootClassName="logs-table" + sticky /> ); } diff --git a/frontend/src/container/MetricsApplication/Tabs/Overview/TopOperationMetrics.tsx b/frontend/src/container/MetricsApplication/Tabs/Overview/TopOperationMetrics.tsx index 22224862a4..ed77512d89 100644 --- a/frontend/src/container/MetricsApplication/Tabs/Overview/TopOperationMetrics.tsx +++ b/frontend/src/container/MetricsApplication/Tabs/Overview/TopOperationMetrics.tsx @@ -114,6 +114,7 @@ function TopOperationMetrics(): JSX.Element { loading={isLoading} renderColumnCell={renderColumnCell} downloadOption={topOperationMetricsDownloadOptions} + sticky /> ); } diff --git a/frontend/src/container/PanelWrapper/TablePanelWrapper.tsx b/frontend/src/container/PanelWrapper/TablePanelWrapper.tsx index db2098554a..0eab4143a2 100644 --- a/frontend/src/container/PanelWrapper/TablePanelWrapper.tsx +++ b/frontend/src/container/PanelWrapper/TablePanelWrapper.tsx @@ -1,3 +1,4 @@ +import { PANEL_TYPES } from 'constants/queryBuilder'; import GridTableComponent from 'container/GridTableComponent'; import { GRID_TABLE_CONFIG } from 'container/GridTableComponent/config'; @@ -18,6 +19,7 @@ function TablePanelWrapper({ thresholds={thresholds} columnUnits={widget.columnUnits} tableProcessedDataRef={tableProcessedDataRef} + sticky={widget.panelTypes === PANEL_TYPES.TABLE} // eslint-disable-next-line react/jsx-props-no-spreading {...GRID_TABLE_CONFIG} /> diff --git a/frontend/src/container/PanelWrapper/__tests__/__snapshots__/TablePanelWrapper.test.tsx.snap b/frontend/src/container/PanelWrapper/__tests__/__snapshots__/TablePanelWrapper.test.tsx.snap index d37ccf5841..1a930f740c 100644 --- a/frontend/src/container/PanelWrapper/__tests__/__snapshots__/TablePanelWrapper.test.tsx.snap +++ b/frontend/src/container/PanelWrapper/__tests__/__snapshots__/TablePanelWrapper.test.tsx.snap @@ -70,20 +70,13 @@ exports[`Table panel wrappper tests table should render fine with the query resp class="ant-table-container" >
- - - - + @@ -222,6 +215,23 @@ exports[`Table panel wrappper tests table should render fine with the query resp +
+
+
+ + + + + diff --git a/frontend/src/container/QueryTable/QueryTable.intefaces.ts b/frontend/src/container/QueryTable/QueryTable.intefaces.ts index f73c407d85..7576d796ec 100644 --- a/frontend/src/container/QueryTable/QueryTable.intefaces.ts +++ b/frontend/src/container/QueryTable/QueryTable.intefaces.ts @@ -18,4 +18,5 @@ export type QueryTableProps = Omit< downloadOption?: DownloadOptions; columns?: ColumnsType; dataSource?: RowData[]; + sticky?: TableProps['sticky']; }; diff --git a/frontend/src/container/QueryTable/QueryTable.styles.scss b/frontend/src/container/QueryTable/QueryTable.styles.scss index e78a4df239..e116356f9d 100644 --- a/frontend/src/container/QueryTable/QueryTable.styles.scss +++ b/frontend/src/container/QueryTable/QueryTable.styles.scss @@ -1,10 +1,16 @@ .query-table { - position: relative; - height: inherit; - .query-table--download { - position: absolute; - top: 15px; - right: 0px; - z-index: 1; - } -} \ No newline at end of file + position: relative; + height: inherit; + .query-table--download { + position: absolute; + top: 15px; + right: 0px; + z-index: 1; + } + + .ant-table { + &::-webkit-scrollbar { + width: 0.1rem; + } + } +} diff --git a/frontend/src/container/QueryTable/QueryTable.tsx b/frontend/src/container/QueryTable/QueryTable.tsx index ccf8221bfe..1786e5d4e3 100644 --- a/frontend/src/container/QueryTable/QueryTable.tsx +++ b/frontend/src/container/QueryTable/QueryTable.tsx @@ -19,6 +19,7 @@ export function QueryTable({ downloadOption, columns, dataSource, + sticky, ...props }: QueryTableProps): JSX.Element { const { isDownloadEnabled = false, fileName = '' } = downloadOption || {}; @@ -71,6 +72,7 @@ export function QueryTable({ dataSource={newDataSource} scroll={{ x: true }} pagination={paginationConfig} + sticky={sticky} // eslint-disable-next-line react/jsx-props-no-spreading {...props} /> diff --git a/frontend/src/container/TracesExplorer/TableView/index.tsx b/frontend/src/container/TracesExplorer/TableView/index.tsx index 775db816c4..849ea9bd8f 100644 --- a/frontend/src/container/TracesExplorer/TableView/index.tsx +++ b/frontend/src/container/TracesExplorer/TableView/index.tsx @@ -47,6 +47,7 @@ function TableView(): JSX.Element { query={stagedQuery || initialQueriesMap.traces} queryTableData={data?.payload?.data?.newResult?.data?.result || []} loading={isLoading} + sticky /> ); From 6f0cf03371ab254b10488f5281a9769b569b78c2 Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Mon, 9 Sep 2024 23:13:14 +0530 Subject: [PATCH 34/43] chore: remove ee references in MIT licensed code (#5901) * chore: remove ee references in MIT licensed code * chore: add target --------- Co-authored-by: Prashant Shahi --- .github/workflows/build.yaml | 7 +++++++ .scripts/commentLinesForSetup.sh | 7 ------- Makefile | 9 +++++++++ ee/query-service/constants/constants.go | 2 -- pkg/query-service/app/parser.go | 5 ++--- pkg/query-service/app/preferences/model.go | 2 +- pkg/query-service/constants/constants.go | 3 +++ 7 files changed, 22 insertions(+), 13 deletions(-) delete mode 100644 .scripts/commentLinesForSetup.sh diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1d8d4e7b70..ba92f9f28f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,6 +8,13 @@ on: - release/v* jobs: + check-no-ee-references: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run check + run: make check-no-ee-references + build-frontend: runs-on: ubuntu-latest steps: diff --git a/.scripts/commentLinesForSetup.sh b/.scripts/commentLinesForSetup.sh deleted file mode 100644 index c0dfd40e9f..0000000000 --- a/.scripts/commentLinesForSetup.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -# It Comments out the Line Query-Service & Frontend Section of deploy/docker/clickhouse-setup/docker-compose.yaml -# Update the Line Numbers when deploy/docker/clickhouse-setup/docker-compose.yaml chnages. -# Docs Ref.: https://github.com/SigNoz/signoz/blob/main/CONTRIBUTING.md#contribute-to-frontend-with-docker-installation-of-signoz - -sed -i 38,62's/.*/# &/' .././deploy/docker/clickhouse-setup/docker-compose.yaml diff --git a/Makefile b/Makefile index 5f4a3c1ac2..c110ebdaf2 100644 --- a/Makefile +++ b/Makefile @@ -178,6 +178,15 @@ clear-swarm-ch: @docker run --rm -v "$(PWD)/$(SWARM_DIRECTORY)/data:/pwd" busybox \ sh -c "cd /pwd && rm -rf clickhouse*/* zookeeper-*/*" +check-no-ee-references: + @echo "Checking for 'ee' package references in 'pkg' directory..." + @if grep -R --include="*.go" '.*/ee/.*' pkg/; then \ + echo "Error: Found references to 'ee' packages in 'pkg' directory"; \ + exit 1; \ + else \ + echo "No references to 'ee' packages found in 'pkg' directory"; \ + fi + test: go test ./pkg/query-service/app/metrics/... go test ./pkg/query-service/cache/... diff --git a/ee/query-service/constants/constants.go b/ee/query-service/constants/constants.go index 51d22e5b63..c1baa6320b 100644 --- a/ee/query-service/constants/constants.go +++ b/ee/query-service/constants/constants.go @@ -11,8 +11,6 @@ const ( var LicenseSignozIo = "https://license.signoz.io/api/v1" var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "") var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "") -var SpanRenderLimitStr = GetOrDefaultEnv("SPAN_RENDER_LIMIT", "2500") -var MaxSpansInTraceStr = GetOrDefaultEnv("MAX_SPANS_IN_TRACE", "250000") var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false") var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL") diff --git a/pkg/query-service/app/parser.go b/pkg/query-service/app/parser.go index bbf97c7adf..9ae242c19f 100644 --- a/pkg/query-service/app/parser.go +++ b/pkg/query-service/app/parser.go @@ -18,7 +18,6 @@ import ( promModel "github.com/prometheus/common/model" "go.uber.org/multierr" - "go.signoz.io/signoz/ee/query-service/constants" "go.signoz.io/signoz/pkg/query-service/app/metrics" "go.signoz.io/signoz/pkg/query-service/app/queryBuilder" "go.signoz.io/signoz/pkg/query-service/auth" @@ -255,7 +254,7 @@ func ParseSearchTracesParams(r *http.Request) (*model.SearchTracesParams, error) levelDownStr = "0" } if SpanRenderLimitStr == "" || SpanRenderLimitStr == "null" { - SpanRenderLimitStr = constants.SpanRenderLimitStr + SpanRenderLimitStr = baseconstants.SpanRenderLimitStr } levelUpInt, err := strconv.Atoi(levelUpStr) @@ -270,7 +269,7 @@ func ParseSearchTracesParams(r *http.Request) (*model.SearchTracesParams, error) if err != nil { return nil, err } - MaxSpansInTraceInt, err := strconv.Atoi(constants.MaxSpansInTraceStr) + MaxSpansInTraceInt, err := strconv.Atoi(baseconstants.MaxSpansInTraceStr) if err != nil { return nil, err } diff --git a/pkg/query-service/app/preferences/model.go b/pkg/query-service/app/preferences/model.go index 82b8e9c9f6..fce34653fb 100644 --- a/pkg/query-service/app/preferences/model.go +++ b/pkg/query-service/app/preferences/model.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/jmoiron/sqlx" - "go.signoz.io/signoz/ee/query-service/model" + "go.signoz.io/signoz/pkg/query-service/model" ) type Range struct { diff --git a/pkg/query-service/constants/constants.go b/pkg/query-service/constants/constants.go index 8c8a038f2f..02211008c1 100644 --- a/pkg/query-service/constants/constants.go +++ b/pkg/query-service/constants/constants.go @@ -421,3 +421,6 @@ const DefaultFilterSuggestionsAttributesLimit = 50 const MaxFilterSuggestionsAttributesLimit = 100 const DefaultFilterSuggestionsExamplesLimit = 2 const MaxFilterSuggestionsExamplesLimit = 10 + +var SpanRenderLimitStr = GetOrDefaultEnv("SPAN_RENDER_LIMIT", "2500") +var MaxSpansInTraceStr = GetOrDefaultEnv("MAX_SPANS_IN_TRACE", "250000") From ee1e2b824f83a654362c5233fd97b6f1fb610f93 Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 10 Sep 2024 11:30:22 +0430 Subject: [PATCH 35/43] fix: make the trace table row act as an anchor tag (#5626) * fix: wrap the trace row cells inside a tag to make them clickable --- .../TracesExplorer/ListView/utils.tsx | 48 ++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/frontend/src/container/TracesExplorer/ListView/utils.tsx b/frontend/src/container/TracesExplorer/ListView/utils.tsx index 254ff93296..a6201436d1 100644 --- a/frontend/src/container/TracesExplorer/ListView/utils.tsx +++ b/frontend/src/container/TracesExplorer/ListView/utils.tsx @@ -5,10 +5,26 @@ import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util'; import { formUrlParams } from 'container/TraceDetail/utils'; import dayjs from 'dayjs'; import { RowData } from 'lib/query/createTableColumnsFromQuery'; +import { Link } from 'react-router-dom'; import { ILog } from 'types/api/logs/log'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { QueryDataV3 } from 'types/api/widgets/getQuery'; +function BlockLink({ + children, + to, +}: { + children: React.ReactNode; + to: string; +}): any { + // Display block to make the whole cell clickable + return ( + + {children} + + ); +} + export const transformDataWithDate = ( data: QueryDataV3[], ): Omit[] => @@ -36,7 +52,11 @@ export const getListColumns = ( typeof item === 'string' ? dayjs(item).format('YYYY-MM-DD HH:mm:ss.SSS') : dayjs(item / 1e6).format('YYYY-MM-DD HH:mm:ss.SSS'); - return {date}; + return ( + + {date} + + ); }, }, ]; @@ -49,22 +69,36 @@ export const getListColumns = ( width: 145, render: (value): JSX.Element => { if (value === '') { - return N/A; + return ( + + N/A + + ); } if (key === 'httpMethod' || key === 'responseStatusCode') { return ( - - {value} - + + + {value} + + ); } if (key === 'durationNano') { - return {getMs(value)}ms; + return ( + + {getMs(value)}ms + + ); } - return {value}; + return ( + + {value} + + ); }, responsive: ['md'], })) || []; From 3c151e3adbb2f051f43959594a411658d4cb8c7f Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 10 Sep 2024 11:31:43 +0430 Subject: [PATCH 36/43] feat: preserve last used saved view in explorer pages (#5453) * feat: preserve last used saved view in explorer pages --- frontend/src/constants/localStorage.ts | 1 + .../ExplorerOptions/ExplorerOptions.tsx | 111 ++++++++++++++++-- .../container/ExplorerOptions/constants.ts | 4 + .../src/container/ExplorerOptions/types.ts | 10 ++ .../TimeSeriesView/TimeSeriesView.tsx | 1 + 5 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 frontend/src/container/ExplorerOptions/constants.ts diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index bab93a7ff1..4e6859a2dd 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -19,5 +19,6 @@ export enum LOCALSTORAGE { SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR', PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES', THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1', + LAST_USED_SAVED_VIEWS = 'LAST_USED_SAVED_VIEWS', SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS', } diff --git a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx index 44378e602b..5be22deb2e 100644 --- a/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx +++ b/frontend/src/container/ExplorerOptions/ExplorerOptions.tsx @@ -19,6 +19,7 @@ import axios from 'axios'; import cx from 'classnames'; import { getViewDetailsUsingViewKey } from 'components/ExplorerCard/utils'; import { SOMETHING_WENT_WRONG } from 'constants/api'; +import { LOCALSTORAGE } from 'constants/localStorage'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; import ROUTES from 'constants/routes'; @@ -48,6 +49,7 @@ import { Dispatch, SetStateAction, useCallback, + useEffect, useMemo, useRef, useState, @@ -61,7 +63,9 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder'; import AppReducer from 'types/reducer/app'; import { USER_ROLES } from 'types/roles'; +import { PreservedViewsTypes } from './constants'; import ExplorerOptionsHideArea from './ExplorerOptionsHideArea'; +import { PreservedViewsInLocalStorage } from './types'; import { DATASOURCE_VS_ROUTES, generateRGBAFromHex, @@ -90,6 +94,12 @@ function ExplorerOptions({ const history = useHistory(); const ref = useRef(null); const isDarkMode = useIsDarkMode(); + const isLogsExplorer = sourcepage === DataSource.LOGS; + + const PRESERVED_VIEW_LOCAL_STORAGE_KEY = LOCALSTORAGE.LAST_USED_SAVED_VIEWS; + const PRESERVED_VIEW_TYPE = isLogsExplorer + ? PreservedViewsTypes.LOGS + : PreservedViewsTypes.TRACES; const onModalToggle = useCallback((value: boolean) => { setIsExport(value); @@ -107,7 +117,7 @@ function ExplorerOptions({ logEvent('Traces Explorer: Save view clicked', { panelType, }); - } else if (sourcepage === DataSource.LOGS) { + } else if (isLogsExplorer) { logEvent('Logs Explorer: Save view clicked', { panelType, }); @@ -141,7 +151,7 @@ function ExplorerOptions({ logEvent('Traces Explorer: Create alert', { panelType, }); - } else if (sourcepage === DataSource.LOGS) { + } else if (isLogsExplorer) { logEvent('Logs Explorer: Create alert', { panelType, }); @@ -166,7 +176,7 @@ function ExplorerOptions({ logEvent('Traces Explorer: Add to dashboard clicked', { panelType, }); - } else if (sourcepage === DataSource.LOGS) { + } else if (isLogsExplorer) { logEvent('Logs Explorer: Add to dashboard clicked', { panelType, }); @@ -265,6 +275,31 @@ function ExplorerOptions({ [viewsData, handleExplorerTabChange], ); + const updatePreservedViewInLocalStorage = (option: { + key: string; + value: string; + }): void => { + // Retrieve stored views from local storage + const storedViews = localStorage.getItem(PRESERVED_VIEW_LOCAL_STORAGE_KEY); + + // Initialize or parse the stored views + const updatedViews: PreservedViewsInLocalStorage = storedViews + ? JSON.parse(storedViews) + : {}; + + // Update the views with the new selection + updatedViews[PRESERVED_VIEW_TYPE] = { + key: option.key, + value: option.value, + }; + + // Save the updated views back to local storage + localStorage.setItem( + PRESERVED_VIEW_LOCAL_STORAGE_KEY, + JSON.stringify(updatedViews), + ); + }; + const handleSelect = ( value: string, option: { key: string; value: string }, @@ -277,18 +312,42 @@ function ExplorerOptions({ panelType, viewName: option?.value, }); - } else if (sourcepage === DataSource.LOGS) { + } else if (isLogsExplorer) { logEvent('Logs Explorer: Select view', { panelType, viewName: option?.value, }); } + + updatePreservedViewInLocalStorage(option); + if (ref.current) { ref.current.blur(); } }; + const removeCurrentViewFromLocalStorage = (): void => { + // Retrieve stored views from local storage + const storedViews = localStorage.getItem(PRESERVED_VIEW_LOCAL_STORAGE_KEY); + + if (storedViews) { + // Parse the stored views + const parsedViews = JSON.parse(storedViews); + + // Remove the current view type from the parsed views + delete parsedViews[PRESERVED_VIEW_TYPE]; + + // Update local storage with the modified views + localStorage.setItem( + PRESERVED_VIEW_LOCAL_STORAGE_KEY, + JSON.stringify(parsedViews), + ); + } + }; + const handleClearSelect = (): void => { + removeCurrentViewFromLocalStorage(); + history.replace(DATASOURCE_VS_ROUTES[sourcepage]); }; @@ -323,7 +382,7 @@ function ExplorerOptions({ panelType, viewName: newViewName, }); - } else if (sourcepage === DataSource.LOGS) { + } else if (isLogsExplorer) { logEvent('Logs Explorer: Save view successful', { panelType, viewName: newViewName, @@ -358,6 +417,44 @@ function ExplorerOptions({ const isEditDeleteSupported = allowedRoles.includes(role as string); + const [ + isRecentlyUsedSavedViewSelected, + setIsRecentlyUsedSavedViewSelected, + ] = useState(false); + + useEffect(() => { + const parsedPreservedView = JSON.parse( + localStorage.getItem(PRESERVED_VIEW_LOCAL_STORAGE_KEY) || '{}', + ); + + const preservedView = parsedPreservedView[PRESERVED_VIEW_TYPE] || {}; + + let timeoutId: string | number | NodeJS.Timeout | undefined; + + if ( + !!preservedView?.key && + viewsData?.data?.data && + !(!!viewName || !!viewKey) && + !isRecentlyUsedSavedViewSelected + ) { + // prevent the race condition with useShareBuilderUrl + timeoutId = setTimeout(() => { + onMenuItemSelectHandler({ key: preservedView.key }); + }, 0); + setIsRecentlyUsedSavedViewSelected(false); + } + + return (): void => clearTimeout(timeoutId); + }, [ + PRESERVED_VIEW_LOCAL_STORAGE_KEY, + PRESERVED_VIEW_TYPE, + isRecentlyUsedSavedViewSelected, + onMenuItemSelectHandler, + viewKey, + viewName, + viewsData?.data?.data, + ]); + return (
{isQueryUpdated && !isExplorerOptionHidden && ( @@ -476,12 +573,12 @@ function ExplorerOptions({ - {sourcepage === DataSource.LOGS + {isLogsExplorer ? 'Learn more about Logs explorer ' : 'Learn more about Traces explorer '} >; } + +export type PreservedViewType = + | PreservedViewsTypes.LOGS + | PreservedViewsTypes.TRACES; + +export type PreservedViewsInLocalStorage = Partial< + Record +>; diff --git a/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx b/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx index fdb5405473..225d115bca 100644 --- a/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx +++ b/frontend/src/container/TimeSeriesView/TimeSeriesView.tsx @@ -140,6 +140,7 @@ function TimeSeriesView({ className="graph-container" style={{ height: '100%', width: '100%' }} ref={graphRef} + data-testid="time-series-graph" > {isLoading && (dataSource === DataSource.LOGS ? : )} From 573d369d4b9ee104a33847e18909c6dabf710e78 Mon Sep 17 00:00:00 2001 From: Vishal Sharma Date: Tue, 10 Sep 2024 13:54:30 +0530 Subject: [PATCH 37/43] chore: segment oss (#5910) Co-authored-by: Prashant Shahi --- pkg/query-service/constants/constants.go | 5 +++ pkg/query-service/telemetry/telemetry.go | 40 +++++++++++++++--------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/pkg/query-service/constants/constants.go b/pkg/query-service/constants/constants.go index 02211008c1..70eda959dc 100644 --- a/pkg/query-service/constants/constants.go +++ b/pkg/query-service/constants/constants.go @@ -25,6 +25,11 @@ var ConfigSignozIo = "https://config.signoz.io/api/v1" var DEFAULT_TELEMETRY_ANONYMOUS = false +func IsOSSTelemetryEnabled() bool { + ossSegmentKey := GetOrDefaultEnv("OSS_TELEMETRY_ENABLED", "true") + return ossSegmentKey == "true" +} + const MaxAllowedPointsInTimeSeries = 300 func IsTelemetryEnabled() bool { diff --git a/pkg/query-service/telemetry/telemetry.go b/pkg/query-service/telemetry/telemetry.go index 88f3a09542..7f282ea3f9 100644 --- a/pkg/query-service/telemetry/telemetry.go +++ b/pkg/query-service/telemetry/telemetry.go @@ -204,11 +204,19 @@ func createTelemetry() { return } - telemetry = &Telemetry{ - ossOperator: analytics.New(api_key), - ipAddress: getOutboundIP(), - rateLimits: make(map[string]int8), - activeUser: make(map[string]int8), + if constants.IsOSSTelemetryEnabled() { + telemetry = &Telemetry{ + ossOperator: analytics.New(api_key), + ipAddress: getOutboundIP(), + rateLimits: make(map[string]int8), + activeUser: make(map[string]int8), + } + } else { + telemetry = &Telemetry{ + ipAddress: getOutboundIP(), + rateLimits: make(map[string]int8), + activeUser: make(map[string]int8), + } } telemetry.minRandInt = 0 telemetry.maxRandInt = int(1 / DEFAULT_SAMPLING) @@ -484,16 +492,18 @@ func (a *Telemetry) IdentifyUser(user *model.User) { }) } - a.ossOperator.Enqueue(analytics.Identify{ - UserId: a.ipAddress, - Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email).Set("ip", a.ipAddress), - }) - // Updating a groups properties - a.ossOperator.Enqueue(analytics.Group{ - UserId: a.ipAddress, - GroupId: a.getCompanyDomain(), - Traits: analytics.NewTraits().Set("company_domain", a.getCompanyDomain()), - }) + if a.ossOperator != nil { + a.ossOperator.Enqueue(analytics.Identify{ + UserId: a.ipAddress, + Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email).Set("ip", a.ipAddress), + }) + // Updating a groups properties + a.ossOperator.Enqueue(analytics.Group{ + UserId: a.ipAddress, + GroupId: a.getCompanyDomain(), + Traits: analytics.NewTraits().Set("company_domain", a.getCompanyDomain()), + }) + } } func (a *Telemetry) SetUserEmail(email string) { From 47d42e6a5754e28171ab528b25f759358799be76 Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 10 Sep 2024 17:06:17 +0430 Subject: [PATCH 38/43] feat: apply resource filters on coming from service details to traces page (#5827) * feat: apply resource fitlers on coming from service details to traces page * fix: remove value splitting from resourceAttributesToTracesFilterItems * chore: handle 'Not IN' inside resourceAttributesToTracesFilterItems * fix: add resource attributes filter to useGetAPMToTracesQueries * fix: update query on changing resource attributes queries --- .../container/MetricsApplication/Tabs/util.ts | 13 ++++++++- .../MetricsApplication/TopOperationsTable.tsx | 28 ++++++++++--------- .../src/hooks/useResourceAttribute/utils.ts | 16 +++++++++++ 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/frontend/src/container/MetricsApplication/Tabs/util.ts b/frontend/src/container/MetricsApplication/Tabs/util.ts index 6832fe9d02..3e4dbeceb4 100644 --- a/frontend/src/container/MetricsApplication/Tabs/util.ts +++ b/frontend/src/container/MetricsApplication/Tabs/util.ts @@ -4,6 +4,8 @@ import ROUTES from 'constants/routes'; import { routeConfig } from 'container/SideNav/config'; import { getQueryString } from 'container/SideNav/helper'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import useResourceAttribute from 'hooks/useResourceAttribute'; +import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils'; import history from 'lib/history'; import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils'; import { Dispatch, SetStateAction, useMemo } from 'react'; @@ -142,7 +144,12 @@ export function useGetAPMToTracesQueries({ filters?: TagFilterItem[]; }): Query { const { updateAllQueriesOperators } = useQueryBuilder(); + const { queries } = useResourceAttribute(); + const resourceAttributesFilters = useMemo( + () => resourceAttributesToTracesFilterItems(queries), + [queries], + ); const finalFilters: TagFilterItem[] = []; let spanKindFilter: TagFilterItem; let dbCallFilter: TagFilterItem; @@ -185,6 +192,10 @@ export function useGetAPMToTracesQueries({ finalFilters.push(...filters); } + if (resourceAttributesFilters?.length) { + finalFilters.push(...resourceAttributesFilters); + } + return useMemo(() => { const updatedQuery = updateAllQueriesOperators( initialQueriesMap.traces, @@ -199,5 +210,5 @@ export function useGetAPMToTracesQueries({ finalFilters, ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [servicename, updateAllQueriesOperators]); + }, [servicename, queries, updateAllQueriesOperators]); } diff --git a/frontend/src/container/MetricsApplication/TopOperationsTable.tsx b/frontend/src/container/MetricsApplication/TopOperationsTable.tsx index d897c8a205..da90045b6e 100644 --- a/frontend/src/container/MetricsApplication/TopOperationsTable.tsx +++ b/frontend/src/container/MetricsApplication/TopOperationsTable.tsx @@ -50,19 +50,21 @@ function TopOperationsTable({ const { servicename: encodedServiceName } = params; const servicename = decodeURIComponent(encodedServiceName); - const opFilter: TagFilterItem = { - id: uuid().slice(0, 8), - key: { - key: 'name', - dataType: DataTypes.String, - type: 'tag', - isColumn: true, - isJSON: false, - id: 'name--string--tag--true', + const opFilters: TagFilterItem[] = [ + { + id: uuid().slice(0, 8), + key: { + key: 'name', + dataType: DataTypes.String, + type: 'tag', + isColumn: true, + isJSON: false, + id: 'name--string--tag--true', + }, + op: 'in', + value: [operation], }, - op: 'in', - value: [operation], - }; + ]; const preparedQuery: Query = { ...apmToTraceQuery, @@ -72,7 +74,7 @@ function TopOperationsTable({ ...item, filters: { ...item.filters, - items: [...item.filters.items, opFilter], + items: [...item.filters.items, ...opFilters], }, })), }, diff --git a/frontend/src/hooks/useResourceAttribute/utils.ts b/frontend/src/hooks/useResourceAttribute/utils.ts index 77fdddfbea..4dd8c56563 100644 --- a/frontend/src/hooks/useResourceAttribute/utils.ts +++ b/frontend/src/hooks/useResourceAttribute/utils.ts @@ -93,6 +93,22 @@ export const resourceAttributesToTagFilterItems = ( value: `${res.tagValue}`.split(','), })); }; +/* Convert resource attributes to trace filters items for queryBuilder */ +export const resourceAttributesToTracesFilterItems = ( + queries: IResourceAttribute[], +): TagFilterItem[] => + queries.map((res) => ({ + id: `${res.id}`, + key: { + key: convertMetricKeyToTrace(res.tagKey), + isColumn: false, + type: MetricsType.Resource, + dataType: DataTypes.String, + id: `${convertMetricKeyToTrace(res.tagKey)}--string--resource--true`, + }, + op: `${res.operator === 'Not IN' ? 'nin' : res.operator}`, + value: res.tagValue, + })); export const OperatorSchema: IOption[] = OperatorConversions.map( (operator) => ({ From 2cc2a43e17f7c63a83e798defbff8953cb775edf Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Wed, 11 Sep 2024 08:52:45 +0430 Subject: [PATCH 39/43] feat: add resource_deployment_environment as fast filter in traces page (#5864) * feat: add resource_deployment_environment as fast filter in traces page * chore: directly use deployment.environment, rather than converting resource_deployment_environment * chore: make environment filter expanded by default * chore: add deployment.environment to defaultOpenSections to pass the test --------- Co-authored-by: Ankit Nayan --- .../pages/TracesExplorer/Filter/Section.tsx | 1 + .../TracesExplorer/Filter/filterUtils.ts | 20 ++++++++++++++----- .../TracesExplorer/__test__/testUtils.ts | 7 ++++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/TracesExplorer/Filter/Section.tsx b/frontend/src/pages/TracesExplorer/Filter/Section.tsx index 9212f610b0..5ba94b1307 100644 --- a/frontend/src/pages/TracesExplorer/Filter/Section.tsx +++ b/frontend/src/pages/TracesExplorer/Filter/Section.tsx @@ -37,6 +37,7 @@ export function Section(props: SectionProps): JSX.Element { 'hasError', 'durationNano', 'serviceName', + 'deployment.environment', ]), ), [selectedFilters], diff --git a/frontend/src/pages/TracesExplorer/Filter/filterUtils.ts b/frontend/src/pages/TracesExplorer/Filter/filterUtils.ts index 88f604a0dc..ea82bc52ef 100644 --- a/frontend/src/pages/TracesExplorer/Filter/filterUtils.ts +++ b/frontend/src/pages/TracesExplorer/Filter/filterUtils.ts @@ -8,10 +8,11 @@ import { import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; import { DataSource } from 'types/common/queryBuilder'; -export const AllTraceFilterKeyValue = { +export const AllTraceFilterKeyValue: Record = { durationNanoMin: 'Duration', durationNano: 'Duration', durationNanoMax: 'Duration', + 'deployment.environment': 'Environment', hasError: 'Status', serviceName: 'Service Name', name: 'Operation / Name', @@ -22,7 +23,7 @@ export const AllTraceFilterKeyValue = { httpRoute: 'HTTP Route', httpUrl: 'HTTP URL', traceID: 'Trace ID', -}; +} as const; export type AllTraceFilterKeys = keyof typeof AllTraceFilterKeyValue; @@ -64,7 +65,7 @@ export const addFilter = ( | undefined > >, - keys?: BaseAutocompleteData, + keys: BaseAutocompleteData, ): void => { setSelectedFilters((prevFilters) => { const isDuration = [ @@ -122,7 +123,7 @@ export const removeFilter = ( | undefined > >, - keys?: BaseAutocompleteData, + keys: BaseAutocompleteData, ): void => { setSelectedFilters((prevFilters) => { if (!prevFilters || !prevFilters[filterType]?.values.length) { @@ -202,6 +203,15 @@ export const traceFilterKeys: Record< isJSON: false, id: 'serviceName--string--tag--true', }, + + 'deployment.environment': { + key: 'deployment.environment', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + id: 'deployment.environment--string--resource--false', + }, name: { key: 'name', dataType: DataTypes.String, @@ -282,7 +292,7 @@ export const traceFilterKeys: Record< isJSON: false, id: 'durationNanoMax--float64--tag--true', }, -}; +} as const; interface AggregateValuesProps { value: AllTraceFilterKeys; diff --git a/frontend/src/pages/TracesExplorer/__test__/testUtils.ts b/frontend/src/pages/TracesExplorer/__test__/testUtils.ts index 80d96c9cf3..8a46740e6a 100644 --- a/frontend/src/pages/TracesExplorer/__test__/testUtils.ts +++ b/frontend/src/pages/TracesExplorer/__test__/testUtils.ts @@ -244,7 +244,12 @@ export function checkIfSectionIsNotOpen( expect(section.querySelector('.ant-collapse-item-active')).toBeNull(); } -export const defaultOpenSections = ['hasError', 'durationNano', 'serviceName']; +export const defaultOpenSections = [ + 'hasError', + 'durationNano', + 'serviceName', + 'deployment.environment', +]; export const defaultClosedSections = Object.keys(AllTraceFilterKeyValue).filter( (section) => From c79520c8745b3e2ee1312f696a7422d36eeb3155 Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Wed, 11 Sep 2024 09:56:59 +0530 Subject: [PATCH 40/43] chore: add base rule and consolidate common logic (#5849) --- ee/query-service/app/server.go | 5 +- ee/query-service/rules/manager.go | 6 +- pkg/query-service/app/server.go | 5 +- pkg/query-service/rules/base_rule.go | 567 ++++++++++++++ pkg/query-service/rules/manager.go | 23 +- pkg/query-service/rules/prom_rule.go | 573 ++------------- pkg/query-service/rules/prom_rule_task.go | 2 +- pkg/query-service/rules/promrule_test.go | 4 +- pkg/query-service/rules/queriers.go | 20 - pkg/query-service/rules/rule.go | 6 +- pkg/query-service/rules/rule_task.go | 2 +- pkg/query-service/rules/threshold_rule.go | 692 ++---------------- .../rules/threshold_rule_test.go | 44 +- 13 files changed, 695 insertions(+), 1254 deletions(-) create mode 100644 pkg/query-service/rules/base_rule.go diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index 7fb9317946..ee019e639a 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -746,10 +746,7 @@ func makeRulesManager( // create manager opts managerOpts := &baserules.ManagerOptions{ NotifierOpts: notifierOpts, - Queriers: &baserules.Queriers{ - PqlEngine: pqle, - Ch: ch.GetConn(), - }, + PqlEngine: pqle, RepoURL: ruleRepoURL, DBConn: db, Context: context.Background(), diff --git a/ee/query-service/rules/manager.go b/ee/query-service/rules/manager.go index 831fb52793..d3bc03f58a 100644 --- a/ee/query-service/rules/manager.go +++ b/ee/query-service/rules/manager.go @@ -18,11 +18,9 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) tr, err := baserules.NewThresholdRule( ruleId, opts.Rule, - baserules.ThresholdRuleOpts{ - EvalDelay: opts.ManagerOpts.EvalDelay, - }, opts.FF, opts.Reader, + baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay), ) if err != nil { @@ -41,8 +39,8 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) ruleId, opts.Rule, opts.Logger, - baserules.PromRuleOpts{}, opts.Reader, + opts.ManagerOpts.PqlEngine, ) if err != nil { diff --git a/pkg/query-service/app/server.go b/pkg/query-service/app/server.go index 77caa9170b..557b082f42 100644 --- a/pkg/query-service/app/server.go +++ b/pkg/query-service/app/server.go @@ -731,10 +731,7 @@ func makeRulesManager( // create manager opts managerOpts := &rules.ManagerOptions{ NotifierOpts: notifierOpts, - Queriers: &rules.Queriers{ - PqlEngine: pqle, - Ch: ch.GetConn(), - }, + PqlEngine: pqle, RepoURL: ruleRepoURL, DBConn: db, Context: context.Background(), diff --git a/pkg/query-service/rules/base_rule.go b/pkg/query-service/rules/base_rule.go new file mode 100644 index 0000000000..492f6f685c --- /dev/null +++ b/pkg/query-service/rules/base_rule.go @@ -0,0 +1,567 @@ +package rules + +import ( + "context" + "fmt" + "math" + "net/url" + "sync" + "time" + + "go.signoz.io/signoz/pkg/query-service/converter" + "go.signoz.io/signoz/pkg/query-service/interfaces" + "go.signoz.io/signoz/pkg/query-service/model" + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" + qslabels "go.signoz.io/signoz/pkg/query-service/utils/labels" + "go.uber.org/zap" +) + +// BaseRule contains common fields and methods for all rule types +type BaseRule struct { + id string + name string + source string + handledRestart bool + + // Type of the rule + typ AlertType + + ruleCondition *RuleCondition + // evalWindow is the time window used for evaluating the rule + // i.e each time we lookback from the current time, we look at data for the last + // evalWindow duration + evalWindow time.Duration + // holdDuration is the duration for which the alert waits before firing + holdDuration time.Duration + + // evalDelay is the delay in evaluation of the rule + // this is useful in cases where the data is not available immediately + evalDelay time.Duration + + // holds the static set of labels and annotations for the rule + // these are the same for all alerts created for this rule + labels qslabels.BaseLabels + annotations qslabels.BaseLabels + // preferredChannels is the list of channels to send the alert to + // if the rule is triggered + preferredChannels []string + mtx sync.Mutex + // the time it took to evaluate the rule (most recent evaluation) + evaluationDuration time.Duration + // the timestamp of the last evaluation + evaluationTimestamp time.Time + + health RuleHealth + lastError error + active map[uint64]*Alert + + // lastTimestampWithDatapoints is the timestamp of the last datapoint we observed + // for this rule + // this is used for missing data alerts + lastTimestampWithDatapoints time.Time + + reader interfaces.Reader + + logger *zap.Logger + + // sendUnmatched sends observed metric values + // even if they dont match the rule condition. this is + // useful in testing the rule + sendUnmatched bool + + // sendAlways will send alert irresepective of resendDelay + // or other params + sendAlways bool +} + +type RuleOption func(*BaseRule) + +func WithSendAlways() RuleOption { + return func(r *BaseRule) { + r.sendAlways = true + } +} + +func WithSendUnmatched() RuleOption { + return func(r *BaseRule) { + r.sendUnmatched = true + } +} + +func WithEvalDelay(dur time.Duration) RuleOption { + return func(r *BaseRule) { + r.evalDelay = dur + } +} + +func WithLogger(logger *zap.Logger) RuleOption { + return func(r *BaseRule) { + r.logger = logger + } +} + +func NewBaseRule(id string, p *PostableRule, reader interfaces.Reader, opts ...RuleOption) (*BaseRule, error) { + if p.RuleCondition == nil || !p.RuleCondition.IsValid() { + return nil, fmt.Errorf("invalid rule condition") + } + + baseRule := &BaseRule{ + id: id, + name: p.AlertName, + source: p.Source, + ruleCondition: p.RuleCondition, + evalWindow: time.Duration(p.EvalWindow), + labels: qslabels.FromMap(p.Labels), + annotations: qslabels.FromMap(p.Annotations), + preferredChannels: p.PreferredChannels, + health: HealthUnknown, + active: map[uint64]*Alert{}, + reader: reader, + } + + if baseRule.evalWindow == 0 { + baseRule.evalWindow = 5 * time.Minute + } + + for _, opt := range opts { + opt(baseRule) + } + + return baseRule, nil +} + +func (r *BaseRule) targetVal() float64 { + if r.ruleCondition == nil || r.ruleCondition.Target == nil { + return 0 + } + + // get the converter for the target unit + unitConverter := converter.FromUnit(converter.Unit(r.ruleCondition.TargetUnit)) + // convert the target value to the y-axis unit + value := unitConverter.Convert(converter.Value{ + F: *r.ruleCondition.Target, + U: converter.Unit(r.ruleCondition.TargetUnit), + }, converter.Unit(r.Unit())) + + return value.F +} + +func (r *BaseRule) matchType() MatchType { + if r.ruleCondition == nil { + return AtleastOnce + } + return r.ruleCondition.MatchType +} + +func (r *BaseRule) compareOp() CompareOp { + if r.ruleCondition == nil { + return ValueIsEq + } + return r.ruleCondition.CompareOp +} + +func (r *BaseRule) currentAlerts() []*Alert { + r.mtx.Lock() + defer r.mtx.Unlock() + + alerts := make([]*Alert, 0, len(r.active)) + for _, a := range r.active { + anew := *a + alerts = append(alerts, &anew) + } + return alerts +} + +func (r *ThresholdRule) hostFromSource() string { + parsedUrl, err := url.Parse(r.source) + if err != nil { + return "" + } + if parsedUrl.Port() != "" { + return fmt.Sprintf("%s://%s:%s", parsedUrl.Scheme, parsedUrl.Hostname(), parsedUrl.Port()) + } + return fmt.Sprintf("%s://%s", parsedUrl.Scheme, parsedUrl.Hostname()) +} + +func (r *BaseRule) ID() string { return r.id } +func (r *BaseRule) Name() string { return r.name } +func (r *BaseRule) Condition() *RuleCondition { return r.ruleCondition } +func (r *BaseRule) Labels() qslabels.BaseLabels { return r.labels } +func (r *BaseRule) Annotations() qslabels.BaseLabels { return r.annotations } +func (r *BaseRule) PreferredChannels() []string { return r.preferredChannels } + +func (r *BaseRule) GeneratorURL() string { + return prepareRuleGeneratorURL(r.ID(), r.source) +} + +func (r *BaseRule) Unit() string { + if r.ruleCondition != nil && r.ruleCondition.CompositeQuery != nil { + return r.ruleCondition.CompositeQuery.Unit + } + return "" +} + +func (r *BaseRule) SetLastError(err error) { + r.mtx.Lock() + defer r.mtx.Unlock() + r.lastError = err +} + +func (r *BaseRule) LastError() error { + r.mtx.Lock() + defer r.mtx.Unlock() + return r.lastError +} + +func (r *BaseRule) SetHealth(health RuleHealth) { + r.mtx.Lock() + defer r.mtx.Unlock() + r.health = health +} + +func (r *BaseRule) Health() RuleHealth { + r.mtx.Lock() + defer r.mtx.Unlock() + return r.health +} + +func (r *BaseRule) SetEvaluationDuration(dur time.Duration) { + r.mtx.Lock() + defer r.mtx.Unlock() + r.evaluationDuration = dur +} + +func (r *BaseRule) GetEvaluationDuration() time.Duration { + r.mtx.Lock() + defer r.mtx.Unlock() + return r.evaluationDuration +} + +func (r *BaseRule) SetEvaluationTimestamp(ts time.Time) { + r.mtx.Lock() + defer r.mtx.Unlock() + r.evaluationTimestamp = ts +} + +func (r *BaseRule) GetEvaluationTimestamp() time.Time { + r.mtx.Lock() + defer r.mtx.Unlock() + return r.evaluationTimestamp +} + +func (r *BaseRule) State() model.AlertState { + maxState := model.StateInactive + for _, a := range r.active { + if a.State > maxState { + maxState = a.State + } + } + return maxState +} + +func (r *BaseRule) ActiveAlerts() []*Alert { + var res []*Alert + for _, a := range r.currentAlerts() { + if a.ResolvedAt.IsZero() { + res = append(res, a) + } + } + return res +} + +func (r *BaseRule) SendAlerts(ctx context.Context, ts time.Time, resendDelay time.Duration, interval time.Duration, notifyFunc NotifyFunc) { + alerts := []*Alert{} + r.ForEachActiveAlert(func(alert *Alert) { + if alert.needsSending(ts, resendDelay) { + alert.LastSentAt = ts + delta := resendDelay + if interval > resendDelay { + delta = interval + } + alert.ValidUntil = ts.Add(4 * delta) + anew := *alert + alerts = append(alerts, &anew) + } + }) + notifyFunc(ctx, "", alerts...) +} + +func (r *BaseRule) ForEachActiveAlert(f func(*Alert)) { + r.mtx.Lock() + defer r.mtx.Unlock() + + for _, a := range r.active { + f(a) + } +} + +func (r *BaseRule) shouldAlert(series v3.Series) (Sample, bool) { + var alertSmpl Sample + var shouldAlert bool + var lbls qslabels.Labels + var lblsNormalized qslabels.Labels + + for name, value := range series.Labels { + lbls = append(lbls, qslabels.Label{Name: name, Value: value}) + lblsNormalized = append(lblsNormalized, qslabels.Label{Name: normalizeLabelName(name), Value: value}) + } + + series.Points = removeGroupinSetPoints(series) + + // nothing to evaluate + if len(series.Points) == 0 { + return alertSmpl, false + } + + switch r.matchType() { + case AtleastOnce: + // If any sample matches the condition, the rule is firing. + if r.compareOp() == ValueIsAbove { + for _, smpl := range series.Points { + if smpl.Value > r.targetVal() { + alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} + shouldAlert = true + break + } + } + } else if r.compareOp() == ValueIsBelow { + for _, smpl := range series.Points { + if smpl.Value < r.targetVal() { + alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} + shouldAlert = true + break + } + } + } else if r.compareOp() == ValueIsEq { + for _, smpl := range series.Points { + if smpl.Value == r.targetVal() { + alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} + shouldAlert = true + break + } + } + } else if r.compareOp() == ValueIsNotEq { + for _, smpl := range series.Points { + if smpl.Value != r.targetVal() { + alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} + shouldAlert = true + break + } + } + } + case AllTheTimes: + // If all samples match the condition, the rule is firing. + shouldAlert = true + alertSmpl = Sample{Point: Point{V: r.targetVal()}, Metric: lblsNormalized, MetricOrig: lbls} + if r.compareOp() == ValueIsAbove { + for _, smpl := range series.Points { + if smpl.Value <= r.targetVal() { + shouldAlert = false + break + } + } + // use min value from the series + if shouldAlert { + var minValue float64 = math.Inf(1) + for _, smpl := range series.Points { + if smpl.Value < minValue { + minValue = smpl.Value + } + } + alertSmpl = Sample{Point: Point{V: minValue}, Metric: lblsNormalized, MetricOrig: lbls} + } + } else if r.compareOp() == ValueIsBelow { + for _, smpl := range series.Points { + if smpl.Value >= r.targetVal() { + shouldAlert = false + break + } + } + if shouldAlert { + var maxValue float64 = math.Inf(-1) + for _, smpl := range series.Points { + if smpl.Value > maxValue { + maxValue = smpl.Value + } + } + alertSmpl = Sample{Point: Point{V: maxValue}, Metric: lblsNormalized, MetricOrig: lbls} + } + } else if r.compareOp() == ValueIsEq { + for _, smpl := range series.Points { + if smpl.Value != r.targetVal() { + shouldAlert = false + break + } + } + } else if r.compareOp() == ValueIsNotEq { + for _, smpl := range series.Points { + if smpl.Value == r.targetVal() { + shouldAlert = false + break + } + } + // use any non-inf or nan value from the series + if shouldAlert { + for _, smpl := range series.Points { + if !math.IsInf(smpl.Value, 0) && !math.IsNaN(smpl.Value) { + alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} + break + } + } + } + } + case OnAverage: + // If the average of all samples matches the condition, the rule is firing. + var sum, count float64 + for _, smpl := range series.Points { + if math.IsNaN(smpl.Value) || math.IsInf(smpl.Value, 0) { + continue + } + sum += smpl.Value + count++ + } + avg := sum / count + alertSmpl = Sample{Point: Point{V: avg}, Metric: lblsNormalized, MetricOrig: lbls} + if r.compareOp() == ValueIsAbove { + if avg > r.targetVal() { + shouldAlert = true + } + } else if r.compareOp() == ValueIsBelow { + if avg < r.targetVal() { + shouldAlert = true + } + } else if r.compareOp() == ValueIsEq { + if avg == r.targetVal() { + shouldAlert = true + } + } else if r.compareOp() == ValueIsNotEq { + if avg != r.targetVal() { + shouldAlert = true + } + } + case InTotal: + // If the sum of all samples matches the condition, the rule is firing. + var sum float64 + + for _, smpl := range series.Points { + if math.IsNaN(smpl.Value) || math.IsInf(smpl.Value, 0) { + continue + } + sum += smpl.Value + } + alertSmpl = Sample{Point: Point{V: sum}, Metric: lblsNormalized, MetricOrig: lbls} + if r.compareOp() == ValueIsAbove { + if sum > r.targetVal() { + shouldAlert = true + } + } else if r.compareOp() == ValueIsBelow { + if sum < r.targetVal() { + shouldAlert = true + } + } else if r.compareOp() == ValueIsEq { + if sum == r.targetVal() { + shouldAlert = true + } + } else if r.compareOp() == ValueIsNotEq { + if sum != r.targetVal() { + shouldAlert = true + } + } + } + return alertSmpl, shouldAlert +} + +func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, prevState, currentState model.AlertState, itemsToAdd []v3.RuleStateHistory) error { + zap.L().Debug("recording rule state history", zap.String("ruleid", r.ID()), zap.Any("prevState", prevState), zap.Any("currentState", currentState), zap.Any("itemsToAdd", itemsToAdd)) + revisedItemsToAdd := map[uint64]v3.RuleStateHistory{} + + lastSavedState, err := r.reader.GetLastSavedRuleStateHistory(ctx, r.ID()) + if err != nil { + return err + } + // if the query-service has been restarted, or the rule has been modified (which re-initializes the rule), + // the state would reset so we need to add the corresponding state changes to previously saved states + if !r.handledRestart && len(lastSavedState) > 0 { + zap.L().Debug("handling restart", zap.String("ruleid", r.ID()), zap.Any("lastSavedState", lastSavedState)) + l := map[uint64]v3.RuleStateHistory{} + for _, item := range itemsToAdd { + l[item.Fingerprint] = item + } + + shouldSkip := map[uint64]bool{} + + for _, item := range lastSavedState { + // for the last saved item with fingerprint, check if there is a corresponding entry in the current state + currentState, ok := l[item.Fingerprint] + if !ok { + // there was a state change in the past, but not in the current state + // if the state was firing, then we should add a resolved state change + if item.State == model.StateFiring || item.State == model.StateNoData { + item.State = model.StateInactive + item.StateChanged = true + item.UnixMilli = time.Now().UnixMilli() + revisedItemsToAdd[item.Fingerprint] = item + } + // there is nothing to do if the prev state was normal + } else { + if item.State != currentState.State { + item.State = currentState.State + item.StateChanged = true + item.UnixMilli = time.Now().UnixMilli() + revisedItemsToAdd[item.Fingerprint] = item + } + } + // do not add this item to revisedItemsToAdd as it is already processed + shouldSkip[item.Fingerprint] = true + } + zap.L().Debug("after lastSavedState loop", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + // if there are any new state changes that were not saved, add them to the revised items + for _, item := range itemsToAdd { + if _, ok := revisedItemsToAdd[item.Fingerprint]; !ok && !shouldSkip[item.Fingerprint] { + revisedItemsToAdd[item.Fingerprint] = item + } + } + zap.L().Debug("after itemsToAdd loop", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + newState := model.StateInactive + for _, item := range revisedItemsToAdd { + if item.State == model.StateFiring || item.State == model.StateNoData { + newState = model.StateFiring + break + } + } + zap.L().Debug("newState", zap.String("ruleid", r.ID()), zap.Any("newState", newState)) + + // if there is a change in the overall state, update the overall state + if lastSavedState[0].OverallState != newState { + for fingerprint, item := range revisedItemsToAdd { + item.OverallState = newState + item.OverallStateChanged = true + revisedItemsToAdd[fingerprint] = item + } + } + zap.L().Debug("revisedItemsToAdd after newState", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + } else { + for _, item := range itemsToAdd { + revisedItemsToAdd[item.Fingerprint] = item + } + } + + if len(revisedItemsToAdd) > 0 && r.reader != nil { + zap.L().Debug("writing rule state history", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) + + entries := make([]v3.RuleStateHistory, 0, len(revisedItemsToAdd)) + for _, item := range revisedItemsToAdd { + entries = append(entries, item) + } + err := r.reader.AddRuleStateHistory(ctx, entries) + if err != nil { + zap.L().Error("error while inserting rule state history", zap.Error(err), zap.Any("itemsToAdd", itemsToAdd)) + } + } + r.handledRestart = true + + return nil +} diff --git a/pkg/query-service/rules/manager.go b/pkg/query-service/rules/manager.go index fe309334b1..120d674a9a 100644 --- a/pkg/query-service/rules/manager.go +++ b/pkg/query-service/rules/manager.go @@ -21,6 +21,7 @@ import ( am "go.signoz.io/signoz/pkg/query-service/integrations/alertManager" "go.signoz.io/signoz/pkg/query-service/interfaces" "go.signoz.io/signoz/pkg/query-service/model" + pqle "go.signoz.io/signoz/pkg/query-service/pqlEngine" "go.signoz.io/signoz/pkg/query-service/telemetry" "go.signoz.io/signoz/pkg/query-service/utils/labels" ) @@ -56,7 +57,7 @@ func prepareTaskName(ruleId interface{}) string { // ManagerOptions bundles options for the Manager. type ManagerOptions struct { NotifierOpts am.NotifierOptions - Queriers *Queriers + PqlEngine *pqle.PqlEngine // RepoURL is used to generate a backlink in sent alert messages RepoURL string @@ -127,11 +128,9 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) { tr, err := NewThresholdRule( ruleId, opts.Rule, - ThresholdRuleOpts{ - EvalDelay: opts.ManagerOpts.EvalDelay, - }, opts.FF, opts.Reader, + WithEvalDelay(opts.ManagerOpts.EvalDelay), ) if err != nil { @@ -150,8 +149,8 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) { ruleId, opts.Rule, opts.Logger, - PromRuleOpts{}, opts.Reader, + opts.ManagerOpts.PqlEngine, ) if err != nil { @@ -793,12 +792,10 @@ func (m *Manager) TestNotification(ctx context.Context, ruleStr string) (int, *m rule, err = NewThresholdRule( alertname, parsedRule, - ThresholdRuleOpts{ - SendUnmatched: true, - SendAlways: true, - }, m.featureFlags, m.reader, + WithSendAlways(), + WithSendUnmatched(), ) if err != nil { @@ -813,10 +810,10 @@ func (m *Manager) TestNotification(ctx context.Context, ruleStr string) (int, *m alertname, parsedRule, m.logger, - PromRuleOpts{ - SendAlways: true, - }, m.reader, + m.opts.PqlEngine, + WithSendAlways(), + WithSendUnmatched(), ) if err != nil { @@ -830,7 +827,7 @@ func (m *Manager) TestNotification(ctx context.Context, ruleStr string) (int, *m // set timestamp to current utc time ts := time.Now().UTC() - count, err := rule.Eval(ctx, ts, m.opts.Queriers) + count, err := rule.Eval(ctx, ts) if err != nil { zap.L().Error("evaluating rule failed", zap.String("rule", rule.Name()), zap.Error(err)) return 0, newApiErrorInternal(fmt.Errorf("rule evaluation failed")) diff --git a/pkg/query-service/rules/prom_rule.go b/pkg/query-service/rules/prom_rule.go index 2241d32a4b..314e268e1f 100644 --- a/pkg/query-service/rules/prom_rule.go +++ b/pkg/query-service/rules/prom_rule.go @@ -4,295 +4,60 @@ import ( "context" "encoding/json" "fmt" - "math" - "sync" "time" "go.uber.org/zap" - plabels "github.com/prometheus/prometheus/model/labels" - pql "github.com/prometheus/prometheus/promql" - "go.signoz.io/signoz/pkg/query-service/converter" + "github.com/prometheus/prometheus/promql" "go.signoz.io/signoz/pkg/query-service/formatter" "go.signoz.io/signoz/pkg/query-service/interfaces" "go.signoz.io/signoz/pkg/query-service/model" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" + pqle "go.signoz.io/signoz/pkg/query-service/pqlEngine" qslabels "go.signoz.io/signoz/pkg/query-service/utils/labels" "go.signoz.io/signoz/pkg/query-service/utils/times" "go.signoz.io/signoz/pkg/query-service/utils/timestamp" yaml "gopkg.in/yaml.v2" ) -type PromRuleOpts struct { - // SendAlways will send alert irresepective of resendDelay - // or other params - SendAlways bool -} - type PromRule struct { - id string - name string - source string - ruleCondition *RuleCondition - - evalWindow time.Duration - holdDuration time.Duration - labels plabels.Labels - annotations plabels.Labels - - preferredChannels []string - - mtx sync.Mutex - evaluationDuration time.Duration - evaluationTimestamp time.Time - - health RuleHealth - - lastError error - - // map of active alerts - active map[uint64]*Alert - - logger *zap.Logger - opts PromRuleOpts - - reader interfaces.Reader - - handledRestart bool + *BaseRule + pqlEngine *pqle.PqlEngine } func NewPromRule( id string, postableRule *PostableRule, logger *zap.Logger, - opts PromRuleOpts, reader interfaces.Reader, + pqlEngine *pqle.PqlEngine, + opts ...RuleOption, ) (*PromRule, error) { - if postableRule.RuleCondition == nil { - return nil, fmt.Errorf("no rule condition") - } else if !postableRule.RuleCondition.IsValid() { - return nil, fmt.Errorf("invalid rule condition") + baseRule, err := NewBaseRule(id, postableRule, reader, opts...) + if err != nil { + return nil, err } p := PromRule{ - id: id, - name: postableRule.AlertName, - source: postableRule.Source, - ruleCondition: postableRule.RuleCondition, - evalWindow: time.Duration(postableRule.EvalWindow), - labels: plabels.FromMap(postableRule.Labels), - annotations: plabels.FromMap(postableRule.Annotations), - preferredChannels: postableRule.PreferredChannels, - health: HealthUnknown, - active: map[uint64]*Alert{}, - logger: logger, - opts: opts, + BaseRule: baseRule, + pqlEngine: pqlEngine, } - p.reader = reader - if int64(p.evalWindow) == 0 { - p.evalWindow = 5 * time.Minute - } query, err := p.getPqlQuery() if err != nil { // can not generate a valid prom QL query return nil, err } - - zap.L().Info("creating new alerting rule", zap.String("name", p.name), zap.String("condition", p.ruleCondition.String()), zap.String("query", query)) - + zap.L().Info("creating new prom rule", zap.String("name", p.name), zap.String("query", query)) return &p, nil } -func (r *PromRule) Name() string { - return r.name -} - -func (r *PromRule) ID() string { - return r.id -} - -func (r *PromRule) Condition() *RuleCondition { - return r.ruleCondition -} - -// targetVal returns the target value for the rule condition -// when the y-axis and target units are non-empty, it -// converts the target value to the y-axis unit -func (r *PromRule) targetVal() float64 { - if r.ruleCondition == nil || r.ruleCondition.Target == nil { - return 0 - } - - // get the converter for the target unit - unitConverter := converter.FromUnit(converter.Unit(r.ruleCondition.TargetUnit)) - // convert the target value to the y-axis unit - value := unitConverter.Convert(converter.Value{ - F: *r.ruleCondition.Target, - U: converter.Unit(r.ruleCondition.TargetUnit), - }, converter.Unit(r.Unit())) - - return value.F -} - func (r *PromRule) Type() RuleType { return RuleTypeProm } -func (r *PromRule) GeneratorURL() string { - return prepareRuleGeneratorURL(r.ID(), r.source) -} - -func (r *PromRule) PreferredChannels() []string { - return r.preferredChannels -} - -func (r *PromRule) SetLastError(err error) { - r.mtx.Lock() - defer r.mtx.Unlock() - r.lastError = err -} - -func (r *PromRule) LastError() error { - r.mtx.Lock() - defer r.mtx.Unlock() - return r.lastError -} - -func (r *PromRule) SetHealth(health RuleHealth) { - r.mtx.Lock() - defer r.mtx.Unlock() - r.health = health -} - -func (r *PromRule) Health() RuleHealth { - r.mtx.Lock() - defer r.mtx.Unlock() - return r.health -} - -// SetEvaluationDuration updates evaluationDuration to the duration it took to evaluate the rule on its last evaluation. -func (r *PromRule) SetEvaluationDuration(dur time.Duration) { - r.mtx.Lock() - defer r.mtx.Unlock() - r.evaluationDuration = dur -} - -func (r *PromRule) HoldDuration() time.Duration { - return r.holdDuration -} - -func (r *PromRule) EvalWindow() time.Duration { - return r.evalWindow -} - -// Labels returns the labels of the alerting rule. -func (r *PromRule) Labels() qslabels.BaseLabels { - return r.labels -} - -// Annotations returns the annotations of the alerting rule. -func (r *PromRule) Annotations() qslabels.BaseLabels { - return r.annotations -} - -// GetEvaluationDuration returns the time in seconds it took to evaluate the alerting rule. -func (r *PromRule) GetEvaluationDuration() time.Duration { - r.mtx.Lock() - defer r.mtx.Unlock() - return r.evaluationDuration -} - -// SetEvaluationTimestamp updates evaluationTimestamp to the timestamp of when the rule was last evaluated. -func (r *PromRule) SetEvaluationTimestamp(ts time.Time) { - r.mtx.Lock() - defer r.mtx.Unlock() - r.evaluationTimestamp = ts -} - -// GetEvaluationTimestamp returns the time the evaluation took place. -func (r *PromRule) GetEvaluationTimestamp() time.Time { - r.mtx.Lock() - defer r.mtx.Unlock() - return r.evaluationTimestamp -} - -// State returns the maximum state of alert instances for this rule. -// StateFiring > StatePending > StateInactive -func (r *PromRule) State() model.AlertState { - - maxState := model.StateInactive - for _, a := range r.active { - if a.State > maxState { - maxState = a.State - } - } - return maxState -} - -func (r *PromRule) currentAlerts() []*Alert { - r.mtx.Lock() - defer r.mtx.Unlock() - - alerts := make([]*Alert, 0, len(r.active)) - - for _, a := range r.active { - anew := *a - alerts = append(alerts, &anew) - } - return alerts -} - -func (r *PromRule) ActiveAlerts() []*Alert { - var res []*Alert - for _, a := range r.currentAlerts() { - if a.ResolvedAt.IsZero() { - res = append(res, a) - } - } - return res -} - -func (r *PromRule) Unit() string { - if r.ruleCondition != nil && r.ruleCondition.CompositeQuery != nil { - return r.ruleCondition.CompositeQuery.Unit - } - return "" -} - -// ForEachActiveAlert runs the given function on each alert. -// This should be used when you want to use the actual alerts from the ThresholdRule -// and not on its copy. -// If you want to run on a copy of alerts then don't use this, get the alerts from 'ActiveAlerts()'. -func (r *PromRule) ForEachActiveAlert(f func(*Alert)) { - r.mtx.Lock() - defer r.mtx.Unlock() - - for _, a := range r.active { - f(a) - } -} - -func (r *PromRule) SendAlerts(ctx context.Context, ts time.Time, resendDelay time.Duration, interval time.Duration, notifyFunc NotifyFunc) { - alerts := []*Alert{} - r.ForEachActiveAlert(func(alert *Alert) { - if r.opts.SendAlways || alert.needsSending(ts, resendDelay) { - alert.LastSentAt = ts - // Allow for two Eval or Alertmanager send failures. - delta := resendDelay - if interval > resendDelay { - delta = interval - } - alert.ValidUntil = ts.Add(4 * delta) - anew := *alert - alerts = append(alerts, &anew) - } - }) - notifyFunc(ctx, "", alerts...) -} - func (r *PromRule) GetSelectedQuery() string { if r.ruleCondition != nil { // If the user has explicitly set the selected query, we return that. @@ -327,117 +92,7 @@ func (r *PromRule) getPqlQuery() (string, error) { return "", fmt.Errorf("invalid promql rule query") } -func (r *PromRule) matchType() MatchType { - if r.ruleCondition == nil { - return AtleastOnce - } - return r.ruleCondition.MatchType -} - -func (r *PromRule) compareOp() CompareOp { - if r.ruleCondition == nil { - return ValueIsEq - } - return r.ruleCondition.CompareOp -} - -// TODO(srikanthccv): implement base rule and use for all types of rules -func (r *PromRule) recordRuleStateHistory(ctx context.Context, prevState, currentState model.AlertState, itemsToAdd []v3.RuleStateHistory) error { - zap.L().Debug("recording rule state history", zap.String("ruleid", r.ID()), zap.Any("prevState", prevState), zap.Any("currentState", currentState), zap.Any("itemsToAdd", itemsToAdd)) - revisedItemsToAdd := map[uint64]v3.RuleStateHistory{} - - lastSavedState, err := r.reader.GetLastSavedRuleStateHistory(ctx, r.ID()) - if err != nil { - return err - } - // if the query-service has been restarted, or the rule has been modified (which re-initializes the rule), - // the state would reset so we need to add the corresponding state changes to previously saved states - if !r.handledRestart && len(lastSavedState) > 0 { - zap.L().Debug("handling restart", zap.String("ruleid", r.ID()), zap.Any("lastSavedState", lastSavedState)) - l := map[uint64]v3.RuleStateHistory{} - for _, item := range itemsToAdd { - l[item.Fingerprint] = item - } - - shouldSkip := map[uint64]bool{} - - for _, item := range lastSavedState { - // for the last saved item with fingerprint, check if there is a corresponding entry in the current state - currentState, ok := l[item.Fingerprint] - if !ok { - // there was a state change in the past, but not in the current state - // if the state was firing, then we should add a resolved state change - if item.State == model.StateFiring || item.State == model.StateNoData { - item.State = model.StateInactive - item.StateChanged = true - item.UnixMilli = time.Now().UnixMilli() - revisedItemsToAdd[item.Fingerprint] = item - } - // there is nothing to do if the prev state was normal - } else { - if item.State != currentState.State { - item.State = currentState.State - item.StateChanged = true - item.UnixMilli = time.Now().UnixMilli() - revisedItemsToAdd[item.Fingerprint] = item - } - } - // do not add this item to revisedItemsToAdd as it is already processed - shouldSkip[item.Fingerprint] = true - } - zap.L().Debug("after lastSavedState loop", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) - - // if there are any new state changes that were not saved, add them to the revised items - for _, item := range itemsToAdd { - if _, ok := revisedItemsToAdd[item.Fingerprint]; !ok && !shouldSkip[item.Fingerprint] { - revisedItemsToAdd[item.Fingerprint] = item - } - } - zap.L().Debug("after itemsToAdd loop", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) - - newState := model.StateInactive - for _, item := range revisedItemsToAdd { - if item.State == model.StateFiring || item.State == model.StateNoData { - newState = model.StateFiring - break - } - } - zap.L().Debug("newState", zap.String("ruleid", r.ID()), zap.Any("newState", newState)) - - // if there is a change in the overall state, update the overall state - if lastSavedState[0].OverallState != newState { - for fingerprint, item := range revisedItemsToAdd { - item.OverallState = newState - item.OverallStateChanged = true - revisedItemsToAdd[fingerprint] = item - } - } - zap.L().Debug("revisedItemsToAdd after newState", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) - - } else { - for _, item := range itemsToAdd { - revisedItemsToAdd[item.Fingerprint] = item - } - } - - if len(revisedItemsToAdd) > 0 && r.reader != nil { - zap.L().Debug("writing rule state history", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) - - entries := make([]v3.RuleStateHistory, 0, len(revisedItemsToAdd)) - for _, item := range revisedItemsToAdd { - entries = append(entries, item) - } - err := r.reader.AddRuleStateHistory(ctx, entries) - if err != nil { - zap.L().Error("error while inserting rule state history", zap.Error(err), zap.Any("itemsToAdd", itemsToAdd)) - } - } - r.handledRestart = true - - return nil -} - -func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) (interface{}, error) { +func (r *PromRule) Eval(ctx context.Context, ts time.Time) (interface{}, error) { prevState := r.State() @@ -452,7 +107,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( return nil, err } zap.L().Info("evaluating promql query", zap.String("name", r.Name()), zap.String("query", q)) - res, err := queriers.PqlEngine.RunAlertQuery(ctx, q, start, end, interval) + res, err := r.pqlEngine.RunAlertQuery(ctx, q, start, end, interval) if err != nil { r.SetHealth(HealthBad) r.SetLastError(err) @@ -476,7 +131,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( continue } - alertSmpl, shouldAlert := r.shouldAlert(series) + alertSmpl, shouldAlert := r.shouldAlert(toCommonSeries(series)) if !shouldAlert { continue } @@ -484,7 +139,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( threshold := valueFormatter.Format(r.targetVal(), r.Unit()) - tmplData := AlertTemplateData(l, valueFormatter.Format(alertSmpl.F, r.Unit()), threshold) + tmplData := AlertTemplateData(l, valueFormatter.Format(alertSmpl.V, r.Unit()), threshold) // Inject some convenience variables that are easier to remember for users // who are not used to Go's templating system. defs := "{{$labels := .Labels}}{{$value := .Value}}{{$threshold := .Threshold}}" @@ -507,20 +162,20 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( return result } - lb := plabels.NewBuilder(alertSmpl.Metric).Del(plabels.MetricName) - resultLabels := plabels.NewBuilder(alertSmpl.Metric).Del(plabels.MetricName).Labels() + lb := qslabels.NewBuilder(alertSmpl.Metric).Del(qslabels.MetricNameLabel) + resultLabels := qslabels.NewBuilder(alertSmpl.Metric).Del(qslabels.MetricNameLabel).Labels() - for _, l := range r.labels { - lb.Set(l.Name, expand(l.Value)) + for name, value := range r.labels.Map() { + lb.Set(name, expand(value)) } lb.Set(qslabels.AlertNameLabel, r.Name()) lb.Set(qslabels.AlertRuleIdLabel, r.ID()) lb.Set(qslabels.RuleSourceLabel, r.GeneratorURL()) - annotations := make(plabels.Labels, 0, len(r.annotations)) - for _, a := range r.annotations { - annotations = append(annotations, plabels.Label{Name: a.Name, Value: expand(a.Value)}) + annotations := make(qslabels.Labels, 0, len(r.annotations.Map())) + for name, value := range r.annotations.Map() { + annotations = append(annotations, qslabels.Label{Name: name, Value: expand(value)}) } lbs := lb.Labels() @@ -542,7 +197,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( Annotations: annotations, ActiveAt: ts, State: model.StatePending, - Value: alertSmpl.F, + Value: alertSmpl.V, GeneratorURL: r.GeneratorURL(), Receivers: r.preferredChannels, } @@ -626,169 +281,11 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( itemsToAdd[idx] = item } - r.recordRuleStateHistory(ctx, prevState, currentState, itemsToAdd) + r.RecordRuleStateHistory(ctx, prevState, currentState, itemsToAdd) return len(r.active), nil } -func (r *PromRule) shouldAlert(series pql.Series) (pql.Sample, bool) { - var alertSmpl pql.Sample - var shouldAlert bool - switch r.matchType() { - case AtleastOnce: - // If any sample matches the condition, the rule is firing. - if r.compareOp() == ValueIsAbove { - for _, smpl := range series.Floats { - if smpl.F > r.targetVal() { - alertSmpl = pql.Sample{F: smpl.F, T: smpl.T, Metric: series.Metric} - shouldAlert = true - break - } - } - } else if r.compareOp() == ValueIsBelow { - for _, smpl := range series.Floats { - if smpl.F < r.targetVal() { - alertSmpl = pql.Sample{F: smpl.F, T: smpl.T, Metric: series.Metric} - shouldAlert = true - break - } - } - } else if r.compareOp() == ValueIsEq { - for _, smpl := range series.Floats { - if smpl.F == r.targetVal() { - alertSmpl = pql.Sample{F: smpl.F, T: smpl.T, Metric: series.Metric} - shouldAlert = true - break - } - } - } else if r.compareOp() == ValueIsNotEq { - for _, smpl := range series.Floats { - if smpl.F != r.targetVal() { - alertSmpl = pql.Sample{F: smpl.F, T: smpl.T, Metric: series.Metric} - shouldAlert = true - break - } - } - } - case AllTheTimes: - // If all samples match the condition, the rule is firing. - shouldAlert = true - alertSmpl = pql.Sample{F: r.targetVal(), Metric: series.Metric} - if r.compareOp() == ValueIsAbove { - for _, smpl := range series.Floats { - if smpl.F <= r.targetVal() { - shouldAlert = false - break - } - } - // use min value from the series - if shouldAlert { - var minValue float64 = math.Inf(1) - for _, smpl := range series.Floats { - if smpl.F < minValue { - minValue = smpl.F - } - } - alertSmpl = pql.Sample{F: minValue, Metric: series.Metric} - } - } else if r.compareOp() == ValueIsBelow { - for _, smpl := range series.Floats { - if smpl.F >= r.targetVal() { - shouldAlert = false - break - } - } - if shouldAlert { - var maxValue float64 = math.Inf(-1) - for _, smpl := range series.Floats { - if smpl.F > maxValue { - maxValue = smpl.F - } - } - alertSmpl = pql.Sample{F: maxValue, Metric: series.Metric} - } - } else if r.compareOp() == ValueIsEq { - for _, smpl := range series.Floats { - if smpl.F != r.targetVal() { - shouldAlert = false - break - } - } - } else if r.compareOp() == ValueIsNotEq { - for _, smpl := range series.Floats { - if smpl.F == r.targetVal() { - shouldAlert = false - break - } - } - if shouldAlert { - for _, smpl := range series.Floats { - if !math.IsInf(smpl.F, 0) && !math.IsNaN(smpl.F) { - alertSmpl = pql.Sample{F: smpl.F, Metric: series.Metric} - break - } - } - } - } - case OnAverage: - // If the average of all samples matches the condition, the rule is firing. - var sum float64 - for _, smpl := range series.Floats { - if math.IsNaN(smpl.F) { - continue - } - sum += smpl.F - } - avg := sum / float64(len(series.Floats)) - alertSmpl = pql.Sample{F: avg, Metric: series.Metric} - if r.compareOp() == ValueIsAbove { - if avg > r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsBelow { - if avg < r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsEq { - if avg == r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsNotEq { - if avg != r.targetVal() { - shouldAlert = true - } - } - case InTotal: - // If the sum of all samples matches the condition, the rule is firing. - var sum float64 - for _, smpl := range series.Floats { - if math.IsNaN(smpl.F) { - continue - } - sum += smpl.F - } - alertSmpl = pql.Sample{F: sum, Metric: series.Metric} - if r.compareOp() == ValueIsAbove { - if sum > r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsBelow { - if sum < r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsEq { - if sum == r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsNotEq { - if sum != r.targetVal() { - shouldAlert = true - } - } - } - return alertSmpl, shouldAlert -} - func (r *PromRule) String() string { ar := PostableRule{ @@ -807,3 +304,23 @@ func (r *PromRule) String() string { return string(byt) } + +func toCommonSeries(series promql.Series) v3.Series { + commonSeries := v3.Series{} + + for _, lbl := range series.Metric { + commonSeries.Labels[lbl.Name] = lbl.Value + commonSeries.LabelsArray = append(commonSeries.LabelsArray, map[string]string{ + lbl.Name: lbl.Value, + }) + } + + for _, f := range series.Floats { + commonSeries.Points = append(commonSeries.Points, v3.Point{ + Timestamp: f.T, + Value: f.F, + }) + } + + return commonSeries +} diff --git a/pkg/query-service/rules/prom_rule_task.go b/pkg/query-service/rules/prom_rule_task.go index 032fc227f2..f78994430a 100644 --- a/pkg/query-service/rules/prom_rule_task.go +++ b/pkg/query-service/rules/prom_rule_task.go @@ -367,7 +367,7 @@ func (g *PromRuleTask) Eval(ctx context.Context, ts time.Time) { } ctx = context.WithValue(ctx, common.LogCommentKey, kvs) - _, err := rule.Eval(ctx, ts, g.opts.Queriers) + _, err := rule.Eval(ctx, ts) if err != nil { rule.SetHealth(HealthBad) rule.SetLastError(err) diff --git a/pkg/query-service/rules/promrule_test.go b/pkg/query-service/rules/promrule_test.go index fef7630bbd..7c559d1eee 100644 --- a/pkg/query-service/rules/promrule_test.go +++ b/pkg/query-service/rules/promrule_test.go @@ -656,12 +656,12 @@ func TestPromRuleShouldAlert(t *testing.T) { postableRule.RuleCondition.MatchType = MatchType(c.matchType) postableRule.RuleCondition.Target = &c.target - rule, err := NewPromRule("69", &postableRule, zap.NewNop(), PromRuleOpts{}, nil) + rule, err := NewPromRule("69", &postableRule, zap.NewNop(), nil, nil) if err != nil { assert.NoError(t, err) } - _, shoulAlert := rule.shouldAlert(c.values) + _, shoulAlert := rule.shouldAlert(toCommonSeries(c.values)) assert.Equal(t, c.expectAlert, shoulAlert, "Test case %d", idx) } } diff --git a/pkg/query-service/rules/queriers.go b/pkg/query-service/rules/queriers.go index 2739e04280..1e8c7fa083 100644 --- a/pkg/query-service/rules/queriers.go +++ b/pkg/query-service/rules/queriers.go @@ -1,21 +1 @@ package rules - -import ( - "github.com/ClickHouse/clickhouse-go/v2" - pqle "go.signoz.io/signoz/pkg/query-service/pqlEngine" -) - -// Queriers register the options for querying metrics or event sources -// which return a condition that results in a alert. Currently we support -// promql engine and clickhouse queries but in future we may include -// api readers for Machine Learning (ML) use cases. -// Note: each rule will pick up the querier it is interested in -// and use it. This allows rules to have flexibility in choosing -// the query engines. -type Queriers struct { - // promql engine - PqlEngine *pqle.PqlEngine - - // metric querier - Ch clickhouse.Conn -} diff --git a/pkg/query-service/rules/rule.go b/pkg/query-service/rules/rule.go index eeb7de9066..bb41a2be13 100644 --- a/pkg/query-service/rules/rule.go +++ b/pkg/query-service/rules/rule.go @@ -5,6 +5,7 @@ import ( "time" "go.signoz.io/signoz/pkg/query-service/model" + v3 "go.signoz.io/signoz/pkg/query-service/model/v3" "go.signoz.io/signoz/pkg/query-service/utils/labels" ) @@ -23,9 +24,8 @@ type Rule interface { PreferredChannels() []string - Eval(context.Context, time.Time, *Queriers) (interface{}, error) + Eval(context.Context, time.Time) (interface{}, error) String() string - // Query() string SetLastError(error) LastError() error SetHealth(RuleHealth) @@ -35,5 +35,7 @@ type Rule interface { SetEvaluationTimestamp(time.Time) GetEvaluationTimestamp() time.Time + RecordRuleStateHistory(ctx context.Context, prevState, currentState model.AlertState, itemsToAdd []v3.RuleStateHistory) error + SendAlerts(ctx context.Context, ts time.Time, resendDelay time.Duration, interval time.Duration, notifyFunc NotifyFunc) } diff --git a/pkg/query-service/rules/rule_task.go b/pkg/query-service/rules/rule_task.go index 61e154d74d..0a969bffc8 100644 --- a/pkg/query-service/rules/rule_task.go +++ b/pkg/query-service/rules/rule_task.go @@ -349,7 +349,7 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) { } ctx = context.WithValue(ctx, common.LogCommentKey, kvs) - _, err := rule.Eval(ctx, ts, g.opts.Queriers) + _, err := rule.Eval(ctx, ts) if err != nil { rule.SetHealth(HealthBad) rule.SetLastError(err) diff --git a/pkg/query-service/rules/threshold_rule.go b/pkg/query-service/rules/threshold_rule.go index e50fb4b761..d35798035e 100644 --- a/pkg/query-service/rules/threshold_rule.go +++ b/pkg/query-service/rules/threshold_rule.go @@ -9,17 +9,13 @@ import ( "net/url" "regexp" "sort" - "sync" "text/template" "time" "unicode" "go.uber.org/zap" - "github.com/ClickHouse/clickhouse-go/v2" - "github.com/ClickHouse/clickhouse-go/v2/lib/driver" "go.signoz.io/signoz/pkg/query-service/common" - "go.signoz.io/signoz/pkg/query-service/converter" "go.signoz.io/signoz/pkg/query-service/model" "go.signoz.io/signoz/pkg/query-service/postprocess" @@ -41,38 +37,7 @@ import ( ) type ThresholdRule struct { - id string - name string - source string - ruleCondition *RuleCondition - // evalWindow is the time window used for evaluating the rule - // i.e each time we lookback from the current time, we look at data for the last - // evalWindow duration - evalWindow time.Duration - // holdDuration is the duration for which the alert waits before firing - holdDuration time.Duration - // holds the static set of labels and annotations for the rule - // these are the same for all alerts created for this rule - labels labels.Labels - annotations labels.Labels - - // preferredChannels is the list of channels to send the alert to - // if the rule is triggered - preferredChannels []string - mtx sync.Mutex - - // the time it took to evaluate the rule - evaluationDuration time.Duration - // the timestamp of the last evaluation - evaluationTimestamp time.Time - - health RuleHealth - - lastError error - - // map of active alerts - active map[uint64]*Alert - + *BaseRule // Ever since we introduced the new metrics query builder, the version is "v4" // for all the rules // if the version is "v3", then we use the old querier @@ -84,80 +49,31 @@ type ThresholdRule struct { // should be fast but we can still avoid the query if we have the data in memory temporalityMap map[string]map[v3.Temporality]bool - opts ThresholdRuleOpts - - // lastTimestampWithDatapoints is the timestamp of the last datapoint we observed - // for this rule - // this is used for missing data alerts - lastTimestampWithDatapoints time.Time - - // Type of the rule - typ AlertType - // querier is used for alerts created before the introduction of new metrics query builder querier interfaces.Querier // querierV2 is used for alerts created after the introduction of new metrics query builder querierV2 interfaces.Querier - - reader interfaces.Reader - evalDelay time.Duration - - handledRestart bool -} - -type ThresholdRuleOpts struct { - // sendUnmatched sends observed metric values - // even if they dont match the rule condition. this is - // useful in testing the rule - SendUnmatched bool - - // sendAlways will send alert irresepective of resendDelay - // or other params - SendAlways bool - - // EvalDelay is the time to wait for data to be available - // before evaluating the rule. This is useful in scenarios - // where data might not be available in the system immediately - // after the timestamp. - EvalDelay time.Duration } func NewThresholdRule( id string, p *PostableRule, - opts ThresholdRuleOpts, featureFlags interfaces.FeatureLookup, reader interfaces.Reader, + opts ...RuleOption, ) (*ThresholdRule, error) { zap.L().Info("creating new ThresholdRule", zap.String("id", id), zap.Any("opts", opts)) - if p.RuleCondition == nil { - return nil, fmt.Errorf("no rule condition") - } else if !p.RuleCondition.IsValid() { - return nil, fmt.Errorf("invalid rule condition") + baseRule, err := NewBaseRule(id, p, reader, opts...) + if err != nil { + return nil, err } t := ThresholdRule{ - id: id, - name: p.AlertName, - source: p.Source, - ruleCondition: p.RuleCondition, - evalWindow: time.Duration(p.EvalWindow), - labels: labels.FromMap(p.Labels), - annotations: labels.FromMap(p.Annotations), - preferredChannels: p.PreferredChannels, - health: HealthUnknown, - active: map[uint64]*Alert{}, - opts: opts, - typ: p.AlertType, - version: p.Version, - temporalityMap: make(map[string]map[v3.Temporality]bool), - evalDelay: opts.EvalDelay, - } - - if int64(t.evalWindow) == 0 { - t.evalWindow = 5 * time.Minute + BaseRule: baseRule, + version: p.Version, + temporalityMap: make(map[string]map[v3.Temporality]bool), } querierOption := querier.QuerierOptions{ @@ -177,203 +93,15 @@ func NewThresholdRule( t.querier = querier.NewQuerier(querierOption) t.querierV2 = querierV2.NewQuerier(querierOptsV2) t.reader = reader - - zap.L().Info("creating new ThresholdRule", zap.String("name", t.name), zap.String("id", t.id)) - return &t, nil } -func (r *ThresholdRule) Name() string { - return r.name -} - -func (r *ThresholdRule) ID() string { - return r.id -} - -func (r *ThresholdRule) Condition() *RuleCondition { - return r.ruleCondition -} - -func (r *ThresholdRule) GeneratorURL() string { - return prepareRuleGeneratorURL(r.ID(), r.source) -} - -func (r *ThresholdRule) PreferredChannels() []string { - return r.preferredChannels -} - -// targetVal returns the target value for the rule condition -// when the y-axis and target units are non-empty, it -// converts the target value to the y-axis unit -func (r *ThresholdRule) targetVal() float64 { - if r.ruleCondition == nil || r.ruleCondition.Target == nil { - return 0 - } - - // get the converter for the target unit - unitConverter := converter.FromUnit(converter.Unit(r.ruleCondition.TargetUnit)) - // convert the target value to the y-axis unit - value := unitConverter.Convert(converter.Value{ - F: *r.ruleCondition.Target, - U: converter.Unit(r.ruleCondition.TargetUnit), - }, converter.Unit(r.Unit())) - - return value.F -} - -func (r *ThresholdRule) matchType() MatchType { - if r.ruleCondition == nil { - return AtleastOnce - } - return r.ruleCondition.MatchType -} - -func (r *ThresholdRule) compareOp() CompareOp { - if r.ruleCondition == nil { - return ValueIsEq - } - return r.ruleCondition.CompareOp -} - func (r *ThresholdRule) Type() RuleType { return RuleTypeThreshold } -func (r *ThresholdRule) SetLastError(err error) { - r.mtx.Lock() - defer r.mtx.Unlock() - r.lastError = err -} - -func (r *ThresholdRule) LastError() error { - r.mtx.Lock() - defer r.mtx.Unlock() - return r.lastError -} - -func (r *ThresholdRule) SetHealth(health RuleHealth) { - r.mtx.Lock() - defer r.mtx.Unlock() - r.health = health -} - -func (r *ThresholdRule) Health() RuleHealth { - r.mtx.Lock() - defer r.mtx.Unlock() - return r.health -} - -// SetEvaluationDuration updates evaluationDuration to the duration it took to evaluate the rule on its last evaluation. -func (r *ThresholdRule) SetEvaluationDuration(dur time.Duration) { - r.mtx.Lock() - defer r.mtx.Unlock() - r.evaluationDuration = dur -} - -func (r *ThresholdRule) HoldDuration() time.Duration { - return r.holdDuration -} - -func (r *ThresholdRule) EvalWindow() time.Duration { - return r.evalWindow -} - -// Labels returns the labels of the alerting rule. -func (r *ThresholdRule) Labels() labels.BaseLabels { - return r.labels -} - -// Annotations returns the annotations of the alerting rule. -func (r *ThresholdRule) Annotations() labels.BaseLabels { - return r.annotations -} - -// GetEvaluationDuration returns the time in seconds it took to evaluate the alerting rule. -func (r *ThresholdRule) GetEvaluationDuration() time.Duration { - r.mtx.Lock() - defer r.mtx.Unlock() - return r.evaluationDuration -} - -// SetEvaluationTimestamp updates evaluationTimestamp to the timestamp of when the rule was last evaluated. -func (r *ThresholdRule) SetEvaluationTimestamp(ts time.Time) { - r.mtx.Lock() - defer r.mtx.Unlock() - r.evaluationTimestamp = ts -} - -// GetEvaluationTimestamp returns the time the evaluation took place. -func (r *ThresholdRule) GetEvaluationTimestamp() time.Time { - r.mtx.Lock() - defer r.mtx.Unlock() - return r.evaluationTimestamp -} - -// State returns the maximum state of alert instances for this rule. -// StateFiring > StatePending > StateInactive -func (r *ThresholdRule) State() model.AlertState { - - maxState := model.StateInactive - for _, a := range r.active { - if a.State > maxState { - maxState = a.State - } - } - return maxState -} - -func (r *ThresholdRule) currentAlerts() []*Alert { - r.mtx.Lock() - defer r.mtx.Unlock() - - alerts := make([]*Alert, 0, len(r.active)) - - for _, a := range r.active { - anew := *a - alerts = append(alerts, &anew) - } - return alerts -} - -func (r *ThresholdRule) ActiveAlerts() []*Alert { - var res []*Alert - for _, a := range r.currentAlerts() { - if a.ResolvedAt.IsZero() { - res = append(res, a) - } - } - return res -} - -func (r *ThresholdRule) FetchTemporality(ctx context.Context, metricNames []string, ch driver.Conn) (map[string]map[v3.Temporality]bool, error) { - - metricNameToTemporality := make(map[string]map[v3.Temporality]bool) - - query := fmt.Sprintf(`SELECT DISTINCT metric_name, temporality FROM %s.%s WHERE metric_name IN $1`, constants.SIGNOZ_METRIC_DBNAME, constants.SIGNOZ_TIMESERIES_v4_1DAY_TABLENAME) - - rows, err := ch.Query(ctx, query, metricNames) - if err != nil { - return nil, err - } - defer rows.Close() - - for rows.Next() { - var metricName, temporality string - err := rows.Scan(&metricName, &temporality) - if err != nil { - return nil, err - } - if _, ok := metricNameToTemporality[metricName]; !ok { - metricNameToTemporality[metricName] = make(map[v3.Temporality]bool) - } - metricNameToTemporality[metricName][v3.Temporality(temporality)] = true - } - return metricNameToTemporality, nil -} - // populateTemporality same as addTemporality but for v4 and better -func (r *ThresholdRule) populateTemporality(ctx context.Context, qp *v3.QueryRangeParamsV3, ch driver.Conn) error { +func (r *ThresholdRule) populateTemporality(ctx context.Context, qp *v3.QueryRangeParamsV3) error { missingTemporality := make([]string, 0) metricNameToTemporality := make(map[string]map[v3.Temporality]bool) @@ -405,7 +133,7 @@ func (r *ThresholdRule) populateTemporality(ctx context.Context, qp *v3.QueryRan var err error if len(missingTemporality) > 0 { - nameToTemporality, err = r.FetchTemporality(ctx, missingTemporality, ch) + nameToTemporality, err = r.reader.FetchTemporality(ctx, missingTemporality) if err != nil { return err } @@ -429,52 +157,13 @@ func (r *ThresholdRule) populateTemporality(ctx context.Context, qp *v3.QueryRan return nil } -// ForEachActiveAlert runs the given function on each alert. -// This should be used when you want to use the actual alerts from the ThresholdRule -// and not on its copy. -// If you want to run on a copy of alerts then don't use this, get the alerts from 'ActiveAlerts()'. -func (r *ThresholdRule) ForEachActiveAlert(f func(*Alert)) { - r.mtx.Lock() - defer r.mtx.Unlock() - - for _, a := range r.active { - f(a) - } -} - -func (r *ThresholdRule) SendAlerts(ctx context.Context, ts time.Time, resendDelay time.Duration, interval time.Duration, notifyFunc NotifyFunc) { - alerts := []*Alert{} - r.ForEachActiveAlert(func(alert *Alert) { - if r.opts.SendAlways || alert.needsSending(ts, resendDelay) { - alert.LastSentAt = ts - // Allow for two Eval or Alertmanager send failures. - delta := resendDelay - if interval > resendDelay { - delta = interval - } - alert.ValidUntil = ts.Add(4 * delta) - anew := *alert - alerts = append(alerts, &anew) - } else { - zap.L().Debug("skipping send alert due to resend delay", zap.String("rule", r.Name()), zap.Any("alert", alert.Labels)) - } - }) - notifyFunc(ctx, "", alerts...) -} - -func (r *ThresholdRule) Unit() string { - if r.ruleCondition != nil && r.ruleCondition.CompositeQuery != nil { - return r.ruleCondition.CompositeQuery.Unit - } - return "" -} - -func (r *ThresholdRule) prepareQueryRange(ts time.Time) *v3.QueryRangeParamsV3 { +func (r *ThresholdRule) prepareQueryRange(ts time.Time) (*v3.QueryRangeParamsV3, error) { zap.L().Info("prepareQueryRange", zap.Int64("ts", ts.UnixMilli()), zap.Int64("evalWindow", r.evalWindow.Milliseconds()), zap.Int64("evalDelay", r.evalDelay.Milliseconds())) start := ts.Add(-time.Duration(r.evalWindow)).UnixMilli() end := ts.UnixMilli() + if r.evalDelay > 0 { start = start - int64(r.evalDelay.Milliseconds()) end = end - int64(r.evalDelay.Milliseconds()) @@ -507,16 +196,12 @@ func (r *ThresholdRule) prepareQueryRange(ts time.Time) *v3.QueryRangeParamsV3 { tmpl := template.New("clickhouse-query") tmpl, err := tmpl.Parse(chQuery.Query) if err != nil { - zap.L().Error("failed to parse clickhouse query to populate vars", zap.String("ruleid", r.ID()), zap.Error(err)) - r.SetHealth(HealthBad) - return params + return nil, err } var query bytes.Buffer err = tmpl.Execute(&query, params.Variables) if err != nil { - zap.L().Error("failed to populate clickhouse query", zap.String("ruleid", r.ID()), zap.Error(err)) - r.SetHealth(HealthBad) - return params + return nil, err } params.CompositeQuery.ClickHouseQueries[name] = &v3.ClickHouseQuery{ Query: query.String(), @@ -524,7 +209,7 @@ func (r *ThresholdRule) prepareQueryRange(ts time.Time) *v3.QueryRangeParamsV3 { Legend: chQuery.Legend, } } - return params + return params, nil } if r.ruleCondition.CompositeQuery != nil && r.ruleCondition.CompositeQuery.BuilderQueries != nil { @@ -548,7 +233,7 @@ func (r *ThresholdRule) prepareQueryRange(ts time.Time) *v3.QueryRangeParamsV3 { CompositeQuery: r.ruleCondition.CompositeQuery, Variables: make(map[string]interface{}, 0), NoCache: true, - } + }, nil } // The following function is used to prepare the where clause for the query @@ -625,7 +310,10 @@ func (r *ThresholdRule) prepareLinksToLogs(ts time.Time, lbls labels.Labels) str return "" } - q := r.prepareQueryRange(ts) + q, err := r.prepareQueryRange(ts) + if err != nil { + return "" + } // Logs list view expects time in milliseconds tr := v3.URLShareableTimeRange{ Start: q.Start, @@ -689,7 +377,10 @@ func (r *ThresholdRule) prepareLinksToTraces(ts time.Time, lbls labels.Labels) s return "" } - q := r.prepareQueryRange(ts) + q, err := r.prepareQueryRange(ts) + if err != nil { + return "" + } // Traces list view expects time in nanoseconds tr := v3.URLShareableTimeRange{ Start: q.Start * time.Second.Microseconds(), @@ -745,17 +436,6 @@ func (r *ThresholdRule) prepareLinksToTraces(ts time.Time, lbls labels.Labels) s return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions) } -func (r *ThresholdRule) hostFromSource() string { - parsedUrl, err := url.Parse(r.source) - if err != nil { - return "" - } - if parsedUrl.Port() != "" { - return fmt.Sprintf("%s://%s:%s", parsedUrl.Scheme, parsedUrl.Hostname(), parsedUrl.Port()) - } - return fmt.Sprintf("%s://%s", parsedUrl.Scheme, parsedUrl.Hostname()) -} - func (r *ThresholdRule) GetSelectedQuery() string { if r.ruleCondition != nil { if r.ruleCondition.SelectedQuery != "" { @@ -797,18 +477,14 @@ func (r *ThresholdRule) GetSelectedQuery() string { return "" } -func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, ts time.Time, ch clickhouse.Conn) (Vector, error) { - if r.ruleCondition == nil || r.ruleCondition.CompositeQuery == nil { - r.SetHealth(HealthBad) - r.SetLastError(fmt.Errorf("no rule condition")) - return nil, fmt.Errorf("invalid rule condition") - } +func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, ts time.Time) (Vector, error) { - params := r.prepareQueryRange(ts) - err := r.populateTemporality(ctx, params, ch) + params, err := r.prepareQueryRange(ts) + if err != nil { + return nil, err + } + err = r.populateTemporality(ctx, params) if err != nil { - r.SetHealth(HealthBad) - zap.L().Error("failed to set temporality", zap.String("rule", r.Name()), zap.Error(err)) return nil, fmt.Errorf("internal error while setting temporality") } @@ -822,24 +498,22 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, ts time.Time, ch c } var results []*v3.Result - var errQuriesByName map[string]error + var queryErrors map[string]error if r.version == "v4" { - results, errQuriesByName, err = r.querierV2.QueryRange(ctx, params, map[string]v3.AttributeKey{}) + results, queryErrors, err = r.querierV2.QueryRange(ctx, params, map[string]v3.AttributeKey{}) } else { - results, errQuriesByName, err = r.querier.QueryRange(ctx, params, map[string]v3.AttributeKey{}) + results, queryErrors, err = r.querier.QueryRange(ctx, params, map[string]v3.AttributeKey{}) } if err != nil { - zap.L().Error("failed to get alert query result", zap.String("rule", r.Name()), zap.Error(err), zap.Any("queries", errQuriesByName)) - r.SetHealth(HealthBad) + zap.L().Error("failed to get alert query result", zap.String("rule", r.Name()), zap.Error(err), zap.Any("errors", queryErrors)) return nil, fmt.Errorf("internal error while querying") } if params.CompositeQuery.QueryType == v3.QueryTypeBuilder { results, err = postprocess.PostProcessResult(results, params) if err != nil { - r.SetHealth(HealthBad) zap.L().Error("failed to post process result", zap.String("rule", r.Name()), zap.Error(err)) return nil, fmt.Errorf("internal error while post processing") } @@ -901,113 +575,14 @@ func normalizeLabelName(name string) string { return normalized } -// TODO(srikanthccv): implement base rule and use for all types of rules -func (r *ThresholdRule) recordRuleStateHistory(ctx context.Context, prevState, currentState model.AlertState, itemsToAdd []v3.RuleStateHistory) error { - zap.L().Debug("recording rule state history", zap.String("ruleid", r.ID()), zap.Any("prevState", prevState), zap.Any("currentState", currentState), zap.Any("itemsToAdd", itemsToAdd)) - revisedItemsToAdd := map[uint64]v3.RuleStateHistory{} - - lastSavedState, err := r.reader.GetLastSavedRuleStateHistory(ctx, r.ID()) - if err != nil { - return err - } - // if the query-service has been restarted, or the rule has been modified (which re-initializes the rule), - // the state would reset so we need to add the corresponding state changes to previously saved states - if !r.handledRestart && len(lastSavedState) > 0 { - zap.L().Debug("handling restart", zap.String("ruleid", r.ID()), zap.Any("lastSavedState", lastSavedState)) - l := map[uint64]v3.RuleStateHistory{} - for _, item := range itemsToAdd { - l[item.Fingerprint] = item - } - - shouldSkip := map[uint64]bool{} - - for _, item := range lastSavedState { - // for the last saved item with fingerprint, check if there is a corresponding entry in the current state - currentState, ok := l[item.Fingerprint] - if !ok { - // there was a state change in the past, but not in the current state - // if the state was firing, then we should add a resolved state change - if item.State == model.StateFiring || item.State == model.StateNoData { - item.State = model.StateInactive - item.StateChanged = true - item.UnixMilli = time.Now().UnixMilli() - revisedItemsToAdd[item.Fingerprint] = item - } - // there is nothing to do if the prev state was normal - } else { - if item.State != currentState.State { - item.State = currentState.State - item.StateChanged = true - item.UnixMilli = time.Now().UnixMilli() - revisedItemsToAdd[item.Fingerprint] = item - } - } - // do not add this item to revisedItemsToAdd as it is already processed - shouldSkip[item.Fingerprint] = true - } - zap.L().Debug("after lastSavedState loop", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) - - // if there are any new state changes that were not saved, add them to the revised items - for _, item := range itemsToAdd { - if _, ok := revisedItemsToAdd[item.Fingerprint]; !ok && !shouldSkip[item.Fingerprint] { - revisedItemsToAdd[item.Fingerprint] = item - } - } - zap.L().Debug("after itemsToAdd loop", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) - - newState := model.StateInactive - for _, item := range revisedItemsToAdd { - if item.State == model.StateFiring || item.State == model.StateNoData { - newState = model.StateFiring - break - } - } - zap.L().Debug("newState", zap.String("ruleid", r.ID()), zap.Any("newState", newState)) - - // if there is a change in the overall state, update the overall state - if lastSavedState[0].OverallState != newState { - for fingerprint, item := range revisedItemsToAdd { - item.OverallState = newState - item.OverallStateChanged = true - revisedItemsToAdd[fingerprint] = item - } - } - zap.L().Debug("revisedItemsToAdd after newState", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) - - } else { - for _, item := range itemsToAdd { - revisedItemsToAdd[item.Fingerprint] = item - } - } - - if len(revisedItemsToAdd) > 0 && r.reader != nil { - zap.L().Debug("writing rule state history", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd)) - - entries := make([]v3.RuleStateHistory, 0, len(revisedItemsToAdd)) - for _, item := range revisedItemsToAdd { - entries = append(entries, item) - } - err := r.reader.AddRuleStateHistory(ctx, entries) - if err != nil { - zap.L().Error("error while inserting rule state history", zap.Error(err), zap.Any("itemsToAdd", itemsToAdd)) - } - } - r.handledRestart = true - - return nil -} - -func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) (interface{}, error) { +func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (interface{}, error) { prevState := r.State() valueFormatter := formatter.FromUnit(r.Unit()) - res, err := r.buildAndRunQuery(ctx, ts, queriers.Ch) + res, err := r.buildAndRunQuery(ctx, ts) if err != nil { - r.SetHealth(HealthBad) - r.SetLastError(err) - zap.L().Error("failure in buildAndRunQuery", zap.String("ruleid", r.ID()), zap.Error(err)) return nil, err } @@ -1054,17 +629,17 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Querie lb := labels.NewBuilder(smpl.Metric).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel) resultLabels := labels.NewBuilder(smpl.MetricOrig).Del(labels.MetricNameLabel).Del(labels.TemporalityLabel).Labels() - for _, l := range r.labels { - lb.Set(l.Name, expand(l.Value)) + for name, value := range r.labels.Map() { + lb.Set(name, expand(value)) } lb.Set(labels.AlertNameLabel, r.Name()) lb.Set(labels.AlertRuleIdLabel, r.ID()) lb.Set(labels.RuleSourceLabel, r.GeneratorURL()) - annotations := make(labels.Labels, 0, len(r.annotations)) - for _, a := range r.annotations { - annotations = append(annotations, labels.Label{Name: normalizeLabelName(a.Name), Value: expand(a.Value)}) + annotations := make(labels.Labels, 0, len(r.annotations.Map())) + for name, value := range r.annotations.Map() { + annotations = append(annotations, labels.Label{Name: normalizeLabelName(name), Value: expand(value)}) } if smpl.IsMissing { lb.Set(labels.AlertNameLabel, "[No data] "+r.Name()) @@ -1092,10 +667,6 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Querie if _, ok := alerts[h]; ok { zap.L().Error("the alert query returns duplicate records", zap.String("ruleid", r.ID()), zap.Any("alert", alerts[h])) err = fmt.Errorf("duplicate alert found, vector contains metrics with the same labelset after applying alert labels") - // We have already acquired the lock above hence using SetHealth and - // SetLastError will deadlock. - r.health = HealthBad - r.lastError = err return nil, err } @@ -1112,7 +683,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Querie } } - zap.L().Info("alerts found", zap.String("name", r.Name()), zap.Int("count", len(alerts))) + zap.L().Info("number of alerts found", zap.String("name", r.Name()), zap.Int("count", len(alerts))) // alerts[h] is ready, add or update active list now for h, a := range alerts { @@ -1127,7 +698,6 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Querie } r.active[h] = a - } itemsToAdd := []v3.RuleStateHistory{} @@ -1190,7 +760,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time, queriers *Querie itemsToAdd[idx] = item } - r.recordRuleStateHistory(ctx, prevState, currentState, itemsToAdd) + r.RecordRuleStateHistory(ctx, prevState, currentState, itemsToAdd) r.health = HealthGood r.lastError = err @@ -1226,179 +796,3 @@ func removeGroupinSetPoints(series v3.Series) []v3.Point { } return result } - -func (r *ThresholdRule) shouldAlert(series v3.Series) (Sample, bool) { - var alertSmpl Sample - var shouldAlert bool - var lbls labels.Labels - var lblsNormalized labels.Labels - - for name, value := range series.Labels { - lbls = append(lbls, labels.Label{Name: name, Value: value}) - lblsNormalized = append(lblsNormalized, labels.Label{Name: normalizeLabelName(name), Value: value}) - } - - series.Points = removeGroupinSetPoints(series) - - // nothing to evaluate - if len(series.Points) == 0 { - return alertSmpl, false - } - - switch r.matchType() { - case AtleastOnce: - // If any sample matches the condition, the rule is firing. - if r.compareOp() == ValueIsAbove { - for _, smpl := range series.Points { - if smpl.Value > r.targetVal() { - alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} - shouldAlert = true - break - } - } - } else if r.compareOp() == ValueIsBelow { - for _, smpl := range series.Points { - if smpl.Value < r.targetVal() { - alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} - shouldAlert = true - break - } - } - } else if r.compareOp() == ValueIsEq { - for _, smpl := range series.Points { - if smpl.Value == r.targetVal() { - alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} - shouldAlert = true - break - } - } - } else if r.compareOp() == ValueIsNotEq { - for _, smpl := range series.Points { - if smpl.Value != r.targetVal() { - alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} - shouldAlert = true - break - } - } - } - case AllTheTimes: - // If all samples match the condition, the rule is firing. - shouldAlert = true - alertSmpl = Sample{Point: Point{V: r.targetVal()}, Metric: lblsNormalized, MetricOrig: lbls} - if r.compareOp() == ValueIsAbove { - for _, smpl := range series.Points { - if smpl.Value <= r.targetVal() { - shouldAlert = false - break - } - } - // use min value from the series - if shouldAlert { - var minValue float64 = math.Inf(1) - for _, smpl := range series.Points { - if smpl.Value < minValue { - minValue = smpl.Value - } - } - alertSmpl = Sample{Point: Point{V: minValue}, Metric: lblsNormalized, MetricOrig: lbls} - } - } else if r.compareOp() == ValueIsBelow { - for _, smpl := range series.Points { - if smpl.Value >= r.targetVal() { - shouldAlert = false - break - } - } - if shouldAlert { - var maxValue float64 = math.Inf(-1) - for _, smpl := range series.Points { - if smpl.Value > maxValue { - maxValue = smpl.Value - } - } - alertSmpl = Sample{Point: Point{V: maxValue}, Metric: lblsNormalized, MetricOrig: lbls} - } - } else if r.compareOp() == ValueIsEq { - for _, smpl := range series.Points { - if smpl.Value != r.targetVal() { - shouldAlert = false - break - } - } - } else if r.compareOp() == ValueIsNotEq { - for _, smpl := range series.Points { - if smpl.Value == r.targetVal() { - shouldAlert = false - break - } - } - // use any non-inf or nan value from the series - if shouldAlert { - for _, smpl := range series.Points { - if !math.IsInf(smpl.Value, 0) && !math.IsNaN(smpl.Value) { - alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} - break - } - } - } - } - case OnAverage: - // If the average of all samples matches the condition, the rule is firing. - var sum, count float64 - for _, smpl := range series.Points { - if math.IsNaN(smpl.Value) || math.IsInf(smpl.Value, 0) { - continue - } - sum += smpl.Value - count++ - } - avg := sum / count - alertSmpl = Sample{Point: Point{V: avg}, Metric: lblsNormalized, MetricOrig: lbls} - if r.compareOp() == ValueIsAbove { - if avg > r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsBelow { - if avg < r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsEq { - if avg == r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsNotEq { - if avg != r.targetVal() { - shouldAlert = true - } - } - case InTotal: - // If the sum of all samples matches the condition, the rule is firing. - var sum float64 - - for _, smpl := range series.Points { - if math.IsNaN(smpl.Value) || math.IsInf(smpl.Value, 0) { - continue - } - sum += smpl.Value - } - alertSmpl = Sample{Point: Point{V: sum}, Metric: lblsNormalized, MetricOrig: lbls} - if r.compareOp() == ValueIsAbove { - if sum > r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsBelow { - if sum < r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsEq { - if sum == r.targetVal() { - shouldAlert = true - } - } else if r.compareOp() == ValueIsNotEq { - if sum != r.targetVal() { - shouldAlert = true - } - } - } - return alertSmpl, shouldAlert -} diff --git a/pkg/query-service/rules/threshold_rule_test.go b/pkg/query-service/rules/threshold_rule_test.go index 6cfeac83d9..734347793d 100644 --- a/pkg/query-service/rules/threshold_rule_test.go +++ b/pkg/query-service/rules/threshold_rule_test.go @@ -685,7 +685,7 @@ func TestThresholdRuleShouldAlert(t *testing.T) { postableRule.RuleCondition.MatchType = MatchType(c.matchType) postableRule.RuleCondition.Target = &c.target - rule, err := NewThresholdRule("69", &postableRule, ThresholdRuleOpts{EvalDelay: 2 * time.Minute}, fm, nil) + rule, err := NewThresholdRule("69", &postableRule, fm, nil, WithEvalDelay(2*time.Minute)) if err != nil { assert.NoError(t, err) } @@ -774,7 +774,7 @@ func TestPrepareLinksToLogs(t *testing.T) { } fm := featureManager.StartManager() - rule, err := NewThresholdRule("69", &postableRule, ThresholdRuleOpts{EvalDelay: 2 * time.Minute}, fm, nil) + rule, err := NewThresholdRule("69", &postableRule, fm, nil, WithEvalDelay(2*time.Minute)) if err != nil { assert.NoError(t, err) } @@ -816,7 +816,7 @@ func TestPrepareLinksToTraces(t *testing.T) { } fm := featureManager.StartManager() - rule, err := NewThresholdRule("69", &postableRule, ThresholdRuleOpts{EvalDelay: 2 * time.Minute}, fm, nil) + rule, err := NewThresholdRule("69", &postableRule, fm, nil, WithEvalDelay(2*time.Minute)) if err != nil { assert.NoError(t, err) } @@ -892,7 +892,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) { postableRule.RuleCondition.MatchType = MatchType(c.matchType) postableRule.RuleCondition.Target = &c.target - rule, err := NewThresholdRule("69", &postableRule, ThresholdRuleOpts{EvalDelay: 2 * time.Minute}, fm, nil) + rule, err := NewThresholdRule("69", &postableRule, fm, nil, WithEvalDelay(2*time.Minute)) if err != nil { assert.NoError(t, err) } @@ -945,17 +945,17 @@ func TestThresholdRuleEvalDelay(t *testing.T) { fm := featureManager.StartManager() for idx, c := range cases { - rule, err := NewThresholdRule("69", &postableRule, ThresholdRuleOpts{}, fm, nil) // no eval delay + rule, err := NewThresholdRule("69", &postableRule, fm, nil) // no eval delay if err != nil { assert.NoError(t, err) } - params := rule.prepareQueryRange(ts) - + params, err := rule.prepareQueryRange(ts) + assert.NoError(t, err) assert.Equal(t, c.expectedQuery, params.CompositeQuery.ClickHouseQueries["A"].Query, "Test case %d", idx) - secondTimeParams := rule.prepareQueryRange(ts) - + secondTimeParams, err := rule.prepareQueryRange(ts) + assert.NoError(t, err) assert.Equal(t, c.expectedQuery, secondTimeParams.CompositeQuery.ClickHouseQueries["A"].Query, "Test case %d", idx) } } @@ -994,17 +994,17 @@ func TestThresholdRuleClickHouseTmpl(t *testing.T) { fm := featureManager.StartManager() for idx, c := range cases { - rule, err := NewThresholdRule("69", &postableRule, ThresholdRuleOpts{EvalDelay: 2 * time.Minute}, fm, nil) + rule, err := NewThresholdRule("69", &postableRule, fm, nil, WithEvalDelay(2*time.Minute)) if err != nil { assert.NoError(t, err) } - params := rule.prepareQueryRange(ts) - + params, err := rule.prepareQueryRange(ts) + assert.NoError(t, err) assert.Equal(t, c.expectedQuery, params.CompositeQuery.ClickHouseQueries["A"].Query, "Test case %d", idx) - secondTimeParams := rule.prepareQueryRange(ts) - + secondTimeParams, err := rule.prepareQueryRange(ts) + assert.NoError(t, err) assert.Equal(t, c.expectedQuery, secondTimeParams.CompositeQuery.ClickHouseQueries["A"].Query, "Test case %d", idx) } } @@ -1137,7 +1137,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) { options := clickhouseReader.NewOptions("", 0, 0, 0, "", "archiveNamespace") reader := clickhouseReader.NewReaderFromClickhouseConnection(mock, options, nil, "", fm, "") - rule, err := NewThresholdRule("69", &postableRule, ThresholdRuleOpts{}, fm, reader) + rule, err := NewThresholdRule("69", &postableRule, fm, reader) rule.temporalityMap = map[string]map[v3.Temporality]bool{ "signoz_calls_total": { v3.Delta: true, @@ -1147,11 +1147,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) { assert.NoError(t, err) } - queriers := Queriers{ - Ch: mock, - } - - retVal, err := rule.Eval(context.Background(), time.Now(), &queriers) + retVal, err := rule.Eval(context.Background(), time.Now()) if err != nil { assert.NoError(t, err) } @@ -1240,7 +1236,7 @@ func TestThresholdRuleNoData(t *testing.T) { options := clickhouseReader.NewOptions("", 0, 0, 0, "", "archiveNamespace") reader := clickhouseReader.NewReaderFromClickhouseConnection(mock, options, nil, "", fm, "") - rule, err := NewThresholdRule("69", &postableRule, ThresholdRuleOpts{}, fm, reader) + rule, err := NewThresholdRule("69", &postableRule, fm, reader) rule.temporalityMap = map[string]map[v3.Temporality]bool{ "signoz_calls_total": { v3.Delta: true, @@ -1250,11 +1246,7 @@ func TestThresholdRuleNoData(t *testing.T) { assert.NoError(t, err) } - queriers := Queriers{ - Ch: mock, - } - - retVal, err := rule.Eval(context.Background(), time.Now(), &queriers) + retVal, err := rule.Eval(context.Background(), time.Now()) if err != nil { assert.NoError(t, err) } From b60b26189ff2bfa29e503b7ff20cee76fa7b3a40 Mon Sep 17 00:00:00 2001 From: Vikrant Gupta Date: Wed, 11 Sep 2024 09:58:17 +0530 Subject: [PATCH 41/43] fix: use just keys to check the filters rather than the whole objects (#5918) --- .../FilterRenderers/Checkbox/Checkbox.tsx | 51 +++++++++++-------- frontend/src/providers/QueryBuilder.tsx | 4 +- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx index fc9a71a7b1..dcf3cc8f3e 100644 --- a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx @@ -82,7 +82,9 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { ); const filterSync = currentQuery?.builder.queryData?.[ lastUsedQuery || 0 - ]?.filters?.items.find((item) => isEqual(item.key, filter.attributeKey)); + ]?.filters?.items.find((item) => + isEqual(item.key?.key, filter.attributeKey.key), + ); if (filterSync) { if (SELECTED_OPERATORS.includes(filterSync.op)) { @@ -127,8 +129,9 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { () => (currentQuery?.builder?.queryData?.[ lastUsedQuery || 0 - ]?.filters?.items?.filter((item) => isEqual(item.key, filter.attributeKey)) - ?.length || 0) > 1, + ]?.filters?.items?.filter((item) => + isEqual(item.key?.key, filter.attributeKey.key), + )?.length || 0) > 1, [currentQuery?.builder?.queryData, lastUsedQuery, filter.attributeKey], ); @@ -149,7 +152,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { items: idx === lastUsedQuery ? item.filters.items.filter( - (fil) => !isEqual(fil.key, filter.attributeKey), + (fil) => !isEqual(fil.key?.key, filter.attributeKey.key), ) : [...item.filters.items], }, @@ -161,7 +164,9 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[ lastUsedQuery || 0 - ]?.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey)); + ]?.filters?.items?.some((item) => + isEqual(item.key?.key, filter.attributeKey.key), + ); const onChange = ( value: string, @@ -180,7 +185,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { : 'Only' : 'Only'; query.filters.items = query.filters.items.filter( - (q) => !isEqual(q.key, filter.attributeKey), + (q) => !isEqual(q.key?.key, filter.attributeKey.key), ); if (isOnlyOrAll === 'Only') { const newFilterItem: TagFilterItem = { @@ -193,12 +198,14 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { } } else if (query?.filters?.items) { if ( - query.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey)) + query.filters?.items?.some((item) => + isEqual(item.key?.key, filter.attributeKey.key), + ) ) { // if there is already a running filter for the current attribute key then // we split the cases by which particular operator is present right now! const currentFilter = query.filters?.items?.find((q) => - isEqual(q.key, filter.attributeKey), + isEqual(q.key?.key, filter.attributeKey.key), ); if (currentFilter) { const runningOperator = currentFilter?.op; @@ -213,7 +220,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { value: [...currentFilter.value, value], }; query.filters.items = query.filters.items.map((item) => { - if (isEqual(item.key, filter.attributeKey)) { + if (isEqual(item.key?.key, filter.attributeKey.key)) { return newFilter; } return item; @@ -225,7 +232,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { value: [currentFilter.value as string, value], }; query.filters.items = query.filters.items.map((item) => { - if (isEqual(item.key, filter.attributeKey)) { + if (isEqual(item.key?.key, filter.attributeKey.key)) { return newFilter; } return item; @@ -242,11 +249,11 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { if (newFilter.value.length === 0) { query.filters.items = query.filters.items.filter( - (item) => !isEqual(item.key, filter.attributeKey), + (item) => !isEqual(item.key?.key, filter.attributeKey.key), ); } else { query.filters.items = query.filters.items.map((item) => { - if (isEqual(item.key, filter.attributeKey)) { + if (isEqual(item.key?.key, filter.attributeKey.key)) { return newFilter; } return item; @@ -255,7 +262,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { } else { // if not an array remove the whole thing altogether! query.filters.items = query.filters.items.filter( - (item) => !isEqual(item.key, filter.attributeKey), + (item) => !isEqual(item.key?.key, filter.attributeKey.key), ); } } @@ -271,7 +278,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { value: [...currentFilter.value, value], }; query.filters.items = query.filters.items.map((item) => { - if (isEqual(item.key, filter.attributeKey)) { + if (isEqual(item.key?.key, filter.attributeKey.key)) { return newFilter; } return item; @@ -283,7 +290,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { value: [currentFilter.value as string, value], }; query.filters.items = query.filters.items.map((item) => { - if (isEqual(item.key, filter.attributeKey)) { + if (isEqual(item.key?.key, filter.attributeKey.key)) { return newFilter; } return item; @@ -299,11 +306,11 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { if (newFilter.value.length === 0) { query.filters.items = query.filters.items.filter( - (item) => !isEqual(item.key, filter.attributeKey), + (item) => !isEqual(item.key?.key, filter.attributeKey.key), ); } else { query.filters.items = query.filters.items.map((item) => { - if (isEqual(item.key, filter.attributeKey)) { + if (isEqual(item.key?.key, filter.attributeKey.key)) { return newFilter; } return item; @@ -311,7 +318,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { } } else { query.filters.items = query.filters.items.filter( - (item) => !isEqual(item.key, filter.attributeKey), + (item) => !isEqual(item.key?.key, filter.attributeKey.key), ); } } @@ -324,14 +331,14 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { value: [currentFilter.value as string, value], }; query.filters.items = query.filters.items.map((item) => { - if (isEqual(item.key, filter.attributeKey)) { + if (isEqual(item.key?.key, filter.attributeKey.key)) { return newFilter; } return item; }); } else if (!checked) { query.filters.items = query.filters.items.filter( - (item) => !isEqual(item.key, filter.attributeKey), + (item) => !isEqual(item.key?.key, filter.attributeKey.key), ); } break; @@ -343,14 +350,14 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { value: [currentFilter.value as string, value], }; query.filters.items = query.filters.items.map((item) => { - if (isEqual(item.key, filter.attributeKey)) { + if (isEqual(item.key?.key, filter.attributeKey.key)) { return newFilter; } return item; }); } else if (checked) { query.filters.items = query.filters.items.filter( - (item) => !isEqual(item.key, filter.attributeKey), + (item) => !isEqual(item.key?.key, filter.attributeKey.key), ); } break; diff --git a/frontend/src/providers/QueryBuilder.tsx b/frontend/src/providers/QueryBuilder.tsx index 305372eea6..b79538cb45 100644 --- a/frontend/src/providers/QueryBuilder.tsx +++ b/frontend/src/providers/QueryBuilder.tsx @@ -233,8 +233,6 @@ export function QueryBuilderProvider({ timeUpdated ? merge(currentQuery, newQueryState) : newQueryState, ); setQueryType(type); - // this is required to reset the last used query when navigating or initializing the query builder - setLastUsedQuery(0); }, [prepareQueryBuilderData, currentQuery], ); @@ -820,6 +818,8 @@ export function QueryBuilderProvider({ currentPathnameRef.current = location.pathname; setStagedQuery(null); + // reset the last used query to 0 when navigating away from the page + setLastUsedQuery(0); } }, [location, stagedQuery, currentQuery]); From 4799d3147b2a1108ea72d1a5d4d35255815b4bf5 Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Wed, 11 Sep 2024 11:49:25 +0530 Subject: [PATCH 42/43] fix: label assignment issue in promql rules (#5920) --- pkg/query-service/rules/prom_rule.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/query-service/rules/prom_rule.go b/pkg/query-service/rules/prom_rule.go index 314e268e1f..7136a88e97 100644 --- a/pkg/query-service/rules/prom_rule.go +++ b/pkg/query-service/rules/prom_rule.go @@ -306,7 +306,11 @@ func (r *PromRule) String() string { } func toCommonSeries(series promql.Series) v3.Series { - commonSeries := v3.Series{} + commonSeries := v3.Series{ + Labels: make(map[string]string), + LabelsArray: make([]map[string]string, 0), + Points: make([]v3.Point, 0), + } for _, lbl := range series.Metric { commonSeries.Labels[lbl.Name] = lbl.Value From 1d8e5b6c0fd5d29f0ef315b258039ed8836bf5f9 Mon Sep 17 00:00:00 2001 From: Prashant Shahi Date: Wed, 11 Sep 2024 13:47:02 +0530 Subject: [PATCH 43/43] =?UTF-8?q?chore(signoz):=20=F0=9F=93=8C=20pin=20ver?= =?UTF-8?q?sions:=20SigNoz=200.54.0,=20SigNoz=20OtelCollector=200.102.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Prashant Shahi --- deploy/docker-swarm/clickhouse-setup/docker-compose.yaml | 8 ++++---- deploy/docker/clickhouse-setup/docker-compose-core.yaml | 4 ++-- .../docker/clickhouse-setup/docker-compose.testing.yaml | 8 ++++---- deploy/docker/clickhouse-setup/docker-compose.yaml | 8 ++++---- go.mod | 2 +- go.sum | 6 ++---- pkg/query-service/tests/test-deploy/docker-compose.yaml | 4 ++-- 7 files changed, 19 insertions(+), 21 deletions(-) diff --git a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml index 007de0fc9b..c922670f82 100644 --- a/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker-swarm/clickhouse-setup/docker-compose.yaml @@ -146,7 +146,7 @@ services: condition: on-failure query-service: - image: signoz/query-service:0.53.0 + image: signoz/query-service:0.54.0 command: [ "-config=/root/config/prometheus.yml", @@ -186,7 +186,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:0.53.0 + image: signoz/frontend:0.54.0 deploy: restart_policy: condition: on-failure @@ -199,7 +199,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector: - image: signoz/signoz-otel-collector:0.102.7 + image: signoz/signoz-otel-collector:0.102.8 command: [ "--config=/etc/otel-collector-config.yaml", @@ -238,7 +238,7 @@ services: - query-service otel-collector-migrator: - image: signoz/signoz-schema-migrator:0.102.7 + image: signoz/signoz-schema-migrator:0.102.8 deploy: restart_policy: condition: on-failure diff --git a/deploy/docker/clickhouse-setup/docker-compose-core.yaml b/deploy/docker/clickhouse-setup/docker-compose-core.yaml index 973e1ae602..9c85a617df 100644 --- a/deploy/docker/clickhouse-setup/docker-compose-core.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose-core.yaml @@ -66,7 +66,7 @@ services: - --storage.path=/data otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.7} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.8} container_name: otel-migrator command: - "--dsn=tcp://clickhouse:9000" @@ -81,7 +81,7 @@ services: # Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md` otel-collector: container_name: signoz-otel-collector - image: signoz/signoz-otel-collector:0.102.7 + image: signoz/signoz-otel-collector:0.102.8 command: [ "--config=/etc/otel-collector-config.yaml", diff --git a/deploy/docker/clickhouse-setup/docker-compose.testing.yaml b/deploy/docker/clickhouse-setup/docker-compose.testing.yaml index 8d3c54be0a..4518940bbf 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.testing.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.testing.yaml @@ -164,7 +164,7 @@ services: # Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md` query-service: - image: signoz/query-service:${DOCKER_TAG:-0.53.0} + image: signoz/query-service:${DOCKER_TAG:-0.54.0} container_name: signoz-query-service command: [ @@ -204,7 +204,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:${DOCKER_TAG:-0.53.0} + image: signoz/frontend:${DOCKER_TAG:-0.54.0} container_name: signoz-frontend restart: on-failure depends_on: @@ -216,7 +216,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.7} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.8} container_name: otel-migrator command: - "--dsn=tcp://clickhouse:9000" @@ -230,7 +230,7 @@ services: otel-collector: - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.7} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.8} container_name: signoz-otel-collector command: [ diff --git a/deploy/docker/clickhouse-setup/docker-compose.yaml b/deploy/docker/clickhouse-setup/docker-compose.yaml index d20728c769..7ade077348 100644 --- a/deploy/docker/clickhouse-setup/docker-compose.yaml +++ b/deploy/docker/clickhouse-setup/docker-compose.yaml @@ -164,7 +164,7 @@ services: # Notes for Maintainers/Contributors who will change Line Numbers of Frontend & Query-Section. Please Update Line Numbers in `./scripts/commentLinesForSetup.sh` & `./CONTRIBUTING.md` query-service: - image: signoz/query-service:${DOCKER_TAG:-0.53.0} + image: signoz/query-service:${DOCKER_TAG:-0.54.0} container_name: signoz-query-service command: [ @@ -203,7 +203,7 @@ services: <<: *db-depend frontend: - image: signoz/frontend:${DOCKER_TAG:-0.53.0} + image: signoz/frontend:${DOCKER_TAG:-0.54.0} container_name: signoz-frontend restart: on-failure depends_on: @@ -215,7 +215,7 @@ services: - ../common/nginx-config.conf:/etc/nginx/conf.d/default.conf otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.7} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.8} container_name: otel-migrator command: - "--dsn=tcp://clickhouse:9000" @@ -229,7 +229,7 @@ services: otel-collector: - image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.7} + image: signoz/signoz-otel-collector:${OTELCOL_TAG:-0.102.8} container_name: signoz-otel-collector command: [ diff --git a/go.mod b/go.mod index f154ae3363..ae6ab36a59 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/ClickHouse/clickhouse-go/v2 v2.23.2 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd - github.com/SigNoz/signoz-otel-collector v0.102.7 + github.com/SigNoz/signoz-otel-collector v0.102.8 github.com/SigNoz/zap_otlp/zap_otlp_encoder v0.0.0-20230822164844-1b861a431974 github.com/SigNoz/zap_otlp/zap_otlp_sync v0.0.0-20230822164844-1b861a431974 github.com/antonmedv/expr v1.15.3 diff --git a/go.sum b/go.sum index 90451965b7..568265c26a 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkb github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc= github.com/SigNoz/prometheus v1.11.1 h1:roM8ugYf4UxaeKKujEeBvoX7ybq3IrS+TB26KiRtIJg= github.com/SigNoz/prometheus v1.11.1/go.mod h1:uv4mQwZQtx7y4GQ6EdHOi8Wsk07uHNn2XHd1zM85m6I= -github.com/SigNoz/signoz-otel-collector v0.102.7 h1:UBjO88GNCGZuWKl1LFukRahR1cu9AGwFHyObo07RrYA= -github.com/SigNoz/signoz-otel-collector v0.102.7/go.mod h1:3s9cSL8yexkBBMfK9mC3WWrAPm8oMtlZhvBxvt+Ziag= +github.com/SigNoz/signoz-otel-collector v0.102.8 h1:8zM/QnZs7wXYAFpvkSUZeQsweDf01R6TKOw8zejpXxM= +github.com/SigNoz/signoz-otel-collector v0.102.8/go.mod h1:3s9cSL8yexkBBMfK9mC3WWrAPm8oMtlZhvBxvt+Ziag= github.com/SigNoz/zap_otlp v0.1.0 h1:T7rRcFN87GavY8lDGZj0Z3Xv6OhJA6Pj3I9dNPmqvRc= github.com/SigNoz/zap_otlp v0.1.0/go.mod h1:lcHvbDbRgvDnPxo9lDlaL1JK2PyOyouP/C3ynnYIvyo= github.com/SigNoz/zap_otlp/zap_otlp_encoder v0.0.0-20230822164844-1b861a431974 h1:PKVgdf83Yw+lZJbFtNGBgqXiXNf3+kOXW2qZ7Ms7OaY= @@ -714,8 +714,6 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/srikanthccv/ClickHouse-go-mock v0.8.0 h1:DeeM8XLbTFl6sjYPPwazPEXx7kmRV8TgPFVkt1SqT0Y= -github.com/srikanthccv/ClickHouse-go-mock v0.8.0/go.mod h1:pgJm+apjvi7FHxEdgw1Bt4MRbUYpVxyhKQ/59Wkig24= github.com/srikanthccv/ClickHouse-go-mock v0.9.0 h1:XKr1Tb7GL1HlifKH874QGR3R6l0e6takXasROUiZawU= github.com/srikanthccv/ClickHouse-go-mock v0.9.0/go.mod h1:pgJm+apjvi7FHxEdgw1Bt4MRbUYpVxyhKQ/59Wkig24= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/pkg/query-service/tests/test-deploy/docker-compose.yaml b/pkg/query-service/tests/test-deploy/docker-compose.yaml index 4c75c8ac43..40635d338a 100644 --- a/pkg/query-service/tests/test-deploy/docker-compose.yaml +++ b/pkg/query-service/tests/test-deploy/docker-compose.yaml @@ -192,7 +192,7 @@ services: <<: *db-depend otel-collector-migrator: - image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.7} + image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-0.102.8} container_name: otel-migrator command: - "--dsn=tcp://clickhouse:9000" @@ -205,7 +205,7 @@ services: # condition: service_healthy otel-collector: - image: signoz/signoz-otel-collector:0.102.7 + image: signoz/signoz-otel-collector:0.102.8 container_name: signoz-otel-collector command: [