From 91068e3e572ae2f3e591acb47328fe7413112790 Mon Sep 17 00:00:00 2001 From: Shaheer Kochai Date: Tue, 27 Aug 2024 12:40:17 +0430 Subject: [PATCH] Feat: get alert history data from API (#5718) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: data state renderer component * feat: get total triggered and avg. resolution cards data from API * fix: hide stats card if we get NaN * chore: improve rule stats types * feat: get top contributors data from API * feat: get timeline table data from API * fix: properly render change percentage indicator * feat: total triggered and avg resolution empty states * fix: fix stats height issue that would cause short border-right in empty case * feat: top contributors empty state * fix: fix table and graph borders * feat: build alert timeline labels filter and handle client side filtering * fix: select the first tab on clicking reset * feat: set param and send in payload on clicking timeline filter tabs * Feat: alert history timeline remaining subtasks except graphs (#5720) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: implement timeline table sorting * chore: add initial count to see more and alert labels * chore: move PaginationInfoText component to /periscope * chore: implement top contributor rows using Ant Table * feat: top contributors view all * fix: hide border for last row and prevent layout shift in top contributors by specifying height * feat: properly display duration in average resolution time * fix: properly display normal alert rule state * feat: add/remove view all top contributors param to url on opening/closing view all * feat: calculate start and end time from relative time and add/remove param to url * fix: fix console warnings * fix: enable timeline table query only if start and end times exist * feat: handle enable/disable alert rule toggle request * chore: replace string values with constants * fix: hide stats card if only past data is available + remove unnecessary states from AlertState * fix: redirect configure alert rule to alert overview tab * fix: display total triggers in timeline chart wrapper based on API response data * fix: choosing the same relative time doesn't udpate start and end time * Feat: total triggered and avg. resolution time graph (#5750) * feat: alert history basic tabs and fitlers UI * feat: route based tabs for alert history and overview and improve the UI to match designs * feat: handle enable/disable alert rule toggle request * feat: stats card line chart * fix: overall improvements to stats card graph * fix: overall UI improvements to match the Figma screens * chore: remove duplicate hook * fix: make the changes w.r.t timeline table API changes to prevent breaking the page * fix: update stats card null check based on updated API response * feat: stats card no previous data UI * feat: redirect to 404 page if rule id is invalid * chore: improve alert enable toggle success toast message * feat: get top contributors row and timeline table row related logs and traces links from API * feat: get total items from API and make pagination work * feat: implement timeline filters based on API response * fix: in case of current and target units, convert the value unit in timeline table * fix: timeline table y axis unit null check * fix: hide stats card graph if only a single entry is there in timeseries * chore: redirect alert from all alerts to overview tab * fix: prevent adding extra unnecessary params on clicking alerts top level tabs * chore: use conditional alert popover in timeline table and import the scss file * fix: prevent infinity if we receive totalPastTriggers as '0' * fix: improve UI to be pixel perfect based on figma designs * fix: fix the incorrect change direction * fix: add height to top contributors row * feat: alert history light mode * fix: remove the extra padding from alert overview query builder tabs * chore: overall improvements * chore: remove mock file * fix: overall improvements * fix: add dark mode support for top contributors empty state * chore: improve timeline chart placeholder bg in light mode * Feat: alert history horizontal timeline chart (#5773) * feat: timeline horizontal chart * fix: remove the labels from horizontal timeline chart * chore: add null check to timeline chart * chore: hide cursor from timeline chart * fix: fix the blank container being displayed in loading state --- frontend/src/api/alerts/patch.ts | 4 + frontend/src/api/alerts/ruleStats.ts | 28 + frontend/src/api/alerts/timelineGraph.ts | 33 + frontend/src/api/alerts/timelineTable.ts | 36 + frontend/src/api/alerts/topContributors.ts | 33 + .../AlertDetailsFilters/Filters.tsx | 9 +- .../AlertDetailsFilters/filters.styles.scss | 7 + frontend/src/constants/reactQueryKeys.ts | 5 + .../AlertPopover/AlertPopover.styles.scss | 3 + .../AlertPopover/AlertPopover.tsx | 122 +++- .../AverageResolutionCard.tsx | 30 +- .../averageResolutionCard.styles.scss | 3 - .../AlertHistory/Statistics/Statistics.tsx | 20 +- .../Statistics/StatsCard/StatsCard.tsx | 82 ++- .../StatsCard/StatsGraph/StatsGraph.tsx | 90 +++ .../StatsCard/statsCard.styles.scss | 47 +- .../StatsCardsRenderer/StatsCardsRenderer.tsx | 102 +++ .../TopContributorsCard.tsx | 107 ++- .../TopContributorsContent.tsx | 59 ++ .../TopContributorsRows.tsx | 86 +++ .../TopContributorsCard/ViewAllDrawer.tsx | 46 ++ .../topContributorsCard.styles.scss | 138 +++- .../Statistics/TopContributorsCard/types.ts | 6 + .../TopContributorsRenderer.tsx | 42 ++ .../TotalTriggeredCard/TotalTriggeredCard.tsx | 28 +- .../totalTriggeredCard.styles.scss | 3 - .../AlertHistory/Statistics/mocks.ts | 128 ---- .../Statistics/statistics.styles.scss | 9 +- .../AlertHistory/Timeline/Graph/Graph.tsx | 110 ++- .../AlertHistory/Timeline/Graph/constants.ts | 32 + .../Timeline/Graph/graph.styles.scss | 20 + .../Timeline/GraphWrapper/GraphWrapper.tsx | 46 ++ .../AlertHistory/Timeline/Table/Table.tsx | 122 ++-- .../Timeline/Table/table.styles.scss | 112 ++- .../AlertHistory/Timeline/Table/types.ts | 9 + .../Timeline/Table/useTimelineTable.tsx | 106 +++ .../TabsAndFilters/TabsAndFilters.tsx | 23 +- .../AlertHistory/Timeline/Timeline.tsx | 42 +- .../Timeline/timeline.styles.scss | 3 +- .../src/container/AlertHistory/constants.ts | 1 + frontend/src/container/AlertHistory/index.tsx | 11 +- frontend/src/container/AppLayout/index.tsx | 6 +- .../FormAlertRules/QuerySection.styles.scss | 3 + .../src/container/FormAlertRules/index.tsx | 3 +- .../container/ListAlertRules/ListAlert.tsx | 2 +- .../lib/uPlotLib/plugins/timelinePlugin.ts | 670 ++++++++++++++++++ .../ActionButtons/actionButtons.styles.scss | 27 + .../AlertHeader/ActionButtons/index.tsx | 29 +- .../AlertDetails/AlertHeader/AlertHeader.tsx | 34 +- .../AlertHeader/AlertLabels/AlertLabels.tsx | 16 +- .../AlertHeader/AlertState/AlertState.tsx | 13 +- .../AlertStatus/alertStatus.styles.scss | 6 + .../AlertHeader/alertHeader.styles.scss | 66 +- .../AlertDetails/alertDetails.styles.scss | 173 ++++- frontend/src/pages/AlertDetails/hooks.tsx | 375 +++++++++- frontend/src/pages/AlertDetails/index.tsx | 83 +-- frontend/src/pages/AlertList/index.tsx | 19 +- frontend/src/pages/EditRules/index.tsx | 6 +- .../copyToClipboard.styles.scss | 27 +- .../components/CopyToClipboard/index.tsx | 9 +- .../DataStateRenderer/DataStateRenderer.tsx | 46 ++ .../KeyValueLabel/keyValueLabel.styles.scss | 26 +- .../PaginationInfoText/PaginationInfoText.tsx | 24 + .../periscope/components/SeeMore/SeeMore.tsx | 12 +- .../components/SeeMore/seeMore.styles.scss | 9 + .../src/periscope/components/Tabs2/Tabs2.tsx | 15 +- .../components/Tabs2/tabs2.styles.scss | 23 + frontend/src/types/api/alerts/def.ts | 66 +- frontend/src/types/api/alerts/ruleStats.ts | 7 + .../src/types/api/alerts/timelineGraph.ts | 11 + .../src/types/api/alerts/timelineTable.ts | 16 + .../src/types/api/alerts/topContributors.ts | 7 + frontend/src/utils/calculateChange.ts | 20 +- frontend/src/utils/timeUtils.ts | 42 +- 74 files changed, 3269 insertions(+), 565 deletions(-) create mode 100644 frontend/src/api/alerts/ruleStats.ts create mode 100644 frontend/src/api/alerts/timelineGraph.ts create mode 100644 frontend/src/api/alerts/timelineTable.ts create mode 100644 frontend/src/api/alerts/topContributors.ts create mode 100644 frontend/src/container/AlertHistory/AlertPopover/AlertPopover.styles.scss delete mode 100644 frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/averageResolutionCard.styles.scss create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts create mode 100644 frontend/src/container/AlertHistory/Statistics/TopContributorsRenderer/TopContributorsRenderer.tsx delete mode 100644 frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/totalTriggeredCard.styles.scss delete mode 100644 frontend/src/container/AlertHistory/Statistics/mocks.ts create mode 100644 frontend/src/container/AlertHistory/Timeline/Graph/constants.ts create mode 100644 frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/types.ts create mode 100644 frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx create mode 100644 frontend/src/container/AlertHistory/constants.ts create mode 100644 frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts create mode 100644 frontend/src/periscope/components/DataStateRenderer/DataStateRenderer.tsx create mode 100644 frontend/src/periscope/components/PaginationInfoText/PaginationInfoText.tsx create mode 100644 frontend/src/types/api/alerts/ruleStats.ts create mode 100644 frontend/src/types/api/alerts/timelineGraph.ts create mode 100644 frontend/src/types/api/alerts/timelineTable.ts create mode 100644 frontend/src/types/api/alerts/topContributors.ts diff --git a/frontend/src/api/alerts/patch.ts b/frontend/src/api/alerts/patch.ts index 920b53ae9fe..0f422cd66c4 100644 --- a/frontend/src/api/alerts/patch.ts +++ b/frontend/src/api/alerts/patch.ts @@ -19,6 +19,10 @@ const patch = async ( payload: response.data.data, }; } catch (error) { + if (window.location.href.includes('alerts/history')) { + throw error as AxiosError; + } + return ErrorResponseHandler(error as AxiosError); } }; diff --git a/frontend/src/api/alerts/ruleStats.ts b/frontend/src/api/alerts/ruleStats.ts new file mode 100644 index 00000000000..2e09751e0f0 --- /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 00000000000..8073943d727 --- /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 00000000000..8d7f3edee71 --- /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 00000000000..7d3f2baec1d --- /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/components/AlertDetailsFilters/Filters.tsx b/frontend/src/components/AlertDetailsFilters/Filters.tsx index f50ca588a8b..c5a2f95fee3 100644 --- a/frontend/src/components/AlertDetailsFilters/Filters.tsx +++ b/frontend/src/components/AlertDetailsFilters/Filters.tsx @@ -2,6 +2,7 @@ import './filters.styles.scss'; import { Button } from 'antd'; import { QueryParams } from 'constants/query'; +import { RelativeTimeMap } from 'container/TopNav/DateTimeSelection/config'; import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2'; import useUrlQuery from 'hooks/useUrlQuery'; import { Undo } from 'lucide-react'; @@ -10,10 +11,12 @@ import { useHistory } from 'react-router-dom'; export function Filters(): JSX.Element { const urlQuery = useUrlQuery(); const history = useHistory(); + const relativeTime = urlQuery.get(QueryParams.relativeTime); const handleFiltersReset = (): void => { - urlQuery.delete(QueryParams.relativeTime); - + urlQuery.set(QueryParams.relativeTime, RelativeTimeMap['30min']); + urlQuery.delete(QueryParams.startTime); + urlQuery.delete(QueryParams.endTime); history.replace({ pathname: history.location.pathname, search: `?${urlQuery.toString()}`, @@ -21,7 +24,7 @@ export function Filters(): JSX.Element { }; return (
- {urlQuery.has(QueryParams.relativeTime) && ( + {relativeTime !== RelativeTimeMap['30min'] && (
); } -function AlertPopover({ children }: Props): JSX.Element { +PopoverContent.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +function AlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: Props): JSX.Element { return ( - } - trigger="click" - > - {children} - +
+ + } + trigger="click" + > + {children} + +
); } +AlertPopover.defaultProps = { + relatedTracesLink: '', + relatedLogsLink: '', +}; + +type ConditionalAlertPopoverProps = { + relatedTracesLink: string; + relatedLogsLink: string; + children: React.ReactNode; +}; +export function ConditionalAlertPopover({ + children, + relatedTracesLink, + relatedLogsLink, +}: ConditionalAlertPopoverProps): JSX.Element { + if (relatedTracesLink || relatedLogsLink) { + return ( + + {children} + + ); + } + return
{children}
; +} export default AlertPopover; diff --git a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx index e00efe1532a..f55c4385ce3 100644 --- a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/AverageResolutionCard.tsx @@ -1,17 +1,27 @@ -import './averageResolutionCard.styles.scss'; +import { AlertRuleStats } from 'types/api/alerts/def'; +import { formatTime } from 'utils/timeUtils'; -import { statsData } from '../mocks'; import StatsCard from '../StatsCard/StatsCard'; -function AverageResolutionCard(): JSX.Element { +type TotalTriggeredCardProps = { + currentAvgResolutionTime: AlertRuleStats['currentAvgResolutionTime']; + pastAvgResolutionTime: AlertRuleStats['pastAvgResolutionTime']; + timeSeries: AlertRuleStats['currentAvgResolutionTimeSeries']['values']; +}; + +function AverageResolutionCard({ + currentAvgResolutionTime, + pastAvgResolutionTime, + timeSeries, +}: TotalTriggeredCardProps): JSX.Element { return ( -
- -
+ ); } diff --git a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/averageResolutionCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/averageResolutionCard.styles.scss deleted file mode 100644 index 6f4d8e6c78d..00000000000 --- a/frontend/src/container/AlertHistory/Statistics/AverageResolutionCard/averageResolutionCard.styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -.average-resolution-card { - width: 21.7%; -} diff --git a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx index b8d076fb92d..dfb3329b96a 100644 --- a/frontend/src/container/AlertHistory/Statistics/Statistics.tsx +++ b/frontend/src/container/AlertHistory/Statistics/Statistics.tsx @@ -1,15 +1,21 @@ import './statistics.styles.scss'; -import AverageResolutionCard from './AverageResolutionCard/AverageResolutionCard'; -import TopContributorsCard from './TopContributorsCard/TopContributorsCard'; -import TotalTriggeredCard from './TotalTriggeredCard/TotalTriggeredCard'; +import { AlertRuleStats } from 'types/api/alerts/def'; -function Statistics(): JSX.Element { +import StatsCardsRenderer from './StatsCardsRenderer/StatsCardsRenderer'; +import TopContributorsRenderer from './TopContributorsRenderer/TopContributorsRenderer'; + +function Statistics({ + setTotalCurrentTriggers, + totalCurrentTriggers, +}: { + setTotalCurrentTriggers: (value: number) => void; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { return (
- - - + +
); } diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx index 74940bf5b5d..44310ec1216 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsCard.tsx @@ -1,10 +1,12 @@ import './statsCard.styles.scss'; -import { DotChartOutlined } from '@ant-design/icons'; import useUrlQuery from 'hooks/useUrlQuery'; import { ArrowDownLeft, ArrowUpRight, Calendar } from 'lucide-react'; +import { AlertRuleStats } from 'types/api/alerts/def'; import { calculateChange } from 'utils/calculateChange'; +import StatsGraph from './StatsGraph/StatsGraph'; + type ChangePercentageProps = { percentage: number; direction: number; @@ -15,55 +17,68 @@ function ChangePercentage({ direction, duration, }: ChangePercentageProps): JSX.Element { - if (!percentage || !duration) { - return
; - } - return ( -
-
- {direction ? ( + if (direction > 0) { + return ( +
+
- ) : ( - - )} +
+
+ {percentage}% vs Last {duration} +
-
- {percentage}% vs Last {duration} + ); + } + if (direction < 0) { + return ( +
+
+ +
+
+ {percentage}% vs Last {duration} +
+ ); + } + + return ( +
+
no previous data
); } type StatsCardProps = { - totalCurrentCount: number; - totalPastCount: number; + 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'); - if (!totalCurrentCount || !totalPastCount) { - return
; - } - const { changePercentage, changeDirection } = calculateChange( totalCurrentCount, totalPastCount, ); return ( -
+
{title}
@@ -73,8 +88,11 @@ function StatsCard({
{relativeTime}
+
-
{totalCurrentCount}
+
+ {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 00000000000..26c381d7068 --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/StatsGraph/StatsGraph.tsx @@ -0,0 +1,90 @@ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useResizeObserver } from 'hooks/useDimensions'; +import { useMemo, useRef } from 'react'; +import { AlertRuleStats } from 'types/api/alerts/def'; + +type Props = { + timeSeries: AlertRuleStats['currentTriggersSeries']['values']; + changeDirection: number; +}; + +const getStyle = ( + changeDirection: number, +): { stroke: string; fill: string } => { + if (changeDirection === 0) { + return { + stroke: Color.BG_ROBIN_500, + fill: 'rgba(78, 116, 248, 0.20)', + }; + } + if (changeDirection > 0) { + return { + stroke: Color.BG_FOREST_500, + fill: 'rgba(37, 225, 146, 0.20)', + }; + } + return { + stroke: Color.BG_CHERRY_500, + fill: ' rgba(229, 72, 77, 0.20)', + }; +}; + +function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element { + const { xData, yData } = useMemo( + () => ({ + xData: timeSeries.map((item) => item.timestamp), + yData: timeSeries.map((item) => Number(item.value)), + }), + [timeSeries], + ); + + const graphRef = useRef(null); + + const containerDimensions = useResizeObserver(graphRef); + + const options: uPlot.Options = useMemo( + () => ({ + width: containerDimensions.width, + height: containerDimensions.height, + + legend: { + show: false, + }, + cursor: { + x: false, + y: false, + drag: { + x: false, + y: false, + }, + }, + padding: [0, 0, 2, 0], + series: [ + {}, + { + ...getStyle(changeDirection), + points: { + show: false, + }, + width: 1.4, + }, + ], + axes: [ + { show: false }, + { + show: false, + }, + ], + }), + [changeDirection, containerDimensions.height, containerDimensions.width], + ); + + return ( +
+ +
+ ); +} + +export default StatsGraph; diff --git a/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss index b9fd44030ad..bb9d3c3e728 100644 --- a/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss +++ b/frontend/src/container/AlertHistory/Statistics/StatsCard/statsCard.styles.scss @@ -1,10 +1,11 @@ .stats-card { - height: 100%; - display: flex; - flex-direction: column; - justify-content: space-between; + width: 21.7%; border-right: 1px solid var(--bg-slate-500); - padding: 9px 12px; + padding: 9px 12px 13px; + + &--empty { + justify-content: normal; + } &__title-wrapper { display: flex; @@ -53,13 +54,6 @@ .graph { width: 100%; height: 72px; - background: #ffffff1f; - display: flex; - align-items: center; - justify-content: center; - .graph-icon { - font-size: 2rem; - } } } } @@ -74,18 +68,45 @@ &--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 { - color: var(--bg-forest-500); 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/StatsCardsRenderer/StatsCardsRenderer.tsx b/frontend/src/container/AlertHistory/Statistics/StatsCardsRenderer/StatsCardsRenderer.tsx new file mode 100644 index 00000000000..e8859131df2 --- /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.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx index 1593886ce75..24bc5830877 100644 --- a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsCard.tsx @@ -1,49 +1,82 @@ import './topContributorsCard.styles.scss'; -import { Progress } from 'antd'; -import AlertPopover from 'container/AlertHistory/AlertPopover/AlertPopover'; +import { Button } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import history from 'lib/history'; import { ArrowRight } from 'lucide-react'; -import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import { useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; -import { statsData, topContributorsData } from '../mocks'; +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() }); + }; -function TopContributorsCard(): JSX.Element { return ( -
-
-
top contributors
-
-
View all
-
- -
-
-
-
- {topContributorsData.contributors.slice(0, 3).map((contributor, index) => ( - -
-
- -
-
- +
+
+
top contributors
+ {topContributorsData.length > 3 && ( +
- - ))} + + )} +
+ +
-
+ {isViewAllVisible && ( + + )} + ); } diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx new file mode 100644 index 00000000000..39a2f8f27ae --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsContent.tsx @@ -0,0 +1,59 @@ +import { Button } from 'antd'; +import { QueryParams } from 'constants/query'; +import ROUTES from 'constants/routes'; +import useUrlQuery from 'hooks/useUrlQuery'; +import history from 'lib/history'; + +import TopContributorsRows from './TopContributorsRows'; +import { TopContributorsCardProps } from './types'; + +function TopContributorsContent({ + topContributorsData, + totalCurrentTriggers, +}: TopContributorsCardProps): JSX.Element { + const isEmpty = !topContributorsData.length; + + const urlQuery = useUrlQuery(); + const ruleIdKey = QueryParams.ruleId; + const relativeTimeKey = QueryParams.relativeTime; + + const handleRedirectToOverview = (): void => { + const params = `${ruleIdKey}=${urlQuery.get( + ruleIdKey, + )}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`; + + history.push(`${ROUTES.ALERT_OVERVIEW}?${params}`); + }; + + if (isEmpty) { + return ( +
+
ℹ️
+
+ Add Group By Field To view top + contributors, please add at least one group by field to your query. +
+
+ +
+
+ ); + } + + return ( +
+ +
+ ); +} + +export default TopContributorsContent; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx new file mode 100644 index 00000000000..ac7f9fd2d9a --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/TopContributorsRows.tsx @@ -0,0 +1,86 @@ +import { Progress, Table } from 'antd'; +import { ColumnsType } from 'antd/es/table'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +function TopContributorsRows({ + topContributors, + totalCurrentTriggers, +}: { + topContributors: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + const columns: ColumnsType = [ + { + title: 'labels', + dataIndex: 'labels', + key: 'labels', + width: '51%', + render: ( + labels: AlertRuleTopContributors['labels'], + record, + ): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'progressBar', + dataIndex: 'count', + key: 'progressBar', + width: '39%', + render: (count: AlertRuleTopContributors['count'], record): JSX.Element => ( + + + + ), + }, + { + title: 'count', + dataIndex: 'count', + key: 'count', + width: '10%', + render: (count: AlertRuleTopContributors['count'], record): JSX.Element => ( + +
+ {count}/{totalCurrentTriggers} +
+
+ ), + }, + ]; + + return ( + `top-contributor-${row.fingerprint}`} + columns={columns} + showHeader={false} + dataSource={topContributors} + pagination={ + topContributors.length > 10 ? { showTotal: PaginationInfoText } : false + } + /> + ); +} + +export default TopContributorsRows; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx new file mode 100644 index 00000000000..1d49c87afda --- /dev/null +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/ViewAllDrawer.tsx @@ -0,0 +1,46 @@ +import { Color } from '@signozhq/design-tokens'; +import { Drawer } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def'; + +import TopContributorsRows from './TopContributorsRows'; + +function ViewAllDrawer({ + isViewAllVisible, + toggleViewAllDrawer, + totalCurrentTriggers, + topContributorsData, +}: { + isViewAllVisible: boolean; + toggleViewAllDrawer: () => void; + topContributorsData: AlertRuleTopContributors[]; + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; +}): JSX.Element { + const isDarkMode = useIsDarkMode(); + return ( + +
+
+ +
+
+
+ ); +} + +export default ViewAllDrawer; diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss index 95fa3c7d753..e6e09fef2a0 100644 --- a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss +++ b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/topContributorsCard.styles.scss @@ -1,5 +1,9 @@ .top-contributors-card { width: 56.6%; + + &--view-all { + width: auto; + } &__header { display: flex; align-items: center; @@ -19,6 +23,12 @@ display: flex; align-items: center; gap: 4px; + cursor: pointer; + padding: 0; + height: 20px; + &:hover { + background-color: transparent !important; + } .label { color: var(--text-vanilla-400); @@ -31,36 +41,73 @@ } } } + .contributors-row { + height: 80px; + } &__content { + .ant-table { + &-cell { + padding: 12px !important; + } + } .contributors-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 12px; - &:hover { - background: rgba(171, 189, 255, 0.04); + background: var(--bg-ink-500); + + td { + border: none !important; } - cursor: pointer; - &:not(:last-of-type) { - border-bottom: 1px solid var(--bg-slate-500); + &: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; - .labels-wrapper { - width: 50%; - } - .contribution-progress-bar { - width: 40%; + &__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; } - .total-contribution { - color: var(--text-robin-500); - font-family: 'Geist Mono'; + } + &__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; - letter-spacing: -0.06px; - padding: 4px 8px; - background: rgba(78, 116, 248, 0.1); - border-radius: 50px; - width: max-content; + display: flex; + align-items: center; } } } @@ -94,3 +141,50 @@ } } } + +.view-all-drawer { + border-radius: 4px; +} + +.lightMode { + .ant-table { + background: inherit; + } + + .top-contributors-card { + &__header { + border-color: var(--bg-vanilla-300); + .title { + color: var(--text-ink-400); + } + .view-all { + .label { + color: var(--text-ink-400); + } + } + } + &__content { + .contributors-row { + background: inherit; + &:not(:last-of-type) td { + border-bottom: 1px solid var(--bg-vanilla-300) !important; + } + } + } + .empty-content { + border-color: var(--bg-vanilla-300); + &__text { + color: var(--text-ink-400); + .bold-text { + color: var(--text-ink-500); + } + } + &__button-wrapper { + .configure-alert-rule-button { + background: var(--bg-vanilla-300); + color: var(--text-ink-500); + } + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts b/frontend/src/container/AlertHistory/Statistics/TopContributorsCard/types.ts new file mode 100644 index 00000000000..f44d2ded99c --- /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 00000000000..b773579ca07 --- /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 index ce6c3e5cf2e..0e4f4128942 100644 --- a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx +++ b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/TotalTriggeredCard.tsx @@ -1,17 +1,25 @@ -import './totalTriggeredCard.styles.scss'; +import { AlertRuleStats } from 'types/api/alerts/def'; -import { statsData } from '../mocks'; import StatsCard from '../StatsCard/StatsCard'; -function TotalTriggeredCard(): JSX.Element { +type TotalTriggeredCardProps = { + totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers']; + totalPastTriggers: AlertRuleStats['totalPastTriggers']; + timeSeries: AlertRuleStats['currentTriggersSeries']['values']; +}; + +function TotalTriggeredCard({ + totalCurrentTriggers, + totalPastTriggers, + timeSeries, +}: TotalTriggeredCardProps): JSX.Element { return ( -
- -
+ ); } diff --git a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/totalTriggeredCard.styles.scss b/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/totalTriggeredCard.styles.scss deleted file mode 100644 index 27c8929928a..00000000000 --- a/frontend/src/container/AlertHistory/Statistics/TotalTriggeredCard/totalTriggeredCard.styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -.total-triggered-card { - width: 21.7%; -} diff --git a/frontend/src/container/AlertHistory/Statistics/mocks.ts b/frontend/src/container/AlertHistory/Statistics/mocks.ts deleted file mode 100644 index 95e27be7e8d..00000000000 --- a/frontend/src/container/AlertHistory/Statistics/mocks.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* eslint-disable sonarjs/no-duplicate-string */ -export const statsData = { - totalCurrentTriggers: 14, - totalPastTriggers: 15, - currentTriggersSeries: { - values: [ - { - timestamp: '1625097600000', // Sample epoch value - value: 100, - }, - ], - }, - pastTriggersSeries: { - values: [ - { - timestamp: '1625097600000', // Sample epoch value - value: 100, - }, - ], - }, - currentAvgResolutionTime: 1.3, - pastAvgResolutionTime: 3.2, - currentAvgResolutionTimeSeries: { - values: [ - { - timestamp: '1625097600000', // Sample epoch value - value: 100, - }, - ], - }, - pastAvgResolutionTimeSeries: { - values: [ - { - timestamp: '1625097600000', // Sample epoch value - value: 100, - }, - ], - }, -}; - -export const topContributorsData = { - contributors: [ - { - labels: { - operation: 'POST /transaction-module/record-payment', - service_name: 'order-service-prod', - k3: 'v3', - }, - count: 6, - }, - { - labels: { - operation: 'GET /financial-module/account-statement', - service_name: 'catalog-manager-001', - k3: 'v3', - }, - count: 4, - }, - { - labels: { - operation: 'GET /financial-module/account-statement', - service_name: 'catalog-manager-001', - k3: 'v3', - }, - count: 2, - }, - { - labels: { - operation: 'GET /financial-module/account-statement', - service_name: 'catalog-manager-001', - k3: 'v3', - }, - count: 2, - }, - ], -}; -function getRandomInt(min: number, max: number): number { - return Math.floor(Math.random() * (max - min + 1)) + min; -} - -function getRandomState(): string { - const states = ['firing', 'resolved', 'pending']; - return states[getRandomInt(0, states.length - 1)]; -} - -function getRandomOperation(): string { - const operations = [ - 'GET /financial-module/account-statement', - 'POST /user/login', - 'DELETE /user/logout', - 'PUT /order/update', - 'PATCH /product/modify', - ]; - return operations[getRandomInt(0, operations.length - 1)]; -} - -function getRandomServiceName(): string { - const services = [ - 'catalog-manager-001', - 'user-service-002', - 'order-service-003', - 'payment-gateway-004', - 'inventory-service-005', - ]; - return services[getRandomInt(0, services.length - 1)]; -} - -function getRandomK3(): string { - const k3Versions = ['v1', 'v2', 'v3', 'v4', 'v5']; - return k3Versions[getRandomInt(0, k3Versions.length - 1)]; -} - -function getRandomUnixMilli(): string { - const start = new Date(2021, 0, 1).getTime(); - const end = new Date(2022, 0, 1).getTime(); - return (getRandomInt(start, end) / 1000).toString(); -} - -export const timelineData = Array.from({ length: 500 }, () => ({ - unixMilli: getRandomUnixMilli(), - state: getRandomState(), - labels: { - operation: getRandomOperation(), - service_name: getRandomServiceName(), - k3: getRandomK3(), - }, - value: getRandomInt(0, 100), -})); diff --git a/frontend/src/container/AlertHistory/Statistics/statistics.styles.scss b/frontend/src/container/AlertHistory/Statistics/statistics.styles.scss index 7afcc8dcae7..cc0a5b1b430 100644 --- a/frontend/src/container/AlertHistory/Statistics/statistics.styles.scss +++ b/frontend/src/container/AlertHistory/Statistics/statistics.styles.scss @@ -1,7 +1,14 @@ .statistics { display: flex; justify-content: space-between; - width: 100%; + height: 280px; border: 1px solid var(--bg-slate-500); border-radius: 4px; + margin: 0 16px; +} + +.lightMode { + .statistics { + border: 1px solid var(--bg-vanilla-300); + } } diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx index 07861445cbc..ce104b8aa59 100644 --- a/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx @@ -1,26 +1,100 @@ -import './graph.styles.scss'; +/* eslint-disable consistent-return */ +/* eslint-disable react/jsx-props-no-spreading */ +import { Color } from '@signozhq/design-tokens'; +import Uplot from 'components/Uplot'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { useResizeObserver } from 'hooks/useDimensions'; +import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin'; +import { useMemo, useRef } from 'react'; +import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def'; +import uPlot, { AlignedData } from 'uplot'; -import { BarChartOutlined } from '@ant-design/icons'; -import { statsData } from 'container/AlertHistory/Statistics/mocks'; -import useUrlQuery from 'hooks/useUrlQuery'; +import { ALERT_STATUS, TIMELINE_OPTIONS } from './constants'; -function Graph(): JSX.Element { - const urlQuery = useUrlQuery(); +type Props = { type: string; data: AlertRuleTimelineGraphResponse[] }; - const relativeTime = urlQuery.get('relativeTime'); +function HorizontalTimelineGraph({ + width, + isDarkMode, + data, +}: { + width: number; + isDarkMode: boolean; + data: AlertRuleTimelineGraphResponse[]; +}): JSX.Element { + const transformedData: AlignedData = useMemo( + () => + data?.length > 1 + ? [ + data.map((item: AlertRuleTimelineGraphResponse) => item.start / 1000), + data.map( + (item: AlertRuleTimelineGraphResponse) => ALERT_STATUS[item.state], + ), + ] + : [[], []], - return ( -
-
- {statsData.totalCurrentTriggers} triggers in {relativeTime} -
-
-
- -
-
-
+ [data], + ); + + const options: uPlot.Options = useMemo( + () => ({ + width, + height: 85, + cursor: { show: false }, + + axes: [ + { + gap: 10, + stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400, + }, + { show: false }, + ], + legend: { + show: false, + }, + padding: [null, 0, null, 0], + series: [ + { + label: 'Time', + }, + { + label: 'States', + }, + ], + plugins: + transformedData?.length > 1 + ? [ + timelinePlugin({ + count: transformedData.length - 1, + ...TIMELINE_OPTIONS, + }), + ] + : [], + }), + [width, isDarkMode, transformedData], ); + return ; +} + +function Graph({ type, data }: Props): JSX.Element | null { + const graphRef = useRef(null); + + const isDarkMode = useIsDarkMode(); + + const containerDimensions = useResizeObserver(graphRef); + + if (type === 'horizontal') { + return ( +
+ +
+ ); + } + return null; } export default Graph; diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts new file mode 100644 index 00000000000..34a79df4862 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Graph/constants.ts @@ -0,0 +1,32 @@ +import { Color } from '@signozhq/design-tokens'; + +export const ALERT_STATUS: { [key: string]: number } = { + firing: 0, + normal: 1, + 'no-data': 2, + disabled: 3, + muted: 4, +}; + +export const STATE_VS_COLOR: { + [key: string]: { stroke: string; fill: string }; +}[] = [ + {}, + { + 0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 }, + 1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 }, + 2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 }, + 3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 }, + 4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 }, + }, +]; + +export const TIMELINE_OPTIONS = { + mode: 1, + fill: (seriesIdx: any, _: any, value: any): any => + STATE_VS_COLOR[seriesIdx][value].fill, + stroke: (seriesIdx: any, _: any, value: any): any => + STATE_VS_COLOR[seriesIdx][value].stroke, + laneWidthOption: 0.3, + showGrid: false, +}; diff --git a/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss b/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss index 11d6e446f04..3ea30fe25a0 100644 --- a/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss +++ b/frontend/src/container/AlertHistory/Timeline/Graph/graph.styles.scss @@ -4,6 +4,9 @@ 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; @@ -30,3 +33,20 @@ } } } + +.lightMode { + .timeline-graph { + background: var(--bg-vanilla-200); + border-color: var(--bg-vanilla-300); + &__title { + background: var(--bg-vanilla-100); + color: var(--text-ink-400); + border-color: var(--bg-vanilla-300); + } + &__chart { + .chart-placeholder { + background: var(--bg-vanilla-300); + } + } + } +} diff --git a/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx new file mode 100644 index 00000000000..adc38a62079 --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/GraphWrapper/GraphWrapper.tsx @@ -0,0 +1,46 @@ +import '../Graph/graph.styles.scss'; + +import useUrlQuery from 'hooks/useUrlQuery'; +import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; + +import Graph from '../Graph/Graph'; + +function GraphWrapper({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { + const urlQuery = useUrlQuery(); + + const relativeTime = urlQuery.get('relativeTime'); + + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineGraphData(); + + return ( +
+
+ {totalCurrentTriggers} triggers in {relativeTime} +
+
+ + {(data): JSX.Element => } + +
+
+ ); +} + +export default GraphWrapper; diff --git a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx index d2f02a1a3a7..2e887fc27fd 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Table/Table.tsx @@ -1,87 +1,63 @@ import './table.styles.scss'; -import { Table, Typography } from 'antd'; -import { ColumnsType } from 'antd/es/table'; -import AlertPopover from 'container/AlertHistory/AlertPopover/AlertPopover'; -import { timelineData } from 'container/AlertHistory/Statistics/mocks'; -import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; -import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState'; -import { formatEpochTimestamp } from 'utils/timeUtils'; +import { Table } from 'antd'; +import { QueryParams } from 'constants/query'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; +import { useTimelineTable } from 'pages/AlertDetails/hooks'; +import { useMemo, useState } from 'react'; +import { useQueryClient } from 'react-query'; +import { useLocation } from 'react-router-dom'; +import { PayloadProps } from 'types/api/alerts/get'; -interface DataType { - state: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - labels: Record; - value: number; - unixMilli: string; -} +import { TimelineTableProps } from './types'; +import { timelineTableColumns } from './useTimelineTable'; + +function TimelineTable({ + timelineData, + totalItems, +}: TimelineTableProps): JSX.Element { + const [searchText, setSearchText] = useState(''); + const { paginationConfig, onChangeHandler } = useTimelineTable({ totalItems }); + + const visibleTimelineData = useMemo(() => { + if (searchText === '') { + return timelineData; + } + return timelineData.filter((data) => + JSON.stringify(data.labels).toLowerCase().includes(searchText.toLowerCase()), + ); + }, [searchText, timelineData]); + + const queryClient = useQueryClient(); + + const { search } = useLocation(); + const params = new URLSearchParams(search); + + const ruleId = params.get(QueryParams.ruleId); -const columns: ColumnsType = [ - { - title: 'STATE', - dataIndex: 'state', - render: (value): JSX.Element => ( - -
- -
-
- ), - }, - { - title: 'LABELS', - dataIndex: 'labels', - render: (labels): JSX.Element => ( - -
- -
-
- ), - }, - { - title: 'VALUE', - dataIndex: 'value', - render: (value): JSX.Element => ( - -
{value}
-
- ), - }, - { - title: 'CREATED AT', - dataIndex: 'unixMilli', - render: (value): JSX.Element => ( - -
{formatEpochTimestamp(value)}
-
- ), - }, -]; + const { currentUnit, targetUnit } = useMemo(() => { + const alertDetailsQuery = queryClient.getQueryData([ + REACT_QUERY_KEY.ALERT_RULE_DETAILS, + ruleId, + ]) as { + payload: PayloadProps; + }; + const condition = alertDetailsQuery?.payload?.data?.condition; + const { targetUnit } = condition ?? {}; + const { unit: currentUnit } = condition?.compositeQuery ?? {}; -const showPaginationItem = (total: number, range: number[]): JSX.Element => ( - <> - - {range[0]} — {range[1]} - - of {total} - -); + return { currentUnit, targetUnit }; + }, [queryClient, ruleId]); -function TimelineTable(): JSX.Element { - const paginationConfig = timelineData.length > 20 && { - pageSize: 20, - showTotal: showPaginationItem, - showSizeChanger: false, - hideOnSinglePage: true, - }; return (
`${row.fingerprint}-${row.value}`} + columns={timelineTableColumns(setSearchText, currentUnit, targetUnit)} + dataSource={visibleTimelineData} pagination={paginationConfig} size="middle" + onChange={onChangeHandler} /> ); diff --git a/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss b/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss index 1519c1de7bb..eede4c8c99c 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss +++ b/frontend/src/container/AlertHistory/Timeline/Table/table.styles.scss @@ -1,15 +1,25 @@ -@mixin cell-width($index, $width) { - &-cell:nth-last-of-type(#{$index}) { - width: $width; - } -} - .timeline-table { + border-top: 1px solid var(--text-slate-500); + + border-radius: 6px; + overflow: hidden; + margin-top: 4px; + .ant-table { background: var(--bg-ink-500); + &-cell { + padding: 12px 16px !important; + vertical-align: baseline; + &::before { + display: none; + } + } &-thead > tr > th { - border: none; + border-color: var(--bg-slate-500); background: var(--bg-ink-500); + font-size: 12px; + font-weight: 500; + padding: 12px 16px 8px !important; &:last-of-type, &:nth-last-of-type(2) { text-align: right; @@ -17,29 +27,32 @@ } &-tbody > tr > td { border: none; - cursor: pointer; &:last-of-type, &:nth-last-of-type(2) { text-align: right; } } - &-cell::before { - display: none; - } - - @include cell-width(1, 17%); - @include cell-width(2, 6%); - @include cell-width(3, 67%); - @include cell-width(4, 10%); } - .ant-pagination-total-text { - margin-right: auto; + + .label-filter { + padding: 6px 8px; + border-radius: 4px; + background: var(--text-ink-400); + border-width: 0; + line-height: 18px; + & ::placeholder { + color: var(--text-vanilla-400); + font-size: 12px; + letter-spacing: 0.6px; + text-transform: uppercase; + font-weight: 500; + } } .alert-rule { &-value, &-created-at { font-size: 14px; - color: var(--text-Vanilla-400); + color: var(--text-vanilla-400); } &-value { font-weight: 500; @@ -50,4 +63,65 @@ letter-spacing: -0.07px; } } + .ant-table.ant-table-middle { + border-bottom: 1px solid var(--bg-slate-500); + border-left: 1px solid var(--bg-slate-500); + border-right: 1px solid var(--bg-slate-500); + + border-radius: 6px; + } + .ant-pagination-item { + &-active { + display: flex; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + padding: 1px 8px; + border-radius: 2px; + background: var(--bg-robin-500); + & > a { + color: var(--text-ink-500); + line-height: 20px; + font-weight: 500; + } + } + } +} + +.lightMode { + .timeline-table { + border-color: var(--bg-vanilla-300); + + .ant-table { + background: var(--bg-vanilla-100); + &-thead { + & > tr > th { + background: var(--bg-vanilla-100); + border-color: var(--bg-vanilla-300); + } + } + &.ant-table-middle { + border-color: var(--bg-vanilla-300); + } + } + .label-filter { + &, + & input { + background: var(--bg-vanilla-200); + } + } + .alert-rule { + &-value, + &-created-at { + color: var(--text-ink-400); + } + } + .ant-pagination-item { + &-active > a { + color: var(--text-vanilla-100); + } + } + } } diff --git a/frontend/src/container/AlertHistory/Timeline/Table/types.ts b/frontend/src/container/AlertHistory/Timeline/Table/types.ts new file mode 100644 index 00000000000..badf6498672 --- /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 00000000000..5a37b145e0b --- /dev/null +++ b/frontend/src/container/AlertHistory/Timeline/Table/useTimelineTable.tsx @@ -0,0 +1,106 @@ +import { Input } from 'antd'; +import { ColumnsType } from 'antd/es/table'; +import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import { convertValue } from 'lib/getConvertedValue'; +import { debounce } from 'lodash-es'; +import { Search } from 'lucide-react'; +import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels'; +import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState'; +import { BaseSyntheticEvent } from 'react'; +import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def'; +import { formatEpochTimestamp } from 'utils/timeUtils'; + +function LabelFilter({ + setSearchText, +}: { + setSearchText: (text: string) => void; +}): JSX.Element { + const handleSearch = (searchEv: BaseSyntheticEvent): void => { + setSearchText(searchEv?.target?.value || ''); + }; + + const handleDebouncedSearch = debounce(handleSearch, 300); + const isDarkMode = useIsDarkMode(); + + return ( + + } + /> + ); +} + +export const timelineTableColumns = ( + setSearchText: (text: string) => void, + currentUnit?: string, + targetUnit?: string, +): ColumnsType => [ + { + title: 'STATE', + dataIndex: 'state', + sorter: true, + width: '12.5%', + render: (value, record): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: , + dataIndex: 'labels', + width: '54.5%', + render: (labels, record): JSX.Element => ( + +
+ +
+
+ ), + }, + { + title: 'VALUE', + dataIndex: 'value', + width: '14%', + render: (value, record): JSX.Element => ( + +
+ {/* convert the value based on y axis and target unit */} + {convertValue(value.toFixed(2), currentUnit, targetUnit)} +
+
+ ), + }, + { + title: 'CREATED AT', + dataIndex: 'unixMilli', + width: '32.5%', + render: (value, record): JSX.Element => ( + +
{formatEpochTimestamp(value)}
+
+ ), + }, +]; diff --git a/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx index a917e07287e..afc4f5b64b4 100644 --- a/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx +++ b/frontend/src/container/AlertHistory/Timeline/TabsAndFilters/TabsAndFilters.tsx @@ -1,8 +1,11 @@ import './tabsAndFilters.styles.scss'; import { TimelineFilter, TimelineTab } from 'container/AlertHistory/types'; +import history from 'lib/history'; import { Info } from 'lucide-react'; import Tabs2 from 'periscope/components/Tabs2/Tabs2'; +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; function ComingSoon(): JSX.Element { return ( @@ -36,6 +39,19 @@ function TimelineTabs(): JSX.Element { } 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, @@ -52,7 +68,12 @@ function TimelineFilters(): JSX.Element { ]; return ( - + ); } diff --git a/frontend/src/container/AlertHistory/Timeline/Timeline.tsx b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx index 40aa4fe0d7d..dca30f481ab 100644 --- a/frontend/src/container/AlertHistory/Timeline/Timeline.tsx +++ b/frontend/src/container/AlertHistory/Timeline/Timeline.tsx @@ -1,10 +1,44 @@ import './timeline.styles.scss'; -import Graph from './Graph/Graph'; +import { useGetAlertRuleDetailsTimelineTable } from 'pages/AlertDetails/hooks'; +import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer'; + +import GraphWrapper from './GraphWrapper/GraphWrapper'; import TimelineTable from './Table/Table'; import TabsAndFilters from './TabsAndFilters/TabsAndFilters'; -function Timeline(): JSX.Element { +function TimelineTableRenderer(): JSX.Element { + const { + isLoading, + isRefetching, + isError, + data, + isValidRuleId, + ruleId, + } = useGetAlertRuleDetailsTimelineTable(); + + return ( + + {(timelineData): JSX.Element => ( + + )} + + ); +} + +function Timeline({ + totalCurrentTriggers, +}: { + totalCurrentTriggers: number; +}): JSX.Element { return (
Timeline
@@ -12,10 +46,10 @@ function Timeline(): JSX.Element {
- +
- +
); diff --git a/frontend/src/container/AlertHistory/Timeline/timeline.styles.scss b/frontend/src/container/AlertHistory/Timeline/timeline.styles.scss index 311fa444f0c..df5875403c1 100644 --- a/frontend/src/container/AlertHistory/Timeline/timeline.styles.scss +++ b/frontend/src/container/AlertHistory/Timeline/timeline.styles.scss @@ -1,7 +1,8 @@ .timeline { display: flex; flex-direction: column; - gap: 12px; + gap: 8px; + margin: 0 16px; &__title { color: var(--text-vanilla-100); diff --git a/frontend/src/container/AlertHistory/constants.ts b/frontend/src/container/AlertHistory/constants.ts new file mode 100644 index 00000000000..2253a27677e --- /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 index 22960fe16f4..584313f71d4 100644 --- a/frontend/src/container/AlertHistory/index.tsx +++ b/frontend/src/container/AlertHistory/index.tsx @@ -1,13 +1,20 @@ import './alertHistory.styles.scss'; +import { useState } from 'react'; + import Statistics from './Statistics/Statistics'; import Timeline from './Timeline/Timeline'; function AlertHistory(): JSX.Element { + const [totalCurrentTriggers, setTotalCurrentTriggers] = useState(0); + return (
- - + +
); } diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index ba64e9af451..d332d0a5d74 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -231,6 +231,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element { routeKey === 'TRACES_EXPLORER' || routeKey === 'TRACES_SAVE_VIEWS'; const isDashboardListView = (): boolean => routeKey === 'ALL_DASHBOARD'; + const isAlertHistory = (): boolean => routeKey === 'ALERT_HISTORY'; + const isAlertOverview = (): boolean => routeKey === 'ALERT_OVERVIEW'; const isDashboardView = (): boolean => { /** * need to match using regex here as the getRoute function will not work for @@ -318,7 +320,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element { isTracesView() || isDashboardView() || isDashboardWidgetView() || - isDashboardListView() + isDashboardListView() || + isAlertHistory() || + isAlertOverview() ? 0 : '0 1rem', }} diff --git a/frontend/src/container/FormAlertRules/QuerySection.styles.scss b/frontend/src/container/FormAlertRules/QuerySection.styles.scss index ee3f4892af0..65d222d8059 100644 --- a/frontend/src/container/FormAlertRules/QuerySection.styles.scss +++ b/frontend/src/container/FormAlertRules/QuerySection.styles.scss @@ -57,5 +57,8 @@ background: var(--bg-vanilla-300) !important; } } + .ant-tabs-tab-btn { + padding: 0; + } } } diff --git a/frontend/src/container/FormAlertRules/index.tsx b/frontend/src/container/FormAlertRules/index.tsx index 4b383eb2723..27083772dca 100644 --- a/frontend/src/container/FormAlertRules/index.tsx +++ b/frontend/src/container/FormAlertRules/index.tsx @@ -19,6 +19,7 @@ import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts'; import { FeatureKeys } from 'constants/features'; import { QueryParams } from 'constants/query'; import { PANEL_TYPES } from 'constants/queryBuilder'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag'; import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag'; @@ -369,7 +370,7 @@ function FormAlertRules({ }); // invalidate rule in cache - ruleCache.invalidateQueries(['ruleId', ruleId]); + ruleCache.invalidateQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]); // eslint-disable-next-line sonarjs/no-identical-functions setTimeout(() => { diff --git a/frontend/src/container/ListAlertRules/ListAlert.tsx b/frontend/src/container/ListAlertRules/ListAlert.tsx index 88069195c2e..25d661262a5 100644 --- a/frontend/src/container/ListAlertRules/ListAlert.tsx +++ b/frontend/src/container/ListAlertRules/ListAlert.tsx @@ -139,7 +139,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element { params.set(QueryParams.ruleId, record.id.toString()); setEditLoader(false); - history.push(`${ROUTES.EDIT_ALERTS}?${params.toString()}`); + history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`); }) .catch(handleError) .finally(() => setEditLoader(false)); diff --git a/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts new file mode 100644 index 00000000000..c300906af38 --- /dev/null +++ b/frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts @@ -0,0 +1,670 @@ +/* eslint-disable @typescript-eslint/no-this-alias */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-empty */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable eqeqeq */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable no-return-assign */ +/* eslint-disable no-nested-ternary */ +import uPlot, { RectH } from 'uplot'; + +export function pointWithin( + px: number, + py: number, + rlft: number, + rtop: number, + rrgt: number, + rbtm: number, +): boolean { + return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm; +} +const MAX_OBJECTS = 10; +const MAX_LEVELS = 4; + +export class Quadtree { + x: number; + + y: number; + + w: number; + + h: number; + + l: number; + + o: any[]; + + q: Quadtree[] | null; + + constructor(x: number, y: number, w: number, h: number, l?: number) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + this.l = l || 0; + this.o = []; + this.q = null; + } + + split(): void { + const t = this; + const { x } = t; + const { y } = t; + const w = t.w / 2; + const h = t.h / 2; + const l = t.l + 1; + + t.q = [ + // top right + new Quadtree(x + w, y, w, h, l), + // top left + new Quadtree(x, y, w, h, l), + // bottom left + new Quadtree(x, y + h, w, h, l), + // bottom right + new Quadtree(x + w, y + h, w, h, l), + ]; + } + + quads( + x: number, + y: number, + w: number, + h: number, + cb: (quad: Quadtree) => void, + ): void { + const t = this; + const { q } = t; + const hzMid = t.x + t.w / 2; + const vtMid = t.y + t.h / 2; + const startIsNorth = y < vtMid; + const startIsWest = x < hzMid; + const endIsEast = x + w > hzMid; + const endIsSouth = y + h > vtMid; + if (q) { + // top-right quad + startIsNorth && endIsEast && cb(q[0]); + // top-left quad + startIsWest && startIsNorth && cb(q[1]); + // bottom-left quad + startIsWest && endIsSouth && cb(q[2]); + // bottom-right quad + endIsEast && endIsSouth && cb(q[3]); + } + } + + add(o: any): void { + const t = this; + + if (t.q != null) { + t.quads(o.x, o.y, o.w, o.h, (q) => { + q.add(o); + }); + } else { + const os = t.o; + + os.push(o); + + if (os.length > MAX_OBJECTS && t.l < MAX_LEVELS) { + t.split(); + + for (let i = 0; i < os.length; i++) { + const oi = os[i]; + + t.quads(oi.x, oi.y, oi.w, oi.h, (q) => { + q.add(oi); + }); + } + + t.o.length = 0; + } + } + } + + get(x: number, y: number, w: number, h: number, cb: (o: any) => void): void { + const t = this; + const os = t.o; + + for (let i = 0; i < os.length; i++) { + cb(os[i]); + } + + if (t.q != null) { + t.quads(x, y, w, h, (q) => { + q.get(x, y, w, h, cb); + }); + } + } + + clear(): void { + this.o.length = 0; + this.q = null; + } +} + +Object.assign(Quadtree.prototype, { + split: Quadtree.prototype.split, + quads: Quadtree.prototype.quads, + add: Quadtree.prototype.add, + get: Quadtree.prototype.get, + clear: Quadtree.prototype.clear, +}); + +const { round, min, max, ceil } = Math; + +function roundDec(val: number, dec: number): any { + return Math.round(val * (dec = 10 ** dec)) / dec; +} + +export const SPACE_BETWEEN = 1; +export const SPACE_AROUND = 2; +export const SPACE_EVENLY = 3; +export const inf = Infinity; + +const coord = (i: number, offs: number, iwid: number, gap: number): any => + roundDec(offs + i * (iwid + gap), 6); + +export function distr( + numItems: number, + sizeFactor: number, + justify: number, + onlyIdx: number | null, + each: { + (i: any, offPct: number, dimPct: number): void; + (arg0: number, arg1: any, arg2: any): void; + }, +): any { + const space = 1 - sizeFactor; + + let gap = + justify == SPACE_BETWEEN + ? space / (numItems - 1) + : justify == SPACE_AROUND + ? space / numItems + : justify == SPACE_EVENLY + ? space / (numItems + 1) + : 0; + + if (Number.isNaN(gap) || gap == Infinity) gap = 0; + + const offs = + justify == SPACE_BETWEEN + ? 0 + : justify == SPACE_AROUND + ? gap / 2 + : justify == SPACE_EVENLY + ? gap + : 0; + + const iwid = sizeFactor / numItems; + const _iwid = roundDec(iwid, 6); + + if (onlyIdx == null) { + for (let i = 0; i < numItems; i++) each(i, coord(i, offs, iwid, gap), _iwid); + } else each(onlyIdx, coord(onlyIdx, offs, iwid, gap), _iwid); +} + +function timelinePlugin(opts: any): any { + const { mode, count, fill, stroke, laneWidthOption, showGrid } = opts; + + const pxRatio = devicePixelRatio; + + const laneWidth = laneWidthOption ?? 0.9; + + const laneDistr = SPACE_BETWEEN; + + const font = `${round(14 * pxRatio)}px Arial`; + + function walk( + yIdx: number | null, + count: any, + dim: number, + draw: { + (iy: any, y0: any, hgt: any): void; + (iy: any, y0: any, hgt: any): void; + (arg0: never, arg1: number, arg2: number): void; + }, + ): any { + distr( + count, + laneWidth, + laneDistr, + yIdx, + (i: any, offPct: number, dimPct: number) => { + const laneOffPx = dim * offPct; + const laneWidPx = dim * dimPct; + + draw(i, laneOffPx, laneWidPx); + }, + ); + } + + const size = opts.size ?? [0.6, Infinity]; + const align = opts.align ?? 0; + + const gapFactor = 1 - size[0]; + const maxWidth = (size[1] ?? inf) * pxRatio; + + const fillPaths = new Map(); + const strokePaths = new Map(); + + function drawBoxes(ctx: { + fillStyle: any; + fill: (arg0: any) => void; + strokeStyle: any; + stroke: (arg0: any) => void; + }): any { + fillPaths.forEach((fillPath, fillStyle) => { + ctx.fillStyle = fillStyle; + ctx.fill(fillPath); + }); + + strokePaths.forEach((strokePath, strokeStyle) => { + ctx.strokeStyle = strokeStyle; + ctx.stroke(strokePath); + }); + + fillPaths.clear(); + strokePaths.clear(); + } + + function putBox( + ctx: any, + rect: RectH, + xOff: number, + yOff: number, + lft: number, + top: number, + wid: number, + hgt: number, + strokeWidth: number, + iy: number, + ix: number, + value: number | null, + ): any { + const fillStyle = fill(iy + 1, ix, value); + let fillPath = fillPaths.get(fillStyle); + + if (fillPath == null) fillPaths.set(fillStyle, (fillPath = new Path2D())); + + rect(fillPath, lft, top, wid, hgt); + + if (strokeWidth) { + const strokeStyle = stroke(iy + 1, ix, value); + let strokePath = strokePaths.get(strokeStyle); + + if (strokePath == null) + strokePaths.set(strokeStyle, (strokePath = new Path2D())); + + rect( + strokePath, + lft + strokeWidth / 2, + top + strokeWidth / 2, + wid - strokeWidth, + hgt - strokeWidth, + ); + } + + qt.add({ + x: round(lft - xOff), + y: round(top - yOff), + w: wid, + h: hgt, + sidx: iy + 1, + didx: ix, + }); + } + + function drawPaths( + u: import('uplot'), + sidx: number, + idx0: any, + idx1: number, + ): any { + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect, + ) => { + const strokeWidth = round((series.width || 0) * pxRatio); + + u.ctx.save(); + rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + walk(sidx - 1, count, yDim, (iy: any, y0: number, hgt: any) => { + // draw spans + if (mode == 1) { + for (let ix = 0; ix < dataY.length; ix++) { + if (dataY[ix] != null) { + const lft = round(valToPosX(dataX[ix], scaleX, xDim, xOff)); + + let nextIx = ix; + while (dataY[++nextIx] === undefined && nextIx < dataY.length) {} + + // to now (not to end of chart) + const rgt = + nextIx == dataY.length + ? xOff + xDim + strokeWidth + : round(valToPosX(dataX[nextIx], scaleX, xDim, xOff)); + + putBox( + u.ctx, + rect, + xOff, + yOff, + lft, + round(yOff + y0), + rgt - lft, + round(hgt), + strokeWidth, + iy, + ix, + dataY[ix], + ); + + ix = nextIx - 1; + } + } + } + // draw matrix + else { + const colWid = + valToPosX(dataX[1], scaleX, xDim, xOff) - + valToPosX(dataX[0], scaleX, xDim, xOff); + const gapWid = colWid * gapFactor; + const barWid = round(min(maxWidth, colWid - gapWid) - strokeWidth); + const xShift = align == 1 ? 0 : align == -1 ? barWid : barWid / 2; + + for (let ix = idx0; ix <= idx1; ix++) { + if (dataY[ix] != null) { + // TODO: all xPos can be pre-computed once for all series in aligned set + const lft = valToPosX(dataX[ix], scaleX, xDim, xOff); + + putBox( + u.ctx, + rect, + xOff, + yOff, + round(lft - xShift), + round(yOff + y0), + barWid, + round(hgt), + strokeWidth, + iy, + ix, + dataY[ix], + ); + } + } + } + }); + + u.ctx.lineWidth = strokeWidth; + drawBoxes(u.ctx); + + u.ctx.restore(); + }, + ); + + return null; + } + + function drawPoints(u: import('uplot'), sidx: number, i0: any, i1: any): any { + u.ctx.save(); + u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + u.ctx.clip(); + + u.ctx.font = font; + u.ctx.fillStyle = 'black'; + u.ctx.textAlign = mode == 1 ? 'left' : 'center'; + u.ctx.textBaseline = 'middle'; + + uPlot.orient( + u, + sidx, + ( + series, + dataX, + dataY, + scaleX, + scaleY, + valToPosX, + valToPosY, + xOff, + yOff, + xDim, + yDim, + moveTo, + lineTo, + rect, + ) => { + const strokeWidth = round((series.width || 0) * pxRatio); + const textOffset = mode == 1 ? strokeWidth + 2 : 0; + + const y = round(yOff + yMids[sidx - 1]); + if (opts.displayTimelineValue) { + for (let ix = 0; ix < dataY.length; ix++) { + if (dataY[ix] != null) { + const x = valToPosX(dataX[ix], scaleX, xDim, xOff) + textOffset; + u.ctx.fillText(String(dataY[ix]), x, y); + } + } + } + }, + ); + + u.ctx.restore(); + + return false; + } + + let qt: { + add: (arg0: { x: any; y: any; w: any; h: any; sidx: any; didx: any }) => void; + clear: () => void; + get: ( + arg0: any, + arg1: any, + arg2: number, + arg3: number, + arg4: (o: any) => void, + ) => void; + }; + const hovered = Array(count).fill(null); + + let yMids = Array(count).fill(0); + const ySplits = Array(count).fill(0); + + const fmtDate = uPlot.fmtDate('{YYYY}-{MM}-{DD} {HH}:{mm}:{ss}'); + let legendTimeValueEl: { textContent: any } | null = null; + + return { + hooks: { + init: (u: { root: { querySelector: (arg0: string) => any } }): any => { + legendTimeValueEl = u.root.querySelector('.u-series:first-child .u-value'); + }, + drawClear: (u: { + bbox: { width: any; height: any }; + series: any[]; + }): any => { + qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); + + qt.clear(); + + // force-clear the path cache to cause drawBars() to rebuild new quadtree + u.series.forEach((s: { _paths: null }) => { + s._paths = null; + }); + }, + setCursor: (u: { + posToVal: (arg0: any, arg1: string) => any; + cursor: { left: any }; + scales: { x: { time: any } }; + }): any => { + if (mode == 1 && legendTimeValueEl) { + const val = u.posToVal(u.cursor.left, 'x'); + legendTimeValueEl.textContent = u.scales.x.time + ? fmtDate(new Date(val * 1e3)) + : val.toFixed(2); + } + }, + }, + opts: (u: { series: { label: any }[] }, opts: any): any => { + uPlot.assign(opts, { + cursor: { + // x: false, + y: false, + dataIdx: ( + u: { cursor: { left: number } }, + seriesIdx: number, + closestIdx: any, + xValue: any, + ) => { + if (seriesIdx == 0) return closestIdx; + + const cx = round(u.cursor.left * pxRatio); + + if (cx >= 0) { + const cy = yMids[seriesIdx - 1]; + + hovered[seriesIdx - 1] = null; + + qt.get(cx, cy, 1, 1, (o: { x: any; y: any; w: any; h: any }) => { + if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) + hovered[seriesIdx - 1] = o; + }); + } + + return hovered[seriesIdx - 1]?.didx; + }, + points: { + fill: 'rgba(0,0,0,0.3)', + bbox: (u: any, seriesIdx: number) => { + const hRect = hovered[seriesIdx - 1]; + + return { + left: hRect ? round(hRect.x / devicePixelRatio) : -10, + top: hRect ? round(hRect.y / devicePixelRatio) : -10, + width: hRect ? round(hRect.w / devicePixelRatio) : 0, + height: hRect ? round(hRect.h / devicePixelRatio) : 0, + }; + }, + }, + }, + scales: { + x: { + range(u: { data: number[][] }, min: number, max: number) { + if (mode == 2) { + const colWid = u.data[0][1] - u.data[0][0]; + const scalePad = colWid / 2; + + if (min <= u.data[0][0]) min = u.data[0][0] - scalePad; + + const lastIdx = u.data[0].length - 1; + + if (max >= u.data[0][lastIdx]) max = u.data[0][lastIdx] + scalePad; + } + + return [min, max]; + }, + }, + y: { + range: [0, 1], + }, + }, + }); + + uPlot.assign(opts.axes[0], { + splits: + mode == 2 + ? ( + u: { data: any[][] }, + axisIdx: any, + scaleMin: number, + scaleMax: number, + foundIncr: number, + foundSpace: any, + ): any => { + const splits = []; + + const dataIncr = u.data[0][1] - u.data[0][0]; + const skipFactor = ceil(foundIncr / dataIncr); + + for (let i = 0; i < u.data[0].length; i += skipFactor) { + const v = u.data[0][i]; + + if (v >= scaleMin && v <= scaleMax) splits.push(v); + } + + return splits; + } + : null, + grid: { + show: showGrid ?? mode != 2, + }, + }); + + uPlot.assign(opts.axes[1], { + splits: ( + u: { + bbox: { height: any }; + posToVal: (arg0: number, arg1: string) => any; + }, + axisIdx: any, + ) => { + walk(null, count, u.bbox.height, (iy: any, y0: number, hgt: number) => { + // vertical midpoints of each series' timeline (stored relative to .u-over) + yMids[iy] = round(y0 + hgt / 2); + ySplits[iy] = u.posToVal(yMids[iy] / pxRatio, 'y'); + }); + + return ySplits; + }, + values: () => + Array(count) + .fill(null) + .map((v, i) => u.series[i + 1].label), + gap: 15, + size: 70, + grid: { show: false }, + ticks: { show: false }, + + side: 3, + }); + + opts.series.forEach((s: any, i: number) => { + if (i > 0) { + uPlot.assign(s, { + // width: 0, + // pxAlign: false, + // stroke: "rgba(255,0,0,0.5)", + paths: drawPaths, + points: { + show: drawPoints, + }, + }); + } + }); + }, + }; +} + +export default timelinePlugin; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/actionButtons.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/actionButtons.styles.scss index 37d46dd0bff..479ac58df47 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/actionButtons.styles.scss +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/actionButtons.styles.scss @@ -3,6 +3,14 @@ align-items: center; gap: 12px; color: var(--bg-slate-400); + .ant-divider-vertical { + height: 16px; + border-color: var(--bg-slate-400); + margin: 0; + } + .dropdown-icon { + margin-right: 4px; + } } .dropdown-menu { border-radius: 4px; @@ -30,3 +38,22 @@ } } } + +.lightMode { + .alert-action-buttons { + .ant-divider-vertical { + border-color: var(--bg-vanilla-300); + } + } + .dropdown-menu { + background: inherit; + .delete-button { + &, + &span { + &:hover { + background: var(--bg-vanilla-300); + } + } + } + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/index.tsx b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/index.tsx index 1393d217b91..fd482982bad 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/index.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/ActionButtons/index.tsx @@ -1,7 +1,9 @@ import './actionButtons.styles.scss'; import { Button, Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; import { Copy, Ellipsis, PenLine, Trash2 } from 'lucide-react'; +import { useAlertRuleStatusToggle } from 'pages/AlertDetails/hooks'; import CopyToClipboard from 'periscope/components/CopyToClipboard'; import React from 'react'; @@ -26,11 +28,25 @@ const menuStyle: React.CSSProperties = { fontSize: 14, }; -function AlertActionButtons(): JSX.Element { +function AlertActionButtons({ + ruleId, + state, +}: { + ruleId: string; + state: string; +}): JSX.Element { + const { + handleAlertStateToggle, + isAlertRuleEnabled, + } = useAlertRuleStatusToggle({ ruleId, state }); + const isDarkMode = useIsDarkMode(); return (
- {/* TODO(shaheer): handle submitting data */} - + @@ -56,7 +72,12 @@ function AlertActionButtons(): JSX.Element { )} > - +
diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx index 19c48c49eca..5d15617ec50 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx @@ -21,23 +21,27 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element { return (
-
-
- -
{alert}
-
{id}
+
+
+
+ +
{alert}
+
{id}
+
- -
-
- +
+ - {/* // TODO(shaheer): Get actual data when we are able to get alert status from API */} - - + {/* // TODO(shaheer): Get actual data when we are able to get alert status from API */} + + +
+
+
+
); diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx index 892e6b872f5..786c53dd468 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels.tsx @@ -4,12 +4,18 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel/KeyValueLabel'; import SeeMore from 'periscope/components/SeeMore/SeeMore'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -type AlertLabelsProps = { labels: Record }; +type AlertLabelsProps = { + labels: Record; + initialCount?: number; +}; -function AlertLabels({ labels }: AlertLabelsProps): JSX.Element { +function AlertLabels({ + labels, + initialCount = 2, +}: AlertLabelsProps): JSX.Element { return (
- + {Object.entries(labels).map(([key, value]) => ( ))} @@ -18,4 +24,8 @@ function AlertLabels({ labels }: AlertLabelsProps): JSX.Element { ); } +AlertLabels.defaultProps = { + initialCount: 2, +}; + export default AlertLabels; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx index 01420209e27..e10a282342b 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertState/AlertState.tsx @@ -1,5 +1,6 @@ import './alertState.styles.scss'; +import { useIsDarkMode } from 'hooks/useDarkMode'; import { BellOff, CircleCheck, CircleOff, Flame } from 'lucide-react'; type AlertStateProps = { @@ -13,11 +14,9 @@ export default function AlertState({ }: AlertStateProps): JSX.Element { let icon; let label; - // TODO(shaheer): implement the states UI based on updated designs after the designs are updated - + const isDarkMode = useIsDarkMode(); switch (state) { case 'no-data': - case 'pending': icon = ( No Data; break; - case 'muted': + case 'disabled': - case 'inactive': icon = ( Firing; break; - case 'resolved': + + case 'normal': icon = ( ); label = Resolved; diff --git a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/alertStatus.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/alertStatus.styles.scss index 6eb306389e7..97549bf21d3 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/alertStatus.styles.scss +++ b/frontend/src/pages/AlertDetails/AlertHeader/AlertStatus/alertStatus.styles.scss @@ -14,3 +14,9 @@ gap: 3px; } } + +.lightMode { + .alert-status-info { + color: var(--text-ink-400); + } +} diff --git a/frontend/src/pages/AlertDetails/AlertHeader/alertHeader.styles.scss b/frontend/src/pages/AlertDetails/AlertHeader/alertHeader.styles.scss index fba6dba76b9..fbddf860fc0 100644 --- a/frontend/src/pages/AlertDetails/AlertHeader/alertHeader.styles.scss +++ b/frontend/src/pages/AlertDetails/AlertHeader/alertHeader.styles.scss @@ -1,31 +1,57 @@ .alert-info { display: flex; - flex-direction: column; - gap: 8px; + justify-content: space-between; + align-items: baseline; + padding: 0 16px; - &__top-section { + &__info-wrapper { display: flex; - align-items: center; - justify-content: space-between; - .alert-title-wrapper { + flex-direction: column; + gap: 8px; + height: 54px; + + .top-section { display: flex; align-items: center; - gap: 8px; - .alert-title { - font-size: 16px; - font-weight: var(--font-weight-medium); - color: var(--text-vanilla-100); - line-height: 24px; - } - .alert-id { - // color: var(--text-slate-50); - color: #62687c; + justify-content: space-between; + .alert-title-wrapper { + display: flex; + align-items: center; + gap: 8px; + .alert-title { + font-size: 16px; + font-weight: 500; + color: var(--text-vanilla-100); + line-height: 24px; + letter-spacing: -0.08px; + } + .alert-id { + // color: var(--text-slate-50); + color: #62687c; + font-size: 15px; + line-height: 20px; + letter-spacing: -0.075px; + } } } + .bottom-section { + display: flex; + align-items: center; + gap: 24px; + } } - &__bottom-section { - display: flex; - align-items: center; - gap: 24px; +} + +.lightMode { + .alert-info { + &__info-wrapper { + .top-section { + .alert-title-wrapper { + .alert-title { + color: var(--text-ink-100); + } + } + } + } } } diff --git a/frontend/src/pages/AlertDetails/alertDetails.styles.scss b/frontend/src/pages/AlertDetails/alertDetails.styles.scss index 357b2e37200..209426457db 100644 --- a/frontend/src/pages/AlertDetails/alertDetails.styles.scss +++ b/frontend/src/pages/AlertDetails/alertDetails.styles.scss @@ -3,18 +3,105 @@ justify-content: space-between; align-items: center; } + +.alert-details-tabs { + .top-level-tab.periscope-tab { + padding: 2px 0; + } + .ant-tabs { + &-nav { + margin-bottom: 0 !important; + &::before { + border-bottom: 1px solid var(--bg-slate-500) !important; + } + } + &-tab { + &[data-node-key='TriggeredAlerts'] { + margin-left: 16px; + } + &:not(:first-of-type) { + margin-left: 24px !important; + } + .periscope-tab { + font-size: 14px; + color: var(--text-vanilla-100); + line-height: 20px; + letter-spacing: -0.07px; + gap: 10px; + } + [aria-selected='false'] { + .periscope-tab { + color: var(--text-vanilla-400); + } + } + } + } +} + .alert-details { - margin-top: 16px; + margin-top: 10px; .divider { - border-color: var(--bg-slate-400); + border-color: var(--bg-slate-500); margin: 16px 0; } + .breadcrumb-divider { + margin-top: 10px; + } + &__breadcrumb { + padding-left: 16px; + .ant-breadcrumb-link { + color: var(--text-vanilla-400); + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + } + + .ant-breadcrumb-separator, + span.ant-breadcrumb-link { + color: var(--var-vanilla-500); + font-family: 'Geist Mono'; + } + } .tabs-and-filters { margin: 1rem 0; - & .ant-tabs-nav { - &::before { - border-bottom: none !important; + .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; } } @@ -31,32 +118,68 @@ @include flex-center; } } - // customize ant tabs - .ant-tabs { - &-nav-list { - border: 1px solid var(--bg-slate-400); - background: var(--bg-ink-400); - border-radius: 2px; - } - - &-tab { - padding: 0; + } +} - &-btn { - padding: 6px 17px; +.lightMode { + .alert-details { + &-tabs { + .ant-tabs-nav { + &::before { + border-bottom: 1px solid var(--bg-vanilla-300) !important; } - - &-active { - background: var(--bg-slate-400, #1d212d); + } + } + &__breadcrumb { + .ant-breadcrumb-link { + color: var(--text-ink-400); + } + .ant-breadcrumb-separator, + span.ant-breadcrumb-link { + color: var(--var-ink-500); + } + } + .tabs-and-filters { + .ant-tabs { + &-nav-list { + border: 1px solid var(--bg-vanilla-300); + background: var(--bg-vanilla-300); + } + &-tab { + &-btn { + &[aria-selected='true'] { + color: var(--text-robin-500) !important; + } + color: var(--text-ink-400) !important; + } + &-active { + background: var(--bg-vanilla-100); + } } } } + .divider { + border-color: var(--bg-vanilla-300); + } } -} -.alert-details-tabs .ant-tabs-nav { - margin-bottom: 0 !important; - &::before { - border-bottom: 1px solid var(--bg-slate-400) !important; + .alert-details-tabs { + .ant-tabs { + &-nav { + &::before { + border-bottom: 1px solid var(--bg-vanilla-300) !important; + } + } + &-tab { + .periscope-tab { + color: var(--text-ink-300); + } + [aria-selected='true'] { + .periscope-tab { + color: var(--text-ink-400); + } + } + } + } } } diff --git a/frontend/src/pages/AlertDetails/hooks.tsx b/frontend/src/pages/AlertDetails/hooks.tsx index 82d47d24506..845afb1d023 100644 --- a/frontend/src/pages/AlertDetails/hooks.tsx +++ b/frontend/src/pages/AlertDetails/hooks.tsx @@ -1,13 +1,40 @@ +import { FilterValue, SorterResult } from 'antd/es/table/interface'; +import { TablePaginationConfig, TableProps } from 'antd/lib'; import get from 'api/alerts/get'; +import patchAlert from 'api/alerts/patch'; +import ruleStats from 'api/alerts/ruleStats'; +import timelineGraph from 'api/alerts/timelineGraph'; +import timelineTable from 'api/alerts/timelineTable'; +import topContributors from 'api/alerts/topContributors'; import { TabRoutes } from 'components/RouteTab/types'; +import { QueryParams } from 'constants/query'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; import AlertHistory from 'container/AlertHistory'; -import { AlertDetailsTab } from 'container/AlertHistory/types'; +import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants'; +import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types'; +import { urlKey } from 'container/AllError/utils'; +import { useNotifications } from 'hooks/useNotifications'; import useUrlQuery from 'hooks/useUrlQuery'; +import createQueryParams from 'lib/createQueryParams'; +import GetMinMax from 'lib/getMinMax'; +import history from 'lib/history'; import { History, Table } from 'lucide-react'; import EditRules from 'pages/EditRules'; -import { useQuery, UseQueryResult } from 'react-query'; +import { OrderPreferenceItems } from 'pages/Logs/config'; +import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, UseQueryResult } from 'react-query'; import { generatePath, useLocation } from 'react-router-dom'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { + AlertRuleStatsPayload, + AlertRuleTimelineGraphResponsePayload, + AlertRuleTimelineTableResponse, + AlertRuleTimelineTableResponsePayload, + AlertRuleTopContributorsPayload, +} from 'types/api/alerts/def'; +import { nanoToMilli } from 'utils/timeUtils'; export const useRouteTabUtils = (): { routes: TabRoutes[] } => { const urlQuery = useUrlQuery(); @@ -15,15 +42,17 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => { const getRouteUrl = (tab: AlertDetailsTab): string => { let route = ''; let params = urlQuery.toString(); + const ruleIdKey = QueryParams.ruleId; + const relativeTimeKey = QueryParams.relativeTime; switch (tab) { case AlertDetailsTab.OVERVIEW: route = ROUTES.ALERT_OVERVIEW; break; case AlertDetailsTab.HISTORY: - params = `ruleId=${urlQuery.get('ruleId')}&relativeTime=${urlQuery.get( - 'relativeTime', - )}`; + params = `${ruleIdKey}=${urlQuery.get( + ruleIdKey, + )}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`; route = ROUTES.ALERT_HISTORY; break; default: @@ -64,16 +93,17 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => { export const useGetAlertRuleDetails = (): { ruleId: string | null; data: UseQueryResult; + isValidRuleId: boolean; } => { const { search } = useLocation(); const params = new URLSearchParams(search); - const ruleId = params.get('ruleId'); + const ruleId = params.get(QueryParams.ruleId); const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; - const data = useQuery(['ruleId', ruleId], { + const data = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { queryFn: () => get({ id: parseInt(ruleId || '', 10), @@ -83,5 +113,334 @@ export const useGetAlertRuleDetails = (): { refetchOnWindowFocus: false, }); - return { ruleId, data }; + return { ruleId, data, 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 { search } = useLocation(); + const params = new URLSearchParams(search); + + const ruleId = params.get(QueryParams.ruleId); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime], + { + queryFn: () => + ruleStats({ + id: parseInt(ruleId || '', 10), + start: parseInt(startTime || '', 10), + end: parseInt(endTime || '', 10), + }), + 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 { search } = useLocation(); + const params = new URLSearchParams(search); + + const ruleId = params.get(QueryParams.ruleId); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime], + { + queryFn: () => + topContributors({ + id: parseInt(ruleId || '', 10), + start: parseInt(startTime || '', 10), + end: parseInt(endTime || '', 10), + }), + enabled: isValidRuleId, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; +}; + +type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimelineTableProps => { + const { search } = useLocation(); + + const params = useMemo(() => new URLSearchParams(search), [search]); + + const { updatedOrder, getUpdatedOffset } = useMemo( + () => ({ + updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC, + getUpdatedOffset: params.get(urlKey.offset) ?? '0', + }), + [params], + ); + + const ruleId = params.get(QueryParams.ruleId); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); + const timelineFilter = params.get('timelineFilter'); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + const hasStartAndEnd = startTime !== null && endTime !== null; + + const { isLoading, isRefetching, isError, data } = useQuery( + [ + REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE, + ruleId, + startTime, + endTime, + timelineFilter, + updatedOrder, + getUpdatedOffset, + ], + { + queryFn: () => + timelineTable({ + id: parseInt(ruleId || '', 10), + start: parseInt(startTime || '', 10), + end: parseInt(endTime || '', 10), + limit: TIMELINE_TABLE_PAGE_SIZE, + order: updatedOrder, + offset: parseInt(getUpdatedOffset, 10), + + // TODO(shaheer): ask Srikanth about why it doesn't work + // filters: { + // items: [ + // { + // key: { key: 'label' }, + // value: 'value', + // op: '=', + // }, + // ], + // }, + + ...(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 updatedOffset = params.get(urlKey.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 { columnKey = '', order } = sorter; + const updatedOrder = order === 'ascend' ? 'asc' : 'desc'; + const params = new URLSearchParams(window.location.search); + + history.replace( + `${pathname}?${createQueryParams({ + ...Object.fromEntries(params), + order: updatedOrder, + offset: current - 1, + orderParam: columnKey, + pageSize, + })}`, + ); + } + }, + [pathname], + ); + + const paginationConfig: TablePaginationConfig = { + pageSize: TIMELINE_TABLE_PAGE_SIZE, + showTotal: PaginationInfoText, + current: parseInt(updatedOffset, 10) + 1, + showSizeChanger: false, + hideOnSinglePage: true, + total: totalItems, + }; + + return { paginationConfig, onChangeHandler }; +}; + +export const useSetStartAndEndTimeFromRelativeTime = (): void => { + const { pathname, search } = useLocation(); + const searchParams = useMemo(() => new URLSearchParams(search), [search]); + + const { relativeTime, startTime, endTime } = useMemo( + () => ({ + relativeTime: searchParams.get(QueryParams.relativeTime), + startTime: searchParams.get(QueryParams.startTime), + endTime: searchParams.get(QueryParams.endTime), + }), + [searchParams], + ); + + useEffect(() => { + if ( + !relativeTime || + pathname !== ROUTES.ALERT_HISTORY || + startTime || + endTime + ) { + return; + } + + const { minTime, maxTime } = GetMinMax(relativeTime); + + searchParams.set(QueryParams.startTime, nanoToMilli(minTime).toString()); + searchParams.set(QueryParams.endTime, nanoToMilli(maxTime).toString()); + + history.push({ search: searchParams.toString() }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [relativeTime, startTime, endTime]); +}; + +export const useAlertRuleStatusToggle = ({ + state, + ruleId, +}: { + state: string; + ruleId: string; +}): { + handleAlertStateToggle: (state: boolean) => void; + isAlertRuleEnabled: boolean; +} => { + const { notifications } = useNotifications(); + const defaultErrorMessage = 'Something went wrong'; + const isAlertRuleInitiallyEnabled = state !== 'disabled'; + const [isAlertRuleEnabled, setIsAlertRuleEnabled] = useState( + isAlertRuleInitiallyEnabled, + ); + + const { mutate: toggleAlertState } = useMutation( + ['toggle-alert-state', ruleId], + patchAlert, + { + onMutate: () => { + setIsAlertRuleEnabled((prev) => !prev); + }, + onSuccess: () => { + notifications.success({ + message: `Alert has been turned ${!isAlertRuleEnabled ? 'on' : 'off'}.`, + }); + }, + onError: () => { + setIsAlertRuleEnabled(isAlertRuleInitiallyEnabled); + notifications.error({ + message: defaultErrorMessage, + }); + }, + }, + ); + + const handleAlertStateToggle = (state: boolean): void => { + const args = { id: parseInt(ruleId, 10), data: { disabled: !state } }; + toggleAlertState(args); + }; + + return { handleAlertStateToggle, isAlertRuleEnabled }; +}; + +type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & { + data: + | SuccessResponse + | ErrorResponse + | undefined; +}; + +export const useGetAlertRuleDetailsTimelineGraphData = (): GetAlertRuleDetailsTimelineGraphProps => { + const { search } = useLocation(); + + const params = useMemo(() => new URLSearchParams(search), [search]); + + const ruleId = params.get(QueryParams.ruleId); + const startTime = params.get(QueryParams.startTime); + const endTime = params.get(QueryParams.endTime); + + const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; + const hasStartAndEnd = startTime !== null && endTime !== null; + + const { isLoading, isRefetching, isError, data } = useQuery( + [REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime], + { + queryFn: () => + timelineGraph({ + id: parseInt(ruleId || '', 10), + start: parseInt(startTime || '', 10), + end: parseInt(endTime || '', 10), + }), + enabled: isValidRuleId && hasStartAndEnd, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId }; }; diff --git a/frontend/src/pages/AlertDetails/index.tsx b/frontend/src/pages/AlertDetails/index.tsx index 4c5ae9b0de0..8e2bee1f833 100644 --- a/frontend/src/pages/AlertDetails/index.tsx +++ b/frontend/src/pages/AlertDetails/index.tsx @@ -1,7 +1,8 @@ import './alertDetails.styles.scss'; -import { Breadcrumb, ConfigProvider, Divider } from 'antd'; +import { Breadcrumb, Divider } from 'antd'; import { Filters } from 'components/AlertDetailsFilters/Filters'; +import NotFound from 'components/NotFound'; import RouteTab from 'components/RouteTab'; import Spinner from 'components/Spinner'; import ROUTES from 'constants/routes'; @@ -11,7 +12,11 @@ import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import AlertHeader from './AlertHeader/AlertHeader'; -import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks'; +import { + useGetAlertRuleDetails, + useRouteTabUtils, + useSetStartAndEndTimeFromRelativeTime, +} from './hooks'; import { AlertDetailsStatusRendererProps } from './types'; function AlertDetailsStatusRenderer({ @@ -33,63 +38,51 @@ function AlertDetailsStatusRenderer({ return ; } - function AlertDetails(): JSX.Element { const { pathname } = useLocation(); const { routes } = useRouteTabUtils(); + useSetStartAndEndTimeFromRelativeTime(); + const { data: { isLoading, data, isRefetching, isError }, ruleId, + isValidRuleId, } = useGetAlertRuleDetails(); + if (isError || !isValidRuleId) { + return ; + } + return ( - + -
- - - + + + + +
+ } /> - -
- } - /> -
- +
); } diff --git a/frontend/src/pages/AlertList/index.tsx b/frontend/src/pages/AlertList/index.tsx index 2db927de521..19d746e8f0d 100644 --- a/frontend/src/pages/AlertList/index.tsx +++ b/frontend/src/pages/AlertList/index.tsx @@ -19,11 +19,13 @@ function AllAlertList(): JSX.Element { const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY; const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW; + const search = urlQuery.get('search'); + const items: TabsProps['items'] = [ { label: ( -
- +
+ Triggered Alerts
), @@ -32,8 +34,8 @@ function AllAlertList(): JSX.Element { }, { label: ( -
- +
+ Alert Rules
), @@ -43,7 +45,7 @@ function AllAlertList(): JSX.Element { }, { label: ( -
+
Configuration
@@ -60,7 +62,12 @@ function AllAlertList(): JSX.Element { activeKey={tab || 'AlertRules'} onChange={(tab): void => { urlQuery.set('tab', tab); - history.replace(`/alerts?${urlQuery.toString()}`); + let params = `tab=${tab}`; + + if (search) { + params += `&search=${search}`; + } + history.replace(`/alerts?${params}`); }} className={`${ isAlertHistory || isAlertOverview ? 'alert-details-tabs' : '' diff --git a/frontend/src/pages/EditRules/index.tsx b/frontend/src/pages/EditRules/index.tsx index 61788e2b2d9..4d9e6c087a7 100644 --- a/frontend/src/pages/EditRules/index.tsx +++ b/frontend/src/pages/EditRules/index.tsx @@ -1,5 +1,7 @@ import get from 'api/alerts/get'; import Spinner from 'components/Spinner'; +import { QueryParams } from 'constants/query'; +import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import ROUTES from 'constants/routes'; import EditRulesContainer from 'container/EditRules'; import { useNotifications } from 'hooks/useNotifications'; @@ -12,14 +14,14 @@ import { useLocation } from 'react-router-dom'; function EditRules(): JSX.Element { const { search } = useLocation(); const params = new URLSearchParams(search); - const ruleId = params.get('ruleId'); + const ruleId = params.get(QueryParams.ruleId); const { t } = useTranslation('common'); const isValidRuleId = ruleId !== null && String(ruleId).length !== 0; const { isLoading, data, isRefetching, isError } = useQuery( - ['ruleId', ruleId], + [REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], { queryFn: () => get({ diff --git a/frontend/src/periscope/components/CopyToClipboard/copyToClipboard.styles.scss b/frontend/src/periscope/components/CopyToClipboard/copyToClipboard.styles.scss index 51e8472ff57..7a55632ae63 100644 --- a/frontend/src/periscope/components/CopyToClipboard/copyToClipboard.styles.scss +++ b/frontend/src/periscope/components/CopyToClipboard/copyToClipboard.styles.scss @@ -3,7 +3,22 @@ align-items: center; gap: 10px; font-size: 14px; - width: 120px; + padding: 4px 6px; + width: 100px; + + &:hover { + background-color: transparent !important; + } + + .ant-btn-icon { + margin: 0 !important; + } + & > * { + color: var(--text-vanilla-400); + font-weight: 400; + line-height: 20px; + letter-spacing: -0.07px; + } &--success { & span, @@ -12,3 +27,13 @@ } } } + +.lightMode { + .copy-to-clipboard { + &:not(&--success) { + & > * { + color: var(--text-ink-400); + } + } + } +} diff --git a/frontend/src/periscope/components/CopyToClipboard/index.tsx b/frontend/src/periscope/components/CopyToClipboard/index.tsx index 6c2d755eacc..adc2551c5a2 100644 --- a/frontend/src/periscope/components/CopyToClipboard/index.tsx +++ b/frontend/src/periscope/components/CopyToClipboard/index.tsx @@ -1,6 +1,7 @@ import './copyToClipboard.styles.scss'; import { Button } from 'antd'; +import { useIsDarkMode } from 'hooks/useDarkMode'; import { CircleCheck, Link2 } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useCopyToClipboard } from 'react-use'; @@ -8,6 +9,7 @@ import { useCopyToClipboard } from 'react-use'; function CopyToClipboard({ textToCopy }: { textToCopy: string }): JSX.Element { const [state, copyToClipboard] = useCopyToClipboard(); const [success, setSuccess] = useState(false); + const isDarkMode = useIsDarkMode(); useEffect(() => { let timer: string | number | NodeJS.Timeout | undefined; @@ -35,7 +37,12 @@ function CopyToClipboard({ textToCopy }: { textToCopy: string }): JSX.Element { @@ -69,6 +73,7 @@ Tabs2.defaultProps = { initialSelectedTab: '', onSelectTab: (): void => {}, hasResetButton: false, + buttonMinWidth: '114px', }; export default Tabs2; diff --git a/frontend/src/periscope/components/Tabs2/tabs2.styles.scss b/frontend/src/periscope/components/Tabs2/tabs2.styles.scss index 04798559986..59b5156cdd4 100644 --- a/frontend/src/periscope/components/Tabs2/tabs2.styles.scss +++ b/frontend/src/periscope/components/Tabs2/tabs2.styles.scss @@ -16,6 +16,13 @@ letter-spacing: -0.07px; padding: 6px 24px; border-color: var(--bg-slate-400); + justify-content: center; + } + &.reset-button { + .ant-btn-icon { + margin: 0; + } + padding: 6px 12px; } &.selected { color: var(--text-vanilla-100); @@ -23,3 +30,19 @@ } } } + +.lightMode { + .tabs-wrapper { + .tab { + &.ant-btn-default { + color: var(--text-ink-400); + background: var(--bg-vanilla-300); + border-color: var(--bg-vanilla-300); + } + &.selected { + color: var(--text-robin-500); + background: var(--bg-vanilla-100); + } + } + } +} diff --git a/frontend/src/types/api/alerts/def.ts b/frontend/src/types/api/alerts/def.ts index c773cb78a2c..9393ccd5a02 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 00000000000..2669a4c6be2 --- /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 00000000000..2d317c4a3c4 --- /dev/null +++ b/frontend/src/types/api/alerts/timelineGraph.ts @@ -0,0 +1,11 @@ +import { AlertDef } from './def'; + +export interface Filters { + [k: string]: string | Record[]; +} + +export interface GetTimelineGraphRequestProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/types/api/alerts/timelineTable.ts b/frontend/src/types/api/alerts/timelineTable.ts new file mode 100644 index 00000000000..d25cc5af0ca --- /dev/null +++ b/frontend/src/types/api/alerts/timelineTable.ts @@ -0,0 +1,16 @@ +import { AlertDef } from './def'; + +export interface Filters { + [k: string]: string | Record[]; +} + +export interface GetTimelineTableRequestProps { + id: AlertDef['id']; + start: number; + end: number; + offset: number; + limit: number; + order: string; + filters?: Filters; + 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 00000000000..74acb4b8717 --- /dev/null +++ b/frontend/src/types/api/alerts/topContributors.ts @@ -0,0 +1,7 @@ +import { AlertDef } from './def'; + +export interface TopContributorsProps { + id: AlertDef['id']; + start: number; + end: number; +} diff --git a/frontend/src/utils/calculateChange.ts b/frontend/src/utils/calculateChange.ts index 17186ac0110..4e3d912f0d0 100644 --- a/frontend/src/utils/calculateChange.ts +++ b/frontend/src/utils/calculateChange.ts @@ -1,11 +1,25 @@ export function calculateChange( - totalCurrentTriggers: number, - totalPastTriggers: number, + 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; - const changeDirection = changePercentage >= 0 ? 1 : -1; + let changeDirection = 0; + + if (changePercentage < 0) { + changeDirection = -1; + } else if (changePercentage > 0) { + changeDirection = 1; + } changePercentage = Math.abs(changePercentage); changePercentage = Math.round(changePercentage); diff --git a/frontend/src/utils/timeUtils.ts b/frontend/src/utils/timeUtils.ts index b21500c3c50..5eb795bf459 100644 --- a/frontend/src/utils/timeUtils.ts +++ b/frontend/src/utils/timeUtils.ts @@ -65,7 +65,7 @@ export const getDurationFromNow = (epochTimestamp: number): string => { * @returns {string} - The formatted date and time string in the format "MMM D, YYYY ⎯ HH:MM:SS". */ export function formatEpochTimestamp(epoch: number): string { - const date = new Date(epoch * 1000); + const date = new Date(epoch); const optionsDate: Intl.DateTimeFormatOptions = { month: 'short', @@ -85,3 +85,43 @@ export function formatEpochTimestamp(epoch: number): string { return `${formattedDate} ⎯ ${formattedTime}`; } + +/** + * Converts a given number of seconds into a human-readable format. + * @param {number} seconds The number of seconds to convert. + * @returns {string} The formatted time string, either in days (e.g., "1.2d"), hours (e.g., "1.2h"), minutes (e.g., "~7m"), or seconds (e.g., "~45s"). + */ + +export function formatTime(seconds: number): string { + const days = seconds / 86400; + + if (days >= 1) { + return `${days.toFixed(1)}d`; + } + + const hours = seconds / 3600; + if (hours >= 1) { + return `${hours.toFixed(1)}h`; + } + + const minutes = seconds / 60; + if (minutes >= 1) { + return `${minutes.toFixed(1)}m`; + } + + return `${seconds.toFixed(1)}s`; +} + +export const nanoToMilli = (nanoseconds: number): number => + nanoseconds / 1_000_000; + +export const epochToTimeString = (epochMs: number): string => { + console.log({ epochMs }); + const date = new Date(epochMs); + const options: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }; + return date.toLocaleTimeString('en-US', options); +};