diff --git a/frontend/package.json b/frontend/package.json index 34e08ea263..51097f7696 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "ansi-to-html": "0.7.2", "antd": "5.11.0", "antd-table-saveas-excel": "2.2.1", - "axios": "1.6.4", + "axios": "1.7.4", "babel-eslint": "^10.1.0", "babel-jest": "^29.6.4", "babel-loader": "9.1.3", @@ -88,7 +88,7 @@ "lucide-react": "0.379.0", "mini-css-extract-plugin": "2.4.5", "papaparse": "5.4.1", - "posthog-js": "1.142.1", + "posthog-js": "1.160.3", "rc-tween-one": "3.0.6", "react": "18.2.0", "react-addons-update": "15.6.3", diff --git a/frontend/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-GB/workspaceLocked.json b/frontend/public/locales/en-GB/workspaceLocked.json new file mode 100644 index 0000000000..1eb6a0da1c --- /dev/null +++ b/frontend/public/locales/en-GB/workspaceLocked.json @@ -0,0 +1,22 @@ +{ + "trialPlanExpired": "Trial Plan Expired", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "upgradeToContinue": "Upgrade to Continue", + "upgradeNow": "Upgrade now to keep enjoying all the great features you’ve been using.", + "yourDataIsSafe": "Your data is safe with us until", + "actNow": "Act now to avoid any disruptions and continue where you left off.", + "contactAdmin": "Contact your admin to proceed with the upgrade.", + "continueMyJourney": "Continue My Journey", + "needMoreTime": "Need More Time?", + "extendTrial": "Extend Trial", + "extendTrialMsgPart1": "If you have a specific reason why you were not able to finish your PoC in the trial period, please write to us on", + "extendTrialMsgPart2": "with the reason. Sometimes we can extend trial by a few days on a case by case basis", + "whyChooseSignoz": "Why choose Signoz", + "enterpriseGradeObservability": "Enterprise-grade Observability", + "observabilityDescription": "Get access to observability at any scale with advanced security and compliance.", + "continueToUpgrade": "Continue to Upgrade", + "youAreInGoodCompany": "You are in good company", + "faqs": "FAQs", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/public/locales/en/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/public/locales/en/workspaceLocked.json b/frontend/public/locales/en/workspaceLocked.json new file mode 100644 index 0000000000..1eb6a0da1c --- /dev/null +++ b/frontend/public/locales/en/workspaceLocked.json @@ -0,0 +1,22 @@ +{ + "trialPlanExpired": "Trial Plan Expired", + "gotQuestions": "Got Questions?", + "contactUs": "Contact Us", + "upgradeToContinue": "Upgrade to Continue", + "upgradeNow": "Upgrade now to keep enjoying all the great features you’ve been using.", + "yourDataIsSafe": "Your data is safe with us until", + "actNow": "Act now to avoid any disruptions and continue where you left off.", + "contactAdmin": "Contact your admin to proceed with the upgrade.", + "continueMyJourney": "Continue My Journey", + "needMoreTime": "Need More Time?", + "extendTrial": "Extend Trial", + "extendTrialMsgPart1": "If you have a specific reason why you were not able to finish your PoC in the trial period, please write to us on", + "extendTrialMsgPart2": "with the reason. Sometimes we can extend trial by a few days on a case by case basis", + "whyChooseSignoz": "Why choose Signoz", + "enterpriseGradeObservability": "Enterprise-grade Observability", + "observabilityDescription": "Get access to observability at any scale with advanced security and compliance.", + "continueToUpgrade": "Continue to Upgrade", + "youAreInGoodCompany": "You are in good company", + "faqs": "FAQs", + "somethingWentWrong": "Something went wrong" +} diff --git a/frontend/src/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/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss new file mode 100644 index 0000000000..c46d9975f4 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.styles.scss @@ -0,0 +1,145 @@ +.checkbox-filter { + display: flex; + flex-direction: column; + padding: 12px; + gap: 12px; + border-bottom: 1px solid var(--bg-slate-400); + .filter-header-checkbox { + display: flex; + align-items: center; + justify-content: space-between; + + .left-action { + display: flex; + align-items: center; + gap: 6px; + + .title { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.07px; + text-transform: capitalize; + } + } + + .right-action { + display: flex; + align-items: center; + + .clear-all { + font-size: 12px; + color: var(--bg-robin-500); + cursor: pointer; + } + } + } + + .values { + display: flex; + flex-direction: column; + gap: 8px; + + .value { + display: flex; + align-items: center; + gap: 8px; + + .checkbox-value-section { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + cursor: pointer; + + &.filter-disabled { + cursor: not-allowed; + + .value-string { + color: var(--bg-slate-200); + } + + .only-btn { + cursor: not-allowed; + color: var(--bg-slate-200); + } + + .toggle-btn { + cursor: not-allowed; + color: var(--bg-slate-200); + } + } + + .value-string { + } + + .only-btn { + display: none; + } + .toggle-btn { + display: none; + } + + .toggle-btn:hover { + background-color: unset; + } + + .only-btn:hover { + background-color: unset; + } + } + + .checkbox-value-section:hover { + .toggle-btn { + display: none; + } + .only-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + } + } + + .value:hover { + .toggle-btn { + display: flex; + align-items: center; + justify-content: center; + height: 21px; + } + } + } + + .no-data { + align-self: center; + } + + .show-more { + display: flex; + align-items: center; + justify-content: center; + + .show-more-text { + color: var(--bg-robin-500); + cursor: pointer; + } + } +} + +.lightMode { + .checkbox-filter { + border-bottom: 1px solid var(--bg-vanilla-300); + .filter-header-checkbox { + .left-action { + .title { + color: var(--bg-ink-400); + } + } + } + } +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx new file mode 100644 index 0000000000..fc9a71a7b1 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Checkbox/Checkbox.tsx @@ -0,0 +1,503 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import './Checkbox.styles.scss'; + +import { Button, Checkbox, Input, Skeleton, Typography } from 'antd'; +import cx from 'classnames'; +import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters'; +import { OPERATORS } from 'constants/queryBuilder'; +import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils'; +import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { cloneDeep, isArray, isEmpty, isEqual } from 'lodash-es'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; +import { DataSource } from 'types/common/queryBuilder'; +import { v4 as uuid } from 'uuid'; + +const SELECTED_OPERATORS = [OPERATORS['='], 'in']; +const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'nin']; + +function setDefaultValues( + values: string[], + trueOrFalse: boolean, +): Record { + const defaultState: Record = {}; + values.forEach((val) => { + defaultState[val] = trueOrFalse; + }); + return defaultState; +} +interface ICheckboxProps { + filter: IQuickFiltersConfig; +} + +export default function CheckboxFilter(props: ICheckboxProps): JSX.Element { + const { filter } = props; + const [searchText, setSearchText] = useState(''); + const [isOpen, setIsOpen] = useState(filter.defaultOpen); + const [visibleItemsCount, setVisibleItemsCount] = useState(10); + + const { + lastUsedQuery, + currentQuery, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + + const { data, isLoading } = useGetAggregateValues( + { + aggregateOperator: 'noop', + dataSource: DataSource.LOGS, + aggregateAttribute: '', + attributeKey: filter.attributeKey.key, + filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY, + tagType: filter.attributeKey.type || '', + searchText: searchText ?? '', + }, + { + enabled: isOpen, + keepPreviousData: true, + }, + ); + + const attributeValues: string[] = useMemo( + () => + ((Object.values(data?.payload || {}).find((el) => !!el) || + []) as string[]).filter((val) => !isEmpty(val)), + [data?.payload], + ); + const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount); + + // derive the state of each filter key here in the renderer itself and keep it in sync with staged query + // also we need to keep a note of last focussed query. + // eslint-disable-next-line sonarjs/cognitive-complexity + const currentFilterState = useMemo(() => { + let filterState: Record = setDefaultValues( + attributeValues, + false, + ); + const filterSync = currentQuery?.builder.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items.find((item) => isEqual(item.key, filter.attributeKey)); + + if (filterSync) { + if (SELECTED_OPERATORS.includes(filterSync.op)) { + if (isArray(filterSync.value)) { + filterSync.value.forEach((val) => { + filterState[val] = true; + }); + } else if (typeof filterSync.value === 'string') { + filterState[filterSync.value] = true; + } else if (typeof filterSync.value === 'boolean') { + filterState[String(filterSync.value)] = true; + } else if (typeof filterSync.value === 'number') { + filterState[String(filterSync.value)] = true; + } + } else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) { + filterState = setDefaultValues(attributeValues, true); + if (isArray(filterSync.value)) { + filterSync.value.forEach((val) => { + filterState[val] = false; + }); + } else if (typeof filterSync.value === 'string') { + filterState[filterSync.value] = false; + } else if (typeof filterSync.value === 'boolean') { + filterState[String(filterSync.value)] = false; + } else if (typeof filterSync.value === 'number') { + filterState[String(filterSync.value)] = false; + } + } + } else { + filterState = setDefaultValues(attributeValues, true); + } + return filterState; + }, [ + attributeValues, + currentQuery?.builder.queryData, + filter.attributeKey, + lastUsedQuery, + ]); + + // disable the filter when there are multiple entries of the same attribute key present in the filter bar + const isFilterDisabled = useMemo( + () => + (currentQuery?.builder?.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items?.filter((item) => isEqual(item.key, filter.attributeKey)) + ?.length || 0) > 1, + + [currentQuery?.builder?.queryData, lastUsedQuery, filter.attributeKey], + ); + + // variable to check if the current filter has multiple values to its name in the key op value section + const isMultipleValuesTrueForTheKey = + Object.values(currentFilterState).filter((val) => val).length > 1; + + const handleClearFilterAttribute = (): void => { + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item, idx) => ({ + ...item, + filters: { + ...item.filters, + items: + idx === lastUsedQuery + ? item.filters.items.filter( + (fil) => !isEqual(fil.key, filter.attributeKey), + ) + : [...item.filters.items], + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }; + + const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[ + lastUsedQuery || 0 + ]?.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey)); + + const onChange = ( + value: string, + checked: boolean, + isOnlyOrAllClicked: boolean, + // eslint-disable-next-line sonarjs/cognitive-complexity + ): void => { + const query = cloneDeep(currentQuery.builder.queryData?.[lastUsedQuery || 0]); + + // if only or all are clicked we do not need to worry about anything just override whatever we have + // by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL. + if (isOnlyOrAllClicked && query?.filters?.items) { + const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute + ? currentFilterState[value] && !isMultipleValuesTrueForTheKey + ? 'All' + : 'Only' + : 'Only'; + query.filters.items = query.filters.items.filter( + (q) => !isEqual(q.key, filter.attributeKey), + ); + if (isOnlyOrAll === 'Only') { + const newFilterItem: TagFilterItem = { + id: uuid(), + op: getOperatorValue(OPERATORS.IN), + key: filter.attributeKey, + value, + }; + query.filters.items = [...query.filters.items, newFilterItem]; + } + } else if (query?.filters?.items) { + if ( + query.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey)) + ) { + // if there is already a running filter for the current attribute key then + // we split the cases by which particular operator is present right now! + const currentFilter = query.filters?.items?.find((q) => + isEqual(q.key, filter.attributeKey), + ); + if (currentFilter) { + const runningOperator = currentFilter?.op; + switch (runningOperator) { + case 'in': + if (checked) { + // if it's an IN operator then if we are checking another value it get's added to the + // filter clause. example - key IN [value1, currentSelectedValue] + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: [...currentFilter.value, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else { + // if the current state wasn't an array we make it one and add our value + const newFilter = { + ...currentFilter, + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else if (!checked) { + // if we are removing some value when the running operator is IN we filter. + // example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: currentFilter.value.filter((val) => val !== value), + }; + + if (newFilter.value.length === 0) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } else { + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else { + // if not an array remove the whole thing altogether! + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + } + break; + case 'nin': + // if the current running operator is NIN then when unchecking the value it gets + // added to the clause like key NIN [value1 , currentUnselectedValue] + if (!checked) { + // in case of array add the currentUnselectedValue to the list. + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: [...currentFilter.value, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else { + // in case of not an array make it one! + const newFilter = { + ...currentFilter, + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else if (checked) { + // opposite of above! + if (isArray(currentFilter.value)) { + const newFilter = { + ...currentFilter, + value: currentFilter.value.filter((val) => val !== value), + }; + + if (newFilter.value.length === 0) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } else { + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } + } else { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + } + break; + case '=': + if (checked) { + const newFilter = { + ...currentFilter, + op: getOperatorValue(OPERATORS.IN), + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else if (!checked) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + break; + case '!=': + if (!checked) { + const newFilter = { + ...currentFilter, + op: getOperatorValue(OPERATORS.NIN), + value: [currentFilter.value as string, value], + }; + query.filters.items = query.filters.items.map((item) => { + if (isEqual(item.key, filter.attributeKey)) { + return newFilter; + } + return item; + }); + } else if (checked) { + query.filters.items = query.filters.items.filter( + (item) => !isEqual(item.key, filter.attributeKey), + ); + } + break; + default: + break; + } + } + } else { + // case - when there is no filter for the current key that means all are selected right now. + const newFilterItem: TagFilterItem = { + id: uuid(), + op: getOperatorValue(OPERATORS.NIN), + key: filter.attributeKey, + value, + }; + query.filters.items = [...query.filters.items, newFilterItem]; + } + } + const finalQuery = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: [ + ...currentQuery.builder.queryData.map((q, idx) => { + if (idx === lastUsedQuery) { + return query; + } + return q; + }), + ], + }, + }; + + redirectWithQueryBuilderData(finalQuery); + }; + + return ( +
+
+
+ {isOpen ? ( + { + setIsOpen(false); + setVisibleItemsCount(10); + }} + /> + ) : ( + setIsOpen(true)} + cursor="pointer" + /> + )} + {filter.title} +
+
+ {isOpen && ( + + Clear All + + )} +
+
+ {isOpen && isLoading && !attributeValues.length && ( +
+ +
+ )} + {isOpen && !isLoading && ( + <> +
+ setSearchText(e.target.value)} + disabled={isFilterDisabled} + /> +
+ {attributeValues.length > 0 ? ( +
+ {currentAttributeKeys.map((value: string) => ( +
+ onChange(value, e.target.checked, false)} + checked={currentFilterState[value]} + disabled={isFilterDisabled} + rootClassName="check-box" + /> + +
{ + if (isFilterDisabled) { + return; + } + onChange(value, currentFilterState[value], true); + }} + > + {filter.customRendererForValue ? ( + filter.customRendererForValue(value) + ) : ( + + {value} + + )} + + +
+
+ ))} +
+ ) : ( +
+ No values found{' '} +
+ )} + {visibleItemsCount < attributeValues?.length && ( +
+ setVisibleItemsCount((prev) => prev + 10)} + > + Show More... + +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.styles.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx new file mode 100644 index 0000000000..f7cd9547e8 --- /dev/null +++ b/frontend/src/components/QuickFilters/FilterRenderers/Slider/Slider.tsx @@ -0,0 +1,14 @@ +import './Slider.styles.scss'; + +import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters'; + +interface ISliderProps { + filter: IQuickFiltersConfig; +} + +// not needed for now build when required +export default function Slider(props: ISliderProps): JSX.Element { + const { filter } = props; + console.log(filter); + return
Slider
; +} diff --git a/frontend/src/components/QuickFilters/QuickFilters.styles.scss b/frontend/src/components/QuickFilters/QuickFilters.styles.scss new file mode 100644 index 0000000000..d5c3460891 --- /dev/null +++ b/frontend/src/components/QuickFilters/QuickFilters.styles.scss @@ -0,0 +1,93 @@ +.quick-filters { + display: flex; + flex-direction: column; + height: 100%; + border-right: 1px solid var(--bg-slate-400); + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10.5px; + border-bottom: 1px solid var(--bg-slate-400); + + .left-actions { + display: flex; + align-items: center; + gap: 6px; + + .text { + color: var(--bg-vanilla-400); + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; + letter-spacing: -0.07px; + } + + .sync-tag { + display: flex; + padding: 5px 9px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 2px; + border: 1px solid rgba(78, 116, 248, 0.2); + background: rgba(78, 116, 248, 0.1); + color: var(--bg-robin-500); + font-family: 'Geist Mono'; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 18px; /* 128.571% */ + text-transform: uppercase; + } + } + + .right-actions { + display: flex; + align-items: center; + gap: 12px; + + .divider-filter { + width: 1px; + height: 14px; + background: #161922; + } + + .sync-icon { + background-color: var(--bg-ink-500); + border: 0; + box-shadow: none; + } + } + } +} + +.lightMode { + .quick-filters { + background-color: var(--bg-vanilla-100); + border-right: 1px solid var(--bg-vanilla-300); + + .header { + border-bottom: 1px solid var(--bg-vanilla-300); + + .left-actions { + .text { + color: var(--bg-ink-400); + } + + .sync-icon { + background-color: var(--bg-vanilla-100); + } + } + .right-actions { + .sync-icon { + background-color: var(--bg-vanilla-100); + } + } + } + } +} diff --git a/frontend/src/components/QuickFilters/QuickFilters.tsx b/frontend/src/components/QuickFilters/QuickFilters.tsx new file mode 100644 index 0000000000..a706e35aef --- /dev/null +++ b/frontend/src/components/QuickFilters/QuickFilters.tsx @@ -0,0 +1,124 @@ +import './QuickFilters.styles.scss'; + +import { + FilterOutlined, + SyncOutlined, + VerticalAlignTopOutlined, +} from '@ant-design/icons'; +import { Tooltip, Typography } from 'antd'; +import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { cloneDeep } from 'lodash-es'; +import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +import Checkbox from './FilterRenderers/Checkbox/Checkbox'; +import Slider from './FilterRenderers/Slider/Slider'; + +export enum FiltersType { + SLIDER = 'SLIDER', + CHECKBOX = 'CHECKBOX', +} + +export enum MinMax { + MIN = 'MIN', + MAX = 'MAX', +} + +export enum SpecficFilterOperations { + ALL = 'ALL', + ONLY = 'ONLY', +} + +export interface IQuickFiltersConfig { + type: FiltersType; + title: string; + attributeKey: BaseAutocompleteData; + customRendererForValue?: (value: string) => JSX.Element; + defaultOpen: boolean; +} + +interface IQuickFiltersProps { + config: IQuickFiltersConfig[]; + handleFilterVisibilityChange: () => void; +} + +export default function QuickFilters(props: IQuickFiltersProps): JSX.Element { + const { config, handleFilterVisibilityChange } = props; + + const { + currentQuery, + lastUsedQuery, + redirectWithQueryBuilderData, + } = useQueryBuilder(); + + // clear all the filters for the query which is in sync with filters + const handleReset = (): void => { + const updatedQuery = cloneDeep( + currentQuery?.builder.queryData?.[lastUsedQuery || 0], + ); + + if (!updatedQuery) { + return; + } + + if (updatedQuery?.filters?.items) { + updatedQuery.filters.items = []; + } + + const preparedQuery: Query = { + ...currentQuery, + builder: { + ...currentQuery.builder, + queryData: currentQuery.builder.queryData.map((item, idx) => ({ + ...item, + filters: { + ...item.filters, + items: idx === lastUsedQuery ? [] : [...item.filters.items], + }, + })), + }, + }; + redirectWithQueryBuilderData(preparedQuery); + }; + + const lastQueryName = + currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName; + return ( +
+
+
+ + Filters for + + {lastQueryName} + +
+
+ + + +
+ + + +
+
+ +
+ {config.map((filter) => { + switch (filter.type) { + case FiltersType.CHECKBOX: + return ; + case FiltersType.SLIDER: + return ; + default: + return ; + } + })} +
+
+ ); +} diff --git a/frontend/src/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/localStorage.ts b/frontend/src/constants/localStorage.ts index c7e8b81179..bab93a7ff1 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -19,4 +19,5 @@ export enum LOCALSTORAGE { SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR', PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES', THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1', + SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS', } diff --git a/frontend/src/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..4cf2e0f5bb 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -78,6 +78,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const isCloudUserVal = isCloudUser(); const showAddCreditCardModal = + isLoggedIn && isChatSupportEnabled && isCloudUserVal && !isPremiumChatSupportEnabled && @@ -213,7 +214,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const pageTitle = t(routeKey); const renderFullScreen = pathname === ROUTES.GET_STARTED || - pathname === ROUTES.WORKSPACE_LOCKED || pathname === ROUTES.GET_STARTED_APPLICATION_MONITORING || pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING || pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT || @@ -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 @@ -279,6 +281,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element { const isSideNavCollapsed = getLocalStorageKey(IS_SIDEBAR_COLLAPSED); + /** + * Note: Right now we don't have a page-level method to pass the sidebar collapse state. + * Since the use case for overriding is not widely needed, we are setting it here + * so that the workspace locked page will have an expanded sidebar regardless of how users + * have set it or what is stored in localStorage. This will not affect the localStorage config. + */ + const isWorkspaceLocked = pathname === ROUTES.WORKSPACE_LOCKED; + return ( )}
{ diff --git a/frontend/src/container/GridCardLayout/GridCard/index.tsx b/frontend/src/container/GridCardLayout/GridCard/index.tsx index 444978f61d..a618f807a5 100644 --- a/frontend/src/container/GridCardLayout/GridCard/index.tsx +++ b/frontend/src/container/GridCardLayout/GridCard/index.tsx @@ -22,6 +22,7 @@ import { getSortedSeriesData } from 'utils/getSortedSeriesData'; import EmptyWidget from '../EmptyWidget'; import { MenuItemKeys } from '../WidgetHeader/contants'; import { GridCardGraphProps } from './types'; +import { isDataAvailableByPanelType } from './utils'; import WidgetGraphComponent from './WidgetGraphComponent'; function GridCardGraph({ @@ -182,7 +183,9 @@ function GridCardGraph({ setErrorMessage(error.message); }, onSettled: (data) => { - dataAvailable?.(Boolean(data?.payload?.data?.result?.length)); + dataAvailable?.( + isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes), + ); }, }, ); diff --git a/frontend/src/container/GridCardLayout/GridCard/utils.ts b/frontend/src/container/GridCardLayout/GridCard/utils.ts index e14903c33d..ec60e662fa 100644 --- a/frontend/src/container/GridCardLayout/GridCard/utils.ts +++ b/frontend/src/container/GridCardLayout/GridCard/utils.ts @@ -1,6 +1,8 @@ /* eslint-disable sonarjs/cognitive-complexity */ import { LOCALSTORAGE } from 'constants/localStorage'; +import { PANEL_TYPES } from 'constants/queryBuilder'; import getLabelName from 'lib/getLabelName'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { QueryData } from 'types/api/widgets/getQuery'; import { LegendEntryProps } from './FullView/types'; @@ -131,3 +133,21 @@ export const toggleGraphsVisibilityInChart = ({ lineChartRef?.current?.toggleGraph(index, showLegendData); }); }; + +export const isDataAvailableByPanelType = ( + data?: MetricRangePayloadProps['data'], + panelType?: string, +): boolean => { + const getPanelData = (): any[] | undefined => { + switch (panelType) { + case PANEL_TYPES.TABLE: + return (data?.result?.[0] as any)?.table?.rows; + case PANEL_TYPES.LIST: + return data?.newResult?.data?.result?.[0]?.list as any[]; + default: + return data?.result; + } + }; + + return Boolean(getPanelData()?.length); +}; diff --git a/frontend/src/container/GridCardLayout/GridCardLayout.tsx b/frontend/src/container/GridCardLayout/GridCardLayout.tsx index a96599b127..c4e4279f9f 100644 --- a/frontend/src/container/GridCardLayout/GridCardLayout.tsx +++ b/frontend/src/container/GridCardLayout/GridCardLayout.tsx @@ -438,6 +438,10 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element { : true, [selectedDashboard], ); + + let isDataAvailableInAnyWidget = false; + const isLogEventCalled = useRef(false); + return isDashboardEmpty ? ( ) : ( @@ -468,6 +472,15 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element { if (currentWidget?.panelTypes === PANEL_GROUP_TYPES.ROW) { const rowWidgetProperties = currentPanelMap[id] || {}; + let { title } = currentWidget; + if (rowWidgetProperties.collapsed) { + const widgetCount = rowWidgetProperties.widgets?.length || 0; + const collapsedText = `(${widgetCount} widget${ + widgetCount > 1 ? 's' : '' + })`; + title += ` ${collapsedText}`; + } + return ( )} - - {currentWidget.title} - + {title} {rowWidgetProperties.collapsed ? ( { + if (!isDataAvailableInAnyWidget && isDataAvailable) { + isDataAvailableInAnyWidget = true; + } + if (!isLogEventCalled.current && isDataAvailableInAnyWidget) { + isLogEventCalled.current = true; + logEvent('Dashboard Detail: Panel data fetched', { + isDataAvailableInAnyWidget, + }); + } + }; + return ( 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/LogDetailedView/TableView/TableViewActions.tsx b/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx index 63912ffa82..74b30bf6de 100644 --- a/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx +++ b/frontend/src/container/LogDetailedView/TableView/TableViewActions.tsx @@ -67,7 +67,6 @@ export function TableViewActions( ); const [isOpen, setIsOpen] = useState(false); - const textToCopy = fieldData.value; if (record.field === 'body') { const parsedBody = recursiveParseJSON(fieldData.value); @@ -89,6 +88,17 @@ export function TableViewActions( : { __html: '' }; const fieldFilterKey = filterKeyForField(fieldData.field); + let textToCopy = fieldData.value; + + // remove starting and ending quotes from the value + try { + textToCopy = textToCopy.replace(/^"|"$/g, ''); + } catch (error) { + console.error( + 'Failed to remove starting and ending quotes from the value', + error, + ); + } return (
diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md index 5be4c4a528..18c5352f97 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EC2InfrastructureMetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)     diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md index 83bb67039b..2c313c455a 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSEc2/md-docs/ecsEc2-createDaemonService.md @@ -51,7 +51,7 @@ aws ecs list-tasks --cluster ${CLUSTER_NAME} --region ${REGION} To verify that the data is being sent to SigNoz Cloud, you can go to the dashboard section of SigNoz and import one of the following dashboards below: - [instancemetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/chore/ecs-dashboards/ecs-infra-metrics/instance-metrics.json) -- [hostmetrics-with-variable.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics-with-variable.json) +- [hostmetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics.json)   diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md index 83bb67039b..2c313c455a 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/ECSExternal/md-docs/ecsExternal-createDaemonService.md @@ -51,7 +51,7 @@ aws ecs list-tasks --cluster ${CLUSTER_NAME} --region ${REGION} To verify that the data is being sent to SigNoz Cloud, you can go to the dashboard section of SigNoz and import one of the following dashboards below: - [instancemetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/chore/ecs-dashboards/ecs-infra-metrics/instance-metrics.json) -- [hostmetrics-with-variable.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics-with-variable.json) +- [hostmetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics.json)   diff --git a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md index 77bd5cb87c..bbdba36523 100644 --- a/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md +++ b/frontend/src/container/OnboardingContainer/Modules/AwsMonitoring/EKS/eks-monitorUsingDashboard.md @@ -1,9 +1,8 @@ ## Monitor using Dashboards -To visualize the Kubernetes Metrics, you can use one of the following pre-built Dashboards: +To visualize the Kubernetes Metrics, you can use following pre-built Dashboards: -- [K8s Node-Level Metrics](https://github.com/SigNoz/dashboards/blob/main/k8s-node-%26-pod-metrics/k8s-node-level-metrics.json) -- [K8s Pod_level Metrics](https://github.com/SigNoz/dashboards/blob/main/k8s-node-%26-pod-metrics/k8s-pod-level-metrics.json) +- [K8s Infra Metrics](https://github.com/SigNoz/dashboards/tree/main/k8s-infra-metrics) You should copy the JSON data in these files and create a New Dashboard in the Dashboard Tab of SigNoz. @@ -13,4 +12,4 @@ By following the previous step, you should also be able to see Kubernetes Pod lo   -To send traces for your application deployed on your Kubernetes cluster, checkout the Application monitoring section of onboarding. \ No newline at end of file +To send traces for your application deployed on your Kubernetes cluster, checkout the Application monitoring section of onboarding. diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/LinuxARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsAMD64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud diff --git a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md index 97c686e0e7..b6009cb839 100644 --- a/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md +++ b/frontend/src/container/OnboardingContainer/Modules/InfrastructureMonitoring/Hostmetrics/md-docs/MacOsARM64/hostmetrics-configureHostmetricsJson.md @@ -1,6 +1,6 @@ ### Step 1: Download/Copy this hostmetrics JSON file -Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) +Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json) ### Step 2: Import hostmetrics JSON file to SigNoz Cloud diff --git a/frontend/src/container/OptionsMenu/useOptionsMenu.ts b/frontend/src/container/OptionsMenu/useOptionsMenu.ts index 7b3cfce035..a4a91d82f4 100644 --- a/frontend/src/container/OptionsMenu/useOptionsMenu.ts +++ b/frontend/src/container/OptionsMenu/useOptionsMenu.ts @@ -140,6 +140,11 @@ const useOptionsMenu = ({ return col; }) .filter(Boolean) as BaseAutocompleteData[]; + + // this is the last point where we can set the default columns and if uptil now also we have an empty array then we will set the default columns + if (!initialSelected || !initialSelected?.length) { + initialSelected = defaultTraceSelectedColumns; + } } return initialSelected || []; diff --git a/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss b/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss index 41949142fa..b81e4d1e51 100644 --- a/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss +++ b/frontend/src/container/PlannedDowntime/PlannedDowntime.styles.scss @@ -77,6 +77,18 @@ color: var(--bg-vanilla-400); } } + + .formItemWithBullet { + margin-bottom: 0; + } + + .scheduleTimeInfoText { + margin-top: 8px; + margin-bottom: 20px; + font-size: 12px; + font-weight: 400; + color: var(--bg-vanilla-400); + } } .alert-rule-tags { @@ -543,5 +555,13 @@ background: var(--bg-vanilla-100); } } + + .scheduleTimeInfoText { + color: var(--bg-slate-300); + } + + .alert-rule-info { + color: var(--bg-slate-300); + } } } diff --git a/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx b/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx index 76b0507558..94d1a5d6eb 100644 --- a/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx +++ b/frontend/src/container/PlannedDowntime/PlannedDowntimeForm.tsx @@ -41,7 +41,7 @@ import { getAlertOptionsFromIds, getDurationInfo, getEndTime, - handleTimeConvertion, + handleTimeConversion, isScheduleRecurring, recurrenceOptions, recurrenceOptionWithSubmenu, @@ -52,6 +52,10 @@ dayjs.locale('en'); dayjs.extend(utc); dayjs.extend(timezone); +const TIME_FORMAT = 'HH:mm'; +const DATE_FORMAT = 'Do MMM YYYY'; +const ORDINAL_FORMAT = 'Do'; + interface PlannedDowntimeFormData { name: string; startTime: dayjs.Dayjs | string; @@ -105,6 +109,10 @@ export function PlannedDowntimeForm( ?.unit || 'm', ); + const [formData, setFormData] = useState( + initialValues?.schedule as PlannedDowntimeFormData, + ); + const [recurrenceType, setRecurrenceType] = useState( (initialValues.schedule?.recurrence?.repeatType as string) || recurrenceOptions.doesNotRepeat.value, @@ -131,7 +139,7 @@ export function PlannedDowntimeForm( .filter((alert) => alert !== undefined) as string[], name: values.name, schedule: { - startTime: handleTimeConvertion( + startTime: handleTimeConversion( values.startTime, timezoneInitialValue, values.timezone, @@ -139,7 +147,7 @@ export function PlannedDowntimeForm( ), timezone: values.timezone, endTime: values.endTime - ? handleTimeConvertion( + ? handleTimeConversion( values.endTime, timezoneInitialValue, values.timezone, @@ -196,14 +204,14 @@ export function PlannedDowntimeForm( ? `${values.recurrence?.duration}${durationUnit}` : undefined, endTime: !isEmpty(values.endTime) - ? handleTimeConvertion( + ? handleTimeConversion( values.endTime, timezoneInitialValue, values.timezone, !isEditMode, ) : undefined, - startTime: handleTimeConvertion( + startTime: handleTimeConversion( values.startTime, timezoneInitialValue, values.timezone, @@ -300,6 +308,116 @@ export function PlannedDowntimeForm( }), ); + const getTimezoneFormattedTime = ( + time: string | dayjs.Dayjs, + timeZone?: string, + isEditMode?: boolean, + format?: string, + ): string => { + if (!time) { + return ''; + } + if (!timeZone) { + return dayjs(time).format(format); + } + return dayjs(time).tz(timeZone, isEditMode).format(format); + }; + + const startTimeText = useMemo((): string => { + let startTime = formData?.startTime; + if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) { + startTime = formData?.recurrence?.startTime || formData?.startTime || ''; + } + + if (!startTime) { + return ''; + } + + if (formData.timezone) { + startTime = handleTimeConversion( + startTime, + timezoneInitialValue, + formData?.timezone, + !isEditMode, + ); + } + const daysOfWeek = formData?.recurrence?.repeatOn; + + const formattedStartTime = getTimezoneFormattedTime( + startTime, + formData.timezone, + !isEditMode, + TIME_FORMAT, + ); + + const formattedStartDate = getTimezoneFormattedTime( + startTime, + formData.timezone, + !isEditMode, + DATE_FORMAT, + ); + + const ordinalFormat = getTimezoneFormattedTime( + startTime, + formData.timezone, + !isEditMode, + ORDINAL_FORMAT, + ); + + const formattedDaysOfWeek = daysOfWeek?.join(', '); + switch (recurrenceType) { + case 'daily': + return `Scheduled from ${formattedStartDate}, daily starting at ${formattedStartTime}.`; + case 'monthly': + return `Scheduled from ${formattedStartDate}, monthly on the ${ordinalFormat} starting at ${formattedStartTime}.`; + case 'weekly': + return `Scheduled from ${formattedStartDate}, weekly ${ + formattedDaysOfWeek ? `on [${formattedDaysOfWeek}]` : '' + } starting at ${formattedStartTime}`; + default: + return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`; + } + }, [formData, recurrenceType, isEditMode, timezoneInitialValue]); + + const endTimeText = useMemo((): string => { + let endTime = formData?.endTime; + if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) { + endTime = formData?.recurrence?.endTime || ''; + + if (!isEditMode && !endTime) { + endTime = formData?.endTime || ''; + } + } + + if (!endTime) { + return ''; + } + + if (formData.timezone) { + endTime = handleTimeConversion( + endTime, + timezoneInitialValue, + formData?.timezone, + !isEditMode, + ); + } + + const formattedEndTime = getTimezoneFormattedTime( + endTime, + formData.timezone, + !isEditMode, + TIME_FORMAT, + ); + + const formattedEndDate = getTimezoneFormattedTime( + endTime, + formData.timezone, + !isEditMode, + DATE_FORMAT, + ); + return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`; + }, [formData, recurrenceType, isEditMode, timezoneInitialValue]); + return ( { setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string); + setFormData(form.getFieldsValue()); }} autoComplete="off" > @@ -333,7 +452,7 @@ export function PlannedDowntimeForm( label="Starts from" name="startTime" rules={formValidationRules} - className="formItemWithBullet" + className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''} getValueProps={(value): any => ({ value: value ? dayjs(value).tz(timezoneInitialValue) : undefined, })} @@ -348,6 +467,9 @@ export function PlannedDowntimeForm( popupClassName="datePicker" /> + {!isEmpty(startTimeText) && ( +
{startTimeText}
+ )} ({ value: value ? dayjs(value).tz(timezoneInitialValue) : undefined, })} @@ -426,6 +548,9 @@ export function PlannedDowntimeForm( popupClassName="datePicker" /> + {!isEmpty(endTimeText) && ( +
{endTimeText}
+ )}
Silence Alerts diff --git a/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts b/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts index 7d0745dc5e..feba0cb13e 100644 --- a/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts +++ b/frontend/src/container/PlannedDowntime/PlannedDowntimeutils.ts @@ -262,7 +262,7 @@ export function formatWithTimezone( return `${parsedDate?.substring(0, 19)}${targetOffset}`; } -export function handleTimeConvertion( +export function handleTimeConversion( dateValue: string | dayjs.Dayjs, timezoneInit?: string, timezone?: string, diff --git a/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss b/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss index dbb7a962ef..7cac6794c5 100644 --- a/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss +++ b/frontend/src/container/QueryBuilder/QueryBuilder.styles.scss @@ -77,6 +77,12 @@ border: 1px solid rgba(242, 71, 105, 0.4); color: var(--bg-sakura-400); } + + &.sync-btn { + border: 1px solid rgba(78, 116, 248, 0.2); + background: rgba(78, 116, 248, 0.1); + color: var(--bg-robin-500); + } } &.formula-btn { diff --git a/frontend/src/container/QueryBuilder/QueryBuilder.tsx b/frontend/src/container/QueryBuilder/QueryBuilder.tsx index 844f9e3ab3..5726087e6d 100644 --- a/frontend/src/container/QueryBuilder/QueryBuilder.tsx +++ b/frontend/src/container/QueryBuilder/QueryBuilder.tsx @@ -1,17 +1,20 @@ import './QueryBuilder.styles.scss'; import { Button, Col, Divider, Row, Tooltip, Typography } from 'antd'; +import cx from 'classnames'; import { MAX_FORMULAS, MAX_QUERIES, OPERATORS, PANEL_TYPES, } from 'constants/queryBuilder'; +import ROUTES from 'constants/routes'; // ** Hooks import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { DatabaseZap, Sigma } from 'lucide-react'; // ** Constants import { memo, useEffect, useMemo, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; import { DataSource } from 'types/common/queryBuilder'; // ** Components @@ -35,6 +38,8 @@ export const QueryBuilder = memo(function QueryBuilder({ handleSetConfig, panelType, initialDataSource, + setLastUsedQuery, + lastUsedQuery, } = useQueryBuilder(); const containerRef = useRef(null); @@ -46,6 +51,10 @@ export const QueryBuilder = memo(function QueryBuilder({ [config], ); + const { pathname } = useLocation(); + + const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER; + useEffect(() => { if (currentDataSource !== initialDataSource || newPanelType !== panelType) { if (newPanelType === PANEL_TYPES.BAR) { @@ -212,6 +221,7 @@ export const QueryBuilder = memo(function QueryBuilder({
setLastUsedQuery(index)} className="query" id={`qb-query-${query.queryName}`} > @@ -265,10 +275,13 @@ export const QueryBuilder = memo(function QueryBuilder({ {!isListViewPanel && ( - {currentQuery.builder.queryData.map((query) => ( + {currentQuery.builder.queryData.map((query, index) => ( + + )}
+ + )} {showOldExplorerCTA && (
)} - {!hasSelectedTimeError && !refreshButtonHidden && ( + {!hasSelectedTimeError && !refreshButtonHidden && showRefreshText && ( 1) { removeQueryBuilderEntityByIndex('queryData', index); } - }, [removeQueryBuilderEntityByIndex, index, currentQuery]); + setLastUsedQuery(0); + }, [ + currentQuery.builder.queryData.length, + setLastUsedQuery, + removeQueryBuilderEntityByIndex, + index, + ]); const handleChangeQueryData: HandleChangeQueryData = useCallback( (key, value) => { diff --git a/frontend/src/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/pages/LogsExplorer/LogsExplorer.styles.scss b/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss index 95d53fe9a4..82d3f5bffc 100644 --- a/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss +++ b/frontend/src/pages/LogsExplorer/LogsExplorer.styles.scss @@ -1,11 +1,35 @@ -.log-explorer-query-container { - display: flex; - flex-direction: column; - flex: 1; - - .logs-explorer-views { - flex: 1; - display: flex; - flex-direction: column; - } -} \ No newline at end of file +.logs-module-page { + display: flex; + height: 100%; + .log-quick-filter-left-section { + width: 0%; + flex-shrink: 0; + } + + .log-module-right-section { + display: flex; + flex-direction: column; + width: 100%; + .log-explorer-query-container { + display: flex; + flex-direction: column; + flex: 1; + + .logs-explorer-views { + flex: 1; + display: flex; + flex-direction: column; + } + } + } + + &.filter-visible { + .log-quick-filter-left-section { + width: 260px; + } + + .log-module-right-section { + width: calc(100% - 260px); + } + } +} diff --git a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx index fab08d51a8..4970d6cf17 100644 --- a/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx +++ b/frontend/src/pages/LogsExplorer/__tests__/LogsExplorer.test.tsx @@ -189,6 +189,8 @@ describe('Logs Explorer Tests', () => { initialDataSource: null, panelType: PANEL_TYPES.TIME_SERIES, isEnabledQuery: false, + lastUsedQuery: 0, + setLastUsedQuery: noop, handleSetQueryData: noop, handleSetFormulaData: noop, handleSetQueryItemData: noop, diff --git a/frontend/src/pages/LogsExplorer/index.tsx b/frontend/src/pages/LogsExplorer/index.tsx index 8873d04e39..9e23b34c2c 100644 --- a/frontend/src/pages/LogsExplorer/index.tsx +++ b/frontend/src/pages/LogsExplorer/index.tsx @@ -1,25 +1,40 @@ import './LogsExplorer.styles.scss'; import * as Sentry from '@sentry/react'; +import getLocalStorageKey from 'api/browser/localstorage/get'; +import setLocalStorageApi from 'api/browser/localstorage/set'; +import cx from 'classnames'; import ExplorerCard from 'components/ExplorerCard/ExplorerCard'; +import QuickFilters from 'components/QuickFilters/QuickFilters'; +import { LOCALSTORAGE } from 'constants/localStorage'; import LogExplorerQuerySection from 'container/LogExplorerQuerySection'; import LogsExplorerViews from 'container/LogsExplorerViews'; import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions'; import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions'; import Toolbar from 'container/Toolbar/Toolbar'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; +import { isNull } from 'lodash-es'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import { useEffect, useMemo, useRef, useState } from 'react'; import { DataSource } from 'types/common/queryBuilder'; import { WrapperStyled } from './styles'; -import { SELECTED_VIEWS } from './utils'; +import { LogsQuickFiltersConfig, SELECTED_VIEWS } from './utils'; function LogsExplorer(): JSX.Element { const [showFrequencyChart, setShowFrequencyChart] = useState(true); const [selectedView, setSelectedView] = useState( SELECTED_VIEWS.SEARCH, ); + const [showFilters, setShowFilters] = useState(() => { + const localStorageValue = getLocalStorageKey( + LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, + ); + if (!isNull(localStorageValue)) { + return localStorageValue === 'true'; + } + return true; + }); const { handleRunQuery, currentQuery } = useQueryBuilder(); @@ -37,6 +52,14 @@ function LogsExplorer(): JSX.Element { setSelectedView(view); }; + const handleFilterVisibilityChange = (): void => { + setLocalStorageApi( + LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, + String(!showFilters), + ); + setShowFilters((prev) => !prev); + }; + // Switch to query builder view if there are more than 1 queries useEffect(() => { if (currentQuery.builder.queryData.length > 1) { @@ -90,46 +113,60 @@ function LogsExplorer(): JSX.Element { return ( }> - - } - rightActions={ - - } - showOldCTA - /> - - -
-
- - - -
-
- + {showFilters && ( +
+ -
-
-
+ + )} +
+ + } + rightActions={ + + } + showOldCTA + /> + + +
+
+ + + +
+
+ +
+
+
+
+
); } diff --git a/frontend/src/pages/LogsExplorer/utils.ts b/frontend/src/pages/LogsExplorer/utils.ts deleted file mode 100644 index 0fedaaece4..0000000000 --- a/frontend/src/pages/LogsExplorer/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Query } from 'types/api/queryBuilder/queryBuilderData'; - -export const prepareQueryWithDefaultTimestamp = (query: Query): Query => ({ - ...query, - builder: { - ...query.builder, - queryData: query.builder.queryData?.map((item) => ({ - ...item, - orderBy: [{ columnName: 'timestamp', order: 'desc' }], - })), - }, -}); - -// eslint-disable-next-line @typescript-eslint/naming-convention -export enum SELECTED_VIEWS { - SEARCH = 'search', - QUERY_BUILDER = 'query-builder', - CLICKHOUSE = 'clickhouse', -} diff --git a/frontend/src/pages/LogsExplorer/utils.tsx b/frontend/src/pages/LogsExplorer/utils.tsx new file mode 100644 index 0000000000..7a197bd467 --- /dev/null +++ b/frontend/src/pages/LogsExplorer/utils.tsx @@ -0,0 +1,113 @@ +import { + FiltersType, + IQuickFiltersConfig, +} from 'components/QuickFilters/QuickFilters'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { Query } from 'types/api/queryBuilder/queryBuilderData'; + +export const prepareQueryWithDefaultTimestamp = (query: Query): Query => ({ + ...query, + builder: { + ...query.builder, + queryData: query.builder.queryData?.map((item) => ({ + ...item, + orderBy: [{ columnName: 'timestamp', order: 'desc' }], + })), + }, +}); + +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum SELECTED_VIEWS { + SEARCH = 'search', + QUERY_BUILDER = 'query-builder', + CLICKHOUSE = 'clickhouse', +} + +export const LogsQuickFiltersConfig: IQuickFiltersConfig[] = [ + { + type: FiltersType.CHECKBOX, + title: 'Severity Text', + attributeKey: { + key: 'severity_text', + dataType: DataTypes.String, + type: '', + isColumn: true, + isJSON: false, + id: 'severity_text--string----true', + }, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Environment', + attributeKey: { + key: 'deployment.environment', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Service Name', + attributeKey: { + key: 'service.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: true, + isJSON: false, + id: 'service.name--string--resource--true', + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Hostname', + attributeKey: { + key: 'hostname', + dataType: DataTypes.String, + type: 'tag', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Cluster Name', + attributeKey: { + key: 'k8s.cluster.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Deployment Name', + attributeKey: { + key: 'k8s.deployment.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: false, + isJSON: false, + }, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'K8s Namespace Name', + attributeKey: { + key: 'k8s.namespace.name', + dataType: DataTypes.String, + type: 'resource', + isColumn: true, + isJSON: false, + }, + defaultOpen: false, + }, +]; diff --git a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx index a28776f0d0..4a3fa8018e 100644 --- a/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx +++ b/frontend/src/pages/TracesExplorer/__test__/TracesExplorer.test.tsx @@ -77,6 +77,14 @@ jest.mock( }, ); +window.ResizeObserver = + window.ResizeObserver || + jest.fn().mockImplementation(() => ({ + disconnect: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + })); + const successNotification = jest.fn(); jest.mock('hooks/useNotifications', () => ({ __esModule: true, diff --git a/frontend/src/pages/TracesExplorer/index.tsx b/frontend/src/pages/TracesExplorer/index.tsx index bb25a37f86..b865fd02bd 100644 --- a/frontend/src/pages/TracesExplorer/index.tsx +++ b/frontend/src/pages/TracesExplorer/index.tsx @@ -259,7 +259,7 @@ function TracesExplorer(): JSX.Element { )}
- +
diff --git a/frontend/src/pages/WorkspaceLocked/CustomerStoryCard.tsx b/frontend/src/pages/WorkspaceLocked/CustomerStoryCard.tsx new file mode 100644 index 0000000000..c22401f7c4 --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/CustomerStoryCard.tsx @@ -0,0 +1,35 @@ +import './customerStoryCard.styles.scss'; + +import { Avatar, Card, Space } from 'antd'; + +interface CustomerStoryCardProps { + avatar: string; + personName: string; + role: string; + message: string; + link: string; +} + +function CustomerStoryCard({ + avatar, + personName, + role, + message, + link, +}: CustomerStoryCardProps): JSX.Element { + return ( + + + + } + title={personName} + description={role} + /> + {message} + + + + ); +} +export default CustomerStoryCard; diff --git a/frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx b/frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx new file mode 100644 index 0000000000..90bec521d7 --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/InfoBlocks.tsx @@ -0,0 +1,30 @@ +import { Col, Row, Space, Typography } from 'antd'; + +interface InfoItem { + title: string; + description: string; + id: string; // Add a unique identifier +} + +interface InfoBlocksProps { + items: InfoItem[]; +} + +function InfoBlocks({ items }: InfoBlocksProps): JSX.Element { + return ( + + {items.map((item) => ( + +
+ {item.title} + + + {item.description} + + + ))} + + ); +} + +export default InfoBlocks; diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss index c35284241a..131601bfb0 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.styles.scss @@ -1,16 +1,161 @@ -.workspace-locked-container { - text-align: center; - padding: 48px; - margin: 24px; +$light-theme: 'lightMode'; + +@keyframes gradientFlow { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } } -.workpace-locked-details { - width: 50%; - margin: 0 auto; +.workspace-locked { + &__modal { + .ant-modal-mask { + backdrop-filter: blur(2px); + } + } + + &__tabs { + margin-top: 148px; + + .ant-tabs { + &-nav { + &::before { + border-color: var(--bg-slate-500); + + .#{$light-theme} & { + border-color: var(--bg-vanilla-300); + } + } + } + &-nav-wrap { + justify-content: center; + } + } + } + + &__modal { + &__header { + display: flex; + justify-content: space-between; + align-items: center; + + &__actions { + display: flex; + align-items: center; + gap: 16px; + } + } + .ant-modal-content { + border-radius: 4px; + border: 1px solid var(--bg-slate-400); + background: linear-gradient( + 139deg, + rgba(18, 19, 23, 0.8) 0%, + rgba(18, 19, 23, 0.9) 98.68% + ); + box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(20px); + + .#{$light-theme} & { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + } + } + + .ant-modal-header { + background: transparent; + } + + .ant-list { + &-item { + border-color: var(--bg-slate-500); + + .#{$light-theme} & { + border-color: var(--bg-vanilla-300); + } + + &-meta { + align-items: center !important; + + &-title { + margin-bottom: 0 !important; + } + + &-avatar { + display: flex; + } + } + } + } + &__title { + font-weight: 400; + color: var(--text-vanilla-400); + + .#{$light-theme} & { + color: var(--text-ink-200); + } + } + &__cta { + margin-top: 54px; + } + } + &__container { + padding-top: 64px; + } + &__details { + width: 80%; + margin: 0 auto; + color: var(--text-vanilla-400, #c0c1c3); + text-align: center; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; /* 150% */ + + .#{$light-theme} & { + color: var(--text-ink-200); + } + + &__highlight { + color: var(--text-vanilla-100, #fff); + font-style: normal; + font-weight: 700; + line-height: 24px; + + .#{$light-theme} & { + color: var(--text-ink-100); + } + } + } + &__title { + background: linear-gradient( + 99deg, + #ead8fd 0%, + #7a97fa 33%, + #fd5ab2 66%, + #ead8fd 100% + ); + background-size: 300% 300%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: gradientFlow 24s ease infinite; + margin-bottom: 18px; + } } .contact-us { margin-top: 48px; + color: var(--text-vanilla-400); + + .#{$light-theme} & { + color: var(--text-ink-200); + } } .cta { diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx index bc6885ae65..e459003665 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.test.tsx @@ -20,17 +20,17 @@ describe('WorkspaceLocked', () => { }); const workspaceLocked = await screen.findByRole('heading', { - name: /workspace locked/i, + name: /upgrade to continue/i, }); expect(workspaceLocked).toBeInTheDocument(); const gotQuestionText = await screen.findByText(/got question?/i); expect(gotQuestionText).toBeInTheDocument(); - const contactUsLink = await screen.findByRole('link', { - name: /contact us/i, + const contactUsBtn = await screen.findByRole('button', { + name: /Contact Us/i, }); - expect(contactUsLink).toBeInTheDocument(); + expect(contactUsBtn).toBeInTheDocument(); }); test('Render for Admin', async () => { @@ -42,11 +42,11 @@ describe('WorkspaceLocked', () => { render(); const contactAdminMessage = await screen.queryByText( - /please contact your administrator for further help/i, + /contact your admin to proceed with the upgrade./i, ); expect(contactAdminMessage).not.toBeInTheDocument(); const updateCreditCardBtn = await screen.findByRole('button', { - name: /update credit card/i, + name: /continue my journey/i, }); expect(updateCreditCardBtn).toBeInTheDocument(); }); @@ -60,12 +60,12 @@ describe('WorkspaceLocked', () => { render(, {}, 'VIEWER'); const updateCreditCardBtn = await screen.queryByRole('button', { - name: /update credit card/i, + name: /Continue My Journey/i, }); expect(updateCreditCardBtn).not.toBeInTheDocument(); const contactAdminMessage = await screen.findByText( - /please contact your administrator for further help/i, + /contact your admin to proceed with the upgrade./i, ); expect(contactAdminMessage).toBeInTheDocument(); }); diff --git a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx index 0cc3990af7..84d977ae81 100644 --- a/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx +++ b/frontend/src/pages/WorkspaceLocked/WorkspaceLocked.tsx @@ -1,21 +1,30 @@ /* eslint-disable react/no-unescaped-entities */ import './WorkspaceLocked.styles.scss'; +import type { TabsProps } from 'antd'; import { - CreditCardOutlined, - LockOutlined, - SendOutlined, -} from '@ant-design/icons'; -import { Button, Card, Skeleton, Typography } from 'antd'; + Alert, + Button, + Col, + Collapse, + Flex, + List, + Modal, + Row, + Skeleton, + Space, + Tabs, + Typography, +} from 'antd'; import updateCreditCardApi from 'api/billing/checkout'; import logEvent from 'api/common/logEvent'; -import { SOMETHING_WENT_WRONG } from 'constants/api'; import ROUTES from 'constants/routes'; -import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader'; import useLicense from 'hooks/useLicense'; import { useNotifications } from 'hooks/useNotifications'; import history from 'lib/history'; +import { CircleArrowRight } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useMutation } from 'react-query'; import { useSelector } from 'react-redux'; import { AppState } from 'store/reducers'; @@ -23,13 +32,22 @@ import { License } from 'types/api/licenses/def'; import AppReducer from 'types/reducer/app'; import { getFormattedDate } from 'utils/timeUtils'; +import CustomerStoryCard from './CustomerStoryCard'; +import InfoBlocks from './InfoBlocks'; +import { + customerStoriesData, + enterpriseGradeValuesData, + faqData, + infoData, +} from './workspaceLocked.data'; + export default function WorkspaceBlocked(): JSX.Element { const { role } = useSelector((state) => state.app); const isAdmin = role === 'ADMIN'; const [activeLicense, setActiveLicense] = useState(null); - const { notifications } = useNotifications(); + const { t } = useTranslation(['workspaceLocked']); const { isFetching: isFetchingLicenseData, isLoading: isLoadingLicenseData, @@ -67,7 +85,7 @@ export default function WorkspaceBlocked(): JSX.Element { }, onError: () => notifications.error({ - message: SOMETHING_WENT_WRONG, + message: t('somethingWentWrong'), }), }, ); @@ -87,73 +105,248 @@ export default function WorkspaceBlocked(): JSX.Element { logEvent('Workspace Blocked: User Clicked Extend Trial', {}); notifications.info({ - message: 'Extend Trial', + message: t('extendTrial'), + duration: 0, description: ( - If you have a specific reason why you were not able to finish your PoC in - the trial period, please write to us on - cloud-support@signoz.io - with the reason. Sometimes we can extend trial by a few days on a case by - case basis + {t('extendTrialMsgPart1')}{' '} + cloud-support@signoz.io{' '} + {t('extendTrialMsgPart2')} ), }); }; - return ( - <> - - - - {isLoadingLicenseData || !licensesData?.payload?.workSpaceBlock ? ( - - ) : ( - <> - - Workspace Locked - - You have been locked out of your workspace because your trial ended - without an upgrade to a paid plan. Your data will continue to be ingested - till{' '} - {getFormattedDate(licensesData?.payload?.gracePeriodEnd || Date.now())} , - at which point we will drop all the ingested data and terminate the - account. - {!isAdmin && 'Please contact your administrator for further help'} - - -
+ const renderCustomerStories = ( + filterCondition: (index: number) => boolean, + ): JSX.Element[] => + customerStoriesData + .filter((_, index) => filterCondition(index)) + .map((story) => ( + + )); + + const tabItems: TabsProps['items'] = [ + { + key: '1', + label: t('whyChooseSignoz'), + children: ( + +
+ + + + + + + + + {t('enterpriseGradeObservability')} + + {t('observabilityDescription')} + + ( + + } title={item.title} /> + + )} + /> + + {isAdmin && ( + + + + )} + + + + ), + }, + { + key: '2', + label: t('youAreInGoodCompany'), + children: ( + + {/* #FIXME: please suggest if there is any better way to loop in different columns to get the masonry layout */} + {renderCustomerStories((index) => index % 2 === 0)} + {renderCustomerStories((index) => index % 2 !== 0)} + {isAdmin && ( + + + + + )} + + ), + }, + // #TODO: comming soon + // { + // key: '3', + // label: 'Our Pricing', + // children: 'Our Pricing', + // }, + { + key: '4', + label: t('faqs'), + children: ( + + + + + {isAdmin && ( + )} + + + + ), + }, + ]; + return ( +
+ + + {t('trialPlanExpired')} + + + + Got Questions? + -
-
- Got Questions? - - Contact Us - -
- - )} - - + + + } + open + closable={false} + footer={null} + width="65%" + > +
+ {isLoadingLicenseData || !licensesData ? ( + + ) : ( + <> + +
+ + +
Upgrade to Continue
+
+ + {t('upgradeNow')} +
+ {t('yourDataIsSafe')}{' '} + + {getFormattedDate( + licensesData.payload?.gracePeriodEnd || Date.now(), + )} + {' '} + {t('actNow')} +
+
+ + + {!isAdmin && ( + + + + + + )} + {isAdmin && ( + + + + + + + + + )} + + + + + + )} + + + ); } diff --git a/frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss b/frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss new file mode 100644 index 0000000000..7abddada3a --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/customerStoryCard.styles.scss @@ -0,0 +1,33 @@ +$component-name: 'customer-story-card'; +$ant-card-override: 'ant-card'; +$light-theme: 'lightMode'; + +.#{$component-name} { + max-width: 385px; + margin: 0 auto; // Center the card within the column + margin-bottom: 24px; + border-radius: 6px; + transition: transform 0.3s ease, box-shadow 0.3s ease; + background-color: var(--bg-ink-400); + border: 1px solid var(--bg-ink-300); + + .#{$light-theme} & { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-100); + } + + .#{$ant-card-override}-meta-title { + margin-bottom: 2px !important; + } + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + background-color: var(--bg-ink-300); + + .#{$light-theme} & { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + background-color: var(--bg-vanilla-100); + } + } +} diff --git a/frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts b/frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts new file mode 100644 index 0000000000..0f4d07b96e --- /dev/null +++ b/frontend/src/pages/WorkspaceLocked/workspaceLocked.data.ts @@ -0,0 +1,156 @@ +export const infoData = [ + { + id: 'infoBlock-1', + title: 'Built for scale', + description: + 'Our powerful ingestion engine has a proven track record of handling 10TB+ data ingestion per day.', + }, + { + id: 'infoBlock-2', + title: 'Trusted across the globe', + description: + 'Used by teams in all 5 continents ⎯ across the mountains, rivers, and the high seas.', + }, + { + id: 'infoBlock-3', + title: 'Powering observability for teams of all sizes', + description: + 'Hundreds of companies ⎯from early-stage start-ups to public enterprises use SigNoz to build more reliable products.', + }, +]; + +export const enterpriseGradeValuesData = [ + { + title: 'SSO and SAML support', + }, + { + title: 'Query API keys', + }, + { + title: 'Advanced security with SOC 2 Type I certification', + }, + { + title: 'AWS Private Link', + }, + { + title: 'VPC peering', + }, + { + title: 'Custom integrations', + }, +]; + +export const customerStoriesData = [ + { + key: 'c-story-1', + avatar: 'https://signoz.io/img/users/subomi-oluwalana.webp', + personName: 'Subomi Oluwalana', + role: 'Founder & CEO at Convoy', + customerName: 'Convoy', + message: + "We use OTel with SigNoz to spot redundant database connect calls. For example, we found that our database driver wasn't using the connection pool even though the documentation claimed otherwise.", + link: + 'https://www.linkedin.com/feed/update/urn:li:activity:7212117589068591105/', + }, + { + key: 'c-story-2', + avatar: 'https://signoz.io/img/users/dhruv-garg.webp', + personName: 'Dhruv Garg', + role: 'Tech Lead at Nudge', + customerName: 'Nudge', + message: + 'SigNoz is one of the best observability tools you can self-host hands down. And they are always there to help on their slack channel when needed.', + link: + 'https://www.linkedin.com/posts/dhruv-garg79_signoz-docker-kubernetes-activity-7205163679028240384-Otlb/', + }, + { + key: 'c-story-3', + avatar: 'https://signoz.io/img/users/vivek-bhakta.webp', + personName: 'Vivek Bhakta', + role: 'CTO at Wombo AI', + customerName: 'Wombo AI', + message: + 'We use SigNoz and have been loving it - can definitely handle scale.', + link: 'https://x.com/notorious_VB/status/1701773119696904242', + }, + { + key: 'c-story-4', + avatar: 'https://signoz.io/img/users/pranay-narang.webp', + personName: 'Pranay Narang', + role: 'Engineering at Azodha', + customerName: 'Azodha', + message: + 'Recently moved metrics and logging to SigNoz. Gotta say, absolutely loving the tool.', + link: 'https://x.com/PranayNarang/status/1676247073396752387', + }, + { + key: 'c-story-4', + avatar: 'https://signoz.io/img/users/shey.webp', + personName: 'Sheheryar Sewani', + role: 'Seasoned Rails Dev & Founder', + customerName: '', + message: + "But wow, I'm glad I tried SigNoz. Setting up SigNoz was easy—they provide super helpful instructions along with a docker-compose file.", + link: + 'https://www.linkedin.com/feed/update/urn:li:activity:7181011853915926528/', + }, + { + key: 'c-story-5', + avatar: 'https://signoz.io/img/users/daniel.webp', + personName: 'Daniel Schell', + role: 'Founder & CTO at Airlockdigital', + customerName: 'Airlockdigital', + message: + 'Have been deep diving Signoz. Seems like the new hotness for an "all-in-one".', + link: 'https://x.com/danonit/status/1749256583157284919', + }, + { + key: 'c-story-6', + avatar: 'https://signoz.io/img/users/go-frendi.webp', + personName: 'Go Frendi Gunawan', + role: 'Data Engineer at Ctlyst.id', + customerName: 'Ctlyst.id', + message: + 'Monitoring done. Thanks to SigNoz, I don’t have to deal with Grafana, Loki, Prometheus, and Jaeger separately.', + link: 'https://x.com/gofrendiasgard/status/1680139003658641408', + }, + { + key: 'c-story-7', + avatar: 'https://signoz.io/img/users/anselm.jpg', + personName: 'Anselm Eickhoff', + role: 'Software Architect', + customerName: '', + message: + 'NewRelic: receiving OpenTelemetry at all takes me 1/2 day to grok, docs are a mess. Traces show up after 5min. I burn the free 100GB/mo in 1 day of light testing. @SignozHQ: can run it locally (∞GB), has a special tutorial for OpenTelemetry + Rust! Traces show up immediately.', + link: + 'https://twitter.com/ae_play/status/1572993932094472195?s=20&t=LWWrW5EP_k5q6_mwbFN4jQ', + }, +]; + +export const faqData = [ + { + key: '1', + label: + 'What is the difference between SigNoz Cloud(Teams) and Community Edition?', + children: + 'You can self-host and manage the community edition yourself. You should choose SigNoz Cloud if you don’t want to worry about managing the SigNoz cluster. There are some exclusive features like SSO & SAML support, which come with SigNoz cloud offering. Our team also offers support on the initial configuration of dashboards & alerts and advises on best practices for setting up your observability stack in the SigNoz cloud offering.', + }, + { + key: '2', + label: 'How are number of samples calculated for metrics pricing?', + children: + "If a timeseries sends data every 30s, then it will generate 2 samples per min. So, if you have 10,000 time series sending data every 30s then you will be sending 20,000 samples per min to SigNoz. This will be around 864 mn samples per month and would cost 86.4 USD/month. Here's an explainer video on how metrics pricing is calculated - Link: https://vimeo.com/973012522", + }, + { + key: '3', + label: 'Do you offer enterprise support plans?', + children: + 'Yes, feel free to reach out to us on hello@signoz.io if you need a dedicated support plan or paid support for setting up your initial SigNoz setup.', + }, + { + key: '4', + label: 'Who should use Enterprise plans?', + children: + 'Teams which need enterprise support or features like SSO, Audit logs, etc. may find our enterprise plans valuable.', + }, +]; 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/providers/QueryBuilder.tsx b/frontend/src/providers/QueryBuilder.tsx index c3b50bbc7e..305372eea6 100644 --- a/frontend/src/providers/QueryBuilder.tsx +++ b/frontend/src/providers/QueryBuilder.tsx @@ -62,6 +62,8 @@ import { v4 as uuid } from 'uuid'; export const QueryBuilderContext = createContext({ currentQuery: initialQueriesMap.metrics, supersetQuery: initialQueriesMap.metrics, + lastUsedQuery: null, + setLastUsedQuery: () => {}, setSupersetQuery: () => {}, stagedQuery: initialQueriesMap.metrics, initialDataSource: null, @@ -117,6 +119,7 @@ export function QueryBuilderProvider({ const [currentQuery, setCurrentQuery] = useState(queryState); const [supersetQuery, setSupersetQuery] = useState(queryState); + const [lastUsedQuery, setLastUsedQuery] = useState(0); const [stagedQuery, setStagedQuery] = useState(null); const [queryType, setQueryType] = useState(queryTypeParam); @@ -230,6 +233,8 @@ export function QueryBuilderProvider({ timeUpdated ? merge(currentQuery, newQueryState) : newQueryState, ); setQueryType(type); + // this is required to reset the last used query when navigating or initializing the query builder + setLastUsedQuery(0); }, [prepareQueryBuilderData, currentQuery], ); @@ -857,6 +862,8 @@ export function QueryBuilderProvider({ () => ({ currentQuery: query, supersetQuery: superQuery, + lastUsedQuery, + setLastUsedQuery, setSupersetQuery, stagedQuery, initialDataSource, @@ -884,6 +891,7 @@ export function QueryBuilderProvider({ [ query, superQuery, + lastUsedQuery, stagedQuery, initialDataSource, panelType, diff --git a/frontend/src/types/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/types/common/queryBuilder.ts b/frontend/src/types/common/queryBuilder.ts index 4a67619a61..fd3b4c0530 100644 --- a/frontend/src/types/common/queryBuilder.ts +++ b/frontend/src/types/common/queryBuilder.ts @@ -189,6 +189,8 @@ export type QueryBuilderData = { export type QueryBuilderContextType = { currentQuery: Query; stagedQuery: Query | null; + lastUsedQuery: number | null; + setLastUsedQuery: Dispatch>; supersetQuery: Query; setSupersetQuery: Dispatch>; initialDataSource: DataSource | null; 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); +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index d12754da95..2ef8b540e0 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5626,12 +5626,12 @@ axe-core@^4.6.2: resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz" integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ== -axios@1.6.4: - version "1.6.4" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.4.tgz#184ee1f63d412caffcf30d2c50982253c3ee86e0" - integrity sha512-heJnIs6N4aa1eSthhN9M5ioILu8Wi8vmQW9iHQ9NUvfkJb0lEEDUiIdQNAuBtfUt3FxReaKdpQA5DbmMOqzF/A== +axios@1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.4.tgz#4c8ded1b43683c8dd362973c393f3ede24052aa2" + integrity sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw== dependencies: - follow-redirects "^1.15.4" + follow-redirects "^1.15.6" form-data "^4.0.0" proxy-from-env "^1.1.0" @@ -8925,7 +8925,7 @@ flubber@^0.4.2: svgpath "^2.2.1" topojson-client "^3.0.0" -follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.4, follow-redirects@^1.15.6: +follow-redirects@^1.0.0, follow-redirects@^1.14.0, follow-redirects@^1.15.6: version "1.15.6" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== @@ -13715,10 +13715,10 @@ postcss@8.4.38, postcss@^8.0.0, postcss@^8.1.1, postcss@^8.3.7, postcss@^8.4.21, picocolors "^1.0.0" source-map-js "^1.2.0" -posthog-js@1.142.1: - version "1.142.1" - resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.142.1.tgz#3b91229732938c5c76b5ee6d410698a267e073e9" - integrity sha512-yqeWTWitlb0sCaH5v6s7UJ+pPspzf/lkzPaSE5pMMXRM2i2KNsMoZEAZqbPCW8fQ8QL6lHs6d8PLjHrvbR288w== +posthog-js@1.160.3: + version "1.160.3" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.160.3.tgz#17c8af4c9ffa2d795d925ca1e7146e61cd5ccabd" + integrity sha512-mGvxOIlWPtdPx8EI0MQ81wNKlnH2K0n4RqwQOl044b34BCKiFVzZ7Hc7geMuZNaRAvCi5/5zyGeWHcAYZQxiMQ== dependencies: fflate "^0.4.8" preact "^10.19.3" diff --git a/pkg/query-service/dao/sqlite/connection.go b/pkg/query-service/dao/sqlite/connection.go index d7e5ad5de9..a4373d5ecd 100644 --- a/pkg/query-service/dao/sqlite/connection.go +++ b/pkg/query-service/dao/sqlite/connection.go @@ -103,6 +103,9 @@ func InitDB(dataSourceName string) (*ModelDaoSqlite, error) { return nil, err } + telemetry.GetInstance().SetUserCountCallback(mds.GetUserCount) + telemetry.GetInstance().SetUserRoleCallback(mds.GetUserRole) + return mds, nil } @@ -140,7 +143,6 @@ func (mds *ModelDaoSqlite) initializeOrgPreferences(ctx context.Context) error { users, _ := mds.GetUsers(ctx) countUsers := len(users) - telemetry.GetInstance().SetCountUsers(int8(countUsers)) if countUsers > 0 { telemetry.GetInstance().SetCompanyDomain(users[countUsers-1].Email) telemetry.GetInstance().SetUserEmail(users[countUsers-1].Email) diff --git a/pkg/query-service/dao/sqlite/rbac.go b/pkg/query-service/dao/sqlite/rbac.go index aba9beb065..bb594ac463 100644 --- a/pkg/query-service/dao/sqlite/rbac.go +++ b/pkg/query-service/dao/sqlite/rbac.go @@ -612,3 +612,19 @@ func (mds *ModelDaoSqlite) PrecheckLogin(ctx context.Context, email, sourceUrl s return resp, nil } + +func (mds *ModelDaoSqlite) GetUserRole(ctx context.Context, groupId string) (string, error) { + role, err := mds.GetGroup(ctx, groupId) + if err != nil || role == nil { + return "", err + } + return role.Name, nil +} + +func (mds *ModelDaoSqlite) GetUserCount(ctx context.Context) (int, error) { + users, err := mds.GetUsers(ctx) + if err != nil { + return 0, err + } + return len(users), nil +} diff --git a/pkg/query-service/rules/manager.go b/pkg/query-service/rules/manager.go index c21873f230..768c753cb8 100644 --- a/pkg/query-service/rules/manager.go +++ b/pkg/query-service/rules/manager.go @@ -12,8 +12,6 @@ import ( "github.com/google/uuid" - "github.com/go-kit/log" - "go.uber.org/zap" "errors" @@ -27,6 +25,17 @@ import ( "go.signoz.io/signoz/pkg/query-service/utils/labels" ) +type PrepareTaskOptions struct { + Rule *PostableRule + TaskName string + RuleDB RuleDB + Logger *zap.Logger + Reader interfaces.Reader + FF interfaces.FeatureLookup + ManagerOpts *ManagerOptions + NotifyFunc NotifyFunc +} + const taskNamesuffix = "webAppEditor" func ruleIdFromTaskName(n string) string { @@ -56,13 +65,15 @@ type ManagerOptions struct { DBConn *sqlx.DB Context context.Context - Logger log.Logger + Logger *zap.Logger ResendDelay time.Duration DisableRules bool FeatureFlags interfaces.FeatureLookup Reader interfaces.Reader EvalDelay time.Duration + + PrepareTaskFunc func(opts PrepareTaskOptions) (Task, error) } // The Manager manages recording and alerting rules. @@ -78,10 +89,12 @@ type Manager struct { // datastore to store alert definitions ruleDB RuleDB - logger log.Logger + logger *zap.Logger featureFlags interfaces.FeatureLookup reader interfaces.Reader + + prepareTaskFunc func(opts PrepareTaskOptions) (Task, error) } func defaultOptions(o *ManagerOptions) *ManagerOptions { @@ -94,9 +107,69 @@ func defaultOptions(o *ManagerOptions) *ManagerOptions { if o.ResendDelay == time.Duration(0) { o.ResendDelay = 1 * time.Minute } + if o.Logger == nil { + o.Logger = zap.L() + } + if o.PrepareTaskFunc == nil { + o.PrepareTaskFunc = defaultPrepareTaskFunc + } return o } +func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) { + + rules := make([]Rule, 0) + var task Task + + ruleId := ruleIdFromTaskName(opts.TaskName) + if opts.Rule.RuleType == RuleTypeThreshold { + // create a threshold rule + tr, err := NewThresholdRule( + ruleId, + opts.Rule, + ThresholdRuleOpts{ + EvalDelay: opts.ManagerOpts.EvalDelay, + }, + opts.FF, + opts.Reader, + ) + + if err != nil { + return task, err + } + + rules = append(rules, tr) + + // create ch rule task for evalution + task = newTask(TaskTypeCh, opts.TaskName, taskNamesuffix, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB) + + } else if opts.Rule.RuleType == RuleTypeProm { + + // create promql rule + pr, err := NewPromRule( + ruleId, + opts.Rule, + opts.Logger, + PromRuleOpts{}, + opts.Reader, + ) + + if err != nil { + return task, err + } + + rules = append(rules, pr) + + // create promql rule task for evalution + task = newTask(TaskTypeProm, opts.TaskName, taskNamesuffix, time.Duration(opts.Rule.Frequency), rules, opts.ManagerOpts, opts.NotifyFunc, opts.RuleDB) + + } else { + return nil, fmt.Errorf("unsupported rule type. Supported types: %s, %s", RuleTypeProm, RuleTypeThreshold) + } + + return task, nil +} + // NewManager returns an implementation of Manager, ready to be started // by calling the Run method. func NewManager(o *ManagerOptions) (*Manager, error) { @@ -116,15 +189,16 @@ func NewManager(o *ManagerOptions) (*Manager, error) { telemetry.GetInstance().SetAlertsInfoCallback(db.GetAlertsInfo) m := &Manager{ - tasks: map[string]Task{}, - rules: map[string]Rule{}, - notifier: notifier, - ruleDB: db, - opts: o, - block: make(chan struct{}), - logger: o.Logger, - featureFlags: o.FeatureFlags, - reader: o.Reader, + tasks: map[string]Task{}, + rules: map[string]Rule{}, + notifier: notifier, + ruleDB: db, + opts: o, + block: make(chan struct{}), + logger: o.Logger, + featureFlags: o.FeatureFlags, + reader: o.Reader, + prepareTaskFunc: o.PrepareTaskFunc, } return m, nil } @@ -251,13 +325,26 @@ func (m *Manager) editTask(rule *PostableRule, taskName string) error { zap.L().Debug("editing a rule task", zap.String("name", taskName)) - newTask, err := m.prepareTask(false, rule, taskName) + newTask, err := m.prepareTaskFunc(PrepareTaskOptions{ + Rule: rule, + TaskName: taskName, + RuleDB: m.ruleDB, + Logger: m.logger, + Reader: m.reader, + FF: m.featureFlags, + ManagerOpts: m.opts, + NotifyFunc: m.prepareNotifyFunc(), + }) if err != nil { zap.L().Error("loading tasks failed", zap.Error(err)) return errors.New("error preparing rule with given parameters, previous rule set restored") } + for _, r := range newTask.Rules() { + m.rules[r.ID()] = r + } + // If there is an old task with the same identifier, stop it and wait for // it to finish the current iteration. Then copy it into the new group. oldTask, ok := m.tasks[taskName] @@ -357,7 +444,20 @@ func (m *Manager) addTask(rule *PostableRule, taskName string) error { defer m.mtx.Unlock() zap.L().Debug("adding a new rule task", zap.String("name", taskName)) - newTask, err := m.prepareTask(false, rule, taskName) + newTask, err := m.prepareTaskFunc(PrepareTaskOptions{ + Rule: rule, + TaskName: taskName, + RuleDB: m.ruleDB, + Logger: m.logger, + Reader: m.reader, + FF: m.featureFlags, + ManagerOpts: m.opts, + NotifyFunc: m.prepareNotifyFunc(), + }) + + for _, r := range newTask.Rules() { + m.rules[r.ID()] = r + } if err != nil { zap.L().Error("creating rule task failed", zap.String("name", taskName), zap.Error(err)) @@ -382,77 +482,6 @@ func (m *Manager) addTask(rule *PostableRule, taskName string) error { return nil } -// prepareTask prepares a rule task from postable rule -func (m *Manager) prepareTask(acquireLock bool, r *PostableRule, taskName string) (Task, error) { - - if acquireLock { - m.mtx.Lock() - defer m.mtx.Unlock() - } - - rules := make([]Rule, 0) - var task Task - - if r.AlertName == "" { - zap.L().Error("task load failed, at least one rule must be set", zap.String("name", taskName)) - return task, fmt.Errorf("task load failed, at least one rule must be set") - } - - ruleId := ruleIdFromTaskName(taskName) - if r.RuleType == RuleTypeThreshold { - // create a threshold rule - tr, err := NewThresholdRule( - ruleId, - r, - ThresholdRuleOpts{ - EvalDelay: m.opts.EvalDelay, - }, - m.featureFlags, - m.reader, - ) - - if err != nil { - return task, err - } - - rules = append(rules, tr) - - // create ch rule task for evalution - task = newTask(TaskTypeCh, taskName, taskNamesuffix, time.Duration(r.Frequency), rules, m.opts, m.prepareNotifyFunc(), m.ruleDB) - - // add rule to memory - m.rules[ruleId] = tr - - } else if r.RuleType == RuleTypeProm { - - // create promql rule - pr, err := NewPromRule( - ruleId, - r, - log.With(m.logger, "alert", r.AlertName), - PromRuleOpts{}, - m.reader, - ) - - if err != nil { - return task, err - } - - rules = append(rules, pr) - - // create promql rule task for evalution - task = newTask(TaskTypeProm, taskName, taskNamesuffix, time.Duration(r.Frequency), rules, m.opts, m.prepareNotifyFunc(), m.ruleDB) - - // add rule to memory - m.rules[ruleId] = pr - - } else { - return nil, fmt.Errorf("unsupported rule type. Supported types: %s, %s", RuleTypeProm, RuleTypeThreshold) - } - - return task, nil -} - // RuleTasks returns the list of manager's rule tasks. func (m *Manager) RuleTasks() []Task { m.mtx.RLock() @@ -783,7 +812,7 @@ func (m *Manager) TestNotification(ctx context.Context, ruleStr string) (int, *m rule, err = NewPromRule( alertname, parsedRule, - log.With(m.logger, "alert", alertname), + m.logger, PromRuleOpts{ SendAlways: true, }, diff --git a/pkg/query-service/rules/prom_rule.go b/pkg/query-service/rules/prom_rule.go index 06f9ae311d..a9890a9503 100644 --- a/pkg/query-service/rules/prom_rule.go +++ b/pkg/query-service/rules/prom_rule.go @@ -8,8 +8,6 @@ import ( "sync" "time" - "github.com/go-kit/log" - "github.com/go-kit/log/level" "go.uber.org/zap" plabels "github.com/prometheus/prometheus/model/labels" @@ -54,7 +52,7 @@ type PromRule struct { // map of active alerts active map[uint64]*Alert - logger log.Logger + logger *zap.Logger opts PromRuleOpts reader interfaces.Reader @@ -63,7 +61,7 @@ type PromRule struct { func NewPromRule( id string, postableRule *PostableRule, - logger log.Logger, + logger *zap.Logger, opts PromRuleOpts, reader interfaces.Reader, ) (*PromRule, error) { @@ -405,7 +403,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time, queriers *Queriers) ( result, err := tmpl.Expand() if err != nil { result = fmt.Sprintf("", err) - level.Warn(r.logger).Log("msg", "Expanding alert template failed", "err", err, "data", tmplData) + r.logger.Warn("Expanding alert template failed", zap.Error(err), zap.Any("data", tmplData)) } return result } @@ -593,6 +591,16 @@ func (r *PromRule) shouldAlert(series pql.Series) (pql.Sample, bool) { break } } + // use min value from the series + if shouldAlert { + var minValue float64 = math.Inf(1) + for _, smpl := range series.Floats { + if smpl.F < minValue { + minValue = smpl.F + } + } + alertSmpl = pql.Sample{F: minValue, Metric: series.Metric} + } } else if r.compareOp() == ValueIsBelow { for _, smpl := range series.Floats { if smpl.F >= r.targetVal() { @@ -600,6 +608,15 @@ func (r *PromRule) shouldAlert(series pql.Series) (pql.Sample, bool) { break } } + if shouldAlert { + var maxValue float64 = math.Inf(-1) + for _, smpl := range series.Floats { + if smpl.F > maxValue { + maxValue = smpl.F + } + } + alertSmpl = pql.Sample{F: maxValue, Metric: series.Metric} + } } else if r.compareOp() == ValueIsEq { for _, smpl := range series.Floats { if smpl.F != r.targetVal() { @@ -614,6 +631,14 @@ func (r *PromRule) shouldAlert(series pql.Series) (pql.Sample, bool) { break } } + if shouldAlert { + for _, smpl := range series.Floats { + if !math.IsInf(smpl.F, 0) && !math.IsNaN(smpl.F) { + alertSmpl = pql.Sample{F: smpl.F, Metric: series.Metric} + break + } + } + } } case OnAverage: // If the average of all samples matches the condition, the rule is firing. diff --git a/pkg/query-service/rules/prom_rule_task.go b/pkg/query-service/rules/prom_rule_task.go index 13c24ca1fa..f2f11cd494 100644 --- a/pkg/query-service/rules/prom_rule_task.go +++ b/pkg/query-service/rules/prom_rule_task.go @@ -7,7 +7,6 @@ import ( "sync" "time" - "github.com/go-kit/log" opentracing "github.com/opentracing/opentracing-go" plabels "github.com/prometheus/prometheus/model/labels" "go.signoz.io/signoz/pkg/query-service/common" @@ -33,7 +32,7 @@ type PromRuleTask struct { terminated chan struct{} pause bool - logger log.Logger + logger *zap.Logger notify NotifyFunc ruleDB RuleDB @@ -60,7 +59,7 @@ func newPromRuleTask(name, file string, frequency time.Duration, rules []Rule, o terminated: make(chan struct{}), notify: notify, ruleDB: ruleDB, - logger: log.With(opts.Logger, "group", name), + logger: opts.Logger, } } diff --git a/pkg/query-service/rules/promrule_test.go b/pkg/query-service/rules/promrule_test.go index a06b510f2e..fef7630bbd 100644 --- a/pkg/query-service/rules/promrule_test.go +++ b/pkg/query-service/rules/promrule_test.go @@ -7,17 +7,9 @@ import ( pql "github.com/prometheus/prometheus/promql" "github.com/stretchr/testify/assert" v3 "go.signoz.io/signoz/pkg/query-service/model/v3" + "go.uber.org/zap" ) -type testLogger struct { - t *testing.T -} - -func (l testLogger) Log(args ...interface{}) error { - l.t.Log(args...) - return nil -} - func TestPromRuleShouldAlert(t *testing.T) { postableRule := PostableRule{ AlertName: "Test Rule", @@ -38,11 +30,12 @@ func TestPromRuleShouldAlert(t *testing.T) { } cases := []struct { - values pql.Series - expectAlert bool - compareOp string - matchType string - target float64 + values pql.Series + expectAlert bool + compareOp string + matchType string + target float64 + expectedAlertSample v3.Point }{ // Test cases for Equals Always { @@ -55,10 +48,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -116,10 +110,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -131,10 +126,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -146,10 +142,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: pql.Series{ @@ -177,10 +174,43 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "2", // Always - target: 1.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "2", // Always + target: 1.5, + expectedAlertSample: v3.Point{Value: 2.0}, + }, + { + values: pql.Series{ + Floats: []pql.FPoint{ + {F: 11.0}, + {F: 4.0}, + {F: 3.0}, + {F: 7.0}, + {F: 12.0}, + }, + }, + expectAlert: true, + compareOp: "1", // Above + matchType: "2", // Always + target: 2.0, + expectedAlertSample: v3.Point{Value: 3.0}, + }, + { + values: pql.Series{ + Floats: []pql.FPoint{ + {F: 11.0}, + {F: 4.0}, + {F: 3.0}, + {F: 7.0}, + {F: 12.0}, + }, + }, + expectAlert: true, + compareOp: "2", // Below + matchType: "2", // Always + target: 13.0, + expectedAlertSample: v3.Point{Value: 12.0}, }, { values: pql.Series{ @@ -208,10 +238,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "1", // Once - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "1", // Once + target: 4.5, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: pql.Series{ @@ -269,10 +300,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: pql.Series{ @@ -300,10 +332,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 0.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: pql.Series{ @@ -330,10 +363,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: pql.Series{ @@ -345,10 +379,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, // Test cases for Less Than Always { @@ -361,10 +396,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 1.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "2", // Always - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "2", // Always + target: 4, + expectedAlertSample: v3.Point{Value: 1.5}, }, { values: pql.Series{ @@ -392,10 +428,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "1", // Once - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "1", // Once + target: 4, + expectedAlertSample: v3.Point{Value: 2.5}, }, { values: pql.Series{ @@ -423,10 +460,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "3", // OnAverage - target: 6.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "3", // OnAverage + target: 6.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: pql.Series{ @@ -453,10 +491,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: pql.Series{ @@ -483,10 +522,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: pql.Series{ @@ -498,10 +538,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "3", // OnAverage - target: 12.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "3", // OnAverage + target: 12.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, // Test cases for InTotal { @@ -514,10 +555,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 30.0}, }, { values: pql.Series{ @@ -540,10 +582,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 10.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "4", // InTotal - target: 9.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "4", // InTotal + target: 9.0, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: pql.Series{ @@ -563,10 +606,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 10.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "4", // InTotal - target: 10.0, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "4", // InTotal + target: 10.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: pql.Series{ @@ -587,10 +631,11 @@ func TestPromRuleShouldAlert(t *testing.T) { {F: 10.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: pql.Series{ @@ -611,7 +656,7 @@ func TestPromRuleShouldAlert(t *testing.T) { postableRule.RuleCondition.MatchType = MatchType(c.matchType) postableRule.RuleCondition.Target = &c.target - rule, err := NewPromRule("69", &postableRule, testLogger{t}, PromRuleOpts{}, nil) + rule, err := NewPromRule("69", &postableRule, zap.NewNop(), PromRuleOpts{}, nil) if err != nil { assert.NoError(t, err) } diff --git a/pkg/query-service/rules/threshold_rule.go b/pkg/query-service/rules/threshold_rule.go index 9bdecbc63d..e657af9288 100644 --- a/pkg/query-service/rules/threshold_rule.go +++ b/pkg/query-service/rules/threshold_rule.go @@ -1205,6 +1205,16 @@ func (r *ThresholdRule) shouldAlert(series v3.Series) (Sample, bool) { break } } + // use min value from the series + if shouldAlert { + var minValue float64 = math.Inf(1) + for _, smpl := range series.Points { + if smpl.Value < minValue { + minValue = smpl.Value + } + } + alertSmpl = Sample{Point: Point{V: minValue}, Metric: lblsNormalized, MetricOrig: lbls} + } } else if r.compareOp() == ValueIsBelow { for _, smpl := range series.Points { if smpl.Value >= r.targetVal() { @@ -1212,6 +1222,15 @@ func (r *ThresholdRule) shouldAlert(series v3.Series) (Sample, bool) { break } } + if shouldAlert { + var maxValue float64 = math.Inf(-1) + for _, smpl := range series.Points { + if smpl.Value > maxValue { + maxValue = smpl.Value + } + } + alertSmpl = Sample{Point: Point{V: maxValue}, Metric: lblsNormalized, MetricOrig: lbls} + } } else if r.compareOp() == ValueIsEq { for _, smpl := range series.Points { if smpl.Value != r.targetVal() { @@ -1226,6 +1245,15 @@ func (r *ThresholdRule) shouldAlert(series v3.Series) (Sample, bool) { break } } + // use any non-inf or nan value from the series + if shouldAlert { + for _, smpl := range series.Points { + if !math.IsInf(smpl.Value, 0) && !math.IsNaN(smpl.Value) { + alertSmpl = Sample{Point: Point{V: smpl.Value}, Metric: lblsNormalized, MetricOrig: lbls} + break + } + } + } } case OnAverage: // If the average of all samples matches the condition, the rule is firing. diff --git a/pkg/query-service/rules/threshold_rule_test.go b/pkg/query-service/rules/threshold_rule_test.go index 05bd613900..6cfeac83d9 100644 --- a/pkg/query-service/rules/threshold_rule_test.go +++ b/pkg/query-service/rules/threshold_rule_test.go @@ -42,11 +42,12 @@ func TestThresholdRuleShouldAlert(t *testing.T) { } cases := []struct { - values v3.Series - expectAlert bool - compareOp string - matchType string - target float64 + values v3.Series + expectAlert bool + compareOp string + matchType string + target float64 + expectedAlertSample v3.Point }{ // Test cases for Equals Always { @@ -59,10 +60,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -120,10 +122,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 0.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -135,10 +138,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -150,10 +154,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 0.0}, }, { values: v3.Series{ @@ -181,10 +186,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "2", // Always - target: 1.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "2", // Always + target: 1.5, + expectedAlertSample: v3.Point{Value: 2.0}, }, { values: v3.Series{ @@ -212,10 +218,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "1", // Once - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "1", // Once + target: 4.5, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: v3.Series{ @@ -273,10 +280,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "2", // Always - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "2", // Always + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: v3.Series{ @@ -304,10 +312,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 0.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: v3.Series{ @@ -334,10 +343,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, { values: v3.Series{ @@ -349,10 +359,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "1", // Once - target: 0.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "1", // Once + target: 0.0, + expectedAlertSample: v3.Point{Value: 1.0}, }, // Test cases for Less Than Always { @@ -365,10 +376,27 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 1.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "2", // Always - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "2", // Always + target: 4, + expectedAlertSample: v3.Point{Value: 1.5}, + }, + { + values: v3.Series{ + Points: []v3.Point{ + {Value: 1.5}, + {Value: 2.5}, + {Value: 1.5}, + {Value: 3.5}, + {Value: 1.5}, + }, + }, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "2", // Always + target: 4, + expectedAlertSample: v3.Point{Value: 3.5}, }, { values: v3.Series{ @@ -396,10 +424,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.5}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "1", // Once - target: 4, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "1", // Once + target: 4, + expectedAlertSample: v3.Point{Value: 2.5}, }, { values: v3.Series{ @@ -427,10 +456,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "3", // OnAverage - target: 6.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "3", // OnAverage + target: 6.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: v3.Series{ @@ -457,10 +487,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, }, { values: v3.Series{ @@ -487,10 +518,43 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "3", // OnAverage - target: 4.5, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "3", // OnAverage + target: 4.5, + expectedAlertSample: v3.Point{Value: 6.0}, + }, + { + values: v3.Series{ + Points: []v3.Point{ + {Value: 11.0}, + {Value: 4.0}, + {Value: 3.0}, + {Value: 7.0}, + {Value: 12.0}, + }, + }, + expectAlert: true, + compareOp: "1", // Above + matchType: "2", // Always + target: 2.0, + expectedAlertSample: v3.Point{Value: 3.0}, + }, + { + values: v3.Series{ + Points: []v3.Point{ + {Value: 11.0}, + {Value: 4.0}, + {Value: 3.0}, + {Value: 7.0}, + {Value: 12.0}, + }, + }, + expectAlert: true, + compareOp: "2", // Below + matchType: "2", // Always + target: 13.0, + expectedAlertSample: v3.Point{Value: 12.0}, }, { values: v3.Series{ @@ -502,10 +566,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "3", // OnAverage - target: 12.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "3", // OnAverage + target: 12.0, + expectedAlertSample: v3.Point{Value: 6.0}, }, // Test cases for InTotal { @@ -518,10 +583,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 2.0}, }, }, - expectAlert: true, - compareOp: "3", // Equals - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "3", // Equals + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 30.0}, }, { values: v3.Series{ @@ -544,10 +610,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 10.0}, }, }, - expectAlert: true, - compareOp: "4", // Not Equals - matchType: "4", // InTotal - target: 9.0, + expectAlert: true, + compareOp: "4", // Not Equals + matchType: "4", // InTotal + target: 9.0, + expectedAlertSample: v3.Point{Value: 10.0}, }, { values: v3.Series{ @@ -567,10 +634,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 10.0}, }, }, - expectAlert: true, - compareOp: "1", // Greater Than - matchType: "4", // InTotal - target: 10.0, + expectAlert: true, + compareOp: "1", // Greater Than + matchType: "4", // InTotal + target: 10.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: v3.Series{ @@ -591,10 +659,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { {Value: 10.0}, }, }, - expectAlert: true, - compareOp: "2", // Less Than - matchType: "4", // InTotal - target: 30.0, + expectAlert: true, + compareOp: "2", // Less Than + matchType: "4", // InTotal + target: 30.0, + expectedAlertSample: v3.Point{Value: 20.0}, }, { values: v3.Series{ @@ -626,8 +695,11 @@ func TestThresholdRuleShouldAlert(t *testing.T) { values.Points[i].Timestamp = time.Now().UnixMilli() } - _, shoulAlert := rule.shouldAlert(c.values) + smpl, shoulAlert := rule.shouldAlert(c.values) assert.Equal(t, c.expectAlert, shoulAlert, "Test case %d", idx) + if shoulAlert { + assert.Equal(t, c.expectedAlertSample.Value, smpl.V, "Test case %d", idx) + } } } diff --git a/pkg/query-service/telemetry/telemetry.go b/pkg/query-service/telemetry/telemetry.go index c916135f4e..88f3a09542 100644 --- a/pkg/query-service/telemetry/telemetry.go +++ b/pkg/query-service/telemetry/telemetry.go @@ -176,16 +176,25 @@ type Telemetry struct { rateLimits map[string]int8 activeUser map[string]int8 patTokenUser bool - countUsers int8 mutex sync.RWMutex alertsInfoCallback func(ctx context.Context) (*model.AlertsInfo, error) + userCountCallback func(ctx context.Context) (int, error) + userRoleCallback func(ctx context.Context, groupId string) (string, error) } func (a *Telemetry) SetAlertsInfoCallback(callback func(ctx context.Context) (*model.AlertsInfo, error)) { a.alertsInfoCallback = callback } +func (a *Telemetry) SetUserCountCallback(callback func(ctx context.Context) (int, error)) { + a.userCountCallback = callback +} + +func (a *Telemetry) SetUserRoleCallback(callback func(ctx context.Context, groupId string) (string, error)) { + a.userRoleCallback = callback +} + func createTelemetry() { // Do not do anything in CI (not even resolving the outbound IP address) if testing.Testing() { @@ -259,6 +268,8 @@ func createTelemetry() { metricsTTL, _ := telemetry.reader.GetTTL(ctx, &model.GetTTLParams{Type: constants.MetricsTTL}) logsTTL, _ := telemetry.reader.GetTTL(ctx, &model.GetTTLParams{Type: constants.LogsTTL}) + userCount, _ := telemetry.userCountCallback(ctx) + data := map[string]interface{}{ "totalSpans": totalSpans, "spansInLastHeartBeatInterval": spansInLastHeartBeatInterval, @@ -266,7 +277,7 @@ func createTelemetry() { "getSamplesInfoInLastHeartBeatInterval": getSamplesInfoInLastHeartBeatInterval, "totalLogs": totalLogs, "getLogsInfoInLastHeartBeatInterval": getLogsInfoInLastHeartBeatInterval, - "countUsers": telemetry.countUsers, + "countUsers": userCount, "metricsTTLStatus": metricsTTL.Status, "tracesTTLStatus": traceTTL.Status, "logsTTLStatus": logsTTL.Status, @@ -450,11 +461,22 @@ func (a *Telemetry) IdentifyUser(user *model.User) { if !a.isTelemetryEnabled() || a.isTelemetryAnonymous() { return } + // extract user group from user.groupId + role, _ := a.userRoleCallback(context.Background(), user.GroupId) + if a.saasOperator != nil { - a.saasOperator.Enqueue(analytics.Identify{ - UserId: a.userEmail, - Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email), - }) + if role != "" { + a.saasOperator.Enqueue(analytics.Identify{ + UserId: a.userEmail, + Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email).Set("role", role), + }) + } else { + a.saasOperator.Enqueue(analytics.Identify{ + UserId: a.userEmail, + Traits: analytics.NewTraits().SetName(user.Name).SetEmail(user.Email), + }) + } + a.saasOperator.Enqueue(analytics.Group{ UserId: a.userEmail, GroupId: a.getCompanyDomain(), @@ -474,10 +496,6 @@ func (a *Telemetry) IdentifyUser(user *model.User) { }) } -func (a *Telemetry) SetCountUsers(countUsers int8) { - a.countUsers = countUsers -} - func (a *Telemetry) SetUserEmail(email string) { a.userEmail = email }