diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index 0eb98e9960..6cfe6e0238 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -38,5 +38,7 @@ "LIST_LICENSES": "SigNoz | List of Licenses", "WORKSPACE_LOCKED": "SigNoz | Workspace Locked", "SUPPORT": "SigNoz | Support", - "DEFAULT": "Open source Observability Platform | SigNoz" + "DEFAULT": "Open source Observability Platform | SigNoz", + "ALERT_HISTORY": "SigNoz | Alert Rule History", + "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview" } diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 4aa2b65dc0..126b8a7ac1 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -50,5 +50,7 @@ "DEFAULT": "Open source Observability Platform | SigNoz", "SHORTCUTS": "SigNoz | Shortcuts", "INTEGRATIONS": "SigNoz | Integrations", + "ALERT_HISTORY": "SigNoz | Alert Rule History", + "ALERT_OVERVIEW": "SigNoz | Alert Rule Overview", "MESSAGING_QUEUES": "SigNoz | Messaging Queues" } diff --git a/frontend/src/AppRoutes/index.tsx b/frontend/src/AppRoutes/index.tsx index 23e7ea9644..b900255172 100644 --- a/frontend/src/AppRoutes/index.tsx +++ b/frontend/src/AppRoutes/index.tsx @@ -19,6 +19,7 @@ import { ResourceProvider } from 'hooks/useResourceAttribute'; import history from 'lib/history'; import { identity, pick, pickBy } from 'lodash-es'; import posthog from 'posthog-js'; +import AlertRuleProvider from 'providers/Alert'; import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { Suspense, useEffect, useState } from 'react'; @@ -236,22 +237,24 @@ function App(): JSX.Element { - - }> - - {routes.map(({ path, component, exact }) => ( - - ))} - - - - - + + + }> + + {routes.map(({ path, component, exact }) => ( + + ))} + + + + + + diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index bce075cef3..0a7764149b 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -92,6 +92,14 @@ export const CreateNewAlerts = Loadable( () => import(/* webpackChunkName: "Create Alerts" */ 'pages/CreateAlert'), ); +export const AlertHistory = Loadable( + () => import(/* webpackChunkName: "Alert History" */ 'pages/AlertList'), +); + +export const AlertOverview = Loadable( + () => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'), +); + export const CreateAlertChannelAlerts = Loadable( () => import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'), diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index 98fdbed392..42ce00c0fb 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -2,6 +2,8 @@ import ROUTES from 'constants/routes'; import { RouteProps } from 'react-router-dom'; import { + AlertHistory, + AlertOverview, AllAlertChannels, AllErrors, APIKeys, @@ -171,6 +173,20 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'ALERTS_NEW', }, + { + path: ROUTES.ALERT_HISTORY, + exact: true, + component: AlertHistory, + isPrivate: true, + key: 'ALERT_HISTORY', + }, + { + path: ROUTES.ALERT_OVERVIEW, + exact: true, + component: AlertOverview, + isPrivate: true, + key: 'ALERT_OVERVIEW', + }, { path: ROUTES.TRACE, exact: true, diff --git a/frontend/src/api/alerts/create.ts b/frontend/src/api/alerts/create.ts index cad7917815..744183fa4b 100644 --- a/frontend/src/api/alerts/create.ts +++ b/frontend/src/api/alerts/create.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/create'; const create = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.post('/rules', { - ...props.data, - }); + const response = await axios.post('/rules', { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default create; diff --git a/frontend/src/api/alerts/delete.ts b/frontend/src/api/alerts/delete.ts index 278e3e2935..56407f3c40 100644 --- a/frontend/src/api/alerts/delete.ts +++ b/frontend/src/api/alerts/delete.ts @@ -1,24 +1,18 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/delete'; const deleteAlerts = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.delete(`/rules/${props.id}`); + const response = await axios.delete(`/rules/${props.id}`); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data.rules, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data.rules, + }; }; export default deleteAlerts; diff --git a/frontend/src/api/alerts/get.ts b/frontend/src/api/alerts/get.ts index 0437f8d1d8..15a741287e 100644 --- a/frontend/src/api/alerts/get.ts +++ b/frontend/src/api/alerts/get.ts @@ -1,24 +1,16 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/get'; const get = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.get(`/rules/${props.id}`); - - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + const response = await axios.get(`/rules/${props.id}`); + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; }; - export default get; diff --git a/frontend/src/api/alerts/patch.ts b/frontend/src/api/alerts/patch.ts index 920b53ae9f..cb64a1046f 100644 --- a/frontend/src/api/alerts/patch.ts +++ b/frontend/src/api/alerts/patch.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/patch'; const patch = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.patch(`/rules/${props.id}`, { - ...props.data, - }); + const response = await axios.patch(`/rules/${props.id}`, { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default patch; diff --git a/frontend/src/api/alerts/put.ts b/frontend/src/api/alerts/put.ts index b8c34e96bd..77d98d3c49 100644 --- a/frontend/src/api/alerts/put.ts +++ b/frontend/src/api/alerts/put.ts @@ -1,26 +1,20 @@ import axios from 'api'; -import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; -import { AxiosError } from 'axios'; import { ErrorResponse, SuccessResponse } from 'types/api'; import { PayloadProps, Props } from 'types/api/alerts/save'; const put = async ( props: Props, ): Promise | ErrorResponse> => { - try { - const response = await axios.put(`/rules/${props.id}`, { - ...props.data, - }); + const response = await axios.put(`/rules/${props.id}`, { + ...props.data, + }); - return { - statusCode: 200, - error: null, - message: response.data.status, - payload: response.data.data, - }; - } catch (error) { - return ErrorResponseHandler(error as AxiosError); - } + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; }; export default put; diff --git a/frontend/src/api/alerts/ruleStats.ts b/frontend/src/api/alerts/ruleStats.ts new file mode 100644 index 0000000000..2e09751e0f --- /dev/null +++ b/frontend/src/api/alerts/ruleStats.ts @@ -0,0 +1,28 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleStatsPayload } from 'types/api/alerts/def'; +import { RuleStatsProps } from 'types/api/alerts/ruleStats'; + +const ruleStats = async ( + props: RuleStatsProps, +): Promise | ErrorResponse> => { + try { + const response = await axios.post(`/rules/${props.id}/history/stats`, { + start: props.start, + end: props.end, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default ruleStats; diff --git a/frontend/src/api/alerts/timelineGraph.ts b/frontend/src/api/alerts/timelineGraph.ts new file mode 100644 index 0000000000..8073943d72 --- /dev/null +++ b/frontend/src/api/alerts/timelineGraph.ts @@ -0,0 +1,33 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def'; +import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph'; + +const timelineGraph = async ( + props: GetTimelineGraphRequestProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post( + `/rules/${props.id}/history/overall_status`, + { + start: props.start, + end: props.end, + }, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default timelineGraph; diff --git a/frontend/src/api/alerts/timelineTable.ts b/frontend/src/api/alerts/timelineTable.ts new file mode 100644 index 0000000000..8d7f3edee7 --- /dev/null +++ b/frontend/src/api/alerts/timelineTable.ts @@ -0,0 +1,36 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def'; +import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable'; + +const timelineTable = async ( + props: GetTimelineTableRequestProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post(`/rules/${props.id}/history/timeline`, { + start: props.start, + end: props.end, + offset: props.offset, + limit: props.limit, + order: props.order, + state: props.state, + // TODO(shaheer): implement filters + filters: props.filters, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default timelineTable; diff --git a/frontend/src/api/alerts/topContributors.ts b/frontend/src/api/alerts/topContributors.ts new file mode 100644 index 0000000000..7d3f2baec1 --- /dev/null +++ b/frontend/src/api/alerts/topContributors.ts @@ -0,0 +1,33 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def'; +import { TopContributorsProps } from 'types/api/alerts/topContributors'; + +const topContributors = async ( + props: TopContributorsProps, +): Promise< + SuccessResponse | ErrorResponse +> => { + try { + const response = await axios.post( + `/rules/${props.id}/history/top_contributors`, + { + start: props.start, + end: props.end, + }, + ); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default topContributors; diff --git a/frontend/src/assets/AlertHistory/ConfigureIcon.tsx b/frontend/src/assets/AlertHistory/ConfigureIcon.tsx new file mode 100644 index 0000000000..05268b8f5f --- /dev/null +++ b/frontend/src/assets/AlertHistory/ConfigureIcon.tsx @@ -0,0 +1,41 @@ +interface ConfigureIconProps { + width?: number; + height?: number; + fill?: string; +} + +function ConfigureIcon({ + width, + height, + fill, +}: ConfigureIconProps): JSX.Element { + return ( + + + + + ); +} + +ConfigureIcon.defaultProps = { + width: 16, + height: 16, + fill: 'none', +}; +export default ConfigureIcon; diff --git a/frontend/src/assets/AlertHistory/LogsIcon.tsx b/frontend/src/assets/AlertHistory/LogsIcon.tsx new file mode 100644 index 0000000000..8ffcaaa90b --- /dev/null +++ b/frontend/src/assets/AlertHistory/LogsIcon.tsx @@ -0,0 +1,65 @@ +interface LogsIconProps { + width?: number; + height?: number; + fill?: string; + strokeColor?: string; + strokeWidth?: number; +} + +function LogsIcon({ + width, + height, + fill, + strokeColor, + strokeWidth, +}: LogsIconProps): JSX.Element { + return ( + + + + + + + + + ); +} + +LogsIcon.defaultProps = { + width: 14, + height: 14, + fill: 'none', + strokeColor: '#C0C1C3', + strokeWidth: 1.167, +}; + +export default LogsIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx b/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx new file mode 100644 index 0000000000..67d0977fe8 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx @@ -0,0 +1,39 @@ +interface SeverityCriticalIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; +} + +function SeverityCriticalIcon({ + width, + height, + fill, + stroke, +}: SeverityCriticalIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityCriticalIcon.defaultProps = { + width: 6, + height: 6, + fill: 'none', + stroke: '#F56C87', +}; + +export default SeverityCriticalIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx b/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx new file mode 100644 index 0000000000..a402289a62 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx @@ -0,0 +1,42 @@ +interface SeverityErrorIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; + strokeWidth?: string; +} + +function SeverityErrorIcon({ + width, + height, + fill, + stroke, + strokeWidth, +}: SeverityErrorIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityErrorIcon.defaultProps = { + width: 2, + height: 6, + fill: 'none', + stroke: '#F56C87', + strokeWidth: '1.02083', +}; + +export default SeverityErrorIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx b/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx new file mode 100644 index 0000000000..72316b2244 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx @@ -0,0 +1,46 @@ +interface SeverityInfoIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; +} + +function SeverityInfoIcon({ + width, + height, + fill, + stroke, +}: SeverityInfoIconProps): JSX.Element { + return ( + + + + + ); +} + +SeverityInfoIcon.defaultProps = { + width: 14, + height: 14, + fill: 'none', + stroke: '#7190F9', +}; + +export default SeverityInfoIcon; diff --git a/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx b/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx new file mode 100644 index 0000000000..204d615a21 --- /dev/null +++ b/frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx @@ -0,0 +1,42 @@ +interface SeverityWarningIconProps { + width?: number; + height?: number; + fill?: string; + stroke?: string; + strokeWidth?: string; +} + +function SeverityWarningIcon({ + width, + height, + fill, + stroke, + strokeWidth, +}: SeverityWarningIconProps): JSX.Element { + return ( + + + + ); +} + +SeverityWarningIcon.defaultProps = { + width: 2, + height: 6, + fill: 'none', + stroke: '#FFD778', + strokeWidth: '0.978299', +}; + +export default SeverityWarningIcon; diff --git a/frontend/src/components/AlertDetailsFilters/Filters.styles.scss b/frontend/src/components/AlertDetailsFilters/Filters.styles.scss new file mode 100644 index 0000000000..6869dd4366 --- /dev/null +++ b/frontend/src/components/AlertDetailsFilters/Filters.styles.scss @@ -0,0 +1,14 @@ +.reset-button { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-ink-300); + border: 1px solid var(--bg-slate-400); +} + +.lightMode { + .reset-button { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } +} diff --git a/frontend/src/components/AlertDetailsFilters/Filters.tsx b/frontend/src/components/AlertDetailsFilters/Filters.tsx new file mode 100644 index 0000000000..baf109bf1d --- /dev/null +++ b/frontend/src/components/AlertDetailsFilters/Filters.tsx @@ -0,0 +1,11 @@ +import './Filters.styles.scss'; + +import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2'; + +export function Filters(): JSX.Element { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss b/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss new file mode 100644 index 0000000000..f3c2ea622a --- /dev/null +++ b/frontend/src/components/TabsAndFilters/Tabs/Tabs.styles.scss @@ -0,0 +1,5 @@ +.tab-title { + display: flex; + gap: 4px; + align-items: center; +} diff --git a/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx b/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx new file mode 100644 index 0000000000..981c291146 --- /dev/null +++ b/frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx @@ -0,0 +1,41 @@ +import './Tabs.styles.scss'; + +import { Radio } from 'antd'; +import { RadioChangeEvent } from 'antd/lib'; +import { History, Table } from 'lucide-react'; +import { useState } from 'react'; + +import { ALERT_TABS } from '../constants'; + +export function Tabs(): JSX.Element { + const [selectedTab, setSelectedTab] = useState('overview'); + + const handleTabChange = (e: RadioChangeEvent): void => { + setSelectedTab(e.target.value); + }; + + return ( + + +
+ + Overview + + + +
+ + History +
+
+ + ); +} diff --git a/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss b/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss new file mode 100644 index 0000000000..5115eabe2e --- /dev/null +++ b/frontend/src/components/TabsAndFilters/TabsAndFilters.styles.scss @@ -0,0 +1,18 @@ +@mixin flex-center { + display: flex; + justify-content: space-between; + align-items: center; +} + +.tabs-and-filters { + @include flex-center; + margin-top: 1rem; + margin-bottom: 1rem; + .filters { + @include flex-center; + gap: 16px; + .reset-button { + @include flex-center; + } + } +} diff --git a/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx b/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx new file mode 100644 index 0000000000..ac6738d491 --- /dev/null +++ b/frontend/src/components/TabsAndFilters/TabsAndFilters.tsx @@ -0,0 +1,16 @@ +import './TabsAndFilters.styles.scss'; + +import { Filters } from 'components/AlertDetailsFilters/Filters'; + +import { Tabs } from './Tabs/Tabs'; + +function TabsAndFilters(): JSX.Element { + return ( +
+ + +
+ ); +} + +export default TabsAndFilters; diff --git a/frontend/src/components/TabsAndFilters/constants.ts b/frontend/src/components/TabsAndFilters/constants.ts new file mode 100644 index 0000000000..b052c0e4cf --- /dev/null +++ b/frontend/src/components/TabsAndFilters/constants.ts @@ -0,0 +1,5 @@ +export const ALERT_TABS = { + OVERVIEW: 'OVERVIEW', + HISTORY: 'HISTORY', + ACTIVITY: 'ACTIVITY', +} as const; diff --git a/frontend/src/constants/global.ts b/frontend/src/constants/global.ts index 42fb29720b..dfa096470d 100644 --- a/frontend/src/constants/global.ts +++ b/frontend/src/constants/global.ts @@ -1,4 +1,17 @@ +import { ManipulateType } from 'dayjs'; + const MAX_RPS_LIMIT = 100; export { MAX_RPS_LIMIT }; export const LEGEND = 'legend'; + +export const DAYJS_MANIPULATE_TYPES: { [key: string]: ManipulateType } = { + DAY: 'day', + WEEK: 'week', + MONTH: 'month', + YEAR: 'year', + HOUR: 'hour', + MINUTE: 'minute', + SECOND: 'second', + MILLISECOND: 'millisecond', +}; diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index 52ae235ef6..ec2353abbf 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -8,5 +8,14 @@ export const REACT_QUERY_KEY = { GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS', DELETE_DASHBOARD: 'DELETE_DASHBOARD', LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW', + ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS', + ALERT_RULE_STATS: 'ALERT_RULE_STATS', + ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS', + ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE', + ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH', GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS', + TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE', + GET_ALL_ALLERTS: 'GET_ALL_ALLERTS', + REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE', + DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE', }; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 8f76cd0386..b4f43ee684 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -22,6 +22,8 @@ const ROUTES = { EDIT_ALERTS: '/alerts/edit', LIST_ALL_ALERT: '/alerts', ALERTS_NEW: '/alerts/new', + ALERT_HISTORY: '/alerts/history', + ALERT_OVERVIEW: '/alerts/overview', ALL_CHANNELS: '/settings/channels', CHANNELS_NEW: '/settings/channels/new', CHANNELS_EDIT: '/settings/channels/:id', diff --git a/frontend/src/container/AlertHistory/AlertHistory.styles.scss b/frontend/src/container/AlertHistory/AlertHistory.styles.scss new file mode 100644 index 0000000000..39fce3ca29 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertHistory.styles.scss @@ -0,0 +1,5 @@ +.alert-history { + display: flex; + flex-direction: column; + gap: 24px; +} diff --git a/frontend/src/container/AlertHistory/AlertHistory.tsx b/frontend/src/container/AlertHistory/AlertHistory.tsx new file mode 100644 index 0000000000..0776cfcebb --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertHistory.tsx @@ -0,0 +1,22 @@ +import './AlertHistory.styles.scss'; + +import { useState } from 'react'; + +import Statistics from './Statistics/Statistics'; +import Timeline from './Timeline/Timeline'; + +function AlertHistory(): JSX.Element { + const [totalCurrentTriggers, setTotalCurrentTriggers] = useState(0); + + return ( +
+ + +
+ ); +} + +export default AlertHistory; diff --git a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss new file mode 100644 index 0000000000..43d645efa5 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss @@ -0,0 +1,3 @@ +.alert-popover { + cursor: pointer; +} diff --git a/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx new file mode 100644 index 0000000000..83605a61d3 --- /dev/null +++ b/frontend/src/container/AlertHistory/AlertPopover/AlertPopover.tsx @@ -0,0 +1,114 @@ +import './AlertPopover.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Popover } from 'antd'; +import LogsIcon from 'assets/AlertHistory/LogsIcon'; +import ROUTES from 'constants/routes'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { DraftingCompass } from 'lucide-react'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +type Props = { + children: React.ReactNode; + relatedTracesLink?: string; + relatedLogsLink?: string; +}; + +function PopoverContent({ + relatedTracesLink, + relatedLogsLink, +}: { + relatedTracesLink?: Props['relatedTracesLink']; + relatedLogsLink?: Props['relatedLogsLink']; +}): JSX.Element { + const isDarkMode = useIsDarkMode(); + return ( +
+ {!!relatedLogsLink && ( + +
+ +
+
View Logs
+ + )} + {!!relatedTracesLink && ( + +
+ +
+
View Traces
+ + )} +
+ ); +} +PopoverContent.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +function AlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: Props): JSX.Element { + return ( +
+ + } + trigger="click" + > + {children} + +
+ ); +} + +AlertPopover.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +type ConditionalAlertPopoverProps = { + relatedTracesLink: string; + relatedLogsLink: string; + children: React.ReactNode; +}; +export function ConditionalAlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: ConditionalAlertPopoverProps): JSX.Element { + if (relatedTracesLink || relatedLogsLink) { + return ( + + {children} + + ); + } + return
{children}
; +} +export default AlertPopover; diff --git a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx new file mode 100644 index 0000000000..f55c4385ce --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx @@ -0,0 +1,28 @@ +import { AlertRuleStats } from 'types/api/alerts/def'; +import { formatTime } from 'utils/timeUtils'; + +import StatsCard from '../StatsCard/StatsCard'; + +type TotalTriggeredCardProps = { + currentAvgResolutionTime: AlertRuleStats['currentAvgResolutionTime']; + pastAvgResolutionTime: AlertRuleStats['pastAvgResolutionTime']; + timeSeries: AlertRuleStats['currentAvgResolutionTimeSeries']['values']; +}; + +function AverageResolutionCard({ + currentAvgResolutionTime, + pastAvgResolutionTime, + timeSeries, +}: TotalTriggeredCardProps): JSX.Element { + return ( + + ); +} + +export default AverageResolutionCard; diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss b/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss new file mode 100644 index 0000000000..cc0a5b1b43 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.styles.scss @@ -0,0 +1,14 @@ +.statistics { + display: flex; + justify-content: space-between; + height: 280px; + border: 1px solid var(--bg-slate-500); + border-radius: 4px; + margin: 0 16px; +} + +.lightMode { + .statistics { + border: 1px solid var(--bg-vanilla-300); + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx new file mode 100644 index 0000000000..7158e0c069 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx @@ -0,0 +1,23 @@ +import './Statistics.styles.scss'; + +import { AlertRuleStats } from 'types/api/alerts/def'; + +import StatsCardsRenderer from './StatsCardsRenderer/StatsCardsRenderer'; +import TopContributorsRenderer from './TopContributorsRenderer/TopContributorsRenderer'; + +function Statistics({ + setTotalCurrentTriggers, + totalCurrentTriggers, +}: { + setTotalCurrentTriggers: (value: number) => void; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + return ( +
+ + +
+ ); +} + +export default Statistics; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss new file mode 100644 index 0000000000..bb9d3c3e72 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.styles.scss @@ -0,0 +1,112 @@ +.stats-card { + width: 21.7%; + border-right: 1px solid var(--bg-slate-500); + padding: 9px 12px 13px; + + &--empty { + justify-content: normal; + } + + &__title-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + + .title { + text-transform: uppercase; + font-size: 13px; + line-height: 22px; + color: var(--bg-vanilla-400); + font-weight: 500; + } + .duration-indicator { + display: flex; + align-items: center; + gap: 4px; + .icon { + display: flex; + align-self: center; + } + .text { + text-transform: uppercase; + color: var(--text-slate-200); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.48px; + } + } + } + &__stats { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 4px; + .count-label { + color: var(--text-vanilla-100); + font-family: 'Geist Mono'; + font-size: 24px; + line-height: 36px; + } + } + &__graph { + margin-top: 80px; + + .graph { + width: 100%; + height: 72px; + } + } +} + +.change-percentage { + width: max-content; + display: flex; + padding: 4px 8px; + border-radius: 20px; + align-items: center; + gap: 4px; + + &--success { + background: rgba(37, 225, 146, 0.1); + color: var(--bg-forest-500); + } + &--error { + background: rgba(229, 72, 77, 0.1); + color: var(--bg-cherry-500); + } + &--no-previous-data { + color: var(--text-robin-500); + background: rgba(78, 116, 248, 0.1); + padding: 4px 16px; + } + &__icon { + display: flex; + align-self: center; + } + &__label { + font-size: 12px; + font-weight: 500; + line-height: 16px; + } +} + +.lightMode { + .stats-card { + border-color: var(--bg-vanilla-300); + &__title-wrapper { + .title { + color: var(--text-ink-400); + } + .duration-indicator { + .text { + color: var(--text-ink-200); + } + } + } + &__stats { + .count-label { + color: var(--text-ink-100); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx new file mode 100644 index 0000000000..f204579f93 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx @@ -0,0 +1,158 @@ +import './StatsCard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Tooltip } from 'antd'; +import { QueryParams } from 'constants/query'; +import useUrlQuery from 'hooks/useUrlQuery'; +import { ArrowDownLeft, ArrowUpRight, Calendar } from 'lucide-react'; +import { AlertRuleStats } from 'types/api/alerts/def'; +import { calculateChange } from 'utils/calculateChange'; + +import StatsGraph from './StatsGraph/StatsGraph'; +import { + convertTimestampToLocaleDateString, + extractDayFromTimestamp, +} from './utils'; + +type ChangePercentageProps = { + percentage: number; + direction: number; + duration: string | null; +}; +function ChangePercentage({ + percentage, + direction, + duration, +}: ChangePercentageProps): JSX.Element { + if (direction > 0) { + return ( +
+
+ +
+
+ {percentage}% vs Last {duration} +
+
+ ); + } + if (direction < 0) { + return ( +
+
+ +
+
+ {percentage}% vs Last {duration} +
+
+ ); + } + + return ( +
+
no previous data
+
+ ); +} + +type StatsCardProps = { + totalCurrentCount?: number; + totalPastCount?: number; + title: string; + isEmpty?: boolean; + emptyMessage?: string; + displayValue?: string | number; + timeSeries?: AlertRuleStats['currentTriggersSeries']['values']; +}; + +function StatsCard({ + displayValue, + totalCurrentCount, + totalPastCount, + title, + isEmpty, + emptyMessage, + timeSeries = [], +}: StatsCardProps): JSX.Element { + const urlQuery = useUrlQuery(); + + const relativeTime = urlQuery.get('relativeTime'); + + const { changePercentage, changeDirection } = calculateChange( + totalCurrentCount, + totalPastCount, + ); + + const startTime = urlQuery.get(QueryParams.startTime); + const endTime = urlQuery.get(QueryParams.endTime); + + let displayTime = relativeTime; + + if (!displayTime && startTime && endTime) { + const formattedStartDate = extractDayFromTimestamp(startTime); + const formattedEndDate = extractDayFromTimestamp(endTime); + displayTime = `${formattedStartDate} to ${formattedEndDate}`; + } + + if (!displayTime) { + displayTime = ''; + } + const formattedStartTimeForTooltip = convertTimestampToLocaleDateString( + startTime, + ); + const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime); + + return ( +
+
+
{title}
+
+
+ +
+ {relativeTime ? ( +
{displayTime}
+ ) : ( + +
{displayTime}
+
+ )} +
+
+ +
+
+ {isEmpty ? emptyMessage : displayValue || totalCurrentCount} +
+ + +
+ +
+
+ {!isEmpty && timeSeries.length > 1 && ( + + )} +
+
+
+ ); +} + +StatsCard.defaultProps = { + totalCurrentCount: 0, + totalPastCount: 0, + isEmpty: false, + emptyMessage: 'No Data', + displayValue: '', + timeSeries: [], +}; + +export default StatsCard; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx new file mode 100644 index 0000000000..26c381d706 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx @@ -0,0 +1,90 @@ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useResizeObserver } from 'hooks/useDimensions'; +import { useMemo, useRef } from 'react'; +import { AlertRuleStats } from 'types/api/alerts/def'; + +type Props = { + timeSeries: AlertRuleStats['currentTriggersSeries']['values']; + changeDirection: number; +}; + +const getStyle = ( + changeDirection: number, +): { stroke: string; fill: string } => { + if (changeDirection === 0) { + return { + stroke: Color.BG_ROBIN_500, + fill: 'rgba(78, 116, 248, 0.20)', + }; + } + if (changeDirection > 0) { + return { + stroke: Color.BG_FOREST_500, + fill: 'rgba(37, 225, 146, 0.20)', + }; + } + return { + stroke: Color.BG_CHERRY_500, + fill: ' rgba(229, 72, 77, 0.20)', + }; +}; + +function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element { + const { xData, yData } = useMemo( + () => ({ + xData: timeSeries.map((item) => item.timestamp), + yData: timeSeries.map((item) => Number(item.value)), + }), + [timeSeries], + ); + + const graphRef = useRef(null); + + const containerDimensions = useResizeObserver(graphRef); + + const options: uPlot.Options = useMemo( + () => ({ + width: containerDimensions.width, + height: containerDimensions.height, + + legend: { + show: false, + }, + cursor: { + x: false, + y: false, + drag: { + x: false, + y: false, + }, + }, + padding: [0, 0, 2, 0], + series: [ + {}, + { + ...getStyle(changeDirection), + points: { + show: false, + }, + width: 1.4, + }, + ], + axes: [ + { show: false }, + { + show: false, + }, + ], + }), + [changeDirection, containerDimensions.height, containerDimensions.width], + ); + + return ( +
+ +
+ ); +} + +export default StatsGraph; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts b/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts new file mode 100644 index 0000000000..a2584aad37 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/utils.ts @@ -0,0 +1,12 @@ +export const extractDayFromTimestamp = (timestamp: string | null): string => { + if (!timestamp) return ''; + const date = new Date(parseInt(timestamp, 10)); + return date.getDate().toString(); +}; + +export const convertTimestampToLocaleDateString = ( + timestamp: string | null, +): string => { + if (!timestamp) return ''; + return new Date(parseInt(timestamp, 10)).toLocaleString(); +}; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx new file mode 100644 index 0000000000..e8859131df --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx @@ -0,0 +1,102 @@ +import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; +import { useEffect } from 'react'; + +import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard'; +import StatsCard from '../StatsCard/StatsCard'; +import TotalTriggeredCard from '../TotalTriggeredCard/TotalTriggeredCard'; + +const hasTotalTriggeredStats = ( + totalCurrentTriggers: number | string, + totalPastTriggers: number | string, +): boolean => + (Number(totalCurrentTriggers) > 0 && Number(totalPastTriggers) > 0) || + Number(totalCurrentTriggers) > 0; + +const hasAvgResolutionTimeStats = ( + currentAvgResolutionTime: number | string, + pastAvgResolutionTime: number | string, +): boolean => + (Number(currentAvgResolutionTime) > 0 && Number(pastAvgResolutionTime) > 0) || + Number(currentAvgResolutionTime) > 0; + +type StatsCardsRendererProps = { + setTotalCurrentTriggers: (value: number) => void; +}; + +// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title +function StatsCardsRenderer({ + setTotalCurrentTriggers, +}: StatsCardsRendererProps): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsStats(); + + useEffect(() => { + if (data?.payload?.data?.totalCurrentTriggers !== undefined) { + setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers); + } + }, [data, setTotalCurrentTriggers]); + + return ( + + {(data): JSX.Element => { + const { + currentAvgResolutionTime, + pastAvgResolutionTime, + totalCurrentTriggers, + totalPastTriggers, + currentAvgResolutionTimeSeries, + currentTriggersSeries, + } = data; + + return ( + <> + {hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) ? ( + + ) : ( + + )} + + {hasAvgResolutionTimeStats( + currentAvgResolutionTime, + pastAvgResolutionTime, + ) ? ( + + ) : ( + + )} + + ); + }} + + ); +} + +export default StatsCardsRenderer; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss new file mode 100644 index 0000000000..4b3c0a6069 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.styles.scss @@ -0,0 +1,191 @@ +.top-contributors-card { + width: 56.6%; + overflow: hidden; + + &--view-all { + width: auto; + } + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + + border-bottom: 1px solid var(--bg-slate-500); + .title { + color: var(--text-vanilla-400); + font-size: 13px; + font-weight: 500; + line-height: 22px; + letter-spacing: 0.52px; + text-transform: uppercase; + } + .view-all { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + padding: 0; + height: 20px; + &:hover { + background-color: transparent !important; + } + + .label { + color: var(--text-vanilla-400); + font-size: 14px; + line-height: 20px; + letter-spacing: -0.07px; + } + .icon { + display: flex; + } + } + } + .contributors-row { + height: 80px; + } + &__content { + .ant-table { + &-cell { + padding: 12px !important; + } + } + .contributors-row { + background: var(--bg-ink-500); + + td { + border: none !important; + } + &:not(:last-of-type) td { + border-bottom: 1px solid var(--bg-slate-500) !important; + } + } + .total-contribution { + color: var(--text-robin-500); + font-family: 'Geist Mono'; + font-size: 12px; + font-weight: 500; + letter-spacing: -0.06px; + padding: 4px 8px; + background: rgba(78, 116, 248, 0.1); + border-radius: 50px; + width: max-content; + } + } + .empty-content { + margin: 16px 12px; + padding: 40px 45px; + display: flex; + flex-direction: column; + gap: 12px; + border: 1px dashed var(--bg-slate-500); + border-radius: 6px; + + &__icon { + font-family: Inter; + font-size: 20px; + line-height: 26px; + letter-spacing: -0.103px; + } + &__text { + color: var(--text-vanilla-400); + line-height: 18px; + .bold-text { + color: var(--text-vanilla-100); + font-weight: 500; + } + } + &__button-wrapper { + margin-top: 12px; + .configure-alert-rule-button { + padding: 8px 16px; + border-radius: 2px; + background: var(--bg-slate-400); + border-width: 0; + color: var(--text-vanilla-100); + line-height: 24px; + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + } + } + } +} + +.ant-popover-inner:has(.contributor-row-popover-buttons) { + padding: 0 !important; +} +.contributor-row-popover-buttons { + display: flex; + flex-direction: column; + border: 1px solid var(--bg-slate-400); + + &__button { + display: flex; + align-items: center; + gap: 6px; + padding: 12px 15px; + color: var(--text-vanilla-400); + font-size: 14px; + letter-spacing: 0.14px; + width: 160px; + cursor: pointer; + + &:hover { + background: var(--bg-slate-400); + } + + .icon { + display: flex; + } + } +} + +.view-all-drawer { + border-radius: 4px; +} + +.lightMode { + .ant-table { + background: inherit; + } + + .top-contributors-card { + &__header { + border-color: var(--bg-vanilla-300); + .title { + color: var(--text-ink-400); + } + .view-all { + .label { + color: var(--text-ink-400); + } + } + } + &__content { + .contributors-row { + background: inherit; + &:not(:last-of-type) td { + border-bottom: 1px solid var(--bg-vanilla-300) !important; + } + } + } + .empty-content { + border-color: var(--bg-vanilla-300); + &__text { + color: var(--text-ink-400); + .bold-text { + color: var(--text-ink-500); + } + } + &__button-wrapper { + .configure-alert-rule-button { + background: var(--bg-vanilla-300); + color: var(--text-ink-500); + } + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx new file mode 100644 index 0000000000..d3cd0bb756 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx @@ -0,0 +1,84 @@ +import './TopContributorsCard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import history from 'lib/history'; +import { ArrowRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; + +import TopContributorsContent from './TopContributorsContent'; +import { TopContributorsCardProps } from './types'; +import ViewAllDrawer from './ViewAllDrawer'; + +function TopContributorsCard({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const { search } = useLocation(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + const viewAllTopContributorsParam = searchParams.get('viewAllTopContributors'); + + const [isViewAllVisible, setIsViewAllVisible] = useState( + !!viewAllTopContributorsParam ?? false, + ); + + const isDarkMode = useIsDarkMode(); + + const toggleViewAllParam = (isOpen: boolean): void => { + if (isOpen) { + searchParams.set('viewAllTopContributors', 'true'); + } else { + searchParams.delete('viewAllTopContributors'); + } + }; + + const toggleViewAllDrawer = (): void => { + setIsViewAllVisible((prev) => { + const newState = !prev; + + toggleViewAllParam(newState); + + return newState; + }); + history.push({ search: searchParams.toString() }); + }; + + return ( + <> +
+
+
top contributors
+ {topContributorsData.length > 3 && ( + + )} +
+ + +
+ {isViewAllVisible && ( + + )} + + ); +} + +export default TopContributorsCard; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx new file mode 100644 index 0000000000..b458871f71 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx @@ -0,0 +1,32 @@ +import TopContributorsRows from './TopContributorsRows'; +import { TopContributorsCardProps } from './types'; + +function TopContributorsContent({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const isEmpty = !topContributorsData.length; + + if (isEmpty) { + return ( +
+
ℹ️
+
+ Top contributors highlight the most frequently triggering group-by + attributes in multi-dimensional alerts +
+
+ ); + } + + return ( +
+ +
+ ); +} + +export default TopContributorsContent; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx new file mode 100644 index 0000000000..85857605f8 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx @@ -0,0 +1,87 @@ +import { Color } from '@signozhq/design-tokens'; +import { Progress, Table } from 'antd'; +import { ColumnsType } from 'antd/es/table'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +function TopContributorsRows({ + topContributors, + totalCurrentTriggers, +}: { + topContributors: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + const columns: ColumnsType = [ + { + title: 'labels', + dataIndex: 'labels', + key: 'labels', + width: '51%', + render: ( + labels: AlertRuleTopContributors['labels'], + record, + ): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'progressBar', + dataIndex: 'count', + key: 'progressBar', + width: '39%', + render: (count: AlertRuleTopContributors['count'], record): JSX.Element => ( + + + + ), + }, + { + title: 'count', + dataIndex: 'count', + key: 'count', + width: '10%', + render: (count: AlertRuleTopContributors['count'], record): JSX.Element => ( + +
+ {count}/{totalCurrentTriggers} +
+
+ ), + }, + ]; + + return ( +
`top-contributor-${row.fingerprint}`} + columns={columns} + showHeader={false} + dataSource={topContributors} + pagination={ + topContributors.length > 10 ? { showTotal: PaginationInfoText } : false + } + /> + ); +} + +export default TopContributorsRows; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx new file mode 100644 index 0000000000..1d49c87afd --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx @@ -0,0 +1,46 @@ +import { Color } from '@signozhq/design-tokens'; +import { Drawer } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +import TopContributorsRows from './TopContributorsRows'; + +function ViewAllDrawer({ + isViewAllVisible, + toggleViewAllDrawer, + totalCurrentTriggers, + topContributorsData, +}: { + isViewAllVisible: boolean; + toggleViewAllDrawer: () => void; + topContributorsData: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + const isDarkMode = useIsDarkMode(); + return ( + +
+
+ +
+
+
+ ); +} + +export default ViewAllDrawer; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts new file mode 100644 index 0000000000..f44d2ded99 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts @@ -0,0 +1,6 @@ +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +export type TopContributorsCardProps = { + topContributorsData: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx new file mode 100644 index 0000000000..b773579ca0 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx @@ -0,0 +1,42 @@ +import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; +import { AlertRuleStats } from 'types/api/alerts/def'; + +import TopContributorsCard from '../TopContributorsCard/TopContributorsCard'; + +type TopContributorsRendererProps = { + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}; + +function TopContributorsRenderer({ + totalCurrentTriggers, +}: TopContributorsRendererProps): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTopContributors(); + const response = data?.payload?.data; + + // TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all + return ( + + {(topContributorsData): JSX.Element => ( + + )} + + ); +} + +export default TopContributorsRenderer; diff --git a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx new file mode 100644 index 0000000000..0e4f412894 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx @@ -0,0 +1,26 @@ +import { AlertRuleStats } from 'types/api/alerts/def'; + +import StatsCard from '../StatsCard/StatsCard'; + +type TotalTriggeredCardProps = { + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; + totalPastTriggers: AlertRuleStats['totalPastTriggers']; + timeSeries: AlertRuleStats['currentTriggersSeries']['values']; +}; + +function TotalTriggeredCard({ + totalCurrentTriggers, + totalPastTriggers, + timeSeries, +}: TotalTriggeredCardProps): JSX.Element { + return ( + + ); +} + +export default TotalTriggeredCard; diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss new file mode 100644 index 0000000000..3ea30fe25a --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.styles.scss @@ -0,0 +1,52 @@ +.timeline-graph { + display: flex; + flex-direction: column; + gap: 24px; + background: var(--bg-ink-400); + padding: 12px; + border-radius: 4px; + border: 1px solid var(--bg-slate-500); + height: 150px; + + &__title { + width: max-content; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid #1d212d; + background: rgba(29, 33, 45, 0.5); + color: #ebebeb; + font-size: 12px; + line-height: 18px; + letter-spacing: -0.06px; + } + &__chart { + .chart-placeholder { + width: 100%; + height: 52px; + background: rgba(255, 255, 255, 0.1215686275); + display: flex; + align-items: center; + justify-content: center; + .chart-icon { + font-size: 2rem; + } + } + } +} + +.lightMode { + .timeline-graph { + background: var(--bg-vanilla-200); + border-color: var(--bg-vanilla-300); + &__title { + background: var(--bg-vanilla-100); + color: var(--text-ink-400); + border-color: var(--bg-vanilla-300); + } + &__chart { + .chart-placeholder { + background: var(--bg-vanilla-300); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx new file mode 100644 index 0000000000..a0534691df --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx @@ -0,0 +1,184 @@ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin'; +import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin'; +import { useMemo, useRef } from 'react'; +import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def'; +import uPlot, { AlignedData } from 'uplot'; + +import { ALERT_STATUS, TIMELINE_OPTIONS } from './constants'; + +type Props = { type: string; data: AlertRuleTimelineGraphResponse[] }; + +function HorizontalTimelineGraph({ + width, + isDarkMode, + data, +}: { + width: number; + isDarkMode: boolean; + data: AlertRuleTimelineGraphResponse[]; +}): JSX.Element { + const transformedData: AlignedData = useMemo(() => { + if (!data?.length) { + return [[], []]; + } + + // add a first and last entry to make sure the graph displays all the data + const FIVE_MINUTES_IN_SECONDS = 300; + + const timestamps = [ + data[0].start / 1000 - FIVE_MINUTES_IN_SECONDS, // 5 minutes before the first entry + ...data.map((item) => item.start / 1000), + data[data.length - 1].end / 1000, // end value of last entry + ]; + + const states = [ + ALERT_STATUS[data[0].state], // Same state as the first entry + ...data.map((item) => ALERT_STATUS[item.state]), + ALERT_STATUS[data[data.length - 1].state], // Same state as the last entry + ]; + + return [timestamps, states]; + }, [data]); + + const options: uPlot.Options = useMemo( + () => ({ + width, + height: 85, + cursor: { show: false }, + + axes: [ + { + gap: 10, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + { show: false }, + ], + legend: { + show: false, + }, + padding: [null, 0, null, 0], + series: [ + { + label: 'Time', + }, + { + label: 'States', + }, + ], + plugins: + transformedData?.length > 1 + ? [ + timelinePlugin({ + count: transformedData.length - 1, + ...TIMELINE_OPTIONS, + }), + ] + : [], + }), + [width, isDarkMode, transformedData], + ); + return ; +} + +const transformVerticalTimelineGraph = (data: any[]): any => [ + data.map((item: { timestamp: any }) => item.timestamp), + Array(data.length).fill(0), + Array(data.length).fill(10), + Array(data.length).fill([0, 1, 2, 3, 4, 5]), + data.map((item: { value: number }) => { + const count = Math.floor(item.value / 10); + return [...Array(count).fill(1), 2]; + }), +]; + +const datatest: any[] = []; +const now = Math.floor(Date.now() / 1000); // current timestamp in seconds +const oneDay = 24 * 60 * 60; // one day in seconds + +for (let i = 0; i < 90; i++) { + const timestamp = now - i * oneDay; + const startOfDay = timestamp - (timestamp % oneDay); + datatest.push({ + timestamp: startOfDay, + value: Math.floor(Math.random() * 30) + 1, + }); +} + +function VerticalTimelineGraph({ + isDarkMode, + width, +}: { + width: number; + isDarkMode: boolean; +}): JSX.Element { + const transformedData = useMemo( + () => transformVerticalTimelineGraph(datatest), + [], + ); + + const options: uPlot.Options = useMemo( + () => ({ + width, + height: 90, + plugins: [heatmapPlugin()], + cursor: { show: false }, + legend: { + show: false, + }, + axes: [ + { + gap: 10, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + { show: false }, + ], + series: [ + {}, + { + paths: (): null => null, + points: { show: false }, + }, + { + paths: (): null => null, + points: { show: false }, + }, + ], + }), + [isDarkMode, width], + ); + return ; +} + +function Graph({ type, data }: Props): JSX.Element | null { + const graphRef = useRef(null); + + const isDarkMode = useIsDarkMode(); + + const containerDimensions = useResizeObserver(graphRef); + + if (type === 'horizontal') { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); +} + +export default Graph; diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts new file mode 100644 index 0000000000..b56499a0d0 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts @@ -0,0 +1,33 @@ +import { Color } from '@signozhq/design-tokens'; + +export const ALERT_STATUS: { [key: string]: number } = { + firing: 0, + inactive: 1, + normal: 1, + 'no-data': 2, + disabled: 3, + muted: 4, +}; + +export const STATE_VS_COLOR: { + [key: string]: { stroke: string; fill: string }; +}[] = [ + {}, + { + 0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 }, + 1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 }, + 2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 }, + 3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 }, + 4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 }, + }, +]; + +export const TIMELINE_OPTIONS = { + mode: 1, + fill: (seriesIdx: any, _: any, value: any): any => + STATE_VS_COLOR[seriesIdx][value].fill, + stroke: (seriesIdx: any, _: any, value: any): any => + STATE_VS_COLOR[seriesIdx][value].stroke, + laneWidthOption: 0.3, + showGrid: false, +}; diff --git a/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx new file mode 100644 index 0000000000..05690a9041 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx @@ -0,0 +1,67 @@ +import '../Graph/Graph.styles.scss'; + +import useUrlQuery from 'hooks/useUrlQuery'; +import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; + +import Graph from '../Graph/Graph'; + +function GraphWrapper({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { + const urlQuery = useUrlQuery(); + + const relativeTime = urlQuery.get('relativeTime'); + + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineGraphData(); + + // TODO(shaheer): uncomment when the API is ready for + // const { startTime } = useAlertHistoryQueryParams(); + + // const [isVerticalGraph, setIsVerticalGraph] = useState(false); + + // useEffect(() => { + // const checkVerticalGraph = (): void => { + // if (startTime) { + // const startTimeDate = dayjs(Number(startTime)); + // const twentyFourHoursAgo = dayjs().subtract( + // HORIZONTAL_GRAPH_HOURS_THRESHOLD, + // DAYJS_MANIPULATE_TYPES.HOUR, + // ); + + // setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo)); + // } + // }; + + // checkVerticalGraph(); + // }, [startTime]); + + return ( +
+
+ {totalCurrentTriggers} triggers in {relativeTime} +
+
+ + {(data): JSX.Element => } + +
+
+ ); +} + +export default GraphWrapper; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss b/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss new file mode 100644 index 0000000000..9d31e0b0ea --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.styles.scss @@ -0,0 +1,134 @@ +.timeline-table { + border-top: 1px solid var(--text-slate-500); + border-radius: 6px; + overflow: hidden; + margin-top: 4px; + min-height: 600px; + .ant-table { + background: var(--bg-ink-500); + &-cell { + padding: 12px 16px !important; + vertical-align: baseline; + &::before { + display: none; + } + } + &-thead > tr > th { + border-color: var(--bg-slate-500); + background: var(--bg-ink-500); + font-size: 12px; + font-weight: 500; + padding: 12px 16px 8px !important; + &:last-of-type + // TODO(shaheer): uncomment when we display value column + // , + // &:nth-last-of-type(2) + { + text-align: right; + } + } + &-tbody > tr > td { + border: none; + &:last-of-type, + &:nth-last-of-type(2) { + text-align: right; + } + } + } + + .label-filter { + padding: 6px 8px; + border-radius: 4px; + background: var(--text-ink-400); + border-width: 0; + line-height: 18px; + & ::placeholder { + color: var(--text-vanilla-400); + font-size: 12px; + letter-spacing: 0.6px; + text-transform: uppercase; + font-weight: 500; + } + } + .alert-rule { + &-value, + &-created-at { + font-size: 14px; + color: var(--text-vanilla-400); + } + &-value { + font-weight: 500; + line-height: 20px; + } + &-created-at { + line-height: 18px; + letter-spacing: -0.07px; + } + } + .ant-table.ant-table-middle { + border-bottom: 1px solid var(--bg-slate-500); + border-left: 1px solid var(--bg-slate-500); + border-right: 1px solid var(--bg-slate-500); + + border-radius: 6px; + } + .ant-pagination-item { + &-active { + display: flex; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + padding: 1px 8px; + border-radius: 2px; + background: var(--bg-robin-500); + & > a { + color: var(--text-ink-500); + line-height: 20px; + font-weight: 500; + } + } + } + .alert-history-label-search { + .ant-select-selector { + border: none; + } + } +} + +.lightMode { + .timeline-table { + border-color: var(--bg-vanilla-300); + + .ant-table { + background: var(--bg-vanilla-100); + &-thead { + & > tr > th { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } + } + &.ant-table-middle { + border-color: var(--bg-vanilla-300); + } + } + .alert-history-label-search { + .ant-select-selector { + background: var(--bg-vanilla-200); + } + } + + .alert-rule { + &-value, + &-created-at { + color: var(--text-ink-400); + } + } + .ant-pagination-item { + &-active > a { + color: var(--text-vanilla-100); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx new file mode 100644 index 0000000000..f3144b88e6 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx @@ -0,0 +1,56 @@ +import './Table.styles.scss'; + +import { Table } from 'antd'; +import { + useGetAlertRuleDetailsTimelineTable, + useTimelineTable, +} from 'pages/AlertDetails/hooks'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { timelineTableColumns } from './useTimelineTable'; + +function TimelineTable(): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineTable(); + + const { timelineData, totalItems } = useMemo(() => { + const response = data?.payload?.data; + return { + timelineData: response?.items, + totalItems: response?.total, + }; + }, [data?.payload?.data]); + + const { paginationConfig, onChangeHandler } = useTimelineTable({ + totalItems: totalItems ?? 0, + }); + + const { t } = useTranslation('common'); + + if (isError || !isValidRuleId || !ruleId) { + return
{t('something_went_wrong')}
; + } + + return ( +
+
`${row.fingerprint}-${row.value}-${row.unixMilli}`} + columns={timelineTableColumns()} + dataSource={timelineData} + pagination={paginationConfig} + size="middle" + onChange={onChangeHandler} + loading={isLoading || isRefetching} + /> + + ); +} + +export default TimelineTable; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/types.ts b/frontend/src/container/AlertHistory/Timeline/Table/types.ts new file mode 100644 index 0000000000..badf649867 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/types.ts @@ -0,0 +1,9 @@ +import { + AlertRuleTimelineTableResponse, + AlertRuleTimelineTableResponsePayload, +} from 'types/api/alerts/def'; + +export type TimelineTableProps = { + timelineData: AlertRuleTimelineTableResponse[]; + totalItems: AlertRuleTimelineTableResponsePayload['data']['total']; +}; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx new file mode 100644 index 0000000000..5a42fcd5bd --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx @@ -0,0 +1,53 @@ +import { ColumnsType } from 'antd/es/table'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState'; +import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; +import { formatEpochTimestamp } from 'utils/timeUtils'; + +export const timelineTableColumns = (): ColumnsType => [ + { + title: 'STATE', + dataIndex: 'state', + sorter: true, + width: '12.5%', + render: (value, record): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'LABELS', + dataIndex: 'labels', + width: '54.5%', + render: (labels, record): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'CREATED AT', + dataIndex: 'unixMilli', + width: '32.5%', + render: (value, record): JSX.Element => ( + +
{formatEpochTimestamp(value)}
+
+ ), + }, +]; diff --git a/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss new file mode 100644 index 0000000000..c153ba65fc --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.styles.scss @@ -0,0 +1,32 @@ +.timeline-tabs-and-filters { + display: flex; + justify-content: space-between; + align-items: center; + .reset-button, + .top-5-contributors { + display: flex; + align-items: center; + gap: 10px; + } + .coming-soon { + display: inline-flex; + padding: 4px 8px; + border-radius: 20px; + border: 1px solid rgba(173, 127, 88, 0.2); + background: rgba(173, 127, 88, 0.1); + justify-content: center; + align-items: center; + gap: 5px; + + &__text { + color: var(--text-sienna-400); + font-size: 10px; + font-weight: 500; + letter-spacing: -0.05px; + line-height: normal; + } + &__icon { + display: flex; + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx new file mode 100644 index 0000000000..515cef1616 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx @@ -0,0 +1,90 @@ +import './TabsAndFilters.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { TimelineFilter, TimelineTab } from 'container/AlertHistory/types'; +import history from 'lib/history'; +import { Info } from 'lucide-react'; +import Tabs2 from 'periscope/components/Tabs2'; +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +function ComingSoon(): JSX.Element { + return ( +
+
Coming Soon
+
+ +
+
+ ); +} +function TimelineTabs(): JSX.Element { + const tabs = [ + { + value: TimelineTab.OVERALL_STATUS, + label: 'Overall Status', + }, + { + value: TimelineTab.TOP_5_CONTRIBUTORS, + label: ( +
+ Top 5 Contributors + +
+ ), + disabled: true, + }, + ]; + + return ; +} + +function TimelineFilters(): JSX.Element { + const { search } = useLocation(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + const initialSelectedTab = useMemo( + () => searchParams.get('timelineFilter') ?? TimelineFilter.ALL, + [searchParams], + ); + + const handleFilter = (value: TimelineFilter): void => { + searchParams.set('timelineFilter', value); + history.push({ search: searchParams.toString() }); + }; + + const tabs = [ + { + value: TimelineFilter.ALL, + label: 'All', + }, + { + value: TimelineFilter.FIRED, + label: 'Fired', + }, + { + value: TimelineFilter.RESOLVED, + label: 'Resolved', + }, + ]; + + return ( + + ); +} + +function TabsAndFilters(): JSX.Element { + return ( +
+ + +
+ ); +} + +export default TabsAndFilters; diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss b/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss new file mode 100644 index 0000000000..1d6b4d7990 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.styles.scss @@ -0,0 +1,22 @@ +.timeline { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0 16px; + + &__title { + color: var(--text-vanilla-100); + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: -0.07px; + } +} + +.lightMode { + .timeline { + &__title { + color: var(--text-ink-400); + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.tsx b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx new file mode 100644 index 0000000000..18430f7144 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx @@ -0,0 +1,32 @@ +import './Timeline.styles.scss'; + +import GraphWrapper from './GraphWrapper/GraphWrapper'; +import TimelineTable from './Table/Table'; +import TabsAndFilters from './TabsAndFilters/TabsAndFilters'; + +function TimelineTableRenderer(): JSX.Element { + return ; +} + +function Timeline({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { + return ( +
+
Timeline
+
+ +
+
+ +
+
+ +
+
+ ); +} + +export default Timeline; diff --git a/frontend/src/container/AlertHistory/Timeline/constants.ts b/frontend/src/container/AlertHistory/Timeline/constants.ts new file mode 100644 index 0000000000..2f1652437f --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/constants.ts @@ -0,0 +1,2 @@ +// setting to 25 hours because we want to display the horizontal graph when the user selects 'Last 1 day' from date and time selector +export const HORIZONTAL_GRAPH_HOURS_THRESHOLD = 25; diff --git a/frontend/src/container/AlertHistory/constants.ts b/frontend/src/container/AlertHistory/constants.ts new file mode 100644 index 0000000000..2253a27677 --- /dev/null +++ b/frontend/src/container/AlertHistory/constants.ts @@ -0,0 +1 @@ +export const TIMELINE_TABLE_PAGE_SIZE = 20; diff --git a/frontend/src/container/AlertHistory/index.tsx b/frontend/src/container/AlertHistory/index.tsx new file mode 100644 index 0000000000..3a99a130a6 --- /dev/null +++ b/frontend/src/container/AlertHistory/index.tsx @@ -0,0 +1,3 @@ +import AlertHistory from './AlertHistory'; + +export default AlertHistory; diff --git a/frontend/src/container/AlertHistory/types.ts b/frontend/src/container/AlertHistory/types.ts new file mode 100644 index 0000000000..797a557eed --- /dev/null +++ b/frontend/src/container/AlertHistory/types.ts @@ -0,0 +1,15 @@ +export enum AlertDetailsTab { + OVERVIEW = 'OVERVIEW', + HISTORY = 'HISTORY', +} + +export enum TimelineTab { + OVERALL_STATUS = 'OVERALL_STATUS', + TOP_5_CONTRIBUTORS = 'TOP_5_CONTRIBUTORS', +} + +export enum TimelineFilter { + ALL = 'ALL', + FIRED = 'FIRED', + RESOLVED = 'RESOLVED', +} diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index e821e67104..85a58aac99 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -253,6 +253,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { routeKey === 'MESSAGING_QUEUES' || routeKey === 'MESSAGING_QUEUES_DETAIL'; const isDashboardListView = (): boolean => routeKey === 'ALL_DASHBOARD'; + const isAlertHistory = (): boolean => routeKey === 'ALERT_HISTORY'; + const isAlertOverview = (): boolean => routeKey === 'ALERT_OVERVIEW'; const isDashboardView = (): boolean => { /** * need to match using regex here as the getRoute function will not work for @@ -341,6 +343,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { isDashboardView() || isDashboardWidgetView() || isDashboardListView() || + isAlertHistory() || + isAlertOverview() || isMessagingQueues() ? 0 : '0 1rem', diff --git a/frontend/src/container/FormAlertRules/QuerySection.styles.scss b/frontend/src/container/FormAlertRules/QuerySection.styles.scss index ee3f4892af..303f6d45d8 100644 --- a/frontend/src/container/FormAlertRules/QuerySection.styles.scss +++ b/frontend/src/container/FormAlertRules/QuerySection.styles.scss @@ -42,6 +42,10 @@ display: flex; align-items: center; } + + .ant-tabs-tab-btn { + padding: 0 !important; + } } .lightMode { diff --git a/frontend/src/container/FormAlertRules/index.tsx b/frontend/src/container/FormAlertRules/index.tsx index 965b21aa5a..f53a6b2cfe 100644 --- a/frontend/src/container/FormAlertRules/index.tsx +++ b/frontend/src/container/FormAlertRules/index.tsx @@ -19,6 +19,7 @@ import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts'; import { FeatureKeys } from 'constants/features'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag'; import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag'; @@ -369,7 +370,7 @@ function FormAlertRules({ }); // invalidate rule in cache - ruleCache.invalidateQueries(['ruleId', ruleId]); + ruleCache.invalidateQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]); // eslint-disable-next-line sonarjs/no-identical-functions setTimeout(() => { diff --git a/frontend/src/container/ListAlertRules/ListAlert.tsx b/frontend/src/container/ListAlertRules/ListAlert.tsx index 3ba953be73..f446d55f90 100644 --- a/frontend/src/container/ListAlertRules/ListAlert.tsx +++ b/frontend/src/container/ListAlertRules/ListAlert.tsx @@ -139,7 +139,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { params.set(QueryParams.ruleId, record.id.toString()); setEditLoader(false); - history.push(`${ROUTES.EDIT_ALERTS}?${params.toString()}`); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); }) .catch(handleError) .finally(() => setEditLoader(false)); diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss b/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss index 22efca5009..601b513f8f 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/DateTimeSelectionV2.styles.scss @@ -62,6 +62,14 @@ .shareable-link-popover { margin-left: 8px; } + .reset-button { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-ink-300); + border: 1px solid var(--bg-slate-400); + margin-right: 16px; + } } .share-modal-content { @@ -296,4 +304,8 @@ } } } + .reset-button { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } } diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts index b652a68202..e0b73deb33 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/config.ts @@ -208,6 +208,8 @@ export const routesToSkip = [ ROUTES.DASHBOARD, ROUTES.DASHBOARD_WIDGET, ROUTES.SERVICE_TOP_LEVEL_OPERATIONS, + ROUTES.ALERT_HISTORY, + ROUTES.ALERT_OVERVIEW, ROUTES.MESSAGING_QUEUES, ROUTES.MESSAGING_QUEUES_DETAIL, ]; diff --git a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx index 3895d8b38c..cd4fb97d4f 100644 --- a/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx +++ b/frontend/src/container/TopNav/DateTimeSelectionV2/index.tsx @@ -27,7 +27,7 @@ import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax'; import getTimeString from 'lib/getTimeString'; import history from 'lib/history'; import { isObject } from 'lodash-es'; -import { Check, Copy, Info, Send } from 'lucide-react'; +import { Check, Copy, Info, Send, Undo } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useQueryClient } from 'react-query'; import { connect, useSelector } from 'react-redux'; @@ -44,6 +44,7 @@ import { GlobalReducer } from 'types/reducer/globalTime'; import AutoRefresh from '../AutoRefreshV2'; import { DateTimeRangeType } from '../CustomDateTimeModal'; +import { RelativeTimeMap } from '../DateTimeSelection/config'; import { convertOldTimeToNewValidCustomTimeFormat, CustomTimeType, @@ -63,7 +64,9 @@ function DateTimeSelection({ location, updateTimeInterval, globalTimeLoading, + showResetButton = false, showOldExplorerCTA = false, + defaultRelativeTime = RelativeTimeMap['6hr'] as Time, }: Props): JSX.Element { const [formSelector] = Form.useForm(); @@ -242,22 +245,25 @@ function DateTimeSelection({ return defaultSelectedOption; }; - const updateLocalStorageForRoutes = (value: Time | string): void => { - const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION); - if (preRoutes !== null) { - const preRoutesObject = JSON.parse(preRoutes); - - const preRoute = { - ...preRoutesObject, - }; - preRoute[location.pathname] = value; - - setLocalStorageKey( - LOCALSTORAGE.METRICS_TIME_IN_DURATION, - JSON.stringify(preRoute), - ); - } - }; + const updateLocalStorageForRoutes = useCallback( + (value: Time | string): void => { + const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION); + if (preRoutes !== null) { + const preRoutesObject = JSON.parse(preRoutes); + + const preRoute = { + ...preRoutesObject, + }; + preRoute[location.pathname] = value; + + setLocalStorageKey( + LOCALSTORAGE.METRICS_TIME_IN_DURATION, + JSON.stringify(preRoute), + ); + } + }, + [location.pathname], + ); const onLastRefreshHandler = useCallback(() => { const currentTime = dayjs(); @@ -297,48 +303,65 @@ function DateTimeSelection({ [location.pathname], ); - const onSelectHandler = (value: Time | CustomTimeType): void => { - if (value !== 'custom') { - setIsOpen(false); - updateTimeInterval(value); - updateLocalStorageForRoutes(value); - setIsValidteRelativeTime(true); - if (refreshButtonHidden) { - setRefreshButtonHidden(false); - } - } else { - setRefreshButtonHidden(true); - setCustomDTPickerVisible(true); - setIsValidteRelativeTime(false); - setEnableAbsoluteTime(false); + const onSelectHandler = useCallback( + (value: Time | CustomTimeType): void => { + if (value !== 'custom') { + setIsOpen(false); + updateTimeInterval(value); + updateLocalStorageForRoutes(value); + setIsValidteRelativeTime(true); + if (refreshButtonHidden) { + setRefreshButtonHidden(false); + } + } else { + setRefreshButtonHidden(true); + setCustomDTPickerVisible(true); + setIsValidteRelativeTime(false); + setEnableAbsoluteTime(false); - return; - } + return; + } - if (!isLogsExplorerPage) { - urlQuery.delete('startTime'); - urlQuery.delete('endTime'); + if (!isLogsExplorerPage) { + urlQuery.delete('startTime'); + urlQuery.delete('endTime'); - urlQuery.set(QueryParams.relativeTime, value); + urlQuery.set(QueryParams.relativeTime, value); - const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; - history.replace(generatedUrl); - } + const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; + history.replace(generatedUrl); + } - // For logs explorer - time range handling is managed in useCopyLogLink.ts:52 + // For logs explorer - time range handling is managed in useCopyLogLink.ts:52 - if (!stagedQuery) { - return; - } - // the second boolean param directs the qb about the time change so to merge the query and retain the current state - // we removed update step interval to stop auto updating the value on time change - initQueryBuilderData(stagedQuery, true); - }; + if (!stagedQuery) { + return; + } + // the second boolean param directs the qb about the time change so to merge the query and retain the current state + // we removed update step interval to stop auto updating the value on time change + initQueryBuilderData(stagedQuery, true); + }, + [ + initQueryBuilderData, + isLogsExplorerPage, + location.pathname, + refreshButtonHidden, + stagedQuery, + updateLocalStorageForRoutes, + updateTimeInterval, + urlQuery, + ], + ); const onRefreshHandler = (): void => { onSelectHandler(selectedTime); onLastRefreshHandler(); }; + const handleReset = useCallback(() => { + if (defaultRelativeTime) { + onSelectHandler(defaultRelativeTime); + } + }, [defaultRelativeTime, onSelectHandler]); const onCustomDateHandler = (dateTimeRange: DateTimeRangeType): void => { if (dateTimeRange !== null) { @@ -446,6 +469,22 @@ function DateTimeSelection({ } const currentRoute = location.pathname; + + // set the default relative time for alert history and overview pages if relative time is not specified + if ( + (!urlQuery.has(QueryParams.startTime) || + !urlQuery.has(QueryParams.endTime)) && + !urlQuery.has(QueryParams.relativeTime) && + (currentRoute === ROUTES.ALERT_OVERVIEW || + currentRoute === ROUTES.ALERT_HISTORY) + ) { + updateTimeInterval(defaultRelativeTime); + urlQuery.set(QueryParams.relativeTime, defaultRelativeTime); + const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; + history.replace(generatedUrl); + return; + } + const time = getDefaultTime(currentRoute); const currentOptions = getOptions(currentRoute); @@ -575,6 +614,19 @@ function DateTimeSelection({ return (
+ {showResetButton && selectedTime !== defaultRelativeTime && ( + + + + )} {showOldExplorerCTA && (
@@ -666,11 +718,15 @@ interface DateTimeSelectionV2Props { showAutoRefresh: boolean; hideShareModal?: boolean; showOldExplorerCTA?: boolean; + showResetButton?: boolean; + defaultRelativeTime?: Time; } DateTimeSelection.defaultProps = { hideShareModal: false, showOldExplorerCTA: false, + showResetButton: false, + defaultRelativeTime: RelativeTimeMap['6hr'] as Time, }; interface DispatchProps { updateTimeInterval: ( diff --git a/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts b/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts new file mode 100644 index 0000000000..d2eb2c09e0 --- /dev/null +++ b/frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts @@ -0,0 +1,49 @@ +import { Color } from '@signozhq/design-tokens'; +import uPlot from 'uplot'; + +const bucketIncr = 5; + +function heatmapPlugin(): uPlot.Plugin { + function fillStyle(count: number): string { + const colors = [Color.BG_CHERRY_500, Color.BG_SLATE_400]; + return colors[count - 1]; + } + + return { + hooks: { + draw: (u: uPlot): void => { + const { ctx, data } = u; + + const yData = (data[3] as unknown) as number[][]; + const yQtys = (data[4] as unknown) as number[][]; + const yHgt = Math.floor( + u.valToPos(bucketIncr, 'y', true) - u.valToPos(0, 'y', true), + ); + + ctx.save(); + ctx.beginPath(); + ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + ctx.clip(); + + yData.forEach((yVals, xi) => { + const xPos = Math.floor(u.valToPos(data[0][xi], 'x', true)); + + // const maxCount = yQtys[xi].reduce( + // (acc, val) => Math.max(val, acc), + // -Infinity, + // ); + + yVals.forEach((yVal, yi) => { + const yPos = Math.floor(u.valToPos(yVal, 'y', true)); + + ctx.fillStyle = fillStyle(yQtys[xi][yi]); + ctx.fillRect(xPos - 4, yPos, 30, yHgt); + }); + }); + + ctx.restore(); + }, + }, + }; +} +export default heatmapPlugin; diff --git a/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts new file mode 100644 index 0000000000..b740fb2b2c --- /dev/null +++ b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts @@ -0,0 +1,632 @@ +import uPlot from 'uplot'; + +export function pointWithin( + px: number, + py: number, + rlft: number, + rtop: number, + rrgt: number, + rbtm: number, +): boolean { + return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm; +} +const MAX_OBJECTS = 10; +const MAX_LEVELS = 4; + +export class Quadtree { + x: number; + + y: number; + + w: number; + + h: number; + + l: number; + + o: any[]; + + q: Quadtree[] | null; + + constructor(x: number, y: number, w: number, h: number, l?: number) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + this.l = l || 0; + this.o = []; + this.q = null; + } + + split(): void { + const w = this.w / 2; + const h = this.h / 2; + const l = this.l + 1; + + this.q = [ + // top right + new Quadtree(this.x + w, this.y, w, h, l), + // top left + new Quadtree(this.x, this.y, w, h, l), + // bottom left + new Quadtree(this.x, this.y + h, w, h, l), + // bottom right + new Quadtree(this.x + w, this.y + h, w, h, l), + ]; + } + + quads( + x: number, + y: number, + w: number, + h: number, + cb: (quad: Quadtree) => void, + ): void { + const { q } = this; + const hzMid = this.x + this.w / 2; + const vtMid = this.y + this.h / 2; + const startIsNorth = y < vtMid; + const startIsWest = x < hzMid; + const endIsEast = x + w > hzMid; + const endIsSouth = y + h > vtMid; + if (q) { + // top-right quad + if (startIsNorth && endIsEast) { + cb(q[0]); + } + // top-left quad + if (startIsWest && startIsNorth) { + cb(q[1]); + } + // bottom-left quad + if (startIsWest && endIsSouth) { + cb(q[2]); + } + // bottom-right quad + if (endIsEast && endIsSouth) { + cb(q[3]); + } + } + } + + add(o: any): void { + if (this.q != null) { + this.quads(o.x, o.y, o.w, o.h, (q) => { + q.add(o); + }); + } else { + const os = this.o; + + os.push(o); + + if (os.length > MAX_OBJECTS && this.l < MAX_LEVELS) { + this.split(); + + for (let i = 0; i < os.length; i++) { + const oi = os[i]; + + this.quads(oi.x, oi.y, oi.w, oi.h, (q) => { + q.add(oi); + }); + } + + this.o.length = 0; + } + } + } + + get(x: number, y: number, w: number, h: number, cb: (o: any) => void): void { + const os = this.o; + + for (let i = 0; i < os.length; i++) { + cb(os[i]); + } + + if (this.q != null) { + this.quads(x, y, w, h, (q) => { + q.get(x, y, w, h, cb); + }); + } + } + + clear(): void { + this.o.length = 0; + this.q = null; + } +} + +Object.assign(Quadtree.prototype, { + split: Quadtree.prototype.split, + quads: Quadtree.prototype.quads, + add: Quadtree.prototype.add, + get: Quadtree.prototype.get, + clear: Quadtree.prototype.clear, +}); + +const { round, min, ceil } = Math; + +function roundDec(val: number, dec: number): number { + return Math.round(val * 10 ** dec) / 10 ** dec; +} + +export const SPACE_BETWEEN = 1; +export const SPACE_AROUND = 2; +export const SPACE_EVENLY = 3; +export const inf = Infinity; + +const coord = (i: number, offs: number, iwid: number, gap: number): number => + roundDec(offs + i * (iwid + gap), 6); + +export function distr( + numItems: number, + sizeFactor: number, + justify: number, + onlyIdx: number | null, + each: (i: number, offPct: number, dimPct: number) => void, +): void { + const space = 1 - sizeFactor; + + let gap = 0; + if (justify === SPACE_BETWEEN) { + gap = space / (numItems - 1); + } else if (justify === SPACE_AROUND) { + gap = space / numItems; + } else if (justify === SPACE_EVENLY) { + gap = space / (numItems + 1); + } + + if (Number.isNaN(gap) || gap === Infinity) gap = 0; + + let offs = 0; + if (justify === SPACE_AROUND) { + offs = gap / 2; + } else if (justify === SPACE_EVENLY) { + offs = gap; + } + + const iwid = sizeFactor / numItems; + const iwidRounded = roundDec(iwid, 6); + + if (onlyIdx == null) { + for (let i = 0; i < numItems; i++) + each(i, coord(i, offs, iwid, gap), iwidRounded); + } else each(onlyIdx, coord(onlyIdx, offs, iwid, gap), iwidRounded); +} + +function timelinePlugin(opts: any): any { + const { mode, count, fill, stroke, laneWidthOption, showGrid } = opts; + + const pxRatio = devicePixelRatio; + + const laneWidth = laneWidthOption ?? 0.9; + + const laneDistr = SPACE_BETWEEN; + + const font = `${round(14 * pxRatio)}px Geist Mono`; + + function walk( + yIdx: number | null, + count: number, + dim: number, + draw: (iy: number, y0: number, hgt: number) => void, + ): void { + distr( + count, + laneWidth, + laneDistr, + yIdx, + (i: number, offPct: number, dimPct: number) => { + const laneOffPx = dim * offPct; + const laneWidPx = dim * dimPct; + + draw(i, laneOffPx, laneWidPx); + }, + ); + } + + const size = opts.size ?? [0.6, Infinity]; + const align = opts.align ?? 0; + + const gapFactor = 1 - size[0]; + const maxWidth = (size[1] ?? inf) * pxRatio; + + const fillPaths = new Map(); + const strokePaths = new Map(); + + function drawBoxes(ctx: CanvasRenderingContext2D): void { + fillPaths.forEach((fillPath, fillStyle) => { + ctx.fillStyle = fillStyle; + ctx.fill(fillPath); + }); + + strokePaths.forEach((strokePath, strokeStyle) => { + ctx.strokeStyle = strokeStyle; + ctx.stroke(strokePath); + }); + + fillPaths.clear(); + strokePaths.clear(); + } + let qt: Quadtree; + + function putBox( + ctx: CanvasRenderingContext2D, + rect: (path: Path2D, x: number, y: number, w: number, h: number) => void, + xOff: number, + yOff: number, + lft: number, + top: number, + wid: number, + hgt: number, + strokeWidth: number, + iy: number, + ix: number, + value: number | null, + ): void { + const fillStyle = fill(iy + 1, ix, value); + let fillPath = fillPaths.get(fillStyle); + + if (fillPath == null) fillPaths.set(fillStyle, (fillPath = new Path2D())); + + rect(fillPath, lft, top, wid, hgt); + + if (strokeWidth) { + const strokeStyle = stroke(iy + 1, ix, value); + let strokePath = strokePaths.get(strokeStyle); + + if (strokePath == null) + strokePaths.set(strokeStyle, (strokePath = new Path2D())); + + rect( + strokePath, + lft + strokeWidth / 2, + top + strokeWidth / 2, + wid - strokeWidth, + hgt - strokeWidth, + ); + } + + qt.add({ + x: round(lft - xOff), + y: round(top - yOff), + w: wid, + h: hgt, + sidx: iy + 1, + didx: ix, + }); + } + + // eslint-disable-next-line sonarjs/cognitive-complexity + function drawPaths(u: uPlot, sidx: number, idx0: number, idx1: number): null { + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect, + ) => { + const strokeWidth = round((series.width || 0) * pxRatio); + + u.ctx.save(); + rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + walk(sidx - 1, count, yDim, (iy: number, y0: number, hgt: number) => { + // draw spans + if (mode === 1) { + for (let ix = 0; ix < dataY.length; ix++) { + if (dataY[ix] != null) { + const lft = round(valToPosX(dataX[ix], scaleX, xDim, xOff)); + + let nextIx = ix; + // eslint-disable-next-line no-empty + while (dataY[++nextIx] === undefined && nextIx < dataY.length) {} + + // to now (not to end of chart) + const rgt = + nextIx === dataY.length + ? xOff + xDim + strokeWidth + : round(valToPosX(dataX[nextIx], scaleX, xDim, xOff)); + + putBox( + u.ctx, + rect, + xOff, + yOff, + lft, + round(yOff + y0), + rgt - lft, + round(hgt), + strokeWidth, + iy, + ix, + dataY[ix], + ); + + ix = nextIx - 1; + } + } + } + // draw matrix + else { + const colWid = + valToPosX(dataX[1], scaleX, xDim, xOff) - + valToPosX(dataX[0], scaleX, xDim, xOff); + const gapWid = colWid * gapFactor; + const barWid = round(min(maxWidth, colWid - gapWid) - strokeWidth); + let xShift; + if (align === 1) { + xShift = 0; + } else if (align === -1) { + xShift = barWid; + } else { + xShift = barWid / 2; + } + + for (let ix = idx0; ix <= idx1; ix++) { + if (dataY[ix] != null) { + // TODO: all xPos can be pre-computed once for all series in aligned set + const lft = valToPosX(dataX[ix], scaleX, xDim, xOff); + + putBox( + u.ctx, + rect, + xOff, + yOff, + round(lft - xShift), + round(yOff + y0), + barWid, + round(hgt), + strokeWidth, + iy, + ix, + dataY[ix], + ); + } + } + } + }); + + // eslint-disable-next-line no-param-reassign + u.ctx.lineWidth = strokeWidth; + drawBoxes(u.ctx); + + u.ctx.restore(); + }, + ); + + return null; + } + const yMids = Array(count).fill(0); + function drawPoints(u: uPlot, sidx: number): boolean { + u.ctx.save(); + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + const { ctx } = u; + ctx.font = font; + ctx.fillStyle = 'black'; + ctx.textAlign = mode === 1 ? 'left' : 'center'; + ctx.textBaseline = 'middle'; + + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + ) => { + const strokeWidth = round((series.width || 0) * pxRatio); + const textOffset = mode === 1 ? strokeWidth + 2 : 0; + + const y = round(yOff + yMids[sidx - 1]); + if (opts.displayTimelineValue) { + for (let ix = 0; ix < dataY.length; ix++) { + if (dataY[ix] != null) { + const x = valToPosX(dataX[ix], scaleX, xDim, xOff) + textOffset; + u.ctx.fillText(String(dataY[ix]), x, y); + } + } + } + }, + ); + + u.ctx.restore(); + + return false; + } + + const hovered = Array(count).fill(null); + + const ySplits = Array(count).fill(0); + + const fmtDate = uPlot.fmtDate('{YYYY}-{MM}-{DD} {HH}:{mm}:{ss}'); + let legendTimeValueEl: HTMLElement | null = null; + + return { + hooks: { + init: (u: uPlot): void => { + legendTimeValueEl = u.root.querySelector('.u-series:first-child .u-value'); + }, + drawClear: (u: uPlot): void => { + qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + + qt.clear(); + + // force-clear the path cache to cause drawBars() to rebuild new quadtree + u.series.forEach((s: any) => { + // eslint-disable-next-line no-param-reassign + s._paths = null; + }); + }, + setCursor: (u: { + posToVal: (arg0: any, arg1: string) => any; + cursor: { left: any }; + scales: { x: { time: any } }; + }): any => { + if (mode === 1 && legendTimeValueEl) { + const val = u.posToVal(u.cursor.left, 'x'); + legendTimeValueEl.textContent = u.scales.x.time + ? fmtDate(new Date(val * 1e3)) + : val.toFixed(2); + } + }, + }, + // eslint-disable-next-line sonarjs/cognitive-complexity + opts: (u: { series: { label: any }[] }, opts: any): any => { + uPlot.assign(opts, { + cursor: { + // x: false, + y: false, + dataIdx: ( + u: { cursor: { left: number } }, + seriesIdx: number, + closestIdx: any, + ) => { + if (seriesIdx === 0) return closestIdx; + + const cx = round(u.cursor.left * pxRatio); + + if (cx >= 0) { + const cy = yMids[seriesIdx - 1]; + + hovered[seriesIdx - 1] = null; + + qt.get(cx, cy, 1, 1, (o: { x: any; y: any; w: any; h: any }) => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) + hovered[seriesIdx - 1] = o; + }); + } + + return hovered[seriesIdx - 1]?.didx; + }, + points: { + fill: 'rgba(0,0,0,0.3)', + bbox: (u: any, seriesIdx: number) => { + const hRect = hovered[seriesIdx - 1]; + + return { + left: hRect ? round(hRect.x / devicePixelRatio) : -10, + top: hRect ? round(hRect.y / devicePixelRatio) : -10, + width: hRect ? round(hRect.w / devicePixelRatio) : 0, + height: hRect ? round(hRect.h / devicePixelRatio) : 0, + }; + }, + }, + }, + scales: { + x: { + range(u: { data: number[][] }, min: number, max: number) { + if (mode === 2) { + const colWid = u.data[0][1] - u.data[0][0]; + const scalePad = colWid / 2; + + // eslint-disable-next-line no-param-reassign + if (min <= u.data[0][0]) min = u.data[0][0] - scalePad; + + const lastIdx = u.data[0].length - 1; + + // eslint-disable-next-line no-param-reassign + if (max >= u.data[0][lastIdx]) max = u.data[0][lastIdx] + scalePad; + } + + return [min, max]; + }, + }, + y: { + range: [0, 1], + }, + }, + }); + + uPlot.assign(opts.axes[0], { + splits: + mode === 2 + ? ( + u: { data: any[][] }, + scaleMin: number, + scaleMax: number, + foundIncr: number, + ): any => { + const splits = []; + + const dataIncr = u.data[0][1] - u.data[0][0]; + const skipFactor = ceil(foundIncr / dataIncr); + + for (let i = 0; i < u.data[0].length; i += skipFactor) { + const v = u.data[0][i]; + + if (v >= scaleMin && v <= scaleMax) splits.push(v); + } + + return splits; + } + : null, + grid: { + show: showGrid ?? mode !== 2, + }, + }); + + uPlot.assign(opts.axes[1], { + splits: (u: { + bbox: { height: any }; + posToVal: (arg0: number, arg1: string) => any; + }) => { + walk(null, count, u.bbox.height, (iy: any, y0: number, hgt: number) => { + // vertical midpoints of each series' timeline (stored relative to .u-over) + yMids[iy] = round(y0 + hgt / 2); + ySplits[iy] = u.posToVal(yMids[iy] / pxRatio, 'y'); + }); + + return ySplits; + }, + values: () => + Array(count) + .fill(null) + .map((v, i) => u.series[i + 1].label), + gap: 15, + size: 70, + grid: { show: false }, + ticks: { show: false }, + + side: 3, + }); + + opts.series.forEach((s: any, i: number) => { + if (i > 0) { + uPlot.assign(s, { + // width: 0, + // pxAlign: false, + // stroke: "rgba(255,0,0,0.5)", + paths: drawPaths, + points: { + show: drawPoints, + }, + }); + } + }); + }, + }; +} + +export default timelinePlugin; diff --git a/frontend/src/pages/AlertDetails/AlertDetails.styles.scss b/frontend/src/pages/AlertDetails/AlertDetails.styles.scss new file mode 100644 index 0000000000..62eeb96ae0 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertDetails.styles.scss @@ -0,0 +1,189 @@ +@mixin flex-center { + display: flex; + justify-content: space-between; + align-items: center; +} + +.alert-details-tabs { + .top-level-tab.periscope-tab { + padding: 2px 0; + } + .ant-tabs { + &-nav { + margin-bottom: 0 !important; + &::before { + border-bottom: 1px solid var(--bg-slate-500) !important; + } + } + &-tab { + &[data-node-key='TriggeredAlerts'] { + margin-left: 16px; + } + &:not(:first-of-type) { + margin-left: 24px !important; + } + .periscope-tab { + font-size: 14px; + color: var(--text-vanilla-100); + line-height: 20px; + letter-spacing: -0.07px; + gap: 10px; + } + [aria-selected='false'] { + .periscope-tab { + color: var(--text-vanilla-400); + } + } + } + } +} + +.alert-details { + margin-top: 10px; + .divider { + border-color: var(--bg-slate-500); + margin: 16px 0; + } + .breadcrumb-divider { + margin-top: 10px; + } + &__breadcrumb { + ol { + align-items: center; + } + padding-left: 16px; + .breadcrumb-item { + color: var(--text-vanilla-400); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + padding: 0; + } + + .ant-breadcrumb-separator, + .breadcrumb-item--last { + color: var(--text-vanilla-500); + font-family: 'Geist Mono'; + } + } + .tabs-and-filters { + margin: 1rem 0; + + .ant-tabs { + &-ink-bar { + background-color: transparent; + } + &-nav { + &-wrap { + padding: 0 16px 16px 16px; + } + &::before { + border-bottom: none !important; + } + } + &-tab { + margin-left: 0 !important; + padding: 0; + + &-btn { + padding: 6px 17px; + color: var(--text-vanilla-400) !important; + letter-spacing: -0.07px; + font-size: 14px; + + &[aria-selected='true'] { + color: var(--text-vanilla-100) !important; + } + } + &-active { + background: var(--bg-slate-400, #1d212d); + } + } + &-extra-content { + padding: 0 16px 16px; + } + &-nav-list { + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-400); + border-radius: 2px; + } + } + + .tab-item { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + } + .filters { + @include flex-center; + gap: 16px; + .reset-button { + @include flex-center; + } + } + } +} + +.lightMode { + .alert-details { + &-tabs { + .ant-tabs-nav { + &::before { + border-bottom: 1px solid var(--bg-vanilla-300) !important; + } + } + } + &__breadcrumb { + .ant-breadcrumb-link { + color: var(--text-ink-400); + } + .ant-breadcrumb-separator, + span.ant-breadcrumb-link { + color: var(--text-ink-500); + } + } + .tabs-and-filters { + .ant-tabs { + &-nav-list { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-300); + } + &-tab { + &-btn { + &[aria-selected='true'] { + color: var(--text-robin-500) !important; + } + color: var(--text-ink-400) !important; + } + &-active { + background: var(--bg-vanilla-100); + } + } + } + } + .divider { + border-color: var(--bg-vanilla-300); + } + } + + .alert-details-tabs { + .ant-tabs { + &-nav { + &::before { + border: none !important; + } + } + &-tab { + .periscope-tab { + color: var(--text-ink-300); + } + [aria-selected='true'] { + .periscope-tab { + color: var(--text-ink-400); + } + } + } + } + } +} diff --git a/frontend/src/pages/AlertDetails/AlertDetails.tsx b/frontend/src/pages/AlertDetails/AlertDetails.tsx new file mode 100644 index 0000000000..c79478fb77 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertDetails.tsx @@ -0,0 +1,123 @@ +import './AlertDetails.styles.scss'; + +import { Breadcrumb, Button, Divider } from 'antd'; +import { Filters } from 'components/AlertDetailsFilters/Filters'; +import NotFound from 'components/NotFound'; +import RouteTab from 'components/RouteTab'; +import Spinner from 'components/Spinner'; +import ROUTES from 'constants/routes'; +import history from 'lib/history'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation } from 'react-router-dom'; + +import AlertHeader from './AlertHeader/AlertHeader'; +import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks'; +import { AlertDetailsStatusRendererProps } from './types'; + +function AlertDetailsStatusRenderer({ + isLoading, + isError, + isRefetching, + data, +}: AlertDetailsStatusRendererProps): JSX.Element { + const alertRuleDetails = useMemo(() => data?.payload?.data, [data]); + const { t } = useTranslation('common'); + + if (isLoading || isRefetching) { + return ; + } + + if (isError) { + return
{data?.error || t('something_went_wrong')}
; + } + + return ; +} + +function BreadCrumbItem({ + title, + isLast, + route, +}: { + title: string | null; + isLast?: boolean; + route?: string; +}): JSX.Element { + if (isLast) { + return
{title}
; + } + const handleNavigate = (): void => { + if (!route) { + return; + } + history.push(ROUTES.LIST_ALL_ALERT); + }; + + return ( + + ); +} + +BreadCrumbItem.defaultProps = { + isLast: false, + route: '', +}; + +function AlertDetails(): JSX.Element { + const { pathname } = useLocation(); + const { routes } = useRouteTabUtils(); + + const { + isLoading, + isRefetching, + isError, + ruleId, + isValidRuleId, + alertDetailsResponse, + } = useGetAlertRuleDetails(); + + if ( + isError || + !isValidRuleId || + (alertDetailsResponse && alertDetailsResponse.statusCode !== 200) + ) { + return ; + } + + return ( +
+ + ), + }, + { + title: , + }, + ]} + /> + + + + +
+ } + /> +
+
+ ); +} + +export default AlertDetails; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss new file mode 100644 index 0000000000..edd94a5bcd --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.styles.scss @@ -0,0 +1,63 @@ +.alert-action-buttons { + display: flex; + align-items: center; + gap: 12px; + color: var(--bg-slate-400); + .ant-divider-vertical { + height: 16px; + border-color: var(--bg-slate-400); + margin: 0; + } + .dropdown-icon { + margin-right: 4px; + } +} +.dropdown-menu { + border-radius: 4px; + box-shadow: none; + background: linear-gradient( + 138.7deg, + rgba(18, 19, 23, 0.8) 0%, + rgba(18, 19, 23, 0.9) 98.68% + ); + + .dropdown-divider { + margin: 0; + } + + .delete-button { + border: none; + display: flex; + align-items: center; + width: 100%; + + &, + & span { + &:hover { + background: var(--bg-slate-400); + color: var(--bg-cherry-400); + } + color: var(--bg-cherry-400); + font-size: 14px; + } + } +} + +.lightMode { + .alert-action-buttons { + .ant-divider-vertical { + border-color: var(--bg-vanilla-300); + } + } + .dropdown-menu { + background: inherit; + .delete-button { + &, + &span { + &:hover { + background: var(--bg-vanilla-300); + } + } + } + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx new file mode 100644 index 0000000000..186a34676b --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/ActionButtons.tsx @@ -0,0 +1,111 @@ +import './ActionButtons.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd'; +import { QueryParams } from 'constants/query'; +import ROUTES from 'constants/routes'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import useUrlQuery from 'hooks/useUrlQuery'; +import history from 'lib/history'; +import { Copy, Ellipsis, PenLine, Trash2 } from 'lucide-react'; +import { + useAlertRuleDelete, + useAlertRuleDuplicate, + useAlertRuleStatusToggle, +} from 'pages/AlertDetails/hooks'; +import CopyToClipboard from 'periscope/components/CopyToClipboard'; +import { useAlertRule } from 'providers/Alert'; +import React from 'react'; +import { CSSProperties } from 'styled-components'; +import { AlertDef } from 'types/api/alerts/def'; + +import { AlertHeaderProps } from '../AlertHeader'; + +const menuItemStyle: CSSProperties = { + fontSize: '14px', + letterSpacing: '0.14px', +}; +function AlertActionButtons({ + ruleId, + alertDetails, +}: { + ruleId: string; + alertDetails: AlertHeaderProps['alertDetails']; +}): JSX.Element { + const { isAlertRuleDisabled } = useAlertRule(); + const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId }); + + const { handleAlertDuplicate } = useAlertRuleDuplicate({ + alertDetails: (alertDetails as unknown) as AlertDef, + }); + const { handleAlertDelete } = useAlertRuleDelete({ ruleId: Number(ruleId) }); + + const params = useUrlQuery(); + + const handleRename = React.useCallback(() => { + params.set(QueryParams.ruleId, String(ruleId)); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); + }, [params, ruleId]); + + const menu: MenuProps['items'] = React.useMemo( + () => [ + { + key: 'rename-rule', + label: 'Rename', + icon: , + onClick: (): void => handleRename(), + style: menuItemStyle, + }, + { + key: 'duplicate-rule', + label: 'Duplicate', + icon: , + onClick: (): void => handleAlertDuplicate(), + style: menuItemStyle, + }, + { type: 'divider' }, + { + key: 'delete-rule', + label: 'Delete', + icon: , + onClick: (): void => handleAlertDelete(), + style: { + ...menuItemStyle, + color: Color.BG_CHERRY_400, + }, + }, + ], + [handleAlertDelete, handleAlertDuplicate, handleRename], + ); + const isDarkMode = useIsDarkMode(); + + return ( +
+ + {isAlertRuleDisabled !== undefined && ( + + )} + + + + + + + + + + +
+ ); +} + +export default AlertActionButtons; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss new file mode 100644 index 0000000000..10a05f2258 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.styles.scss @@ -0,0 +1,50 @@ +.alert-info { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 0 16px; + + &__info-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + height: 54px; + + .top-section { + display: flex; + align-items: center; + justify-content: space-between; + .alert-title-wrapper { + display: flex; + align-items: center; + gap: 8px; + .alert-title { + font-size: 16px; + font-weight: 500; + color: var(--text-vanilla-100); + line-height: 24px; + letter-spacing: -0.08px; + } + } + } + .bottom-section { + display: flex; + align-items: center; + gap: 24px; + } + } +} + +.lightMode { + .alert-info { + &__info-wrapper { + .top-section { + .alert-title-wrapper { + .alert-title { + color: var(--text-ink-100); + } + } + } + } + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx new file mode 100644 index 0000000000..f4ff7b933b --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx @@ -0,0 +1,66 @@ +import './AlertHeader.styles.scss'; + +import { useAlertRule } from 'providers/Alert'; +import { useEffect, useMemo } from 'react'; + +import AlertActionButtons from './ActionButtons/ActionButtons'; +import AlertLabels from './AlertLabels/AlertLabels'; +import AlertSeverity from './AlertSeverity/AlertSeverity'; +import AlertState from './AlertState/AlertState'; + +export type AlertHeaderProps = { + alertDetails: { + state: string; + alert: string; + id: string; + labels: Record; + disabled: boolean; + }; +}; +function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element { + const { state, alert, labels, disabled } = alertDetails; + + const labelsWithoutSeverity = useMemo( + () => + Object.fromEntries( + Object.entries(labels).filter(([key]) => key !== 'severity'), + ), + [labels], + ); + + const { isAlertRuleDisabled, setIsAlertRuleDisabled } = useAlertRule(); + + useEffect(() => { + if (isAlertRuleDisabled === undefined) { + setIsAlertRuleDisabled(disabled); + } + }, [disabled, setIsAlertRuleDisabled, isAlertRuleDisabled]); + + return ( +
+
+
+
+ +
{alert}
+
+
+
+ + + {/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */} + {/* */} + +
+
+
+ +
+
+ ); +} + +export default AlertHeader; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss new file mode 100644 index 0000000000..3468bad7ec --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.styles.scss @@ -0,0 +1,5 @@ +.alert-labels { + display: flex; + flex-wrap: wrap; + gap: 4px 6px; +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx new file mode 100644 index 0000000000..bdc5eaa019 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx @@ -0,0 +1,31 @@ +import './AlertLabels.styles.scss'; + +import KeyValueLabel from 'periscope/components/KeyValueLabel'; +import SeeMore from 'periscope/components/SeeMore'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AlertLabelsProps = { + labels: Record; + initialCount?: number; +}; + +function AlertLabels({ + labels, + initialCount = 2, +}: AlertLabelsProps): JSX.Element { + return ( +
+ + {Object.entries(labels).map(([key, value]) => ( + + ))} + +
+ ); +} + +AlertLabels.defaultProps = { + initialCount: 2, +}; + +export default AlertLabels; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss new file mode 100644 index 0000000000..ba0226a11d --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.styles.scss @@ -0,0 +1,40 @@ +@mixin severity-styles($background, $text-color) { + .alert-severity__icon { + background: $background; + } + .alert-severity__text { + color: $text-color; + } +} + +.alert-severity { + display: flex; + align-items: center; + gap: 8px; + + overflow: hidden; + &__icon { + display: flex; + align-items: center; + justify-content: center; + height: 14px; + width: 14px; + border-radius: 3.5px; + } + &__text { + color: var(--text-sakura-400); + font-size: 14px; + line-height: 18px; + } + + &--critical, + &--error { + @include severity-styles(rgba(245, 108, 135, 0.2), var(--text-sakura-400)); + } + &--warning { + @include severity-styles(rgba(255, 215, 120, 0.2), var(--text-amber-400)); + } + &--info { + @include severity-styles(rgba(113, 144, 249, 0.2), var(--text-robin-400)); + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx new file mode 100644 index 0000000000..90e7c14de4 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertSeverity/AlertSeverity.tsx @@ -0,0 +1,42 @@ +import './AlertSeverity.styles.scss'; + +import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon'; +import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon'; +import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon'; +import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon'; + +export default function AlertSeverity({ + severity, +}: { + severity: string; +}): JSX.Element { + const severityConfig: Record> = { + critical: { + text: 'Critical', + className: 'alert-severity--critical', + icon: , + }, + error: { + text: 'Error', + className: 'alert-severity--error', + icon: , + }, + warning: { + text: 'Warning', + className: 'alert-severity--warning', + icon: , + }, + info: { + text: 'Info', + className: 'alert-severity--info', + icon: , + }, + }; + const severityDetails = severityConfig[severity]; + return ( +
+
{severityDetails.icon}
+
{severityDetails.text}
+
+ ); +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss new file mode 100644 index 0000000000..582494e54a --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.styles.scss @@ -0,0 +1,10 @@ +.alert-state { + display: flex; + align-items: center; + gap: 6px; + &__label { + font-size: 14px; + line-height: 18px; + letter-spacing: -0.07px; + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx new file mode 100644 index 0000000000..d2be316d8a --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx @@ -0,0 +1,73 @@ +import './AlertState.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { BellOff, CircleCheck, CircleOff, Flame } from 'lucide-react'; + +type AlertStateProps = { + state: string; + showLabel?: boolean; +}; + +export default function AlertState({ + state, + showLabel, +}: AlertStateProps): JSX.Element { + let icon; + let label; + const isDarkMode = useIsDarkMode(); + switch (state) { + case 'no-data': + icon = ( + + ); + label = No Data; + break; + + case 'disabled': + icon = ( + + ); + label = Muted; + break; + case 'firing': + icon = ( + + ); + label = Firing; + break; + + case 'normal': + case 'inactive': + icon = ( + + ); + label = Resolved; + break; + + default: + icon = null; + } + + return ( +
+ {icon} {showLabel &&
{label}
} +
+ ); +} + +AlertState.defaultProps = { + showLabel: false, +}; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss new file mode 100644 index 0000000000..97549bf21d --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.styles.scss @@ -0,0 +1,22 @@ +.alert-status-info { + gap: 6px; + color: var(--text-vanilla-400); + &__icon { + display: flex; + align-items: baseline; + } + &, + &__details { + display: flex; + align-items: center; + } + &__details { + gap: 3px; + } +} + +.lightMode { + .alert-status-info { + color: var(--text-ink-400); + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx new file mode 100644 index 0000000000..dd06d107bb --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/AlertStatus.tsx @@ -0,0 +1,54 @@ +import './AlertStatus.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { CircleCheck, Siren } from 'lucide-react'; +import { useMemo } from 'react'; +import { getDurationFromNow } from 'utils/timeUtils'; + +import { AlertStatusProps, StatusConfig } from './types'; + +export default function AlertStatus({ + status, + timestamp, +}: AlertStatusProps): JSX.Element { + const statusConfig: StatusConfig = useMemo( + () => ({ + firing: { + icon: , + text: 'Firing since', + extraInfo: timestamp ? ( + <> +
+
{getDurationFromNow(timestamp)}
+ + ) : null, + className: 'alert-status-info--firing', + }, + resolved: { + icon: ( + + ), + text: 'Resolved', + extraInfo: null, + className: 'alert-status-info--resolved', + }, + }), + [timestamp], + ); + + const currentStatus = statusConfig[status]; + + return ( +
+
{currentStatus.icon}
+
+
{currentStatus.text}
+ {currentStatus.extraInfo} +
+
+ ); +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts new file mode 100644 index 0000000000..c297480f38 --- /dev/null +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/types.ts @@ -0,0 +1,18 @@ +export type AlertStatusProps = + | { status: 'firing'; timestamp: number } + | { status: 'resolved'; timestamp?: number }; + +export type StatusConfig = { + firing: { + icon: JSX.Element; + text: string; + extraInfo: JSX.Element | null; + className: string; + }; + resolved: { + icon: JSX.Element; + text: string; + extraInfo: JSX.Element | null; + className: string; + }; +}; diff --git a/frontend/src/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx new file mode 100644 index 0000000000..fc6219b195 --- /dev/null +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -0,0 +1,525 @@ +import { FilterValue, SorterResult } from 'antd/es/table/interface'; +import { TablePaginationConfig, TableProps } from 'antd/lib'; +import deleteAlerts from 'api/alerts/delete'; +import get from 'api/alerts/get'; +import getAll from 'api/alerts/getAll'; +import patchAlert from 'api/alerts/patch'; +import ruleStats from 'api/alerts/ruleStats'; +import save from 'api/alerts/save'; +import timelineGraph from 'api/alerts/timelineGraph'; +import timelineTable from 'api/alerts/timelineTable'; +import topContributors from 'api/alerts/topContributors'; +import { TabRoutes } from 'components/RouteTab/types'; +import { QueryParams } from 'constants/query'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import ROUTES from 'constants/routes'; +import AlertHistory from 'container/AlertHistory'; +import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants'; +import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types'; +import { urlKey } from 'container/AllError/utils'; +import useAxiosError from 'hooks/useAxiosError'; +import { useNotifications } from 'hooks/useNotifications'; +import useUrlQuery from 'hooks/useUrlQuery'; +import createQueryParams from 'lib/createQueryParams'; +import GetMinMax from 'lib/getMinMax'; +import history from 'lib/history'; +import { History, Table } from 'lucide-react'; +import EditRules from 'pages/EditRules'; +import { OrderPreferenceItems } from 'pages/Logs/config'; +import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; +import { useAlertRule } from 'providers/Alert'; +import { useCallback, useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; +import { useSelector } from 'react-redux'; +import { generatePath, useLocation } from 'react-router-dom'; +import { AppState } from 'store/reducers'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + AlertDef, + AlertRuleStatsPayload, + AlertRuleTimelineGraphResponsePayload, + AlertRuleTimelineTableResponse, + AlertRuleTimelineTableResponsePayload, + AlertRuleTopContributorsPayload, +} from 'types/api/alerts/def'; +import { PayloadProps } from 'types/api/alerts/get'; +import { GlobalReducer } from 'types/reducer/globalTime'; +import { nanoToMilli } from 'utils/timeUtils'; + +export const useAlertHistoryQueryParams = (): { + ruleId: string | null; + startTime: number; + endTime: number; + hasStartAndEndParams: boolean; + params: URLSearchParams; +} => { + const params = useUrlQuery(); + + const globalTime = useSelector( + (state) => state.globalTime, + ); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); + + const intStartTime = parseInt(startTime || '0', 10); + const intEndTime = parseInt(endTime || '0', 10); + const hasStartAndEndParams = !!intStartTime && !!intEndTime; + + const { maxTime, minTime } = useMemo(() => { + if (hasStartAndEndParams) + return GetMinMax('custom', [intStartTime, intEndTime]); + return GetMinMax(globalTime.selectedTime); + }, [hasStartAndEndParams, intStartTime, intEndTime, globalTime.selectedTime]); + + const ruleId = params.get(QueryParams.ruleId); + + return { + ruleId, + startTime: Math.floor(nanoToMilli(minTime)), + endTime: Math.floor(nanoToMilli(maxTime)), + hasStartAndEndParams, + params, + }; +}; +export const useRouteTabUtils = (): { routes: TabRoutes[] } => { + const urlQuery = useUrlQuery(); + + const getRouteUrl = (tab: AlertDetailsTab): string => { + let route = ''; + let params = urlQuery.toString(); + const ruleIdKey = QueryParams.ruleId; + const relativeTimeKey = QueryParams.relativeTime; + + switch (tab) { + case AlertDetailsTab.OVERVIEW: + route = ROUTES.ALERT_OVERVIEW; + break; + case AlertDetailsTab.HISTORY: + params = `${ruleIdKey}=${urlQuery.get( + ruleIdKey, + )}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`; + route = ROUTES.ALERT_HISTORY; + break; + default: + return ''; + } + + return `${generatePath(route)}?${params}`; + }; + + const routes = [ + { + Component: EditRules, + name: ( +
+
+ Overview + + ), + route: getRouteUrl(AlertDetailsTab.OVERVIEW), + key: ROUTES.ALERT_OVERVIEW, + }, + { + Component: AlertHistory, + name: ( +
+ + History +
+ ), + route: getRouteUrl(AlertDetailsTab.HISTORY), + key: ROUTES.ALERT_HISTORY, + }, + ]; + + return { routes }; +}; +type Props = { + ruleId: string | null; + isValidRuleId: boolean; + alertDetailsResponse: + | SuccessResponse + | ErrorResponse + | undefined; + isLoading: boolean; + isRefetching: boolean; + isError: boolean; +}; + +export const useGetAlertRuleDetails = (): Props => { + const { ruleId } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { + isLoading, + data: alertDetailsResponse, + isRefetching, + isError, + } = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { + queryFn: () => + get({ + id: parseInt(ruleId || '', 10), + }), + enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + return { + ruleId, + isLoading, + alertDetailsResponse, + isRefetching, + isError, + isValidRuleId, + }; +}; + +type GetAlertRuleDetailsApiProps = { + isLoading: boolean; + isRefetching: boolean; + isError: boolean; + isValidRuleId: boolean; + ruleId: string | null; +}; + +type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsStats = (): GetAlertRuleDetailsStatsProps => { + const { ruleId, startTime, endTime } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime], + { + queryFn: () => + ruleStats({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + }), + enabled: isValidRuleId && !!startTime && !!endTime, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTopContributors = (): GetAlertRuleDetailsTopContributorsProps => { + const { ruleId, startTime, endTime } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime], + { + queryFn: () => + topContributors({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + }), + enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimelineTableProps => { + const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams(); + const { updatedOrder, offset } = useMemo( + () => ({ + updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC, + offset: parseInt(params.get(urlKey.offset) ?? '1', 10), + }), + [params], + ); + + const timelineFilter = params.get('timelineFilter'); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + const hasStartAndEnd = startTime !== null && endTime !== null; + + const { isLoading, isRefetching, isError, data } = useQuery( + [ + REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE, + ruleId, + startTime, + endTime, + timelineFilter, + updatedOrder, + offset, + ], + { + queryFn: () => + timelineTable({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + limit: TIMELINE_TABLE_PAGE_SIZE, + order: updatedOrder, + offset, + + ...(timelineFilter && timelineFilter !== TimelineFilter.ALL + ? { + state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal', + } + : {}), + }), + enabled: isValidRuleId && hasStartAndEnd, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +export const useTimelineTable = ({ + totalItems, +}: { + totalItems: number; +}): { + paginationConfig: TablePaginationConfig; + onChangeHandler: ( + pagination: TablePaginationConfig, + sorter: any, + filters: any, + extra: any, + ) => void; +} => { + const { pathname } = useLocation(); + + const { search } = useLocation(); + + const params = useMemo(() => new URLSearchParams(search), [search]); + + const offset = params.get('offset') ?? '0'; + + const onChangeHandler: TableProps['onChange'] = useCallback( + ( + pagination: TablePaginationConfig, + filters: Record, + sorter: + | SorterResult[] + | SorterResult, + ) => { + if (!Array.isArray(sorter)) { + const { pageSize = 0, current = 0 } = pagination; + const { order } = sorter; + const updatedOrder = order === 'ascend' ? 'asc' : 'desc'; + const params = new URLSearchParams(window.location.search); + + history.replace( + `${pathname}?${createQueryParams({ + ...Object.fromEntries(params), + order: updatedOrder, + offset: current * TIMELINE_TABLE_PAGE_SIZE - TIMELINE_TABLE_PAGE_SIZE, + pageSize, + })}`, + ); + } + }, + [pathname], + ); + + const offsetInt = parseInt(offset, 10); + const pageSize = params.get('pageSize') ?? String(TIMELINE_TABLE_PAGE_SIZE); + const pageSizeInt = parseInt(pageSize, 10); + + const paginationConfig: TablePaginationConfig = { + pageSize: pageSizeInt, + showTotal: PaginationInfoText, + current: offsetInt / TIMELINE_TABLE_PAGE_SIZE + 1, + showSizeChanger: false, + hideOnSinglePage: true, + total: totalItems, + }; + + return { paginationConfig, onChangeHandler }; +}; + +export const useAlertRuleStatusToggle = ({ + ruleId, +}: { + ruleId: string; +}): { + handleAlertStateToggle: (state: boolean) => void; +} => { + const { isAlertRuleDisabled, setIsAlertRuleDisabled } = useAlertRule(); + const { notifications } = useNotifications(); + + const queryClient = useQueryClient(); + const handleError = useAxiosError(); + + const { mutate: toggleAlertState } = useMutation( + [REACT_QUERY_KEY.TOGGLE_ALERT_STATE, ruleId], + patchAlert, + { + onMutate: () => { + setIsAlertRuleDisabled((prev) => !prev); + }, + onSuccess: () => { + notifications.success({ + message: `Alert has been ${isAlertRuleDisabled ? 'enabled' : 'disabled'}.`, + }); + }, + onError: (error) => { + queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS]); + handleError(error); + }, + }, + ); + + const handleAlertStateToggle = (): void => { + const args = { + id: parseInt(ruleId, 10), + data: { disabled: !isAlertRuleDisabled }, + }; + toggleAlertState(args); + }; + + return { handleAlertStateToggle }; +}; + +export const useAlertRuleDuplicate = ({ + alertDetails, +}: { + alertDetails: AlertDef; +}): { + handleAlertDuplicate: () => void; +} => { + const { notifications } = useNotifications(); + + const params = useUrlQuery(); + + const { refetch } = useQuery(REACT_QUERY_KEY.GET_ALL_ALLERTS, { + queryFn: getAll, + cacheTime: 0, + }); + const handleError = useAxiosError(); + const { mutate: duplicateAlert } = useMutation( + [REACT_QUERY_KEY.DUPLICATE_ALERT_RULE], + save, + { + onSuccess: async () => { + notifications.success({ + message: `Success`, + }); + + const { data: allAlertsData } = await refetch(); + + if ( + allAlertsData && + allAlertsData.payload && + allAlertsData.payload.length > 0 + ) { + const clonedAlert = + allAlertsData.payload[allAlertsData.payload.length - 1]; + params.set(QueryParams.ruleId, String(clonedAlert.id)); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); + } + }, + onError: handleError, + }, + ); + + const handleAlertDuplicate = (): void => { + const args = { + data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') }, + }; + duplicateAlert(args); + }; + + return { handleAlertDuplicate }; +}; + +export const useAlertRuleDelete = ({ + ruleId, +}: { + ruleId: number; +}): { + handleAlertDelete: () => void; +} => { + const { notifications } = useNotifications(); + const handleError = useAxiosError(); + + const { mutate: deleteAlert } = useMutation( + [REACT_QUERY_KEY.REMOVE_ALERT_RULE, ruleId], + deleteAlerts, + { + onSuccess: async () => { + notifications.success({ + message: `Success`, + }); + + history.push(ROUTES.LIST_ALL_ALERT); + }, + onError: handleError, + }, + ); + + const handleAlertDelete = (): void => { + const args = { id: ruleId }; + deleteAlert(args); + }; + + return { handleAlertDelete }; +}; + +type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTimelineGraphData = (): GetAlertRuleDetailsTimelineGraphProps => { + const { ruleId, startTime, endTime } = useAlertHistoryQueryParams(); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + const hasStartAndEnd = startTime !== null && endTime !== null; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime], + { + queryFn: () => + timelineGraph({ + id: parseInt(ruleId || '', 10), + start: startTime, + end: endTime, + }), + enabled: isValidRuleId && hasStartAndEnd, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; diff --git a/frontend/src/pages/AlertDetails/index.tsx b/frontend/src/pages/AlertDetails/index.tsx new file mode 100644 index 0000000000..aa6eb0b819 --- /dev/null +++ b/frontend/src/pages/AlertDetails/index.tsx @@ -0,0 +1,3 @@ +import AlertDetails from './AlertDetails'; + +export default AlertDetails; diff --git a/frontend/src/pages/AlertDetails/types.ts b/frontend/src/pages/AlertDetails/types.ts new file mode 100644 index 0000000000..f68fa9c512 --- /dev/null +++ b/frontend/src/pages/AlertDetails/types.ts @@ -0,0 +1,6 @@ +export type AlertDetailsStatusRendererProps = { + isLoading: boolean; + isError: boolean; + isRefetching: boolean; + data: any; +}; diff --git a/frontend/src/pages/AlertHistory/index.tsx b/frontend/src/pages/AlertHistory/index.tsx new file mode 100644 index 0000000000..7a7b0d01d8 --- /dev/null +++ b/frontend/src/pages/AlertHistory/index.tsx @@ -0,0 +1,3 @@ +import AlertHistory from 'container/AlertHistory'; + +export default AlertHistory; diff --git a/frontend/src/pages/AlertList/index.tsx b/frontend/src/pages/AlertList/index.tsx index 1bf3d9a6ea..19d746e8f0 100644 --- a/frontend/src/pages/AlertList/index.tsx +++ b/frontend/src/pages/AlertList/index.tsx @@ -1,10 +1,14 @@ import { Tabs } from 'antd'; import { TabsProps } from 'antd/lib'; +import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon'; +import ROUTES from 'constants/routes'; import AllAlertRules from 'container/ListAlertRules'; import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime'; import TriggeredAlerts from 'container/TriggeredAlerts'; import useUrlQuery from 'hooks/useUrlQuery'; import history from 'lib/history'; +import { GalleryVerticalEnd, Pyramid } from 'lucide-react'; +import AlertDetails from 'pages/AlertDetails'; import { useLocation } from 'react-router-dom'; function AllAlertList(): JSX.Element { @@ -12,15 +16,40 @@ function AllAlertList(): JSX.Element { const location = useLocation(); const tab = urlQuery.get('tab'); + const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY; + const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW; + + const search = urlQuery.get('search'); + const items: TabsProps['items'] = [ - { label: 'Alert Rules', key: 'AlertRules', children: }, { - label: 'Triggered Alerts', + label: ( +
+ + Triggered Alerts +
+ ), key: 'TriggeredAlerts', children: , }, { - label: 'Configuration', + label: ( +
+ + Alert Rules +
+ ), + key: 'AlertRules', + children: + isAlertHistory || isAlertOverview ? : , + }, + { + label: ( +
+ + Configuration +
+ ), key: 'Configuration', children: , }, @@ -33,8 +62,16 @@ function AllAlertList(): JSX.Element { activeKey={tab || 'AlertRules'} onChange={(tab): void => { urlQuery.set('tab', tab); - history.replace(`${location.pathname}?${urlQuery.toString()}`); + let params = `tab=${tab}`; + + if (search) { + params += `&search=${search}`; + } + history.replace(`/alerts?${params}`); }} + className={`${ + isAlertHistory || isAlertOverview ? 'alert-details-tabs' : '' + }`} /> ); } diff --git a/frontend/src/pages/EditRules/EditRules.styles.scss b/frontend/src/pages/EditRules/EditRules.styles.scss index 412cddd1ad..a01a6e7ab7 100644 --- a/frontend/src/pages/EditRules/EditRules.styles.scss +++ b/frontend/src/pages/EditRules/EditRules.styles.scss @@ -1,32 +1,33 @@ .edit-rules-container { - display: flex; - justify-content: center; - align-items: center; - margin-top: 5rem; + padding: 0 16px; + &--error { + display: flex; + justify-content: center; + align-items: center; + margin-top: 5rem; + } } - .edit-rules-card { - width: 20rem; - padding: 1rem; + width: 20rem; + padding: 1rem; } .content { - font-style: normal; + font-style: normal; font-weight: 300; font-size: 18px; line-height: 20px; display: flex; align-items: center; - justify-content: center; - text-align: center; + justify-content: center; + text-align: center; margin: 0; } .btn-container { - display: flex; - justify-content: center; - align-items: center; - margin-top: 2rem; + display: flex; + justify-content: center; + align-items: center; + margin-top: 2rem; } - diff --git a/frontend/src/pages/EditRules/index.tsx b/frontend/src/pages/EditRules/index.tsx index cccfc6aee2..372a8a199e 100644 --- a/frontend/src/pages/EditRules/index.tsx +++ b/frontend/src/pages/EditRules/index.tsx @@ -4,6 +4,7 @@ import { Button, Card } from 'antd'; import get from 'api/alerts/get'; import Spinner from 'components/Spinner'; import { QueryParams } from 'constants/query'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; import EditRulesContainer from 'container/EditRules'; import { useNotifications } from 'hooks/useNotifications'; @@ -21,19 +22,21 @@ import { function EditRules(): JSX.Element { const params = useUrlQuery(); - const ruleId = params.get('ruleId'); + const ruleId = params.get(QueryParams.ruleId); const { t } = useTranslation('common'); const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; const { isLoading, data, isRefetching, isError } = useQuery( - ['ruleId', ruleId], + [REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { queryFn: () => get({ id: parseInt(ruleId || '', 10), }), enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, }, ); @@ -62,7 +65,7 @@ function EditRules(): JSX.Element { (data?.payload?.data === undefined && !isLoading) ) { return ( -
+

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

+ +
); } diff --git a/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss new file mode 100644 index 0000000000..7a55632ae6 --- /dev/null +++ b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.styles.scss @@ -0,0 +1,39 @@ +.copy-to-clipboard { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + padding: 4px 6px; + width: 100px; + + &:hover { + background-color: transparent !important; + } + + .ant-btn-icon { + margin: 0 !important; + } + & > * { + color: var(--text-vanilla-400); + font-weight: 400; + line-height: 20px; + letter-spacing: -0.07px; + } + + &--success { + & span, + &:hover { + color: var(--bg-forest-400); + } + } +} + +.lightMode { + .copy-to-clipboard { + &:not(&--success) { + & > * { + color: var(--text-ink-400); + } + } + } +} diff --git a/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx new file mode 100644 index 0000000000..598f6e5a3f --- /dev/null +++ b/frontend/src/periscope/components/CopyToClipboard/CopyToClipboard.tsx @@ -0,0 +1,54 @@ +import './CopyToClipboard.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { CircleCheck, Link2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useCopyToClipboard } from 'react-use'; + +function CopyToClipboard({ textToCopy }: { textToCopy: string }): JSX.Element { + const [state, copyToClipboard] = useCopyToClipboard(); + const [success, setSuccess] = useState(false); + const isDarkMode = useIsDarkMode(); + + useEffect(() => { + let timer: string | number | NodeJS.Timeout | undefined; + if (state.value) { + setSuccess(true); + timer = setTimeout(() => setSuccess(false), 1000); + } + + return (): void => clearTimeout(timer); + }, [state]); + + if (success) { + return ( + + ); + } + + return ( + + ); +} + +export default CopyToClipboard; diff --git a/frontend/src/periscope/components/CopyToClipboard/index.tsx b/frontend/src/periscope/components/CopyToClipboard/index.tsx new file mode 100644 index 0000000000..7b6b62c1b5 --- /dev/null +++ b/frontend/src/periscope/components/CopyToClipboard/index.tsx @@ -0,0 +1,3 @@ +import CopyToClipboard from './CopyToClipboard'; + +export default CopyToClipboard; diff --git a/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx new file mode 100644 index 0000000000..7d6c6eb5a1 --- /dev/null +++ b/frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx @@ -0,0 +1,46 @@ +import Spinner from 'components/Spinner'; +import { useTranslation } from 'react-i18next'; + +interface DataStateRendererProps { + isLoading: boolean; + isRefetching: boolean; + isError: boolean; + data: T | null; + errorMessage?: string; + loadingMessage?: string; + children: (data: T) => React.ReactNode; +} + +/** + * TODO(shaheer): add empty state and optionally accept empty state custom component + * TODO(shaheer): optionally accept custom error state component + * TODO(shaheer): optionally accept custom loading state component + */ +function DataStateRenderer({ + isLoading, + isRefetching, + isError, + data, + errorMessage, + loadingMessage, + children, +}: DataStateRendererProps): JSX.Element { + const { t } = useTranslation('common'); + + if (isLoading || isRefetching || !data) { + return ; + } + + if (isError || data === null) { + return
{errorMessage ?? t('something_went_wrong')}
; + } + + return <>{children(data)}; +} + +DataStateRenderer.defaultProps = { + errorMessage: '', + loadingMessage: 'Loading...', +}; + +export default DataStateRenderer; diff --git a/frontend/src/periscope/components/DataStateRenderer/index.tsx b/frontend/src/periscope/components/DataStateRenderer/index.tsx new file mode 100644 index 0000000000..e4afdfa3bd --- /dev/null +++ b/frontend/src/periscope/components/DataStateRenderer/index.tsx @@ -0,0 +1,3 @@ +import DataStateRenderer from './DataStateRenderer'; + +export default DataStateRenderer; diff --git a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss new file mode 100644 index 0000000000..88ae57f4e8 --- /dev/null +++ b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.styles.scss @@ -0,0 +1,37 @@ +.key-value-label { + display: flex; + align-items: center; + border: 1px solid var(--bg-slate-400); + border-radius: 2px; + flex-wrap: wrap; + + &__key, + &__value { + padding: 1px 6px; + font-size: 14px; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.005em; + } + &__key { + background: var(--bg-ink-400); + border-radius: 2px 0 0 2px; + } + &__value { + background: var(--bg-slate-400); + } + color: var(--text-vanilla-400); +} + +.lightMode { + .key-value-label { + border-color: var(--bg-vanilla-400); + color: var(--text-ink-400); + &__key { + background: var(--bg-vanilla-300); + } + &__value { + background: var(--bg-vanilla-200); + } + } +} diff --git a/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx new file mode 100644 index 0000000000..aa14dd6380 --- /dev/null +++ b/frontend/src/periscope/components/KeyValueLabel/KeyValueLabel.tsx @@ -0,0 +1,18 @@ +import './KeyValueLabel.styles.scss'; + +type KeyValueLabelProps = { badgeKey: string; badgeValue: string }; + +export default function KeyValueLabel({ + badgeKey, + badgeValue, +}: KeyValueLabelProps): JSX.Element | null { + if (!badgeKey || !badgeValue) { + return null; + } + return ( +
+
{badgeKey}
+
{badgeValue}
+
+ ); +} diff --git a/frontend/src/periscope/components/KeyValueLabel/index.tsx b/frontend/src/periscope/components/KeyValueLabel/index.tsx new file mode 100644 index 0000000000..7341e057e8 --- /dev/null +++ b/frontend/src/periscope/components/KeyValueLabel/index.tsx @@ -0,0 +1,3 @@ +import KeyValueLabel from './KeyValueLabel'; + +export default KeyValueLabel; diff --git a/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx b/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx new file mode 100644 index 0000000000..205e1d3db8 --- /dev/null +++ b/frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx @@ -0,0 +1,24 @@ +import { Typography } from 'antd'; + +function PaginationInfoText( + total: number, + [start, end]: number[], +): JSX.Element { + return ( + + + {start} — {end} + + of {total} + + ); +} + +export default PaginationInfoText; diff --git a/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss b/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss new file mode 100644 index 0000000000..002b04294b --- /dev/null +++ b/frontend/src/periscope/components/SeeMore/SeeMore.styles.scss @@ -0,0 +1,26 @@ +.see-more-button { + background: none; + padding: 2px; + font-size: 14px; + line-height: 18px; + letter-spacing: -0.005em; + color: var(--text-vanilla-400); + border: none; + cursor: pointer; +} + +.see-more-popover-content { + display: flex; + gap: 6px; + flex-wrap: wrap; + width: 300px; +} + +.lightMode { + .see-more-button { + color: var(--text-ink-400); + } + .see-more-popover-content { + background: var(--bg-vanilla-100); + } +} diff --git a/frontend/src/periscope/components/SeeMore/SeeMore.tsx b/frontend/src/periscope/components/SeeMore/SeeMore.tsx new file mode 100644 index 0000000000..f94da8a564 --- /dev/null +++ b/frontend/src/periscope/components/SeeMore/SeeMore.tsx @@ -0,0 +1,48 @@ +import './SeeMore.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Popover } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; + +type SeeMoreProps = { + children: JSX.Element[]; + initialCount?: number; + moreLabel: string; +}; + +function SeeMore({ + children, + initialCount = 2, + moreLabel, +}: SeeMoreProps): JSX.Element { + const remainingCount = children.length - initialCount; + const isDarkMode = useIsDarkMode(); + + return ( + <> + {children.slice(0, initialCount)} + {remainingCount > 0 && ( + + {children.slice(initialCount)} +
+ } + > + + + )} + + ); +} + +SeeMore.defaultProps = { + initialCount: 2, +}; + +export default SeeMore; diff --git a/frontend/src/periscope/components/SeeMore/index.tsx b/frontend/src/periscope/components/SeeMore/index.tsx new file mode 100644 index 0000000000..9ee14a54c9 --- /dev/null +++ b/frontend/src/periscope/components/SeeMore/index.tsx @@ -0,0 +1,3 @@ +import SeeMore from './SeeMore'; + +export default SeeMore; diff --git a/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss b/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss new file mode 100644 index 0000000000..59b5156cdd --- /dev/null +++ b/frontend/src/periscope/components/Tabs2/Tabs2.styles.scss @@ -0,0 +1,48 @@ +.tabs-wrapper { + display: flex; + align-items: center; + gap: 12px; + + .tab { + &.ant-btn-default { + box-shadow: none; + display: flex; + align-items: center; + gap: 10px; + color: var(--text-vanilla-400); + background: var(--bg-ink-400); + font-size: 14px; + line-height: 20px; + letter-spacing: -0.07px; + padding: 6px 24px; + border-color: var(--bg-slate-400); + justify-content: center; + } + &.reset-button { + .ant-btn-icon { + margin: 0; + } + padding: 6px 12px; + } + &.selected { + color: var(--text-vanilla-100); + background: var(--bg-slate-400); + } + } +} + +.lightMode { + .tabs-wrapper { + .tab { + &.ant-btn-default { + color: var(--text-ink-400); + background: var(--bg-vanilla-300); + border-color: var(--bg-vanilla-300); + } + &.selected { + color: var(--text-robin-500); + background: var(--bg-vanilla-100); + } + } + } +} diff --git a/frontend/src/periscope/components/Tabs2/Tabs2.tsx b/frontend/src/periscope/components/Tabs2/Tabs2.tsx new file mode 100644 index 0000000000..051d80365e --- /dev/null +++ b/frontend/src/periscope/components/Tabs2/Tabs2.tsx @@ -0,0 +1,80 @@ +import './Tabs2.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { Button } from 'antd'; +import { TimelineFilter } from 'container/AlertHistory/types'; +import { Undo } from 'lucide-react'; +import { useState } from 'react'; + +interface Tab { + value: string; + label: string | JSX.Element; + disabled?: boolean; + icon?: string | JSX.Element; +} + +interface TimelineTabsProps { + tabs: Tab[]; + onSelectTab?: (selectedTab: TimelineFilter) => void; + initialSelectedTab?: string; + hasResetButton?: boolean; + buttonMinWidth?: string; +} + +function Tabs2({ + tabs, + onSelectTab, + initialSelectedTab, + hasResetButton, + buttonMinWidth = '114px', +}: TimelineTabsProps): JSX.Element { + const [selectedTab, setSelectedTab] = useState( + initialSelectedTab || tabs[0].value, + ); + + const handleTabClick = (tabValue: string): void => { + setSelectedTab(tabValue); + if (onSelectTab) { + onSelectTab(tabValue as TimelineFilter); + } + }; + + return ( +
+ {hasResetButton && selectedTab !== tabs[0].value && ( + + )} + + {tabs.map((tab) => ( + + ))} + +
+ ); +} + +Tabs2.defaultProps = { + initialSelectedTab: '', + onSelectTab: (): void => {}, + hasResetButton: false, + buttonMinWidth: '114px', +}; + +export default Tabs2; diff --git a/frontend/src/periscope/components/Tabs2/index.tsx b/frontend/src/periscope/components/Tabs2/index.tsx new file mode 100644 index 0000000000..0338314a3a --- /dev/null +++ b/frontend/src/periscope/components/Tabs2/index.tsx @@ -0,0 +1,3 @@ +import Tabs2 from './Tabs2'; + +export default Tabs2; diff --git a/frontend/src/providers/Alert.tsx b/frontend/src/providers/Alert.tsx new file mode 100644 index 0000000000..337eec9ba5 --- /dev/null +++ b/frontend/src/providers/Alert.tsx @@ -0,0 +1,43 @@ +import React, { createContext, useContext, useState } from 'react'; + +interface AlertRuleContextType { + isAlertRuleDisabled: boolean | undefined; + setIsAlertRuleDisabled: React.Dispatch< + React.SetStateAction + >; +} + +const AlertRuleContext = createContext( + undefined, +); + +function AlertRuleProvider({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + const [isAlertRuleDisabled, setIsAlertRuleDisabled] = useState< + boolean | undefined + >(undefined); + + const value = React.useMemo( + () => ({ isAlertRuleDisabled, setIsAlertRuleDisabled }), + [isAlertRuleDisabled], + ); + + return ( + + {children} + + ); +} + +export const useAlertRule = (): AlertRuleContextType => { + const context = useContext(AlertRuleContext); + if (context === undefined) { + throw new Error('useAlertRule must be used within an AlertRuleProvider'); + } + return context; +}; + +export default AlertRuleProvider; diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts index c773cb78a2..9393ccd5a0 100644 --- a/frontend/src/types/api/alerts/def.ts +++ b/frontend/src/types/api/alerts/def.ts @@ -38,7 +38,71 @@ export interface RuleCondition { alertOnAbsent?: boolean | undefined; absentFor?: number | undefined; } - export interface Labels { [key: string]: string; } + +export interface AlertRuleStats { + totalCurrentTriggers: number; + totalPastTriggers: number; + currentTriggersSeries: CurrentTriggersSeries; + pastTriggersSeries: CurrentTriggersSeries | null; + currentAvgResolutionTime: number; + pastAvgResolutionTime: number; + currentAvgResolutionTimeSeries: CurrentTriggersSeries; + pastAvgResolutionTimeSeries: any | null; +} + +interface CurrentTriggersSeries { + labels: Labels; + labelsArray: any | null; + values: StatsTimeSeriesItem[]; +} + +export interface StatsTimeSeriesItem { + timestamp: number; + value: string; +} + +export type AlertRuleStatsPayload = { + data: AlertRuleStats; +}; + +export interface AlertRuleTopContributors { + fingerprint: number; + labels: Labels; + count: number; + relatedLogsLink: string; + relatedTracesLink: string; +} +export type AlertRuleTopContributorsPayload = { + data: AlertRuleTopContributors[]; +}; + +export interface AlertRuleTimelineTableResponse { + ruleID: string; + ruleName: string; + overallState: string; + overallStateChanged: boolean; + state: string; + stateChanged: boolean; + unixMilli: number; + labels: Labels; + fingerprint: number; + value: number; + relatedTracesLink: string; + relatedLogsLink: string; +} +export type AlertRuleTimelineTableResponsePayload = { + data: { items: AlertRuleTimelineTableResponse[]; total: number }; +}; +type AlertState = 'firing' | 'normal' | 'no-data' | 'muted'; + +export interface AlertRuleTimelineGraphResponse { + start: number; + end: number; + state: AlertState; +} +export type AlertRuleTimelineGraphResponsePayload = { + data: AlertRuleTimelineGraphResponse[]; +}; diff --git a/frontend/src/types/api/alerts/ruleStats.ts b/frontend/src/types/api/alerts/ruleStats.ts new file mode 100644 index 0000000000..2669a4c6be --- /dev/null +++ b/frontend/src/types/api/alerts/ruleStats.ts @@ -0,0 +1,7 @@ +import { AlertDef } from './def'; + +export interface RuleStatsProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/types/api/alerts/timelineGraph.ts b/frontend/src/types/api/alerts/timelineGraph.ts new file mode 100644 index 0000000000..99e9601f1e --- /dev/null +++ b/frontend/src/types/api/alerts/timelineGraph.ts @@ -0,0 +1,7 @@ +import { AlertDef } from './def'; + +export interface GetTimelineGraphRequestProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/types/api/alerts/timelineTable.ts b/frontend/src/types/api/alerts/timelineTable.ts new file mode 100644 index 0000000000..b2e27a4d1c --- /dev/null +++ b/frontend/src/types/api/alerts/timelineTable.ts @@ -0,0 +1,13 @@ +import { TagFilter } from '../queryBuilder/queryBuilderData'; +import { AlertDef } from './def'; + +export interface GetTimelineTableRequestProps { + id: AlertDef['id']; + start: number; + end: number; + offset: number; + limit: number; + order: string; + filters?: TagFilter; + state?: string; +} diff --git a/frontend/src/types/api/alerts/topContributors.ts b/frontend/src/types/api/alerts/topContributors.ts new file mode 100644 index 0000000000..74acb4b871 --- /dev/null +++ b/frontend/src/types/api/alerts/topContributors.ts @@ -0,0 +1,7 @@ +import { AlertDef } from './def'; + +export interface TopContributorsProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/utils/calculateChange.ts b/frontend/src/utils/calculateChange.ts new file mode 100644 index 0000000000..4e3d912f0d --- /dev/null +++ b/frontend/src/utils/calculateChange.ts @@ -0,0 +1,31 @@ +export function calculateChange( + totalCurrentTriggers: number | undefined, + totalPastTriggers: number | undefined, +): { changePercentage: number; changeDirection: number } { + if ( + totalCurrentTriggers === undefined || + totalPastTriggers === undefined || + [0, '0'].includes(totalPastTriggers) + ) { + return { changePercentage: 0, changeDirection: 0 }; + } + + let changePercentage = + ((totalCurrentTriggers - totalPastTriggers) / totalPastTriggers) * 100; + + let changeDirection = 0; + + if (changePercentage < 0) { + changeDirection = -1; + } else if (changePercentage > 0) { + changeDirection = 1; + } + + changePercentage = Math.abs(changePercentage); + changePercentage = Math.round(changePercentage); + + return { + changePercentage, + changeDirection, + }; +} diff --git a/frontend/src/utils/permission/index.ts b/frontend/src/utils/permission/index.ts index 1845e77941..8a35121f57 100644 --- a/frontend/src/utils/permission/index.ts +++ b/frontend/src/utils/permission/index.ts @@ -64,6 +64,8 @@ export const routePermission: Record = { ERROR_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'], HOME_PAGE: ['ADMIN', 'EDITOR', 'VIEWER'], LIST_ALL_ALERT: ['ADMIN', 'EDITOR', 'VIEWER'], + ALERT_HISTORY: ['ADMIN', 'EDITOR', 'VIEWER'], + ALERT_OVERVIEW: ['ADMIN'], LOGIN: ['ADMIN', 'EDITOR', 'VIEWER'], NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR'], PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'], diff --git a/frontend/src/utils/timeUtils.ts b/frontend/src/utils/timeUtils.ts index 277c0c04af..5eb795bf45 100644 --- a/frontend/src/utils/timeUtils.ts +++ b/frontend/src/utils/timeUtils.ts @@ -1,8 +1,11 @@ import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; +import duration from 'dayjs/plugin/duration'; dayjs.extend(customParseFormat); +dayjs.extend(duration); + export function toUTCEpoch(time: number): number { const x = new Date(); return time + x.getTimezoneOffset() * 60 * 1000; @@ -28,3 +31,97 @@ export const getRemainingDays = (billingEndDate: number): number => { return Math.ceil(timeDifference / (1000 * 60 * 60 * 24)); }; + +/** + * Calculates the duration from the given epoch timestamp to the current time. + * + * + * @param {number} epochTimestamp + * @returns {string} - human readable string representing the duration from the given epoch timestamp to the current time e.g. "3d 14h" + */ +export const getDurationFromNow = (epochTimestamp: number): string => { + const now = dayjs(); + const inputTime = dayjs(epochTimestamp); + const duration = dayjs.duration(now.diff(inputTime)); + + const days = duration.days(); + const hours = duration.hours(); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + + let result = ''; + if (days > 0) result += `${days}d `; + if (hours > 0) result += `${hours}h `; + if (minutes > 0) result += `${minutes}m `; + if (seconds > 0) result += `${seconds}s`; + + return result.trim(); +}; + +/** + * Formats an epoch timestamp into a human-readable date and time string. + * + * @param {number} epoch - The epoch timestamp to format. + * @returns {string} - The formatted date and time string in the format "MMM D, YYYY ⎯ HH:MM:SS". + */ +export function formatEpochTimestamp(epoch: number): string { + const date = new Date(epoch); + + const optionsDate: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + }; + + const optionsTime: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }; + + const formattedDate = date.toLocaleDateString('en-US', optionsDate); + const formattedTime = date.toLocaleTimeString('en-US', optionsTime); + + return `${formattedDate} ⎯ ${formattedTime}`; +} + +/** + * Converts a given number of seconds into a human-readable format. + * @param {number} seconds The number of seconds to convert. + * @returns {string} The formatted time string, either in days (e.g., "1.2d"), hours (e.g., "1.2h"), minutes (e.g., "~7m"), or seconds (e.g., "~45s"). + */ + +export function formatTime(seconds: number): string { + const days = seconds / 86400; + + if (days >= 1) { + return `${days.toFixed(1)}d`; + } + + const hours = seconds / 3600; + if (hours >= 1) { + return `${hours.toFixed(1)}h`; + } + + const minutes = seconds / 60; + if (minutes >= 1) { + return `${minutes.toFixed(1)}m`; + } + + return `${seconds.toFixed(1)}s`; +} + +export const nanoToMilli = (nanoseconds: number): number => + nanoseconds / 1_000_000; + +export const epochToTimeString = (epochMs: number): string => { + console.log({ epochMs }); + const date = new Date(epochMs); + const options: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }; + return date.toLocaleTimeString('en-US', options); +};