From 0fc85d267979c2934dcaa64a2f5bd5a460f8e5eb Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Wed, 7 Aug 2024 14:21:06 +0430 Subject: [PATCH 01/17] feat: alert history basic tabs and fitlers UI --- .../TabsAndFilters/Filters/Filters.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 frontend/src/components/TabsAndFilters/Filters/Filters.tsx diff --git a/frontend/src/components/TabsAndFilters/Filters/Filters.tsx b/frontend/src/components/TabsAndFilters/Filters/Filters.tsx new file mode 100644 index 0000000000..f8ae36b667 --- /dev/null +++ b/frontend/src/components/TabsAndFilters/Filters/Filters.tsx @@ -0,0 +1,35 @@ +import { Button } from 'antd'; +import { QueryParams } from 'constants/query'; +import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2'; +import useUrlQuery from 'hooks/useUrlQuery'; +import { Undo } from 'lucide-react'; +import { useHistory } from 'react-router-dom'; + +export function Filters(): JSX.Element { + const urlQuery = useUrlQuery(); + const history = useHistory(); + + const handleFiltersReset = (): void => { + urlQuery.delete(QueryParams.relativeTime); + + history.replace({ + pathname: history.location.pathname, + search: `?${urlQuery.toString()}`, + }); + }; + return ( +
+ {urlQuery.has(QueryParams.relativeTime) && ( + + )} + +
+ ); +} From 1868a682e2a8ade1edb5483cfa31624f8f6a6617 Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Wed, 7 Aug 2024 16:57:30 +0430 Subject: [PATCH 02/17] feat: route based tabs for alert history and overview and improve the UI to match designs --- .../TabsAndFilters/Filters/Filters.tsx | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100644 frontend/src/components/TabsAndFilters/Filters/Filters.tsx diff --git a/frontend/src/components/TabsAndFilters/Filters/Filters.tsx b/frontend/src/components/TabsAndFilters/Filters/Filters.tsx deleted file mode 100644 index f8ae36b667..0000000000 --- a/frontend/src/components/TabsAndFilters/Filters/Filters.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Button } from 'antd'; -import { QueryParams } from 'constants/query'; -import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2'; -import useUrlQuery from 'hooks/useUrlQuery'; -import { Undo } from 'lucide-react'; -import { useHistory } from 'react-router-dom'; - -export function Filters(): JSX.Element { - const urlQuery = useUrlQuery(); - const history = useHistory(); - - const handleFiltersReset = (): void => { - urlQuery.delete(QueryParams.relativeTime); - - history.replace({ - pathname: history.location.pathname, - search: `?${urlQuery.toString()}`, - }); - }; - return ( -
- {urlQuery.has(QueryParams.relativeTime) && ( - - )} - -
- ); -} From 419cc49ae5c99f8913f032f5247c521c1bb705ca Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sat, 17 Aug 2024 12:20:50 +0430 Subject: [PATCH 03/17] feat: data state renderer component --- .../DataStateRenderer/DataStateRenderer.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx diff --git a/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx new file mode 100644 index 0000000000..ed5f4c6be8 --- /dev/null +++ b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx @@ -0,0 +1,41 @@ +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; +} + +function DataStateRenderer({ + isLoading, + isRefetching, + isError, + data, + errorMessage, + loadingMessage, + children, +}: DataStateRendererProps): JSX.Element { + const { t } = useTranslation('common'); + + if (isError || data === null) { + return
{errorMessage ?? t('something_went_wrong')}
; + } + + if (isLoading || isRefetching || !data) { + return ; + } + + return <>{children(data)}; +} + +DataStateRenderer.defaultProps = { + errorMessage: '', + loadingMessage: 'Loading...', +}; + +export default DataStateRenderer; From 3b6d0c2299d022c8a40e1a2c38355d2a07df2f32 Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sat, 17 Aug 2024 12:24:38 +0430 Subject: [PATCH 04/17] feat: get total triggered and avg. resolution cards data from API --- frontend/src/api/alerts/ruleStats.ts | 28 +++++++++++ .../AverageResolutionCard.tsx | 17 +++++-- .../AlertHistory/Statistics/Statistics.tsx | 47 ++++++++++++++++++- .../Statistics/StatsCard/StatsCard.tsx | 4 -- .../TotalTriggeredCard/TotalTriggeredCard.tsx | 17 +++++-- .../AlertDetails/alertDetails.styles.scss | 3 ++ frontend/src/pages/AlertDetails/hooks.tsx | 43 +++++++++++++++++ frontend/src/pages/AlertDetails/index.tsx | 1 + frontend/src/types/api/alerts/def.ts | 26 ++++++++++ frontend/src/types/api/alerts/ruleStats.ts | 7 +++ 10 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 frontend/src/api/alerts/ruleStats.ts create mode 100644 frontend/src/types/api/alerts/ruleStats.ts diff --git a/frontend/src/api/alerts/ruleStats.ts b/frontend/src/api/alerts/ruleStats.ts new file mode 100644 index 0000000000..0926e20844 --- /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 { AlertRuleStatsPayloadProps } 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/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx index e00efe1532..fb4b7fb59a 100644 --- a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx @@ -1,14 +1,23 @@ import './averageResolutionCard.styles.scss'; -import { statsData } from '../mocks'; +import { AlertRuleStats } from 'types/api/alerts/def'; + import StatsCard from '../StatsCard/StatsCard'; -function AverageResolutionCard(): JSX.Element { +type TotalTriggeredCardProps = { + currentAvgResolutionTime: AlertRuleStats['currentAvgResolutionTime']; + pastAvgResolutionTime: AlertRuleStats['pastAvgResolutionTime']; +}; + +function AverageResolutionCard({ + currentAvgResolutionTime, + pastAvgResolutionTime, +}: TotalTriggeredCardProps): JSX.Element { return (
diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx index b8d076fb92..845a05ad32 100644 --- a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx @@ -1,14 +1,57 @@ import './statistics.styles.scss'; +import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; + import AverageResolutionCard from './AverageResolutionCard/AverageResolutionCard'; import TopContributorsCard from './TopContributorsCard/TopContributorsCard'; import TotalTriggeredCard from './TotalTriggeredCard/TotalTriggeredCard'; +function StatsCardsRenderer(): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsStats(); + + return ( + + {(data): JSX.Element => { + const { + currentAvgResolutionTime, + pastAvgResolutionTime, + totalCurrentTriggers, + totalPastTriggers, + } = data; + + return ( + <> + + + + ); + }} + + ); +} function Statistics(): JSX.Element { return (
- - +
); diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx index 74940bf5b5..6f7ee7c92a 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx @@ -53,10 +53,6 @@ function StatsCard({ const relativeTime = urlQuery.get('relativeTime'); - if (!totalCurrentCount || !totalPastCount) { - return
; - } - const { changePercentage, changeDirection } = calculateChange( totalCurrentCount, totalPastCount, diff --git a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx index ce6c3e5cf2..dbc9cf85ec 100644 --- a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx @@ -1,14 +1,23 @@ import './totalTriggeredCard.styles.scss'; -import { statsData } from '../mocks'; +import { AlertRuleStats } from 'types/api/alerts/def'; + import StatsCard from '../StatsCard/StatsCard'; -function TotalTriggeredCard(): JSX.Element { +type TotalTriggeredCardProps = { + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; + totalPastTriggers: AlertRuleStats['totalPastTriggers']; +}; + +function TotalTriggeredCard({ + totalCurrentTriggers, + totalPastTriggers, +}: TotalTriggeredCardProps): JSX.Element { return (
diff --git a/frontend/src/pages/AlertDetails/alertDetails.styles.scss b/frontend/src/pages/AlertDetails/alertDetails.styles.scss index 357b2e3720..50a04dbeba 100644 --- a/frontend/src/pages/AlertDetails/alertDetails.styles.scss +++ b/frontend/src/pages/AlertDetails/alertDetails.styles.scss @@ -13,6 +13,9 @@ margin: 1rem 0; & .ant-tabs-nav { + &-wrap { + margin-bottom: 16px; + } &::before { border-bottom: none !important; } diff --git a/frontend/src/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx index 82d47d2450..d6de092bd7 100644 --- a/frontend/src/pages/AlertDetails/hooks.tsx +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -1,4 +1,5 @@ import get from 'api/alerts/get'; +import ruleStats from 'api/alerts/ruleStats'; import { TabRoutes } from 'components/RouteTab/types'; import ROUTES from 'constants/routes'; import AlertHistory from 'container/AlertHistory'; @@ -8,6 +9,8 @@ import { History, Table } from 'lucide-react'; import EditRules from 'pages/EditRules'; import { useQuery, UseQueryResult } from 'react-query'; import { generatePath, useLocation } from 'react-router-dom'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleStatsPayloadProps } from 'types/api/alerts/def'; export const useRouteTabUtils = (): { routes: TabRoutes[] } => { const urlQuery = useUrlQuery(); @@ -85,3 +88,43 @@ export const useGetAlertRuleDetails = (): { return { ruleId, data }; }; + +type GetAlertRuleDetailsStatsProps = { + isLoading: boolean; + isRefetching: boolean; + isError: boolean; + data: + | SuccessResponse + | ErrorResponse + | undefined; + isValidRuleId: boolean; + ruleId: string | null; +}; + +export const useGetAlertRuleDetailsStats = (): GetAlertRuleDetailsStatsProps => { + const { search } = useLocation(); + const params = new URLSearchParams(search); + + const ruleId = params.get('ruleId'); + const startTime = params.get('startTime'); + const endTime = params.get('endTime'); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + ['ruleIdStats', ruleId, startTime, endTime], + { + queryFn: () => + ruleStats({ + id: parseInt(ruleId || '', 10), + start: parseInt(startTime || '', 10), + end: parseInt(endTime || '', 10), + }), + enabled: isValidRuleId, + 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 index 4c5ae9b0de..2dcbb0b343 100644 --- a/frontend/src/pages/AlertDetails/index.tsx +++ b/frontend/src/pages/AlertDetails/index.tsx @@ -76,6 +76,7 @@ function AlertDetails(): JSX.Element { ]} /> + {/* TODO(shaheer): use DataStateRenderer component instead */} diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts index c773cb78a2..3562794a7d 100644 --- a/frontend/src/types/api/alerts/def.ts +++ b/frontend/src/types/api/alerts/def.ts @@ -42,3 +42,29 @@ export interface RuleCondition { export interface Labels { [key: string]: string; } + +export interface AlertRuleStats { + totalCurrentTriggers: number; + totalPastTriggers: number; + currentTriggersSeries: CurrentTriggersSeries | null; + pastTriggersSeries: any | null; + currentAvgResolutionTime: number; + pastAvgResolutionTime: number; + currentAvgResolutionTimeSeries: any | null; + pastAvgResolutionTimeSeries: any | null; +} + +interface CurrentTriggersSeries { + labels: Labels; + labelsArray: any | null; + values: Value[]; +} + +interface Value { + timestamp: number; + value: string; +} + +export type AlertRuleStatsPayloadProps = { + data: AlertRuleStats; +}; 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; +} From ec6478bb95e1dbd249d2bbb37bfc39be9947011e Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sat, 17 Aug 2024 13:43:51 +0430 Subject: [PATCH 05/17] fix: hide stats card if we get NaN --- .../AlertHistory/Statistics/Statistics.tsx | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx index 845a05ad32..fb3b552407 100644 --- a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx @@ -7,6 +7,20 @@ import AverageResolutionCard from './AverageResolutionCard/AverageResolutionCard import TopContributorsCard from './TopContributorsCard/TopContributorsCard'; import TotalTriggeredCard from './TotalTriggeredCard/TotalTriggeredCard'; +const isTypeNotNan = (value: unknown): boolean => value !== 'NaN'; + +const hasTotalTriggeredStats = ( + totalCurrentTriggers: unknown, + totalPastTriggers: unknown, +): boolean => + isTypeNotNan(totalCurrentTriggers) || isTypeNotNan(totalPastTriggers); + +const hasAvgResolutionTimeStats = ( + currentAvgResolutionTime: unknown, + pastAvgResolutionTime: unknown, +): boolean => + isTypeNotNan(currentAvgResolutionTime) || isTypeNotNan(pastAvgResolutionTime); + function StatsCardsRenderer(): JSX.Element { const { isLoading, @@ -34,14 +48,24 @@ function StatsCardsRenderer(): JSX.Element { return ( <> - - + {/* TODO(shaheer): get hasTotalTriggeredStats when it's available in the API */} + {hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) && ( + + )} + + {/* TODO(shaheer): get hasAvgResolutionTimeStats when it's available in the API */} + {hasAvgResolutionTimeStats( + currentAvgResolutionTime, + pastAvgResolutionTime, + ) && ( + + )} ); }} From 5af9b9e024890e9eca8fc0632ffadf569cc32437 Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sun, 18 Aug 2024 09:04:34 +0430 Subject: [PATCH 06/17] chore: improve rule stats types --- frontend/src/api/alerts/ruleStats.ts | 4 ++-- frontend/src/pages/AlertDetails/hooks.tsx | 13 ++++++++----- frontend/src/types/api/alerts/def.ts | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/src/api/alerts/ruleStats.ts b/frontend/src/api/alerts/ruleStats.ts index 0926e20844..2e09751e0f 100644 --- a/frontend/src/api/alerts/ruleStats.ts +++ b/frontend/src/api/alerts/ruleStats.ts @@ -2,12 +2,12 @@ import axios from 'api'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; -import { AlertRuleStatsPayloadProps } from 'types/api/alerts/def'; +import { AlertRuleStatsPayload } from 'types/api/alerts/def'; import { RuleStatsProps } from 'types/api/alerts/ruleStats'; const ruleStats = async ( props: RuleStatsProps, -): Promise | ErrorResponse> => { +): Promise | ErrorResponse> => { try { const response = await axios.post(`/rules/${props.id}/history/stats`, { start: props.start, diff --git a/frontend/src/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx index d6de092bd7..58a9ae11ec 100644 --- a/frontend/src/pages/AlertDetails/hooks.tsx +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -10,7 +10,7 @@ import EditRules from 'pages/EditRules'; import { useQuery, UseQueryResult } from 'react-query'; import { generatePath, useLocation } from 'react-router-dom'; import { ErrorResponse, SuccessResponse } from 'types/api'; -import { AlertRuleStatsPayloadProps } from 'types/api/alerts/def'; +import { AlertRuleStatsPayload } from 'types/api/alerts/def'; export const useRouteTabUtils = (): { routes: TabRoutes[] } => { const urlQuery = useUrlQuery(); @@ -89,16 +89,19 @@ export const useGetAlertRuleDetails = (): { return { ruleId, data }; }; -type GetAlertRuleDetailsStatsProps = { +type GetAlertRuleDetailsApiProps = { isLoading: boolean; isRefetching: boolean; isError: boolean; + isValidRuleId: boolean; + ruleId: string | null; +}; + +type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & { data: - | SuccessResponse + | SuccessResponse | ErrorResponse | undefined; - isValidRuleId: boolean; - ruleId: string | null; }; export const useGetAlertRuleDetailsStats = (): GetAlertRuleDetailsStatsProps => { diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts index 3562794a7d..9b343841d0 100644 --- a/frontend/src/types/api/alerts/def.ts +++ b/frontend/src/types/api/alerts/def.ts @@ -65,6 +65,6 @@ interface Value { value: string; } -export type AlertRuleStatsPayloadProps = { +export type AlertRuleStatsPayload = { data: AlertRuleStats; }; From e18b9359c3d7ba263509eb402716ce8f0e945c4f Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sun, 18 Aug 2024 10:08:35 +0430 Subject: [PATCH 07/17] feat: get top contributors data from API --- frontend/src/api/alerts/topContributors.ts | 33 ++++++++ .../AlertHistory/Statistics/Statistics.tsx | 78 ++--------------- .../StatsCardsRenderer/StatsCardsRenderer.tsx | 84 +++++++++++++++++++ .../TopContributorsCard.tsx | 16 +++- .../TopContributorsRenderer.tsx | 42 ++++++++++ frontend/src/pages/AlertDetails/hooks.tsx | 41 ++++++++- .../DataStateRenderer/DataStateRenderer.tsx | 5 ++ frontend/src/types/api/alerts/def.ts | 9 ++ .../src/types/api/alerts/topContributors.ts | 7 ++ 9 files changed, 238 insertions(+), 77 deletions(-) create mode 100644 frontend/src/api/alerts/topContributors.ts create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx create mode 100644 frontend/src/types/api/alerts/topContributors.ts 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/container/AlertHistory/Statistics/Statistics.tsx b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx index fb3b552407..9b424709cc 100644 --- a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx @@ -1,82 +1,16 @@ import './statistics.styles.scss'; -import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks'; -import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; +import { useState } from 'react'; -import AverageResolutionCard from './AverageResolutionCard/AverageResolutionCard'; -import TopContributorsCard from './TopContributorsCard/TopContributorsCard'; -import TotalTriggeredCard from './TotalTriggeredCard/TotalTriggeredCard'; +import StatsCardsRenderer from './StatsCardsRenderer/StatsCardsRenderer'; +import TopContributorsRenderer from './TopContributorsRenderer/TopContributorsRenderer'; -const isTypeNotNan = (value: unknown): boolean => value !== 'NaN'; - -const hasTotalTriggeredStats = ( - totalCurrentTriggers: unknown, - totalPastTriggers: unknown, -): boolean => - isTypeNotNan(totalCurrentTriggers) || isTypeNotNan(totalPastTriggers); - -const hasAvgResolutionTimeStats = ( - currentAvgResolutionTime: unknown, - pastAvgResolutionTime: unknown, -): boolean => - isTypeNotNan(currentAvgResolutionTime) || isTypeNotNan(pastAvgResolutionTime); - -function StatsCardsRenderer(): JSX.Element { - const { - isLoading, - isRefetching, - isError, - data, - isValidRuleId, - ruleId, - } = useGetAlertRuleDetailsStats(); - - return ( - - {(data): JSX.Element => { - const { - currentAvgResolutionTime, - pastAvgResolutionTime, - totalCurrentTriggers, - totalPastTriggers, - } = data; - - return ( - <> - {/* TODO(shaheer): get hasTotalTriggeredStats when it's available in the API */} - {hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) && ( - - )} - - {/* TODO(shaheer): get hasAvgResolutionTimeStats when it's available in the API */} - {hasAvgResolutionTimeStats( - currentAvgResolutionTime, - pastAvgResolutionTime, - ) && ( - - )} - - ); - }} - - ); -} function Statistics(): JSX.Element { + const [totalCurrentTriggers, setTotalCurrentTriggers] = useState(0); return (
- - + +
); } 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..6a19c3fb05 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx @@ -0,0 +1,84 @@ +import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; + +import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard'; +import TotalTriggeredCard from '../TotalTriggeredCard/TotalTriggeredCard'; + +const isTypeNotNan = (value: unknown): boolean => value !== 'NaN'; + +const hasTotalTriggeredStats = ( + totalCurrentTriggers: unknown, + totalPastTriggers: unknown, +): boolean => + isTypeNotNan(totalCurrentTriggers) || isTypeNotNan(totalPastTriggers); + +const hasAvgResolutionTimeStats = ( + currentAvgResolutionTime: unknown, + pastAvgResolutionTime: unknown, +): boolean => + isTypeNotNan(currentAvgResolutionTime) || isTypeNotNan(pastAvgResolutionTime); + +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(); + + return ( + + {(data): JSX.Element => { + const { + currentAvgResolutionTime, + pastAvgResolutionTime, + totalCurrentTriggers, + totalPastTriggers, + } = data; + + if (setTotalCurrentTriggers) { + setTotalCurrentTriggers(totalCurrentTriggers); + } + + return ( + <> + {/* TODO(shaheer): get hasTotalTriggeredStats when it's available in the API */} + {hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) && ( + + )} + + {/* TODO(shaheer): get hasAvgResolutionTimeStats when it's available in the API */} + {hasAvgResolutionTimeStats( + currentAvgResolutionTime, + pastAvgResolutionTime, + ) && ( + + )} + + ); + }} + + ); +} + +export default StatsCardsRenderer; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx index 1593886ce7..3324ba14c9 100644 --- a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx @@ -4,10 +4,18 @@ import { Progress } from 'antd'; import AlertPopover from 'container/AlertHistory/AlertPopover/AlertPopover'; import { ArrowRight } from 'lucide-react'; import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; -import { statsData, topContributorsData } from '../mocks'; +import { statsData } from '../mocks'; -function TopContributorsCard(): JSX.Element { +type TopContributorsCardProps = { + topContributorsData: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}; +function TopContributorsCard({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { return (
@@ -20,7 +28,7 @@ function TopContributorsCard(): JSX.Element {
- {topContributorsData.contributors.slice(0, 3).map((contributor, index) => ( + {topContributorsData.slice(0, 3).map((contributor, index) => ( @@ -37,7 +45,7 @@ function TopContributorsCard(): JSX.Element { />
- {contributor.count}/{statsData.totalCurrentTriggers} + {contributor.count}/{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/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx index 58a9ae11ec..503db241ac 100644 --- a/frontend/src/pages/AlertDetails/hooks.tsx +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -1,5 +1,6 @@ import get from 'api/alerts/get'; import ruleStats from 'api/alerts/ruleStats'; +import topContributors from 'api/alerts/topContributors'; import { TabRoutes } from 'components/RouteTab/types'; import ROUTES from 'constants/routes'; import AlertHistory from 'container/AlertHistory'; @@ -10,7 +11,10 @@ import EditRules from 'pages/EditRules'; import { useQuery, UseQueryResult } from 'react-query'; import { generatePath, useLocation } from 'react-router-dom'; import { ErrorResponse, SuccessResponse } from 'types/api'; -import { AlertRuleStatsPayload } from 'types/api/alerts/def'; +import { + AlertRuleStatsPayload, + AlertRuleTopContributorsPayload, +} from 'types/api/alerts/def'; export const useRouteTabUtils = (): { routes: TabRoutes[] } => { const urlQuery = useUrlQuery(); @@ -131,3 +135,38 @@ export const useGetAlertRuleDetailsStats = (): GetAlertRuleDetailsStatsProps => return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; }; + +type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTopContributors = (): GetAlertRuleDetailsTopContributorsProps => { + const { search } = useLocation(); + const params = new URLSearchParams(search); + + const ruleId = params.get('ruleId'); + const startTime = params.get('startTime'); + const endTime = params.get('endTime'); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + ['ruleIdTopContributors', ruleId, startTime, endTime], + { + queryFn: () => + topContributors({ + id: parseInt(ruleId || '', 10), + start: parseInt(startTime || '', 10), + end: parseInt(endTime || '', 10), + }), + enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; diff --git a/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx index ed5f4c6be8..a70a2bf602 100644 --- a/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx +++ b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx @@ -11,6 +11,11 @@ interface DataStateRendererProps { 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, diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts index 9b343841d0..e8383850db 100644 --- a/frontend/src/types/api/alerts/def.ts +++ b/frontend/src/types/api/alerts/def.ts @@ -68,3 +68,12 @@ interface Value { export type AlertRuleStatsPayload = { data: AlertRuleStats; }; + +export interface AlertRuleTopContributors { + fingerprint: number; + labels: Labels; + count: number; +} +export type AlertRuleTopContributorsPayload = { + data: AlertRuleTopContributors[]; +}; 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; +} From 824439917a11301acaf4a181002514b08586e877 Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sun, 18 Aug 2024 12:07:40 +0430 Subject: [PATCH 08/17] feat: get timeline table data from API --- frontend/src/api/alerts/timelineTable.ts | 35 +++++ .../AlertHistory/Timeline/Table/Table.tsx | 37 ++---- .../AlertHistory/Timeline/Timeline.tsx | 29 +++- .../src/container/AlertHistory/constants.ts | 1 + frontend/src/pages/AlertDetails/hooks.tsx | 124 ++++++++++++++++++ frontend/src/types/api/alerts/def.ts | 16 +++ .../src/types/api/alerts/timelineTable.ts | 15 +++ frontend/src/utils/timeUtils.ts | 2 +- 8 files changed, 231 insertions(+), 28 deletions(-) create mode 100644 frontend/src/api/alerts/timelineTable.ts create mode 100644 frontend/src/container/AlertHistory/constants.ts create mode 100644 frontend/src/types/api/alerts/timelineTable.ts diff --git a/frontend/src/api/alerts/timelineTable.ts b/frontend/src/api/alerts/timelineTable.ts new file mode 100644 index 0000000000..d8ac47d237 --- /dev/null +++ b/frontend/src/api/alerts/timelineTable.ts @@ -0,0 +1,35 @@ +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, + // 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/container/AlertHistory/Timeline/Table/Table.tsx b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx index d2f02a1a3a..782861dc3e 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx @@ -1,22 +1,15 @@ import './table.styles.scss'; -import { Table, Typography } from 'antd'; +import { Table } from 'antd'; import { ColumnsType } from 'antd/es/table'; import AlertPopover from 'container/AlertHistory/AlertPopover/AlertPopover'; -import { timelineData } from 'container/AlertHistory/Statistics/mocks'; import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState'; +import { useTimelineTable } from 'pages/AlertDetails/hooks'; +import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; import { formatEpochTimestamp } from 'utils/timeUtils'; -interface DataType { - state: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - labels: Record; - value: number; - unixMilli: string; -} - -const columns: ColumnsType = [ +const columns: ColumnsType = [ { title: 'STATE', dataIndex: 'state', @@ -59,22 +52,12 @@ const columns: ColumnsType = [ }, ]; -const showPaginationItem = (total: number, range: number[]): JSX.Element => ( - <> - - {range[0]} — {range[1]} - - of {total} - -); +type TimelineTableProps = { + timelineData: AlertRuleTimelineTableResponse[]; +}; -function TimelineTable(): JSX.Element { - const paginationConfig = timelineData.length > 20 && { - pageSize: 20, - showTotal: showPaginationItem, - showSizeChanger: false, - hideOnSinglePage: true, - }; +function TimelineTable({ timelineData }: TimelineTableProps): JSX.Element { + const { paginationConfig, onChangeHandler } = useTimelineTable(); return (
); diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.tsx b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx index 40aa4fe0d7..fcc6a186e1 100644 --- a/frontend/src/container/AlertHistory/Timeline/Timeline.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx @@ -1,9 +1,36 @@ import './timeline.styles.scss'; +import { useGetAlertRuleDetailsTimelineTable } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; + import Graph from './Graph/Graph'; import TimelineTable from './Table/Table'; import TabsAndFilters from './TabsAndFilters/TabsAndFilters'; +function TimelineTableRenderer(): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineTable(); + + return ( + + {(timelineData): JSX.Element => ( + + )} + + ); +} + function Timeline(): JSX.Element { return (
@@ -15,7 +42,7 @@ function Timeline(): JSX.Element {
- +
); 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/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx index 503db241ac..09819e46c5 100644 --- a/frontend/src/pages/AlertDetails/hooks.tsx +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -1,18 +1,30 @@ +import { Typography } from 'antd'; +import { FilterValue, SorterResult } from 'antd/es/table/interface'; +import { TablePaginationConfig, TableProps } from 'antd/lib'; import get from 'api/alerts/get'; import ruleStats from 'api/alerts/ruleStats'; +import timelineTable from 'api/alerts/timelineTable'; import topContributors from 'api/alerts/topContributors'; import { TabRoutes } from 'components/RouteTab/types'; import ROUTES from 'constants/routes'; import AlertHistory from 'container/AlertHistory'; +import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants'; import { AlertDetailsTab } from 'container/AlertHistory/types'; +import { urlKey } from 'container/AllError/utils'; import useUrlQuery from 'hooks/useUrlQuery'; +import createQueryParams from 'lib/createQueryParams'; +import history from 'lib/history'; import { History, Table } from 'lucide-react'; import EditRules from 'pages/EditRules'; +import { OrderPreferenceItems } from 'pages/Logs/config'; +import { useCallback, useMemo } from 'react'; import { useQuery, UseQueryResult } from 'react-query'; import { generatePath, useLocation } from 'react-router-dom'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { AlertRuleStatsPayload, + AlertRuleTimelineTableResponse, + AlertRuleTimelineTableResponsePayload, AlertRuleTopContributorsPayload, } from 'types/api/alerts/def'; @@ -170,3 +182,115 @@ export const useGetAlertRuleDetailsTopContributors = (): GetAlertRuleDetailsTopC return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; }; + +type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimelineTableProps => { + const { search } = useLocation(); + + const params = useMemo(() => new URLSearchParams(search), [search]); + + const { updatedOrder, getUpdatedOffset } = useMemo( + () => ({ + updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC, + getUpdatedOffset: params.get(urlKey.offset) ?? '0', + }), + [params], + ); + + const ruleId = params.get('ruleId'); + const startTime = params.get('startTime'); + const endTime = params.get('endTime'); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + ['ruleIdTimelineTable', ruleId, startTime, endTime], + { + queryFn: () => + timelineTable({ + id: parseInt(ruleId || '', 10), + start: parseInt(startTime || '', 10), + end: parseInt(endTime || '', 10), + limit: 20, + order: updatedOrder, + offset: parseInt(getUpdatedOffset, 10), + }), + enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +const showPaginationItem = (total: number, range: number[]): JSX.Element => ( + <> + + {range[0]} — {range[1]} + + of {total} + +); + +export const useTimelineTable = (): { + 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 updatedOffset = params.get(urlKey.offset) ?? '0'; + + const onChangeHandler: TableProps['onChange'] = useCallback( + ( + paginations: TablePaginationConfig, + filters: Record, + sorter: + | SorterResult[] + | SorterResult, + ) => { + if (!Array.isArray(sorter)) { + const { pageSize = 0, current = 0 } = paginations; + const { columnKey = '', 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 - 1) * pageSize, + orderParam: columnKey, + pageSize, + })}`, + ); + } + }, + [pathname], + ); + + const paginationConfig = { + pageSize: TIMELINE_TABLE_PAGE_SIZE, + showTotal: showPaginationItem, + current: parseInt(updatedOffset, 10) / TIMELINE_TABLE_PAGE_SIZE + 1, + showSizeChanger: false, + hideOnSinglePage: true, + }; + + return { paginationConfig, onChangeHandler }; +}; diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts index e8383850db..1eb8dfeb35 100644 --- a/frontend/src/types/api/alerts/def.ts +++ b/frontend/src/types/api/alerts/def.ts @@ -77,3 +77,19 @@ export interface AlertRuleTopContributors { 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; +} +export type AlertRuleTimelineTableResponsePayload = { + data: AlertRuleTimelineTableResponse[]; +}; diff --git a/frontend/src/types/api/alerts/timelineTable.ts b/frontend/src/types/api/alerts/timelineTable.ts new file mode 100644 index 0000000000..dad06f6f2c --- /dev/null +++ b/frontend/src/types/api/alerts/timelineTable.ts @@ -0,0 +1,15 @@ +import { AlertDef } from './def'; + +export interface Filters { + [k: string]: string[]; +} + +export interface GetTimelineTableRequestProps { + id: AlertDef['id']; + start: number; + end: number; + offset: number; + limit: number; + order: string; + filters?: Filters; +} diff --git a/frontend/src/utils/timeUtils.ts b/frontend/src/utils/timeUtils.ts index b21500c3c5..132296d65d 100644 --- a/frontend/src/utils/timeUtils.ts +++ b/frontend/src/utils/timeUtils.ts @@ -65,7 +65,7 @@ export const getDurationFromNow = (epochTimestamp: number): string => { * @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 * 1000); + const date = new Date(epoch); const optionsDate: Intl.DateTimeFormatOptions = { month: 'short', From b4e029d87a202755bf2612ea01359c47740a19bf Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sun, 18 Aug 2024 12:21:52 +0430 Subject: [PATCH 09/17] fix: properly render change percentage indicator --- .../AlertHistory/Statistics/StatsCard/StatsCard.tsx | 6 +++--- .../AlertHistory/Statistics/StatsCard/statsCard.styles.scss | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx index 6f7ee7c92a..708ad85b44 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx @@ -16,16 +16,16 @@ function ChangePercentage({ duration, }: ChangePercentageProps): JSX.Element { if (!percentage || !duration) { - return
; + return
; } return (
0 ? 'change-percentage--success' : 'change-percentage--error' }`} >
- {direction ? ( + {direction > 0 ? ( ) : ( diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss index b9fd44030a..d9b4bd4580 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss @@ -74,16 +74,17 @@ &--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); } &__icon { display: flex; align-self: center; } &__label { - color: var(--bg-forest-500); font-size: 12px; font-weight: 500; line-height: 16px; From 585ae5d63eff16f103f4d6725e8ca15159c71aec Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sun, 18 Aug 2024 12:40:41 +0430 Subject: [PATCH 10/17] feat: total triggered and avg resolution empty states --- .../AverageResolutionCard.tsx | 14 +++----- .../averageResolutionCard.styles.scss | 3 -- .../Statistics/StatsCard/StatsCard.tsx | 33 ++++++++++++++----- .../StatsCard/statsCard.styles.scss | 5 +++ .../StatsCardsRenderer/StatsCardsRenderer.tsx | 17 ++++++++-- .../TotalTriggeredCard/TotalTriggeredCard.tsx | 14 +++----- .../totalTriggeredCard.styles.scss | 3 -- frontend/src/utils/calculateChange.ts | 12 +++++-- 8 files changed, 65 insertions(+), 36 deletions(-) delete mode 100644 frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/averageResolutionCard.styles.scss delete mode 100644 frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/totalTriggeredCard.styles.scss diff --git a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx index fb4b7fb59a..9a39c5a0e1 100644 --- a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx @@ -1,5 +1,3 @@ -import './averageResolutionCard.styles.scss'; - import { AlertRuleStats } from 'types/api/alerts/def'; import StatsCard from '../StatsCard/StatsCard'; @@ -14,13 +12,11 @@ function AverageResolutionCard({ pastAvgResolutionTime, }: TotalTriggeredCardProps): JSX.Element { return ( -
- -
+ ); } diff --git a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/averageResolutionCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/averageResolutionCard.styles.scss deleted file mode 100644 index 6f4d8e6c78..0000000000 --- a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/averageResolutionCard.styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -.average-resolution-card { - width: 21.7%; -} diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx index 708ad85b44..427b286a52 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx @@ -39,15 +39,19 @@ function ChangePercentage({ } type StatsCardProps = { - totalCurrentCount: number; - totalPastCount: number; + totalCurrentCount?: number; + totalPastCount?: number; title: string; + isEmpty?: boolean; + emptyMessage?: string; }; function StatsCard({ totalCurrentCount, totalPastCount, title, + isEmpty, + emptyMessage, }: StatsCardProps): JSX.Element { const urlQuery = useUrlQuery(); @@ -59,7 +63,7 @@ function StatsCard({ ); return ( -
+
{title}
@@ -69,8 +73,11 @@ function StatsCard({
{relativeTime}
+
-
{totalCurrentCount}
+
+ {isEmpty ? emptyMessage : totalCurrentCount} +
-
-
- + + {!isEmpty && ( +
+
+ +
-
+ )}
); } +StatsCard.defaultProps = { + totalCurrentCount: 0, + totalPastCount: 0, + isEmpty: false, + emptyMessage: 'No Data', +}; + export default StatsCard; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss index d9b4bd4580..1e6e9744da 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss @@ -1,4 +1,5 @@ .stats-card { + width: 21.7%; height: 100%; display: flex; flex-direction: column; @@ -6,6 +7,10 @@ border-right: 1px solid var(--bg-slate-500); padding: 9px 12px; + &--empty { + justify-content: normal; + } + &__title-wrapper { display: flex; justify-content: space-between; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx index 6a19c3fb05..227538feba 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx +++ b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx @@ -2,6 +2,7 @@ import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks'; import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard'; +import StatsCard from '../StatsCard/StatsCard'; import TotalTriggeredCard from '../TotalTriggeredCard/TotalTriggeredCard'; const isTypeNotNan = (value: unknown): boolean => value !== 'NaN'; @@ -57,22 +58,34 @@ function StatsCardsRenderer({ return ( <> {/* TODO(shaheer): get hasTotalTriggeredStats when it's available in the API */} - {hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) && ( + {hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) ? ( + ) : ( + )} {/* TODO(shaheer): get hasAvgResolutionTimeStats when it's available in the API */} {hasAvgResolutionTimeStats( currentAvgResolutionTime, pastAvgResolutionTime, - ) && ( + ) ? ( + ) : ( + )} ); diff --git a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx index dbc9cf85ec..177d86d27e 100644 --- a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx @@ -1,5 +1,3 @@ -import './totalTriggeredCard.styles.scss'; - import { AlertRuleStats } from 'types/api/alerts/def'; import StatsCard from '../StatsCard/StatsCard'; @@ -14,13 +12,11 @@ function TotalTriggeredCard({ totalPastTriggers, }: TotalTriggeredCardProps): JSX.Element { return ( -
- -
+ ); } diff --git a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/totalTriggeredCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/totalTriggeredCard.styles.scss deleted file mode 100644 index 27c8929928..0000000000 --- a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/totalTriggeredCard.styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -.total-triggered-card { - width: 21.7%; -} diff --git a/frontend/src/utils/calculateChange.ts b/frontend/src/utils/calculateChange.ts index 17186ac011..78975e9903 100644 --- a/frontend/src/utils/calculateChange.ts +++ b/frontend/src/utils/calculateChange.ts @@ -1,7 +1,15 @@ export function calculateChange( - totalCurrentTriggers: number, - totalPastTriggers: number, + totalCurrentTriggers: number | undefined, + totalPastTriggers: number | undefined, ): { changePercentage: number; changeDirection: number } { + if ( + totalCurrentTriggers === undefined || + totalPastTriggers === undefined || + totalPastTriggers === 0 + ) { + return { changePercentage: 0, changeDirection: 0 }; + } + let changePercentage = ((totalCurrentTriggers - totalPastTriggers) / totalPastTriggers) * 100; From 43a9e72dd61d70bb179b97c3c92b1411d9939ca7 Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sun, 18 Aug 2024 13:07:32 +0430 Subject: [PATCH 11/17] fix: fix stats height issue that would cause short border-right in empty case --- .../AlertHistory/Statistics/StatsCard/statsCard.styles.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss index 1e6e9744da..581611df30 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss @@ -1,6 +1,5 @@ .stats-card { width: 21.7%; - height: 100%; display: flex; flex-direction: column; justify-content: space-between; From b0605fd0e617b3fd0e1a03dc61bb6b5e902a8de7 Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sun, 18 Aug 2024 16:57:49 +0430 Subject: [PATCH 12/17] feat: top contributors empty state --- .../TopContributorsCard.tsx | 78 +++++++++++++------ .../topContributorsCard.styles.scss | 39 ++++++++++ 2 files changed, 94 insertions(+), 23 deletions(-) diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx index 3324ba14c9..f83483b961 100644 --- a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx @@ -1,6 +1,6 @@ import './topContributorsCard.styles.scss'; -import { Progress } from 'antd'; +import { Button, Progress } from 'antd'; import AlertPopover from 'container/AlertHistory/AlertPopover/AlertPopover'; import { ArrowRight } from 'lucide-react'; import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; @@ -8,6 +8,56 @@ import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; import { statsData } from '../mocks'; +function TopContributorsContent({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const isEmpty = !topContributorsData.length; + + if (isEmpty) { + return ( +
+
ℹ️
+
+ Add Group By Field To view top + contributors, please add at least one group by field to your query. +
+
+ +
+
+ ); + } + + return ( + <> + {topContributorsData.slice(0, 3).map((contributor, index) => ( + +
+
+ +
+
+ +
+
+ {contributor.count}/{totalCurrentTriggers} +
+
+
+ ))} + + ); +} type TopContributorsCardProps = { topContributorsData: AlertRuleTopContributors[]; totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; @@ -28,28 +78,10 @@ function TopContributorsCard({
- {topContributorsData.slice(0, 3).map((contributor, index) => ( - -
-
- -
-
- -
-
- {contributor.count}/{totalCurrentTriggers} -
-
-
- ))} +
); diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss index 95fa3c7d75..a53cd0a82b 100644 --- a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss @@ -64,6 +64,45 @@ } } } + .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) { From 26846f3dc079e349476edfa47b4ee79d434aa612 Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sun, 18 Aug 2024 17:14:48 +0430 Subject: [PATCH 13/17] fix: fix table and graph borders --- .../AlertHistory/Timeline/Graph/graph.styles.scss | 2 ++ .../AlertHistory/Timeline/Table/table.styles.scss | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss b/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss index 11d6e446f0..5b5bf64c72 100644 --- a/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss +++ b/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss @@ -4,6 +4,8 @@ gap: 24px; background: var(--bg-ink-400); padding: 12px; + border-radius: 4px; + border: 1px solid var(--bg-slate-500); &__title { width: max-content; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss b/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss index 1519c1de7b..6cd7caff85 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss +++ b/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss @@ -5,10 +5,14 @@ } .timeline-table { + border: 1px solid var(--text-slate-500); + border-radius: 6px; + overflow: hidden; + .ant-table { background: var(--bg-ink-500); &-thead > tr > th { - border: none; + border-color: var(--bg-slate-500); background: var(--bg-ink-500); &:last-of-type, &:nth-last-of-type(2) { @@ -23,8 +27,12 @@ text-align: right; } } - &-cell::before { - display: none; + &-cell { + padding: 16px !important; + + &::before { + display: none; + } } @include cell-width(1, 17%); From 81e0bfc19cb096720bd326caf6bc0bfb3bf6b4be Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sun, 18 Aug 2024 18:22:55 +0430 Subject: [PATCH 14/17] feat: build alert timeline labels filter and handle client side filtering --- .../AlertHistory/Timeline/Table/Table.tsx | 70 ++++------------- .../Timeline/Table/table.styles.scss | 15 ++++ .../AlertHistory/Timeline/Table/types.ts | 5 ++ .../Timeline/Table/useTimelineTable.tsx | 76 +++++++++++++++++++ 4 files changed, 112 insertions(+), 54 deletions(-) create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/types.ts create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx index 782861dc3e..d220cdf92a 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx @@ -1,68 +1,30 @@ import './table.styles.scss'; import { Table } from 'antd'; -import { ColumnsType } from 'antd/es/table'; -import AlertPopover from 'container/AlertHistory/AlertPopover/AlertPopover'; -import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; -import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState'; import { useTimelineTable } from 'pages/AlertDetails/hooks'; -import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; -import { formatEpochTimestamp } from 'utils/timeUtils'; +import { useMemo, useState } from 'react'; -const columns: ColumnsType = [ - { - title: 'STATE', - dataIndex: 'state', - render: (value): JSX.Element => ( - -
- -
-
- ), - }, - { - title: 'LABELS', - dataIndex: 'labels', - render: (labels): JSX.Element => ( - -
- -
-
- ), - }, - { - title: 'VALUE', - dataIndex: 'value', - render: (value): JSX.Element => ( - -
{value}
-
- ), - }, - { - title: 'CREATED AT', - dataIndex: 'unixMilli', - render: (value): JSX.Element => ( - -
{formatEpochTimestamp(value)}
-
- ), - }, -]; - -type TimelineTableProps = { - timelineData: AlertRuleTimelineTableResponse[]; -}; +import { TimelineTableProps } from './types'; +import { timelineTableColumns } from './useTimelineTable'; function TimelineTable({ timelineData }: TimelineTableProps): JSX.Element { + const [searchText, setSearchText] = useState(''); const { paginationConfig, onChangeHandler } = useTimelineTable(); + + const visibleTimelineData = useMemo(() => { + if (searchText === '') { + return timelineData; + } + return timelineData.filter((data) => + JSON.stringify(data.labels).toLowerCase().includes(searchText.toLowerCase()), + ); + }, [searchText, timelineData]); + return (
tr > th { border-color: var(--bg-slate-500); background: var(--bg-ink-500); + font-size: 12px; + font-weight: 500; &:last-of-type, &:nth-last-of-type(2) { text-align: right; @@ -43,6 +45,19 @@ .ant-pagination-total-text { margin-right: auto; } + .label-filter { + padding: 6px 8px; + border-radius: 4px; + background: var(--text-ink-400); + border-width: 0; + & ::placeholder { + color: var(--text-vanilla-400); + font-size: 12px; + letter-spacing: 0.6px; + text-transform: uppercase; + font-weight: 500; + } + } .alert-rule { &-value, &-created-at { 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..0ad90fbb03 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/types.ts @@ -0,0 +1,5 @@ +import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; + +export type TimelineTableProps = { + timelineData: AlertRuleTimelineTableResponse[]; +}; 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..adf0049919 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx @@ -0,0 +1,76 @@ +import { Input } from 'antd'; +import { ColumnsType } from 'antd/es/table'; +import AlertPopover from 'container/AlertHistory/AlertPopover/AlertPopover'; +import { debounce } from 'lodash-es'; +import { Search } from 'lucide-react'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState'; +import { BaseSyntheticEvent } from 'react'; +import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; +import { formatEpochTimestamp } from 'utils/timeUtils'; + +function LabelFilter({ + setSearchText, +}: { + setSearchText: (text: string) => void; +}): JSX.Element { + const handleSearch = (searchEv: BaseSyntheticEvent): void => { + setSearchText(searchEv?.target?.value || ''); + }; + + const handleDebouncedSearch = debounce(handleSearch, 300); + + return ( + } + /> + ); +} + +export const timelineTableColumns = ( + setSearchText: (text: string) => void, +): ColumnsType => [ + { + title: 'STATE', + dataIndex: 'state', + render: (value): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: , + dataIndex: 'labels', + render: (labels): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'VALUE', + dataIndex: 'value', + render: (value): JSX.Element => ( + +
{value}
+
+ ), + }, + { + title: 'CREATED AT', + dataIndex: 'unixMilli', + render: (value): JSX.Element => ( + +
{formatEpochTimestamp(value)}
+
+ ), + }, +]; From 0b84e7488de82043f14fe0d1a8dcd9c198314d1f Mon Sep 17 00:00:00 2001 From: ahmadshaheer Date: Sun, 18 Aug 2024 20:36:54 +0430 Subject: [PATCH 15/17] fix: select the first tab on clicking reset --- frontend/src/periscope/components/Tabs2/Tabs2.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/periscope/components/Tabs2/Tabs2.tsx b/frontend/src/periscope/components/Tabs2/Tabs2.tsx index b0c41973d1..9d1bab1621 100644 --- a/frontend/src/periscope/components/Tabs2/Tabs2.tsx +++ b/frontend/src/periscope/components/Tabs2/Tabs2.tsx @@ -1,6 +1,7 @@ import './tabs2.styles.scss'; import { Button } from 'antd'; +import { TimelineFilter } from 'container/AlertHistory/types'; import { Undo } from 'lucide-react'; import { useState } from 'react'; @@ -13,7 +14,7 @@ interface Tab { interface TimelineTabsProps { tabs: Tab[]; - onSelectTab?: (selectedTab: string) => void; + onSelectTab?: (selectedTab: TimelineFilter) => void; initialSelectedTab?: string; hasResetButton?: boolean; } @@ -31,17 +32,17 @@ function Tabs2({ const handleTabClick = (tabValue: string): void => { setSelectedTab(tabValue); if (onSelectTab) { - onSelectTab(tabValue); + onSelectTab(tabValue as TimelineFilter); } }; return (
- {hasResetButton && selectedTab !== initialSelectedTab && ( + {hasResetButton && selectedTab !== tabs[0].value && (
); } -function AlertPopover({ children }: Props): JSX.Element { +PopoverContent.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +function AlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: Props): JSX.Element { return ( - } - trigger="click" - > - {children} - +
+ + } + 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 index 9a39c5a0e1..f55c4385ce 100644 --- a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx @@ -1,21 +1,26 @@ 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 ( ); } diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx index 9b424709cc..dfb3329b96 100644 --- a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx @@ -1,12 +1,17 @@ import './statistics.styles.scss'; -import { useState } from 'react'; +import { AlertRuleStats } from 'types/api/alerts/def'; import StatsCardsRenderer from './StatsCardsRenderer/StatsCardsRenderer'; import TopContributorsRenderer from './TopContributorsRenderer/TopContributorsRenderer'; -function Statistics(): JSX.Element { - const [totalCurrentTriggers, setTotalCurrentTriggers] = useState(0); +function Statistics({ + setTotalCurrentTriggers, + totalCurrentTriggers, +}: { + setTotalCurrentTriggers: (value: number) => void; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { return (
diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx index 427b286a52..44310ec121 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx @@ -1,10 +1,12 @@ import './statsCard.styles.scss'; -import { DotChartOutlined } from '@ant-design/icons'; 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'; + type ChangePercentageProps = { percentage: number; direction: number; @@ -15,25 +17,34 @@ function ChangePercentage({ direction, duration, }: ChangePercentageProps): JSX.Element { - if (!percentage || !duration) { - return
; - } - return ( -
0 ? 'change-percentage--success' : 'change-percentage--error' - }`} - > -
- {direction > 0 ? ( + if (direction > 0) { + return ( +
+
- ) : ( - - )} +
+
+ {percentage}% vs Last {duration} +
-
- {percentage}% vs Last {duration} + ); + } + if (direction < 0) { + return ( +
+
+ +
+
+ {percentage}% vs Last {duration} +
+ ); + } + + return ( +
+
no previous data
); } @@ -44,14 +55,18 @@ type StatsCardProps = { 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(); @@ -76,7 +91,7 @@ function StatsCard({
- {isEmpty ? emptyMessage : totalCurrentCount} + {isEmpty ? emptyMessage : displayValue || totalCurrentCount}
- {!isEmpty && ( -
-
- -
+
+
+ {!isEmpty && timeSeries.length > 1 && ( + + )}
- )} +
); } @@ -102,6 +117,8 @@ StatsCard.defaultProps = { 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/statsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss index 581611df30..bb9d3c3e72 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss @@ -1,10 +1,7 @@ .stats-card { width: 21.7%; - display: flex; - flex-direction: column; - justify-content: space-between; border-right: 1px solid var(--bg-slate-500); - padding: 9px 12px; + padding: 9px 12px 13px; &--empty { justify-content: normal; @@ -57,13 +54,6 @@ .graph { width: 100%; height: 72px; - background: #ffffff1f; - display: flex; - align-items: center; - justify-content: center; - .graph-icon { - font-size: 2rem; - } } } } @@ -84,6 +74,11 @@ 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; @@ -94,3 +89,24 @@ 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/StatsCardsRenderer/StatsCardsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx index 227538feba..e8859131df 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx +++ b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx @@ -1,23 +1,24 @@ 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 isTypeNotNan = (value: unknown): boolean => value !== 'NaN'; - const hasTotalTriggeredStats = ( - totalCurrentTriggers: unknown, - totalPastTriggers: unknown, + totalCurrentTriggers: number | string, + totalPastTriggers: number | string, ): boolean => - isTypeNotNan(totalCurrentTriggers) || isTypeNotNan(totalPastTriggers); + (Number(totalCurrentTriggers) > 0 && Number(totalPastTriggers) > 0) || + Number(totalCurrentTriggers) > 0; const hasAvgResolutionTimeStats = ( - currentAvgResolutionTime: unknown, - pastAvgResolutionTime: unknown, + currentAvgResolutionTime: number | string, + pastAvgResolutionTime: number | string, ): boolean => - isTypeNotNan(currentAvgResolutionTime) || isTypeNotNan(pastAvgResolutionTime); + (Number(currentAvgResolutionTime) > 0 && Number(pastAvgResolutionTime) > 0) || + Number(currentAvgResolutionTime) > 0; type StatsCardsRendererProps = { setTotalCurrentTriggers: (value: number) => void; @@ -36,6 +37,12 @@ function StatsCardsRenderer({ ruleId, } = useGetAlertRuleDetailsStats(); + useEffect(() => { + if (data?.payload?.data?.totalCurrentTriggers !== undefined) { + setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers); + } + }, [data, setTotalCurrentTriggers]); + return ( - {/* TODO(shaheer): get hasTotalTriggeredStats when it's available in the API */} {hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) ? ( ) : ( )} - {/* TODO(shaheer): get hasAvgResolutionTimeStats when it's available in the API */} {hasAvgResolutionTimeStats( currentAvgResolutionTime, pastAvgResolutionTime, @@ -79,6 +83,7 @@ function StatsCardsRenderer({ ) : ( new URLSearchParams(search), [search]); - if (isEmpty) { - return ( -
-
ℹ️
-
- Add Group By Field To view top - contributors, please add at least one group by field to your query. -
-
- -
-
- ); - } + const viewAllTopContributorsParam = searchParams.get('viewAllTopContributors'); - return ( - <> - {topContributorsData.slice(0, 3).map((contributor, index) => ( - -
-
- -
-
- -
-
- {contributor.count}/{totalCurrentTriggers} -
-
-
- ))} - + const [isViewAllVisible, setIsViewAllVisible] = useState( + !!viewAllTopContributorsParam ?? false, ); -} -type TopContributorsCardProps = { - topContributorsData: AlertRuleTopContributors[]; - totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; -}; -function TopContributorsCard({ - topContributorsData, - totalCurrentTriggers, -}: TopContributorsCardProps): JSX.Element { + + 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
-
-
View all
-
- -
+ <> +
+
+
top contributors
+ {topContributorsData.length > 3 && ( + + )}
-
-
+
-
+ {isViewAllVisible && ( + + )} + ); } 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..39a2f8f27a --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx @@ -0,0 +1,59 @@ +import { Button } from 'antd'; +import { QueryParams } from 'constants/query'; +import ROUTES from 'constants/routes'; +import useUrlQuery from 'hooks/useUrlQuery'; +import history from 'lib/history'; + +import TopContributorsRows from './TopContributorsRows'; +import { TopContributorsCardProps } from './types'; + +function TopContributorsContent({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const isEmpty = !topContributorsData.length; + + const urlQuery = useUrlQuery(); + const ruleIdKey = QueryParams.ruleId; + const relativeTimeKey = QueryParams.relativeTime; + + const handleRedirectToOverview = (): void => { + const params = `${ruleIdKey}=${urlQuery.get( + ruleIdKey, + )}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`; + + history.push(`${ROUTES.ALERT_OVERVIEW}?${params}`); + }; + + if (isEmpty) { + return ( +
+
ℹ️
+
+ Add Group By Field To view top + contributors, please add at least one group by field to your query. +
+
+ +
+
+ ); + } + + 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..ac7f9fd2d9 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx @@ -0,0 +1,86 @@ +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/topContributorsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss index a53cd0a82b..e6e09fef2a 100644 --- a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss @@ -1,5 +1,9 @@ .top-contributors-card { width: 56.6%; + + &--view-all { + width: auto; + } &__header { display: flex; align-items: center; @@ -19,6 +23,12 @@ display: flex; align-items: center; gap: 4px; + cursor: pointer; + padding: 0; + height: 20px; + &:hover { + background-color: transparent !important; + } .label { color: var(--text-vanilla-400); @@ -31,38 +41,36 @@ } } } + .contributors-row { + height: 80px; + } &__content { - .contributors-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 12px; - &:hover { - background: rgba(171, 189, 255, 0.04); - } - cursor: pointer; - &:not(:last-of-type) { - border-bottom: 1px solid var(--bg-slate-500); + .ant-table { + &-cell { + padding: 12px !important; } + } + .contributors-row { + background: var(--bg-ink-500); - .labels-wrapper { - width: 50%; + td { + border: none !important; } - .contribution-progress-bar { - width: 40%; - } - .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; + &: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; @@ -80,7 +88,7 @@ letter-spacing: -0.103px; } &__text { - color: var(--text-Vanilla-400); + color: var(--text-vanilla-400); line-height: 18px; .bold-text { color: var(--text-vanilla-100); @@ -133,3 +141,50 @@ } } } + +.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/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/TotalTriggeredCard/TotalTriggeredCard.tsx b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx index 177d86d27e..0e4f412894 100644 --- a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx @@ -5,17 +5,20 @@ 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 ( ); } diff --git a/frontend/src/container/AlertHistory/Statistics/mocks.ts b/frontend/src/container/AlertHistory/Statistics/mocks.ts deleted file mode 100644 index 95e27be7e8..0000000000 --- a/frontend/src/container/AlertHistory/Statistics/mocks.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* eslint-disable sonarjs/no-duplicate-string */ -export const statsData = { - totalCurrentTriggers: 14, - totalPastTriggers: 15, - currentTriggersSeries: { - values: [ - { - timestamp: '1625097600000', // Sample epoch value - value: 100, - }, - ], - }, - pastTriggersSeries: { - values: [ - { - timestamp: '1625097600000', // Sample epoch value - value: 100, - }, - ], - }, - currentAvgResolutionTime: 1.3, - pastAvgResolutionTime: 3.2, - currentAvgResolutionTimeSeries: { - values: [ - { - timestamp: '1625097600000', // Sample epoch value - value: 100, - }, - ], - }, - pastAvgResolutionTimeSeries: { - values: [ - { - timestamp: '1625097600000', // Sample epoch value - value: 100, - }, - ], - }, -}; - -export const topContributorsData = { - contributors: [ - { - labels: { - operation: 'POST /transaction-module/record-payment', - service_name: 'order-service-prod', - k3: 'v3', - }, - count: 6, - }, - { - labels: { - operation: 'GET /financial-module/account-statement', - service_name: 'catalog-manager-001', - k3: 'v3', - }, - count: 4, - }, - { - labels: { - operation: 'GET /financial-module/account-statement', - service_name: 'catalog-manager-001', - k3: 'v3', - }, - count: 2, - }, - { - labels: { - operation: 'GET /financial-module/account-statement', - service_name: 'catalog-manager-001', - k3: 'v3', - }, - count: 2, - }, - ], -}; -function getRandomInt(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -function getRandomState(): string { - const states = ['firing', 'resolved', 'pending']; - return states[getRandomInt(0, states.length - 1)]; -} - -function getRandomOperation(): string { - const operations = [ - 'GET /financial-module/account-statement', - 'POST /user/login', - 'DELETE /user/logout', - 'PUT /order/update', - 'PATCH /product/modify', - ]; - return operations[getRandomInt(0, operations.length - 1)]; -} - -function getRandomServiceName(): string { - const services = [ - 'catalog-manager-001', - 'user-service-002', - 'order-service-003', - 'payment-gateway-004', - 'inventory-service-005', - ]; - return services[getRandomInt(0, services.length - 1)]; -} - -function getRandomK3(): string { - const k3Versions = ['v1', 'v2', 'v3', 'v4', 'v5']; - return k3Versions[getRandomInt(0, k3Versions.length - 1)]; -} - -function getRandomUnixMilli(): string { - const start = new Date(2021, 0, 1).getTime(); - const end = new Date(2022, 0, 1).getTime(); - return (getRandomInt(start, end) / 1000).toString(); -} - -export const timelineData = Array.from({ length: 500 }, () => ({ - unixMilli: getRandomUnixMilli(), - state: getRandomState(), - labels: { - operation: getRandomOperation(), - service_name: getRandomServiceName(), - k3: getRandomK3(), - }, - value: getRandomInt(0, 100), -})); diff --git a/frontend/src/container/AlertHistory/Statistics/statistics.styles.scss b/frontend/src/container/AlertHistory/Statistics/statistics.styles.scss index 7afcc8dcae..cc0a5b1b43 100644 --- a/frontend/src/container/AlertHistory/Statistics/statistics.styles.scss +++ b/frontend/src/container/AlertHistory/Statistics/statistics.styles.scss @@ -1,7 +1,14 @@ .statistics { display: flex; justify-content: space-between; - width: 100%; + 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/Timeline/Graph/Graph.tsx b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx index 07861445cb..ce104b8aa5 100644 --- a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx @@ -1,26 +1,100 @@ -import './graph.styles.scss'; +/* eslint-disable consistent-return */ +/* eslint-disable react/jsx-props-no-spreading */ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +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 { BarChartOutlined } from '@ant-design/icons'; -import { statsData } from 'container/AlertHistory/Statistics/mocks'; -import useUrlQuery from 'hooks/useUrlQuery'; +import { ALERT_STATUS, TIMELINE_OPTIONS } from './constants'; -function Graph(): JSX.Element { - const urlQuery = useUrlQuery(); +type Props = { type: string; data: AlertRuleTimelineGraphResponse[] }; - const relativeTime = urlQuery.get('relativeTime'); +function HorizontalTimelineGraph({ + width, + isDarkMode, + data, +}: { + width: number; + isDarkMode: boolean; + data: AlertRuleTimelineGraphResponse[]; +}): JSX.Element { + const transformedData: AlignedData = useMemo( + () => + data?.length > 1 + ? [ + data.map((item: AlertRuleTimelineGraphResponse) => item.start / 1000), + data.map( + (item: AlertRuleTimelineGraphResponse) => ALERT_STATUS[item.state], + ), + ] + : [[], []], - return ( -
-
- {statsData.totalCurrentTriggers} triggers in {relativeTime} -
-
-
- -
-
-
+ [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 ; +} + +function Graph({ type, data }: Props): JSX.Element | null { + const graphRef = useRef(null); + + const isDarkMode = useIsDarkMode(); + + const containerDimensions = useResizeObserver(graphRef); + + if (type === 'horizontal') { + return ( +
+ +
+ ); + } + return null; } 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..34a79df486 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts @@ -0,0 +1,32 @@ +import { Color } from '@signozhq/design-tokens'; + +export const ALERT_STATUS: { [key: string]: number } = { + firing: 0, + 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/Graph/graph.styles.scss b/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss index 5b5bf64c72..3ea30fe25a 100644 --- a/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss +++ b/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss @@ -6,6 +6,7 @@ padding: 12px; border-radius: 4px; border: 1px solid var(--bg-slate-500); + height: 150px; &__title { width: max-content; @@ -32,3 +33,20 @@ } } } + +.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/GraphWrapper/GraphWrapper.tsx b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx new file mode 100644 index 0000000000..adc38a6207 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx @@ -0,0 +1,46 @@ +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(); + + return ( +
+
+ {totalCurrentTriggers} triggers in {relativeTime} +
+
+ + {(data): JSX.Element => } + +
+
+ ); +} + +export default GraphWrapper; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx index d220cdf92a..2e887fc27f 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx @@ -1,15 +1,23 @@ import './table.styles.scss'; import { Table } from 'antd'; +import { QueryParams } from 'constants/query'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { useTimelineTable } from 'pages/AlertDetails/hooks'; import { useMemo, useState } from 'react'; +import { useQueryClient } from 'react-query'; +import { useLocation } from 'react-router-dom'; +import { PayloadProps } from 'types/api/alerts/get'; import { TimelineTableProps } from './types'; import { timelineTableColumns } from './useTimelineTable'; -function TimelineTable({ timelineData }: TimelineTableProps): JSX.Element { +function TimelineTable({ + timelineData, + totalItems, +}: TimelineTableProps): JSX.Element { const [searchText, setSearchText] = useState(''); - const { paginationConfig, onChangeHandler } = useTimelineTable(); + const { paginationConfig, onChangeHandler } = useTimelineTable({ totalItems }); const visibleTimelineData = useMemo(() => { if (searchText === '') { @@ -20,15 +28,36 @@ function TimelineTable({ timelineData }: TimelineTableProps): JSX.Element { ); }, [searchText, timelineData]); + const queryClient = useQueryClient(); + + const { search } = useLocation(); + const params = new URLSearchParams(search); + + const ruleId = params.get(QueryParams.ruleId); + + const { currentUnit, targetUnit } = useMemo(() => { + const alertDetailsQuery = queryClient.getQueryData([ + REACT_QUERY_KEY.ALERT_RULE_DETAILS, + ruleId, + ]) as { + payload: PayloadProps; + }; + const condition = alertDetailsQuery?.payload?.data?.condition; + const { targetUnit } = condition ?? {}; + const { unit: currentUnit } = condition?.compositeQuery ?? {}; + + return { currentUnit, targetUnit }; + }, [queryClient, ruleId]); + return (
`${row.fingerprint}-${row.value}`} + columns={timelineTableColumns(setSearchText, currentUnit, targetUnit)} dataSource={visibleTimelineData} pagination={paginationConfig} size="middle" onChange={onChangeHandler} - // TODO(shaheer): get total entries when we get an API for it /> ); diff --git a/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss b/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss index dc22093d7d..eede4c8c99 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss +++ b/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss @@ -1,21 +1,25 @@ -@mixin cell-width($index, $width) { - &-cell:nth-last-of-type(#{$index}) { - width: $width; - } -} - .timeline-table { - border: 1px solid var(--text-slate-500); + border-top: 1px solid var(--text-slate-500); + border-radius: 6px; overflow: hidden; + margin-top: 4px; .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, &:nth-last-of-type(2) { text-align: right; @@ -23,33 +27,19 @@ } &-tbody > tr > td { border: none; - cursor: pointer; &:last-of-type, &:nth-last-of-type(2) { text-align: right; } } - &-cell { - padding: 16px !important; - - &::before { - display: none; - } - } - - @include cell-width(1, 17%); - @include cell-width(2, 6%); - @include cell-width(3, 67%); - @include cell-width(4, 10%); - } - .ant-pagination-total-text { - margin-right: auto; } + .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; @@ -62,7 +52,7 @@ &-value, &-created-at { font-size: 14px; - color: var(--text-Vanilla-400); + color: var(--text-vanilla-400); } &-value { font-weight: 500; @@ -73,4 +63,65 @@ 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; + } + } + } +} + +.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); + } + } + .label-filter { + &, + & input { + 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/types.ts b/frontend/src/container/AlertHistory/Timeline/Table/types.ts index 0ad90fbb03..badf649867 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/types.ts +++ b/frontend/src/container/AlertHistory/Timeline/Table/types.ts @@ -1,5 +1,9 @@ -import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; +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 index adf0049919..5a37b145e0 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx @@ -1,6 +1,8 @@ import { Input } from 'antd'; import { ColumnsType } from 'antd/es/table'; -import AlertPopover from 'container/AlertHistory/AlertPopover/AlertPopover'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { convertValue } from 'lib/getConvertedValue'; import { debounce } from 'lodash-es'; import { Search } from 'lucide-react'; import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; @@ -19,58 +21,86 @@ function LabelFilter({ }; const handleDebouncedSearch = debounce(handleSearch, 300); + const isDarkMode = useIsDarkMode(); return ( } + suffix={ + + } /> ); } export const timelineTableColumns = ( setSearchText: (text: string) => void, + currentUnit?: string, + targetUnit?: string, ): ColumnsType => [ { title: 'STATE', dataIndex: 'state', - render: (value): JSX.Element => ( - + sorter: true, + width: '12.5%', + render: (value, record): JSX.Element => ( +
-
+ ), }, { title: , dataIndex: 'labels', - render: (labels): JSX.Element => ( - + width: '54.5%', + render: (labels, record): JSX.Element => ( +
-
+ ), }, { title: 'VALUE', dataIndex: 'value', - render: (value): JSX.Element => ( - -
{value}
-
+ width: '14%', + render: (value, record): JSX.Element => ( + +
+ {/* convert the value based on y axis and target unit */} + {convertValue(value.toFixed(2), currentUnit, targetUnit)} +
+
), }, { title: 'CREATED AT', dataIndex: 'unixMilli', - render: (value): JSX.Element => ( - + width: '32.5%', + render: (value, record): JSX.Element => ( +
{formatEpochTimestamp(value)}
-
+ ), }, ]; diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.tsx b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx index fcc6a186e1..dca30f481a 100644 --- a/frontend/src/container/AlertHistory/Timeline/Timeline.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx @@ -3,7 +3,7 @@ import './timeline.styles.scss'; import { useGetAlertRuleDetailsTimelineTable } from 'pages/AlertDetails/hooks'; import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; -import Graph from './Graph/Graph'; +import GraphWrapper from './GraphWrapper/GraphWrapper'; import TimelineTable from './Table/Table'; import TabsAndFilters from './TabsAndFilters/TabsAndFilters'; @@ -25,13 +25,20 @@ function TimelineTableRenderer(): JSX.Element { data={data?.payload?.data || null} > {(timelineData): JSX.Element => ( - + )} ); } -function Timeline(): JSX.Element { +function Timeline({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { return (
Timeline
@@ -39,7 +46,7 @@ function Timeline(): JSX.Element {
- +
diff --git a/frontend/src/container/AlertHistory/Timeline/timeline.styles.scss b/frontend/src/container/AlertHistory/Timeline/timeline.styles.scss index 311fa444f0..df5875403c 100644 --- a/frontend/src/container/AlertHistory/Timeline/timeline.styles.scss +++ b/frontend/src/container/AlertHistory/Timeline/timeline.styles.scss @@ -1,7 +1,8 @@ .timeline { display: flex; flex-direction: column; - gap: 12px; + gap: 8px; + margin: 0 16px; &__title { color: var(--text-vanilla-100); diff --git a/frontend/src/container/AlertHistory/index.tsx b/frontend/src/container/AlertHistory/index.tsx index 22960fe16f..584313f71d 100644 --- a/frontend/src/container/AlertHistory/index.tsx +++ b/frontend/src/container/AlertHistory/index.tsx @@ -1,13 +1,20 @@ 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 (
- - + +
); } diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index ba64e9af45..d332d0a5d7 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -231,6 +231,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { routeKey === 'TRACES_EXPLORER' || routeKey === 'TRACES_SAVE_VIEWS'; 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 @@ -318,7 +320,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element { isTracesView() || isDashboardView() || isDashboardWidgetView() || - isDashboardListView() + isDashboardListView() || + isAlertHistory() || + isAlertOverview() ? 0 : '0 1rem', }} diff --git a/frontend/src/container/FormAlertRules/QuerySection.styles.scss b/frontend/src/container/FormAlertRules/QuerySection.styles.scss index ee3f4892af..65d222d805 100644 --- a/frontend/src/container/FormAlertRules/QuerySection.styles.scss +++ b/frontend/src/container/FormAlertRules/QuerySection.styles.scss @@ -57,5 +57,8 @@ background: var(--bg-vanilla-300) !important; } } + .ant-tabs-tab-btn { + padding: 0; + } } } diff --git a/frontend/src/container/FormAlertRules/index.tsx b/frontend/src/container/FormAlertRules/index.tsx index 4b383eb272..27083772dc 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 88069195c2..25d661262a 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/lib/uPlotLib/plugins/timelinePlugin.ts b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts new file mode 100644 index 0000000000..c300906af3 --- /dev/null +++ b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts @@ -0,0 +1,670 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-empty */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable eqeqeq */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable no-return-assign */ +/* eslint-disable no-nested-ternary */ +import uPlot, { RectH } 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 t = this; + const { x } = t; + const { y } = t; + const w = t.w / 2; + const h = t.h / 2; + const l = t.l + 1; + + t.q = [ + // top right + new Quadtree(x + w, y, w, h, l), + // top left + new Quadtree(x, y, w, h, l), + // bottom left + new Quadtree(x, y + h, w, h, l), + // bottom right + new Quadtree(x + w, y + h, w, h, l), + ]; + } + + quads( + x: number, + y: number, + w: number, + h: number, + cb: (quad: Quadtree) => void, + ): void { + const t = this; + const { q } = t; + const hzMid = t.x + t.w / 2; + const vtMid = t.y + t.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 + startIsNorth && endIsEast && cb(q[0]); + // top-left quad + startIsWest && startIsNorth && cb(q[1]); + // bottom-left quad + startIsWest && endIsSouth && cb(q[2]); + // bottom-right quad + endIsEast && endIsSouth && cb(q[3]); + } + } + + add(o: any): void { + const t = this; + + if (t.q != null) { + t.quads(o.x, o.y, o.w, o.h, (q) => { + q.add(o); + }); + } else { + const os = t.o; + + os.push(o); + + if (os.length > MAX_OBJECTS && t.l < MAX_LEVELS) { + t.split(); + + for (let i = 0; i < os.length; i++) { + const oi = os[i]; + + t.quads(oi.x, oi.y, oi.w, oi.h, (q) => { + q.add(oi); + }); + } + + t.o.length = 0; + } + } + } + + get(x: number, y: number, w: number, h: number, cb: (o: any) => void): void { + const t = this; + const os = t.o; + + for (let i = 0; i < os.length; i++) { + cb(os[i]); + } + + if (t.q != null) { + t.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, max, ceil } = Math; + +function roundDec(val: number, dec: number): any { + return Math.round(val * (dec = 10 ** dec)) / 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): any => + roundDec(offs + i * (iwid + gap), 6); + +export function distr( + numItems: number, + sizeFactor: number, + justify: number, + onlyIdx: number | null, + each: { + (i: any, offPct: number, dimPct: number): void; + (arg0: number, arg1: any, arg2: any): void; + }, +): any { + const space = 1 - sizeFactor; + + let gap = + justify == SPACE_BETWEEN + ? space / (numItems - 1) + : justify == SPACE_AROUND + ? space / numItems + : justify == SPACE_EVENLY + ? space / (numItems + 1) + : 0; + + if (Number.isNaN(gap) || gap == Infinity) gap = 0; + + const offs = + justify == SPACE_BETWEEN + ? 0 + : justify == SPACE_AROUND + ? gap / 2 + : justify == SPACE_EVENLY + ? gap + : 0; + + const iwid = sizeFactor / numItems; + const _iwid = roundDec(iwid, 6); + + if (onlyIdx == null) { + for (let i = 0; i < numItems; i++) each(i, coord(i, offs, iwid, gap), _iwid); + } else each(onlyIdx, coord(onlyIdx, offs, iwid, gap), _iwid); +} + +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 Arial`; + + function walk( + yIdx: number | null, + count: any, + dim: number, + draw: { + (iy: any, y0: any, hgt: any): void; + (iy: any, y0: any, hgt: any): void; + (arg0: never, arg1: number, arg2: number): void; + }, + ): any { + distr( + count, + laneWidth, + laneDistr, + yIdx, + (i: any, 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: { + fillStyle: any; + fill: (arg0: any) => void; + strokeStyle: any; + stroke: (arg0: any) => void; + }): any { + fillPaths.forEach((fillPath, fillStyle) => { + ctx.fillStyle = fillStyle; + ctx.fill(fillPath); + }); + + strokePaths.forEach((strokePath, strokeStyle) => { + ctx.strokeStyle = strokeStyle; + ctx.stroke(strokePath); + }); + + fillPaths.clear(); + strokePaths.clear(); + } + + function putBox( + ctx: any, + rect: RectH, + xOff: number, + yOff: number, + lft: number, + top: number, + wid: number, + hgt: number, + strokeWidth: number, + iy: number, + ix: number, + value: number | null, + ): any { + 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, + }); + } + + function drawPaths( + u: import('uplot'), + sidx: number, + idx0: any, + idx1: number, + ): any { + 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: any, y0: number, hgt: any) => { + // 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; + 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); + const xShift = align == 1 ? 0 : align == -1 ? barWid : 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], + ); + } + } + } + }); + + u.ctx.lineWidth = strokeWidth; + drawBoxes(u.ctx); + + u.ctx.restore(); + }, + ); + + return null; + } + + function drawPoints(u: import('uplot'), sidx: number, i0: any, i1: any): any { + u.ctx.save(); + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + u.ctx.font = font; + u.ctx.fillStyle = 'black'; + u.ctx.textAlign = mode == 1 ? 'left' : 'center'; + u.ctx.textBaseline = 'middle'; + + 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); + 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; + } + + let qt: { + add: (arg0: { x: any; y: any; w: any; h: any; sidx: any; didx: any }) => void; + clear: () => void; + get: ( + arg0: any, + arg1: any, + arg2: number, + arg3: number, + arg4: (o: any) => void, + ) => void; + }; + const hovered = Array(count).fill(null); + + let yMids = Array(count).fill(0); + const ySplits = Array(count).fill(0); + + const fmtDate = uPlot.fmtDate('{YYYY}-{MM}-{DD} {HH}:{mm}:{ss}'); + let legendTimeValueEl: { textContent: any } | null = null; + + return { + hooks: { + init: (u: { root: { querySelector: (arg0: string) => any } }): any => { + legendTimeValueEl = u.root.querySelector('.u-series:first-child .u-value'); + }, + drawClear: (u: { + bbox: { width: any; height: any }; + series: any[]; + }): any => { + 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: { _paths: null }) => { + 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); + } + }, + }, + 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, + xValue: 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; + + if (min <= u.data[0][0]) min = u.data[0][0] - scalePad; + + const lastIdx = u.data[0].length - 1; + + 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[][] }, + axisIdx: any, + scaleMin: number, + scaleMax: number, + foundIncr: number, + foundSpace: any, + ): 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; + }, + axisIdx: 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/AlertHeader/ActionButtons/actionButtons.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/actionButtons.styles.scss index 37d46dd0bf..479ac58df4 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/actionButtons.styles.scss +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/actionButtons.styles.scss @@ -3,6 +3,14 @@ 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; @@ -30,3 +38,22 @@ } } } + +.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/index.tsx b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/index.tsx index 1393d217b9..fd482982ba 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/index.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/index.tsx @@ -1,7 +1,9 @@ import './actionButtons.styles.scss'; import { Button, Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; import { Copy, Ellipsis, PenLine, Trash2 } from 'lucide-react'; +import { useAlertRuleStatusToggle } from 'pages/AlertDetails/hooks'; import CopyToClipboard from 'periscope/components/CopyToClipboard'; import React from 'react'; @@ -26,11 +28,25 @@ const menuStyle: React.CSSProperties = { fontSize: 14, }; -function AlertActionButtons(): JSX.Element { +function AlertActionButtons({ + ruleId, + state, +}: { + ruleId: string; + state: string; +}): JSX.Element { + const { + handleAlertStateToggle, + isAlertRuleEnabled, + } = useAlertRuleStatusToggle({ ruleId, state }); + const isDarkMode = useIsDarkMode(); return (
- {/* TODO(shaheer): handle submitting data */} - + @@ -56,7 +72,12 @@ function AlertActionButtons(): JSX.Element { )} > - +
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx index 19c48c49ec..5d15617ec5 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx @@ -21,23 +21,27 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element { return (
-
-
- -
{alert}
-
{id}
+
+
+
+ +
{alert}
+
{id}
+
- -
-
- +
+ - {/* // TODO(shaheer): Get actual data when we are able to get alert status from API */} - - + {/* // TODO(shaheer): Get actual data when we are able to get alert status from API */} + + +
+
+
+
); diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx index 892e6b872f..786c53dd46 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx @@ -4,12 +4,18 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel/KeyValueLabel'; import SeeMore from 'periscope/components/SeeMore/SeeMore'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -type AlertLabelsProps = { labels: Record }; +type AlertLabelsProps = { + labels: Record; + initialCount?: number; +}; -function AlertLabels({ labels }: AlertLabelsProps): JSX.Element { +function AlertLabels({ + labels, + initialCount = 2, +}: AlertLabelsProps): JSX.Element { return (
- + {Object.entries(labels).map(([key, value]) => ( ))} @@ -18,4 +24,8 @@ function AlertLabels({ labels }: AlertLabelsProps): JSX.Element { ); } +AlertLabels.defaultProps = { + initialCount: 2, +}; + export default AlertLabels; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx index 01420209e2..e10a282342 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx @@ -1,5 +1,6 @@ import './alertState.styles.scss'; +import { useIsDarkMode } from 'hooks/useDarkMode'; import { BellOff, CircleCheck, CircleOff, Flame } from 'lucide-react'; type AlertStateProps = { @@ -13,11 +14,9 @@ export default function AlertState({ }: AlertStateProps): JSX.Element { let icon; let label; - // TODO(shaheer): implement the states UI based on updated designs after the designs are updated - + const isDarkMode = useIsDarkMode(); switch (state) { case 'no-data': - case 'pending': icon = ( No Data; break; - case 'muted': + case 'disabled': - case 'inactive': icon = ( Firing; break; - case 'resolved': + + case 'normal': icon = ( ); label = Resolved; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/alertStatus.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/alertStatus.styles.scss index 6eb306389e..97549bf21d 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/alertStatus.styles.scss +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/alertStatus.styles.scss @@ -14,3 +14,9 @@ gap: 3px; } } + +.lightMode { + .alert-status-info { + color: var(--text-ink-400); + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/alertHeader.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/alertHeader.styles.scss index fba6dba76b..fbddf860fc 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/alertHeader.styles.scss +++ b/frontend/src/pages/AlertDetails/AlertHeader/alertHeader.styles.scss @@ -1,31 +1,57 @@ .alert-info { display: flex; - flex-direction: column; - gap: 8px; + justify-content: space-between; + align-items: baseline; + padding: 0 16px; - &__top-section { + &__info-wrapper { display: flex; - align-items: center; - justify-content: space-between; - .alert-title-wrapper { + flex-direction: column; + gap: 8px; + height: 54px; + + .top-section { display: flex; align-items: center; - gap: 8px; - .alert-title { - font-size: 16px; - font-weight: var(--font-weight-medium); - color: var(--text-vanilla-100); - line-height: 24px; - } - .alert-id { - // color: var(--text-slate-50); - color: #62687c; + 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; + } + .alert-id { + // color: var(--text-slate-50); + color: #62687c; + font-size: 15px; + line-height: 20px; + letter-spacing: -0.075px; + } } } + .bottom-section { + display: flex; + align-items: center; + gap: 24px; + } } - &__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/alertDetails.styles.scss b/frontend/src/pages/AlertDetails/alertDetails.styles.scss index 50a04dbeba..209426457d 100644 --- a/frontend/src/pages/AlertDetails/alertDetails.styles.scss +++ b/frontend/src/pages/AlertDetails/alertDetails.styles.scss @@ -3,21 +3,105 @@ 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: 16px; + margin-top: 10px; .divider { - border-color: var(--bg-slate-400); + border-color: var(--bg-slate-500); margin: 16px 0; } + .breadcrumb-divider { + margin-top: 10px; + } + &__breadcrumb { + padding-left: 16px; + .ant-breadcrumb-link { + color: var(--text-vanilla-400); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + } + + .ant-breadcrumb-separator, + span.ant-breadcrumb-link { + color: var(--var-vanilla-500); + font-family: 'Geist Mono'; + } + } .tabs-and-filters { margin: 1rem 0; - & .ant-tabs-nav { - &-wrap { - margin-bottom: 16px; + .ant-tabs { + &-ink-bar { + background-color: transparent; } - &::before { - border-bottom: none !important; + &-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; } } @@ -34,32 +118,68 @@ @include flex-center; } } - // customize ant tabs - .ant-tabs { - &-nav-list { - border: 1px solid var(--bg-slate-400); - background: var(--bg-ink-400); - border-radius: 2px; - } - - &-tab { - padding: 0; + } +} - &-btn { - padding: 6px 17px; +.lightMode { + .alert-details { + &-tabs { + .ant-tabs-nav { + &::before { + border-bottom: 1px solid var(--bg-vanilla-300) !important; } - - &-active { - background: var(--bg-slate-400, #1d212d); + } + } + &__breadcrumb { + .ant-breadcrumb-link { + color: var(--text-ink-400); + } + .ant-breadcrumb-separator, + span.ant-breadcrumb-link { + color: var(--var-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 { - margin-bottom: 0 !important; - &::before { - border-bottom: 1px solid var(--bg-slate-400) !important; + .alert-details-tabs { + .ant-tabs { + &-nav { + &::before { + border-bottom: 1px solid var(--bg-vanilla-300) !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/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx index 407576a1eb..845afb1d02 100644 --- a/frontend/src/pages/AlertDetails/hooks.tsx +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -1,32 +1,40 @@ -import { Typography } from 'antd'; import { FilterValue, SorterResult } from 'antd/es/table/interface'; import { TablePaginationConfig, TableProps } from 'antd/lib'; import get from 'api/alerts/get'; +import patchAlert from 'api/alerts/patch'; import ruleStats from 'api/alerts/ruleStats'; +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 { 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 { useCallback, useMemo } from 'react'; -import { useQuery, UseQueryResult } from 'react-query'; +import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, UseQueryResult } from 'react-query'; import { generatePath, useLocation } from 'react-router-dom'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { AlertRuleStatsPayload, + AlertRuleTimelineGraphResponsePayload, AlertRuleTimelineTableResponse, AlertRuleTimelineTableResponsePayload, AlertRuleTopContributorsPayload, } from 'types/api/alerts/def'; +import { nanoToMilli } from 'utils/timeUtils'; export const useRouteTabUtils = (): { routes: TabRoutes[] } => { const urlQuery = useUrlQuery(); @@ -34,15 +42,17 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => { 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 = `ruleId=${urlQuery.get('ruleId')}&relativeTime=${urlQuery.get( - 'relativeTime', - )}`; + params = `${ruleIdKey}=${urlQuery.get( + ruleIdKey, + )}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`; route = ROUTES.ALERT_HISTORY; break; default: @@ -83,16 +93,17 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => { export const useGetAlertRuleDetails = (): { ruleId: string | null; data: UseQueryResult; + isValidRuleId: boolean; } => { const { search } = useLocation(); const params = new URLSearchParams(search); - const ruleId = params.get('ruleId'); + const ruleId = params.get(QueryParams.ruleId); const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; - const data = useQuery(['ruleId', ruleId], { + const data = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { queryFn: () => get({ id: parseInt(ruleId || '', 10), @@ -102,7 +113,7 @@ export const useGetAlertRuleDetails = (): { refetchOnWindowFocus: false, }); - return { ruleId, data }; + return { ruleId, data, isValidRuleId }; }; type GetAlertRuleDetailsApiProps = { @@ -124,14 +135,14 @@ export const useGetAlertRuleDetailsStats = (): GetAlertRuleDetailsStatsProps => const { search } = useLocation(); const params = new URLSearchParams(search); - const ruleId = params.get('ruleId'); - const startTime = params.get('startTime'); - const endTime = params.get('endTime'); + const ruleId = params.get(QueryParams.ruleId); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; const { isLoading, isRefetching, isError, data } = useQuery( - ['ruleIdStats', ruleId, startTime, endTime], + [REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime], { queryFn: () => ruleStats({ @@ -139,7 +150,7 @@ export const useGetAlertRuleDetailsStats = (): GetAlertRuleDetailsStatsProps => start: parseInt(startTime || '', 10), end: parseInt(endTime || '', 10), }), - enabled: isValidRuleId, + enabled: isValidRuleId && !!startTime && !!endTime, refetchOnMount: false, refetchOnWindowFocus: false, }, @@ -159,14 +170,14 @@ export const useGetAlertRuleDetailsTopContributors = (): GetAlertRuleDetailsTopC const { search } = useLocation(); const params = new URLSearchParams(search); - const ruleId = params.get('ruleId'); - const startTime = params.get('startTime'); - const endTime = params.get('endTime'); + const ruleId = params.get(QueryParams.ruleId); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; const { isLoading, isRefetching, isError, data } = useQuery( - ['ruleIdTopContributors', ruleId, startTime, endTime], + [REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime], { queryFn: () => topContributors({ @@ -203,15 +214,24 @@ export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimeli [params], ); - const ruleId = params.get('ruleId'); - const startTime = params.get('startTime'); - const endTime = params.get('endTime'); + const ruleId = params.get(QueryParams.ruleId); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); 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( - ['ruleIdTimelineTable', ruleId, startTime, endTime, timelineFilter], + [ + REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE, + ruleId, + startTime, + endTime, + timelineFilter, + updatedOrder, + getUpdatedOffset, + ], { queryFn: () => timelineTable({ @@ -232,16 +252,14 @@ export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimeli // }, // ], // }, + ...(timelineFilter && timelineFilter !== TimelineFilter.ALL ? { - filters: { - // TODO(shaheer): confirm whether the TimelineFilter.RESOLVED and TimelineFilter.FIRED are valid states - items: [{ key: { key: 'state' }, value: 'firing', op: '=' }], - }, + state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal', } : {}), }), - enabled: isValidRuleId, + enabled: isValidRuleId && hasStartAndEnd, refetchOnMount: false, refetchOnWindowFocus: false, }, @@ -250,16 +268,11 @@ export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimeli return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; }; -const showPaginationItem = (total: number, range: number[]): JSX.Element => ( - <> - - {range[0]} — {range[1]} - - of {total} - -); - -export const useTimelineTable = (): { +export const useTimelineTable = ({ + totalItems, +}: { + totalItems: number; +}): { paginationConfig: TablePaginationConfig; onChangeHandler: ( pagination: TablePaginationConfig, @@ -278,14 +291,14 @@ export const useTimelineTable = (): { const onChangeHandler: TableProps['onChange'] = useCallback( ( - paginations: TablePaginationConfig, + pagination: TablePaginationConfig, filters: Record, sorter: | SorterResult[] | SorterResult, ) => { if (!Array.isArray(sorter)) { - const { pageSize = 0, current = 0 } = paginations; + const { pageSize = 0, current = 0 } = pagination; const { columnKey = '', order } = sorter; const updatedOrder = order === 'ascend' ? 'asc' : 'desc'; const params = new URLSearchParams(window.location.search); @@ -294,7 +307,7 @@ export const useTimelineTable = (): { `${pathname}?${createQueryParams({ ...Object.fromEntries(params), order: updatedOrder, - offset: (current - 1) * pageSize, + offset: current - 1, orderParam: columnKey, pageSize, })}`, @@ -304,13 +317,130 @@ export const useTimelineTable = (): { [pathname], ); - const paginationConfig = { + const paginationConfig: TablePaginationConfig = { pageSize: TIMELINE_TABLE_PAGE_SIZE, - showTotal: showPaginationItem, - current: parseInt(updatedOffset, 10) / TIMELINE_TABLE_PAGE_SIZE + 1, + showTotal: PaginationInfoText, + current: parseInt(updatedOffset, 10) + 1, showSizeChanger: false, hideOnSinglePage: true, + total: totalItems, }; return { paginationConfig, onChangeHandler }; }; + +export const useSetStartAndEndTimeFromRelativeTime = (): void => { + const { pathname, search } = useLocation(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + const { relativeTime, startTime, endTime } = useMemo( + () => ({ + relativeTime: searchParams.get(QueryParams.relativeTime), + startTime: searchParams.get(QueryParams.startTime), + endTime: searchParams.get(QueryParams.endTime), + }), + [searchParams], + ); + + useEffect(() => { + if ( + !relativeTime || + pathname !== ROUTES.ALERT_HISTORY || + startTime || + endTime + ) { + return; + } + + const { minTime, maxTime } = GetMinMax(relativeTime); + + searchParams.set(QueryParams.startTime, nanoToMilli(minTime).toString()); + searchParams.set(QueryParams.endTime, nanoToMilli(maxTime).toString()); + + history.push({ search: searchParams.toString() }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [relativeTime, startTime, endTime]); +}; + +export const useAlertRuleStatusToggle = ({ + state, + ruleId, +}: { + state: string; + ruleId: string; +}): { + handleAlertStateToggle: (state: boolean) => void; + isAlertRuleEnabled: boolean; +} => { + const { notifications } = useNotifications(); + const defaultErrorMessage = 'Something went wrong'; + const isAlertRuleInitiallyEnabled = state !== 'disabled'; + const [isAlertRuleEnabled, setIsAlertRuleEnabled] = useState( + isAlertRuleInitiallyEnabled, + ); + + const { mutate: toggleAlertState } = useMutation( + ['toggle-alert-state', ruleId], + patchAlert, + { + onMutate: () => { + setIsAlertRuleEnabled((prev) => !prev); + }, + onSuccess: () => { + notifications.success({ + message: `Alert has been turned ${!isAlertRuleEnabled ? 'on' : 'off'}.`, + }); + }, + onError: () => { + setIsAlertRuleEnabled(isAlertRuleInitiallyEnabled); + notifications.error({ + message: defaultErrorMessage, + }); + }, + }, + ); + + const handleAlertStateToggle = (state: boolean): void => { + const args = { id: parseInt(ruleId, 10), data: { disabled: !state } }; + toggleAlertState(args); + }; + + return { handleAlertStateToggle, isAlertRuleEnabled }; +}; + +type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTimelineGraphData = (): GetAlertRuleDetailsTimelineGraphProps => { + const { search } = useLocation(); + + const params = useMemo(() => new URLSearchParams(search), [search]); + + const ruleId = params.get(QueryParams.ruleId); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); + + 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: parseInt(startTime || '', 10), + end: parseInt(endTime || '', 10), + }), + 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 index 2dcbb0b343..8e2bee1f83 100644 --- a/frontend/src/pages/AlertDetails/index.tsx +++ b/frontend/src/pages/AlertDetails/index.tsx @@ -1,7 +1,8 @@ import './alertDetails.styles.scss'; -import { Breadcrumb, ConfigProvider, Divider } from 'antd'; +import { Breadcrumb, 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'; @@ -11,7 +12,11 @@ import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import AlertHeader from './AlertHeader/AlertHeader'; -import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks'; +import { + useGetAlertRuleDetails, + useRouteTabUtils, + useSetStartAndEndTimeFromRelativeTime, +} from './hooks'; import { AlertDetailsStatusRendererProps } from './types'; function AlertDetailsStatusRenderer({ @@ -33,64 +38,51 @@ function AlertDetailsStatusRenderer({ return ; } - function AlertDetails(): JSX.Element { const { pathname } = useLocation(); const { routes } = useRouteTabUtils(); + useSetStartAndEndTimeFromRelativeTime(); + const { data: { isLoading, data, isRefetching, isError }, ruleId, + isValidRuleId, } = useGetAlertRuleDetails(); + if (isError || !isValidRuleId) { + return ; + } + return ( - + -
- - - {/* TODO(shaheer): use DataStateRenderer component instead */} - + + + + +
+ } /> - -
- } - /> -
- +
); } diff --git a/frontend/src/pages/AlertList/index.tsx b/frontend/src/pages/AlertList/index.tsx index 2db927de52..19d746e8f0 100644 --- a/frontend/src/pages/AlertList/index.tsx +++ b/frontend/src/pages/AlertList/index.tsx @@ -19,11 +19,13 @@ function AllAlertList(): JSX.Element { const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY; const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW; + const search = urlQuery.get('search'); + const items: TabsProps['items'] = [ { label: ( -
- +
+ Triggered Alerts
), @@ -32,8 +34,8 @@ function AllAlertList(): JSX.Element { }, { label: ( -
- +
+ Alert Rules
), @@ -43,7 +45,7 @@ function AllAlertList(): JSX.Element { }, { label: ( -
+
Configuration
@@ -60,7 +62,12 @@ function AllAlertList(): JSX.Element { activeKey={tab || 'AlertRules'} onChange={(tab): void => { urlQuery.set('tab', tab); - history.replace(`/alerts?${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/index.tsx b/frontend/src/pages/EditRules/index.tsx index 61788e2b2d..4d9e6c087a 100644 --- a/frontend/src/pages/EditRules/index.tsx +++ b/frontend/src/pages/EditRules/index.tsx @@ -1,5 +1,7 @@ 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'; @@ -12,14 +14,14 @@ import { useLocation } from 'react-router-dom'; function EditRules(): JSX.Element { const { search } = useLocation(); const params = new URLSearchParams(search); - 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({ diff --git a/frontend/src/periscope/components/CopyToClipboard/copyToClipboard.styles.scss b/frontend/src/periscope/components/CopyToClipboard/copyToClipboard.styles.scss index 51e8472ff5..7a55632ae6 100644 --- a/frontend/src/periscope/components/CopyToClipboard/copyToClipboard.styles.scss +++ b/frontend/src/periscope/components/CopyToClipboard/copyToClipboard.styles.scss @@ -3,7 +3,22 @@ align-items: center; gap: 10px; font-size: 14px; - width: 120px; + 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, @@ -12,3 +27,13 @@ } } } + +.lightMode { + .copy-to-clipboard { + &:not(&--success) { + & > * { + color: var(--text-ink-400); + } + } + } +} diff --git a/frontend/src/periscope/components/CopyToClipboard/index.tsx b/frontend/src/periscope/components/CopyToClipboard/index.tsx index 6c2d755eac..adc2551c5a 100644 --- a/frontend/src/periscope/components/CopyToClipboard/index.tsx +++ b/frontend/src/periscope/components/CopyToClipboard/index.tsx @@ -1,6 +1,7 @@ import './copyToClipboard.styles.scss'; 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'; @@ -8,6 +9,7 @@ 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; @@ -35,7 +37,12 @@ function CopyToClipboard({ textToCopy }: { textToCopy: string }): JSX.Element { @@ -70,6 +73,7 @@ Tabs2.defaultProps = { initialSelectedTab: '', onSelectTab: (): void => {}, hasResetButton: false, + buttonMinWidth: '114px', }; export default Tabs2; diff --git a/frontend/src/periscope/components/Tabs2/tabs2.styles.scss b/frontend/src/periscope/components/Tabs2/tabs2.styles.scss index 0479855998..59b5156cdd 100644 --- a/frontend/src/periscope/components/Tabs2/tabs2.styles.scss +++ b/frontend/src/periscope/components/Tabs2/tabs2.styles.scss @@ -16,6 +16,13 @@ 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); @@ -23,3 +30,19 @@ } } } + +.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/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts index 1eb8dfeb35..9393ccd5a0 100644 --- a/frontend/src/types/api/alerts/def.ts +++ b/frontend/src/types/api/alerts/def.ts @@ -38,7 +38,6 @@ export interface RuleCondition { alertOnAbsent?: boolean | undefined; absentFor?: number | undefined; } - export interface Labels { [key: string]: string; } @@ -46,21 +45,21 @@ export interface Labels { export interface AlertRuleStats { totalCurrentTriggers: number; totalPastTriggers: number; - currentTriggersSeries: CurrentTriggersSeries | null; - pastTriggersSeries: any | null; + currentTriggersSeries: CurrentTriggersSeries; + pastTriggersSeries: CurrentTriggersSeries | null; currentAvgResolutionTime: number; pastAvgResolutionTime: number; - currentAvgResolutionTimeSeries: any | null; + currentAvgResolutionTimeSeries: CurrentTriggersSeries; pastAvgResolutionTimeSeries: any | null; } interface CurrentTriggersSeries { labels: Labels; labelsArray: any | null; - values: Value[]; + values: StatsTimeSeriesItem[]; } -interface Value { +export interface StatsTimeSeriesItem { timestamp: number; value: string; } @@ -73,6 +72,8 @@ export interface AlertRuleTopContributors { fingerprint: number; labels: Labels; count: number; + relatedLogsLink: string; + relatedTracesLink: string; } export type AlertRuleTopContributorsPayload = { data: AlertRuleTopContributors[]; @@ -89,7 +90,19 @@ export interface AlertRuleTimelineTableResponse { labels: Labels; fingerprint: number; value: number; + relatedTracesLink: string; + relatedLogsLink: string; } export type AlertRuleTimelineTableResponsePayload = { - data: AlertRuleTimelineTableResponse[]; + 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/timelineGraph.ts b/frontend/src/types/api/alerts/timelineGraph.ts new file mode 100644 index 0000000000..2d317c4a3c --- /dev/null +++ b/frontend/src/types/api/alerts/timelineGraph.ts @@ -0,0 +1,11 @@ +import { AlertDef } from './def'; + +export interface Filters { + [k: string]: string | Record[]; +} + +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 index be146e3521..d25cc5af0c 100644 --- a/frontend/src/types/api/alerts/timelineTable.ts +++ b/frontend/src/types/api/alerts/timelineTable.ts @@ -12,4 +12,5 @@ export interface GetTimelineTableRequestProps { limit: number; order: string; filters?: Filters; + state?: string; } diff --git a/frontend/src/utils/calculateChange.ts b/frontend/src/utils/calculateChange.ts index 78975e9903..4e3d912f0d 100644 --- a/frontend/src/utils/calculateChange.ts +++ b/frontend/src/utils/calculateChange.ts @@ -5,7 +5,7 @@ export function calculateChange( if ( totalCurrentTriggers === undefined || totalPastTriggers === undefined || - totalPastTriggers === 0 + [0, '0'].includes(totalPastTriggers) ) { return { changePercentage: 0, changeDirection: 0 }; } @@ -13,7 +13,13 @@ export function calculateChange( let changePercentage = ((totalCurrentTriggers - totalPastTriggers) / totalPastTriggers) * 100; - const changeDirection = changePercentage >= 0 ? 1 : -1; + let changeDirection = 0; + + if (changePercentage < 0) { + changeDirection = -1; + } else if (changePercentage > 0) { + changeDirection = 1; + } changePercentage = Math.abs(changePercentage); changePercentage = Math.round(changePercentage); diff --git a/frontend/src/utils/timeUtils.ts b/frontend/src/utils/timeUtils.ts index 132296d65d..5eb795bf45 100644 --- a/frontend/src/utils/timeUtils.ts +++ b/frontend/src/utils/timeUtils.ts @@ -85,3 +85,43 @@ export function formatEpochTimestamp(epoch: number): string { 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); +};