Compare commits

...

4 Commits

Author SHA1 Message Date
Shaheer Kochai
df726be7b3 Fix/alert history UI fixes (#5776)
* fix: remove extra padding from alert overview query section tabs

* fix: add padding to alert overview container

* fix: improve breadcrumb click behavior

* chore: temporarily hide reset button from alert details timepicker

* fix: improve breadcrumb click behavior

* chore: hide alert firing since

* fix: don't use the data state renderer for timeline table
2024-08-27 16:05:02 +05:30
Shaheer Kochai
7960a7bcc9 alert rule history skeleton using static data (#5688)
* feat: alert history basic tabs and fitlers UI

* feat: route based tabs for alert history and overview and improve the UI to match designs

* feat: top contributors UI using static data

* feat: avg. resolution time and total triggered stats card UI using static data

* feat: tabs component

* feat: timeline tabs and filters

* feat: overall status graph UI using dummy data with graph placeholder

* feat: timeline table and pagination UI using dummy data

* fix: bugfix in reset tabs

* feat: add popover to go to logs/traces to top contributors and timeline table

* chore: remove comments

* chore: rename AlertIcon to AlertState

* fix: add cursor pointer to timeline table rows

* feat: add parent tabs to alert history

* chore: add icon to the configure tab

* fix: display popover on hovering the more button in see more component

* fix: wrap key value label

* feat: alert rule history enable/disable toggle UI

* Feat: get alert history data from API (#5718)

* feat: alert history basic tabs and fitlers UI

* feat: route based tabs for alert history and overview and improve the UI to match designs

* feat: data state renderer component

* feat: get total triggered and avg. resolution cards data from API

* fix: hide stats card if we get NaN

* chore: improve rule stats types

* feat: get top contributors data from API

* feat: get timeline table data from API

* fix: properly render change percentage indicator

* feat: total triggered and avg resolution empty states

* fix: fix stats height issue that would cause short border-right in empty case

* feat: top contributors empty state

* fix: fix table and graph borders

* feat: build alert timeline labels filter and handle client side filtering

* fix: select the first tab on clicking reset

* feat: set param and send in payload on clicking timeline filter tabs

* Feat: alert history timeline remaining subtasks except graphs (#5720)

* feat: alert history basic tabs and fitlers UI

* feat: route based tabs for alert history and overview and improve the UI to match designs

* feat: implement timeline table sorting

* chore: add initial count to see more and alert labels

* chore: move PaginationInfoText component to /periscope

* chore: implement top contributor rows using Ant Table

* feat: top contributors view all

* fix: hide border for last row and prevent layout shift in top contributors by specifying height

* feat: properly display duration in average resolution time

* fix: properly display normal alert rule state

* feat: add/remove view all top contributors param to url on opening/closing view all

* feat: calculate start and end time from relative time and add/remove param to url

* fix: fix console warnings

* fix: enable timeline table query only if start and end times exist

* feat: handle enable/disable alert rule toggle request

* chore: replace string values with constants

* fix: hide stats card if only past data is available + remove unnecessary states from AlertState

* fix: redirect configure alert rule to alert overview tab

* fix: display total triggers in timeline chart wrapper based on API response data

* fix: choosing the same relative time doesn't udpate start and end time

* Feat: total triggered and avg. resolution time graph (#5750)

* feat: alert history basic tabs and fitlers UI

* feat: route based tabs for alert history and overview and improve the UI to match designs

* feat: handle enable/disable alert rule toggle request

* feat: stats card line chart

* fix: overall improvements to stats card graph

* fix: overall UI improvements to match the Figma screens

* chore: remove duplicate hook

* fix: make the changes w.r.t timeline table API changes to prevent breaking the page

* fix: update stats card null check based on updated API response

* feat: stats card no previous data UI

* feat: redirect to 404 page if rule id is invalid

* chore: improve alert enable toggle success toast message

* feat: get top contributors row and timeline table row related logs and traces links from API

* feat: get total items from API and make pagination work

* feat: implement timeline filters based on API response

* fix: in case of current and target units, convert the value unit in timeline table

* fix: timeline table y axis unit null check

* fix: hide stats card graph if only a single entry is there in timeseries

* chore: redirect alert from all alerts to overview tab

* fix: prevent adding extra unnecessary params on clicking alerts top level tabs

* chore: use conditional alert popover in timeline table and import the scss file

* fix: prevent infinity if we receive totalPastTriggers as '0'

* fix: improve UI to be pixel perfect based on figma designs

* fix: fix the incorrect change direction

* fix: add height to top contributors row

* feat: alert history light mode

* fix: remove the extra padding from alert overview query builder tabs

* chore: overall improvements

* chore: remove mock file

* fix: overall improvements

* fix: add dark mode support for top contributors empty state

* chore: improve timeline chart placeholder bg in light mode

* Feat: alert history horizontal timeline chart (#5773)

* feat: timeline horizontal chart

* fix: remove the labels from horizontal timeline chart

* chore: add null check to timeline chart

* chore: hide cursor from timeline chart

* fix: fix the blank container being displayed in loading state
2024-08-27 13:21:05 +04:30
Shaheer Kochai
bd9990d86c feat: alert rule details metadata header (#5675)
* feat: alert history basic tabs and fitlers UI

* feat: route based tabs for alert history and overview and improve the UI to match designs

* chore: unused components and files cleanup

* feat: copy to clipboard component

* feat: see more component

* feat: key value label component

* feat: alert rule details meta data header

* fix: apply the missing changes

* chore: uncomment the alert status with static data

* chore: compress the alert status svg icons and define props, types, and defaultProps
2024-08-27 13:14:06 +04:30
Shaheer Kochai
a3f2da24dd feat: tabs and filters for alert history page (#5655)
* feat: alert history page route and component setup

* feat: alert history basic tabs and fitlers UI

* feat: route based tabs for alert history and overview and improve the UI to match designs

* chore: unused components and files cleanup

* chore: improve alert history and overview route paths

* chore: use parent selector in scss files

* chore: alert -> alerts
2024-08-27 13:14:06 +04:30
102 changed files with 5236 additions and 32 deletions

View File

@@ -38,5 +38,7 @@
"LIST_LICENSES": "SigNoz | List of Licenses",
"WORKSPACE_LOCKED": "SigNoz | Workspace Locked",
"SUPPORT": "SigNoz | Support",
"DEFAULT": "Open source Observability Platform | SigNoz"
"DEFAULT": "Open source Observability Platform | SigNoz",
"ALERT_HISTORY": "SigNoz | Alert Rule History",
"ALERT_OVERVIEW": "SigNoz | Alert Rule Overview"
}

View File

@@ -49,5 +49,7 @@
"TRACES_SAVE_VIEWS": "SigNoz | Traces Saved Views",
"DEFAULT": "Open source Observability Platform | SigNoz",
"SHORTCUTS": "SigNoz | Shortcuts",
"INTEGRATIONS": "SigNoz | Integrations"
"INTEGRATIONS": "SigNoz | Integrations",
"ALERT_HISTORY": "SigNoz | Alert Rule History",
"ALERT_OVERVIEW": "SigNoz | Alert Rule Overview"
}

View File

@@ -92,6 +92,14 @@ export const CreateNewAlerts = Loadable(
() => import(/* webpackChunkName: "Create Alerts" */ 'pages/CreateAlert'),
);
export const AlertHistory = Loadable(
() => import(/* webpackChunkName: "Alert History" */ 'pages/AlertList'),
);
export const AlertOverview = Loadable(
() => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'),
);
export const CreateAlertChannelAlerts = Loadable(
() =>
import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'),

View File

@@ -2,6 +2,8 @@ import ROUTES from 'constants/routes';
import { RouteProps } from 'react-router-dom';
import {
AlertHistory,
AlertOverview,
AllAlertChannels,
AllErrors,
APIKeys,
@@ -169,6 +171,20 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'ALERTS_NEW',
},
{
path: ROUTES.ALERT_HISTORY,
exact: true,
component: AlertHistory,
isPrivate: true,
key: 'ALERT_HISTORY',
},
{
path: ROUTES.ALERT_OVERVIEW,
exact: true,
component: AlertOverview,
isPrivate: true,
key: 'ALERT_OVERVIEW',
},
{
path: ROUTES.TRACE,
exact: true,

View File

@@ -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);
}
};

View File

@@ -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<SuccessResponse<AlertRuleStatsPayload> | 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;

View File

@@ -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<AlertRuleTimelineGraphResponsePayload> | 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;

View File

@@ -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<AlertRuleTimelineTableResponsePayload> | 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;

View File

@@ -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<AlertRuleTopContributorsPayload> | 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;

View File

@@ -0,0 +1,41 @@
interface ConfigureIconProps {
width?: number;
height?: number;
fill?: string;
}
function ConfigureIcon({
width,
height,
fill,
}: ConfigureIconProps): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
>
<path
stroke="#C0C1C3"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.333"
d="M9.71 4.745a.576.576 0 000 .806l.922.922a.576.576 0 00.806 0l2.171-2.171a3.455 3.455 0 01-4.572 4.572l-3.98 3.98a1.222 1.222 0 11-1.727-1.728l3.98-3.98a3.455 3.455 0 014.572-4.572L9.717 4.739l-.006.006z"
/>
<path
stroke="#C0C1C3"
strokeLinecap="round"
strokeWidth="1.333"
d="M4 7L2.527 5.566a1.333 1.333 0 01-.013-1.898l.81-.81a1.333 1.333 0 011.991.119L5.333 3m5.417 7.988l1.179 1.178m0 0l-.138.138a.833.833 0 00.387 1.397v0a.833.833 0 00.792-.219l.446-.446a.833.833 0 00.176-.917v0a.833.833 0 00-1.355-.261l-.308.308z"
/>
</svg>
);
}
ConfigureIcon.defaultProps = {
width: 16,
height: 16,
fill: 'none',
};
export default ConfigureIcon;

View File

@@ -0,0 +1,65 @@
interface LogsIconProps {
width?: number;
height?: number;
fill?: string;
strokeColor?: string;
strokeWidth?: number;
}
function LogsIcon({
width,
height,
fill,
strokeColor,
strokeWidth,
}: LogsIconProps): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
>
<path
stroke={strokeColor}
strokeWidth={strokeWidth}
d="M2.917 3.208v7.875"
/>
<ellipse
cx="6.417"
cy="3.208"
stroke={strokeColor}
strokeWidth={strokeWidth}
rx="3.5"
ry="1.458"
/>
<ellipse cx="6.417" cy="3.165" fill={strokeColor} rx="0.875" ry="0.365" />
<path
stroke={strokeColor}
strokeWidth={strokeWidth}
d="M9.917 11.083c0 .645-1.567 1.167-3.5 1.167s-3.5-.522-3.5-1.167"
/>
<path
stroke={strokeColor}
strokeLinecap="round"
strokeWidth={strokeWidth}
d="M5.25 6.417v1.117c0 .028.02.053.049.057l1.652.276A.058.058 0 017 7.924v1.993"
/>
<path
stroke={strokeColor}
strokeWidth={strokeWidth}
d="M9.917 3.208v3.103c0 .046.05.074.089.05L12.182 5a.058.058 0 01.088.035l.264 1.059a.058.058 0 01-.013.053l-2.59 2.877a.058.058 0 00-.014.04v2.018"
/>
</svg>
);
}
LogsIcon.defaultProps = {
width: 14,
height: 14,
fill: 'none',
strokeColor: '#C0C1C3',
strokeWidth: 1.167,
};
export default LogsIcon;

View File

@@ -0,0 +1,39 @@
interface SeverityCriticalIconProps {
width?: number;
height?: number;
fill?: string;
stroke?: string;
}
function SeverityCriticalIcon({
width,
height,
fill,
stroke,
}: SeverityCriticalIconProps): JSX.Element {
return (
<svg
width={width}
height={height}
fill={fill}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.99707.666056.99707 2.99939M.99707 5.33337H.991237M3.00293.666056 3.00293 2.99939M3.00293 5.33337H2.9971M5.00879.666056V2.99939M5.00879 5.33337H5.00296"
stroke={stroke}
strokeWidth="1.16667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
SeverityCriticalIcon.defaultProps = {
width: 6,
height: 6,
fill: 'none',
stroke: '#F56C87',
};
export default SeverityCriticalIcon;

View File

@@ -0,0 +1,42 @@
interface SeverityErrorIconProps {
width?: number;
height?: number;
fill?: string;
stroke?: string;
strokeWidth?: string;
}
function SeverityErrorIcon({
width,
height,
fill,
stroke,
strokeWidth,
}: SeverityErrorIconProps): JSX.Element {
return (
<svg
width={width}
height={height}
fill={fill}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.00781.957845 1.00781 2.99951M1.00781 5.04175H1.00228"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
SeverityErrorIcon.defaultProps = {
width: 2,
height: 6,
fill: 'none',
stroke: '#F56C87',
strokeWidth: '1.02083',
};
export default SeverityErrorIcon;

View File

@@ -0,0 +1,46 @@
interface SeverityInfoIconProps {
width?: number;
height?: number;
fill?: string;
stroke?: string;
}
function SeverityInfoIcon({
width,
height,
fill,
stroke,
}: SeverityInfoIconProps): JSX.Element {
return (
<svg
width={width}
height={height}
fill={fill}
xmlns="http://www.w3.org/2000/svg"
>
<rect
width={width}
height={height}
rx="3.5"
fill={stroke}
fillOpacity=".2"
/>
<path
d="M7 9.33346V7.00012M7 4.66675H7.00583"
stroke={stroke}
strokeWidth="1.16667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
SeverityInfoIcon.defaultProps = {
width: 14,
height: 14,
fill: 'none',
stroke: '#7190F9',
};
export default SeverityInfoIcon;

View File

@@ -0,0 +1,42 @@
interface SeverityWarningIconProps {
width?: number;
height?: number;
fill?: string;
stroke?: string;
strokeWidth?: string;
}
function SeverityWarningIcon({
width,
height,
fill,
stroke,
strokeWidth,
}: SeverityWarningIconProps): JSX.Element {
return (
<svg
width={width}
height={height}
fill={fill}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.00732.957845 1.00732 2.99951M1.00732 5.04175H1.00179"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
SeverityWarningIcon.defaultProps = {
width: 2,
height: 6,
fill: 'none',
stroke: '#FFD778',
strokeWidth: '0.978299',
};
export default SeverityWarningIcon;

View File

@@ -0,0 +1,35 @@
import './filters.styles.scss';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
export function Filters(): JSX.Element {
// const urlQuery = useUrlQuery();
// const history = useHistory();
// const relativeTime = urlQuery.get(QueryParams.relativeTime);
// const handleFiltersReset = (): void => {
// urlQuery.set(QueryParams.relativeTime, RelativeTimeMap['30min']);
// urlQuery.delete(QueryParams.startTime);
// urlQuery.delete(QueryParams.endTime);
// history.replace({
// pathname: history.location.pathname,
// search: `?${urlQuery.toString()}`,
// });
// };
return (
<div className="filters">
{/* TODO(shaheer): re-enable reset button after fixing the issue w.r.t. updated timeInterval not updating in time picker */}
{/* {relativeTime !== RelativeTimeMap['30min'] && (
<Button
type="default"
className="reset-button"
onClick={handleFiltersReset}
icon={<Undo size={14} />}
>
Reset
</Button>
)} */}
<DateTimeSelector showAutoRefresh={false} hideShareModal />
</div>
);
}

View File

@@ -0,0 +1,14 @@
.reset-button {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--Ink-300, #16181d);
border: 1px solid var(--Slate-400, #1d212d);
}
.lightMode {
.reset-button {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
}

View File

@@ -0,0 +1,41 @@
import './tabs.styles.scss';
import { Radio } from 'antd';
import { RadioChangeEvent } from 'antd/lib';
import { History, Table } from 'lucide-react';
import { useState } from 'react';
import { ALERT_TABS } from '../constants';
export function Tabs(): JSX.Element {
const [selectedTab, setSelectedTab] = useState('overview');
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedTab(e.target.value);
};
return (
<Radio.Group className="tabs" onChange={handleTabChange} value={selectedTab}>
<Radio.Button
className={
selectedTab === ALERT_TABS.OVERVIEW ? 'selected_view tab' : 'tab'
}
value={ALERT_TABS.OVERVIEW}
>
<div className="tab-title">
<Table size={14} />
Overview
</div>
</Radio.Button>
<Radio.Button
className={selectedTab === ALERT_TABS.HISTORY ? 'selected_view tab' : 'tab'}
value={ALERT_TABS.HISTORY}
>
<div className="tab-title">
<History size={14} />
History
</div>
</Radio.Button>
</Radio.Group>
);
}

View File

@@ -0,0 +1,5 @@
.tab-title {
display: flex;
gap: 4px;
align-items: center;
}

View File

@@ -0,0 +1,16 @@
import './tabsAndFilters.styles.scss';
import { Filters } from './Filters/Filters';
import { Tabs } from './Tabs/Tabs';
function TabsAndFilters(): JSX.Element {
// TODO(shaheer): make it a reusable component inside periscope
return (
<div className="tabs-and-filters">
<Tabs />
<Filters />
</div>
);
}
export default TabsAndFilters;

View File

@@ -0,0 +1,5 @@
export const ALERT_TABS = {
OVERVIEW: 'OVERVIEW',
HISTORY: 'HISTORY',
ACTIVITY: 'ACTIVITY',
} as const;

View File

@@ -0,0 +1,18 @@
@mixin flex-center {
display: flex;
justify-content: space-between;
align-items: center;
}
.tabs-and-filters {
@include flex-center;
margin-top: 1rem;
margin-bottom: 1rem;
.filters {
@include flex-center;
gap: 16px;
.reset-button {
@include flex-center;
}
}
}

View File

@@ -8,4 +8,9 @@ export const REACT_QUERY_KEY = {
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS',
ALERT_RULE_STATS: 'ALERT_RULE_STATS',
ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS',
ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE',
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
};

View File

@@ -22,6 +22,8 @@ const ROUTES = {
EDIT_ALERTS: '/alerts/edit',
LIST_ALL_ALERT: '/alerts',
ALERTS_NEW: '/alerts/new',
ALERT_HISTORY: '/alerts/history',
ALERT_OVERVIEW: '/alerts/overview',
ALL_CHANNELS: '/settings/channels',
CHANNELS_NEW: '/settings/channels/new',
CHANNELS_EDIT: '/settings/channels/:id',

View File

@@ -0,0 +1,3 @@
.alert-popover {
cursor: pointer;
}

View File

@@ -0,0 +1,113 @@
import './AlertPopover.styles.scss';
import { Popover } from 'antd';
import LogsIcon from 'assets/AlertHistory/LogsIcon';
import ROUTES from 'constants/routes';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { DraftingCompass } from 'lucide-react';
import React from 'react';
import { Link } from 'react-router-dom';
type Props = {
children: React.ReactNode;
relatedTracesLink?: string;
relatedLogsLink?: string;
};
function PopoverContent({
relatedTracesLink,
relatedLogsLink,
}: {
relatedTracesLink?: Props['relatedTracesLink'];
relatedLogsLink?: Props['relatedLogsLink'];
}): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<div className="contributor-row-popover-buttons">
{!!relatedTracesLink && (
<Link
to={`${ROUTES.LOGS_EXPLORER}?${relatedTracesLink}`}
className="contributor-row-popover-buttons__button"
>
<div className="icon">
<LogsIcon />
</div>
<div className="text">View Logs</div>
</Link>
)}
{!!relatedLogsLink && (
<Link
to={`${ROUTES.TRACES_EXPLORER}?${relatedLogsLink}`}
className="contributor-row-popover-buttons__button"
>
<div className="icon">
<DraftingCompass
size={14}
color={isDarkMode ? 'var(--bg-vanilla-400)' : 'var(--text-ink-400'}
/>
</div>
<div className="text">View Traces</div>
</Link>
)}
</div>
);
}
PopoverContent.defaultProps = {
relatedTracesLink: '',
relatedLogsLink: '',
};
function AlertPopover({
children,
relatedTracesLink,
relatedLogsLink,
}: Props): JSX.Element {
return (
<div className="alert-popover">
<Popover
showArrow={false}
placement="bottom"
color="linear-gradient(139deg, rgba(18, 19, 23, 1) 0%, rgba(18, 19, 23, 1) 98.68%)"
destroyTooltipOnHide
content={
<PopoverContent
relatedTracesLink={relatedTracesLink}
relatedLogsLink={relatedLogsLink}
/>
}
trigger="click"
>
{children}
</Popover>
</div>
);
}
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 (
<AlertPopover
relatedTracesLink={relatedTracesLink}
relatedLogsLink={relatedLogsLink}
>
{children}
</AlertPopover>
);
}
return <div>{children}</div>;
}
export default AlertPopover;

View File

@@ -0,0 +1,28 @@
import { AlertRuleStats } from 'types/api/alerts/def';
import { formatTime } from 'utils/timeUtils';
import StatsCard from '../StatsCard/StatsCard';
type TotalTriggeredCardProps = {
currentAvgResolutionTime: AlertRuleStats['currentAvgResolutionTime'];
pastAvgResolutionTime: AlertRuleStats['pastAvgResolutionTime'];
timeSeries: AlertRuleStats['currentAvgResolutionTimeSeries']['values'];
};
function AverageResolutionCard({
currentAvgResolutionTime,
pastAvgResolutionTime,
timeSeries,
}: TotalTriggeredCardProps): JSX.Element {
return (
<StatsCard
displayValue={formatTime(currentAvgResolutionTime)}
totalCurrentCount={currentAvgResolutionTime}
totalPastCount={pastAvgResolutionTime}
title="Avg. Resolution Time"
timeSeries={timeSeries}
/>
);
}
export default AverageResolutionCard;

View File

@@ -0,0 +1,23 @@
import './statistics.styles.scss';
import { AlertRuleStats } from 'types/api/alerts/def';
import StatsCardsRenderer from './StatsCardsRenderer/StatsCardsRenderer';
import TopContributorsRenderer from './TopContributorsRenderer/TopContributorsRenderer';
function Statistics({
setTotalCurrentTriggers,
totalCurrentTriggers,
}: {
setTotalCurrentTriggers: (value: number) => void;
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
}): JSX.Element {
return (
<div className="statistics">
<StatsCardsRenderer setTotalCurrentTriggers={setTotalCurrentTriggers} />
<TopContributorsRenderer totalCurrentTriggers={totalCurrentTriggers} />
</div>
);
}
export default Statistics;

View File

@@ -0,0 +1,124 @@
import './statsCard.styles.scss';
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;
duration: string | null;
};
function ChangePercentage({
percentage,
direction,
duration,
}: ChangePercentageProps): JSX.Element {
if (direction > 0) {
return (
<div className="change-percentage change-percentage--success">
<div className="change-percentage__icon">
<ArrowDownLeft size={14} color="var(--bg-forest-500)" />
</div>
<div className="change-percentage__label">
{percentage}% vs Last {duration}
</div>
</div>
);
}
if (direction < 0) {
return (
<div className="change-percentage change-percentage--error">
<div className="change-percentage__icon">
<ArrowUpRight size={14} color="var(--bg-cherry-500)" />
</div>
<div className="change-percentage__label">
{percentage}% vs Last {duration}
</div>
</div>
);
}
return (
<div className="change-percentage change-percentage--no-previous-data">
<div className="change-percentage__label">no previous data</div>
</div>
);
}
type StatsCardProps = {
totalCurrentCount?: number;
totalPastCount?: number;
title: string;
isEmpty?: boolean;
emptyMessage?: string;
displayValue?: string | number;
timeSeries?: AlertRuleStats['currentTriggersSeries']['values'];
};
function StatsCard({
displayValue,
totalCurrentCount,
totalPastCount,
title,
isEmpty,
emptyMessage,
timeSeries = [],
}: StatsCardProps): JSX.Element {
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const { changePercentage, changeDirection } = calculateChange(
totalCurrentCount,
totalPastCount,
);
return (
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
<div className="stats-card__title-wrapper">
<div className="title">{title}</div>
<div className="duration-indicator">
<div className="icon">
<Calendar size={14} color="var(--bg-slate-200)" />
</div>
<div className="text">{relativeTime}</div>
</div>
</div>
<div className="stats-card__stats">
<div className="count-label">
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
</div>
<ChangePercentage
direction={changeDirection}
percentage={changePercentage}
duration={relativeTime}
/>
</div>
<div className="stats-card__graph">
<div className="graph">
{!isEmpty && timeSeries.length > 1 && (
<StatsGraph timeSeries={timeSeries} changeDirection={changeDirection} />
)}
</div>
</div>
</div>
);
}
StatsCard.defaultProps = {
totalCurrentCount: 0,
totalPastCount: 0,
isEmpty: false,
emptyMessage: 'No Data',
displayValue: '',
timeSeries: [],
};
export default StatsCard;

View File

@@ -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<HTMLDivElement>(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 (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<Uplot data={[xData, yData]} options={options} />
</div>
);
}
export default StatsGraph;

View File

@@ -0,0 +1,112 @@
.stats-card {
width: 21.7%;
border-right: 1px solid var(--bg-slate-500);
padding: 9px 12px 13px;
&--empty {
justify-content: normal;
}
&__title-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
.title {
text-transform: uppercase;
font-size: 13px;
line-height: 22px;
color: var(--bg-vanilla-400);
font-weight: 500;
}
.duration-indicator {
display: flex;
align-items: center;
gap: 4px;
.icon {
display: flex;
align-self: center;
}
.text {
text-transform: uppercase;
color: var(--text-slate-200);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.48px;
}
}
}
&__stats {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 4px;
.count-label {
color: var(--text-vanilla-100);
font-family: 'Geist Mono';
font-size: 24px;
line-height: 36px;
}
}
&__graph {
margin-top: 80px;
.graph {
width: 100%;
height: 72px;
}
}
}
.change-percentage {
width: max-content;
display: flex;
padding: 4px 8px;
border-radius: 20px;
align-items: center;
gap: 4px;
&--success {
background: rgba(37, 225, 146, 0.1);
color: var(--bg-forest-500);
}
&--error {
background: rgba(229, 72, 77, 0.1);
color: var(--bg-cherry-500);
}
&--no-previous-data {
color: var(--text-robin-500);
background: rgba(78, 116, 248, 0.1);
padding: 4px 16px;
}
&__icon {
display: flex;
align-self: center;
}
&__label {
font-size: 12px;
font-weight: 500;
line-height: 16px;
}
}
.lightMode {
.stats-card {
border-color: var(--bg-vanilla-300);
&__title-wrapper {
.title {
color: var(--text-ink-400);
}
.duration-indicator {
.text {
color: var(--text-ink-200);
}
}
}
&__stats {
.count-label {
color: var(--text-ink-100);
}
}
}
}

View File

@@ -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 (
<DataStateRenderer
isLoading={isLoading}
isRefetching={isRefetching}
isError={isError || !isValidRuleId || !ruleId}
data={data?.payload?.data || null}
>
{(data): JSX.Element => {
const {
currentAvgResolutionTime,
pastAvgResolutionTime,
totalCurrentTriggers,
totalPastTriggers,
currentAvgResolutionTimeSeries,
currentTriggersSeries,
} = data;
return (
<>
{hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) ? (
<TotalTriggeredCard
totalCurrentTriggers={totalCurrentTriggers}
totalPastTriggers={totalPastTriggers}
timeSeries={currentTriggersSeries?.values}
/>
) : (
<StatsCard
title="Total Triggered"
isEmpty
emptyMessage="None Triggered."
/>
)}
{hasAvgResolutionTimeStats(
currentAvgResolutionTime,
pastAvgResolutionTime,
) ? (
<AverageResolutionCard
currentAvgResolutionTime={currentAvgResolutionTime}
pastAvgResolutionTime={pastAvgResolutionTime}
timeSeries={currentAvgResolutionTimeSeries?.values}
/>
) : (
<StatsCard
title="Avg. Resolution Time"
isEmpty
emptyMessage="No Resolutions."
/>
)}
</>
);
}}
</DataStateRenderer>
);
}
export default StatsCardsRenderer;

View File

@@ -0,0 +1,83 @@
import './topContributorsCard.styles.scss';
import { Button } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import history from 'lib/history';
import { ArrowRight } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import TopContributorsContent from './TopContributorsContent';
import { TopContributorsCardProps } from './types';
import ViewAllDrawer from './ViewAllDrawer';
function TopContributorsCard({
topContributorsData,
totalCurrentTriggers,
}: TopContributorsCardProps): JSX.Element {
const { search } = useLocation();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
const viewAllTopContributorsParam = searchParams.get('viewAllTopContributors');
const [isViewAllVisible, setIsViewAllVisible] = useState(
!!viewAllTopContributorsParam ?? false,
);
const isDarkMode = useIsDarkMode();
const toggleViewAllParam = (isOpen: boolean): void => {
if (isOpen) {
searchParams.set('viewAllTopContributors', 'true');
} else {
searchParams.delete('viewAllTopContributors');
}
};
const toggleViewAllDrawer = (): void => {
setIsViewAllVisible((prev) => {
const newState = !prev;
toggleViewAllParam(newState);
return newState;
});
history.push({ search: searchParams.toString() });
};
return (
<>
<div className="top-contributors-card">
<div className="top-contributors-card__header">
<div className="title">top contributors</div>
{topContributorsData.length > 3 && (
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
<div className="label">View all</div>
<div className="icon">
<ArrowRight
size={14}
color={isDarkMode ? 'var(--bg-vanilla-400)' : 'var(--bg-ink-400)'}
/>
</div>
</Button>
)}
</div>
<TopContributorsContent
topContributorsData={topContributorsData}
totalCurrentTriggers={totalCurrentTriggers}
/>
</div>
{isViewAllVisible && (
<ViewAllDrawer
isViewAllVisible={isViewAllVisible}
toggleViewAllDrawer={toggleViewAllDrawer}
totalCurrentTriggers={totalCurrentTriggers}
topContributorsData={topContributorsData}
/>
)}
</>
);
}
export default TopContributorsCard;

View File

@@ -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 (
<div className="empty-content">
<div className="empty-content__icon"></div>
<div className="empty-content__text">
<span className="bold-text">Add Group By Field</span> To view top
contributors, please add at least one group by field to your query.
</div>
<div className="empty-content__button-wrapper">
<Button
type="default"
className="configure-alert-rule-button"
onClick={handleRedirectToOverview}
>
Configure Alert Rule
</Button>
</div>
</div>
);
}
return (
<div className="top-contributors-card__content">
<TopContributorsRows
topContributors={topContributorsData.slice(0, 3)}
totalCurrentTriggers={totalCurrentTriggers}
/>
</div>
);
}
export default TopContributorsContent;

View File

@@ -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<AlertRuleTopContributors> = [
{
title: 'labels',
dataIndex: 'labels',
key: 'labels',
width: '51%',
render: (
labels: AlertRuleTopContributors['labels'],
record,
): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div>
<AlertLabels labels={labels} />
</div>
</ConditionalAlertPopover>
),
},
{
title: 'progressBar',
dataIndex: 'count',
key: 'progressBar',
width: '39%',
render: (count: AlertRuleTopContributors['count'], record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<Progress
percent={(count / totalCurrentTriggers) * 100}
showInfo={false}
trailColor="rgba(255, 255, 255, 0)"
strokeColor="var(--bg-robin-500)"
/>
</ConditionalAlertPopover>
),
},
{
title: 'count',
dataIndex: 'count',
key: 'count',
width: '10%',
render: (count: AlertRuleTopContributors['count'], record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="total-contribution">
{count}/{totalCurrentTriggers}
</div>
</ConditionalAlertPopover>
),
},
];
return (
<Table
rowClassName="contributors-row"
rowKey={(row): string => `top-contributor-${row.fingerprint}`}
columns={columns}
showHeader={false}
dataSource={topContributors}
pagination={
topContributors.length > 10 ? { showTotal: PaginationInfoText } : false
}
/>
);
}
export default TopContributorsRows;

View File

@@ -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 (
<Drawer
open={isViewAllVisible}
destroyOnClose
onClose={toggleViewAllDrawer}
placement="right"
width="50%"
className="view-all-drawer"
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
title="Viewing All Contributors"
>
<div className="top-contributors-card--view-all">
<div className="top-contributors-card__content">
<TopContributorsRows
topContributors={topContributorsData}
totalCurrentTriggers={totalCurrentTriggers}
/>
</div>
</div>
</Drawer>
);
}
export default ViewAllDrawer;

View File

@@ -0,0 +1,190 @@
.top-contributors-card {
width: 56.6%;
&--view-all {
width: auto;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-slate-500);
.title {
color: var(--text-vanilla-400);
font-size: 13px;
font-weight: 500;
line-height: 22px;
letter-spacing: 0.52px;
text-transform: uppercase;
}
.view-all {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 0;
height: 20px;
&:hover {
background-color: transparent !important;
}
.label {
color: var(--text-vanilla-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.icon {
display: flex;
}
}
}
.contributors-row {
height: 80px;
}
&__content {
.ant-table {
&-cell {
padding: 12px !important;
}
}
.contributors-row {
background: var(--bg-ink-500);
td {
border: none !important;
}
&:not(:last-of-type) td {
border-bottom: 1px solid var(--bg-slate-500) !important;
}
}
.total-contribution {
color: var(--text-robin-500);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 500;
letter-spacing: -0.06px;
padding: 4px 8px;
background: rgba(78, 116, 248, 0.1);
border-radius: 50px;
width: max-content;
}
}
.empty-content {
margin: 16px 12px;
padding: 40px 45px;
display: flex;
flex-direction: column;
gap: 12px;
border: 1px dashed var(--bg-slate-500);
border-radius: 6px;
&__icon {
font-family: Inter;
font-size: 20px;
line-height: 26px;
letter-spacing: -0.103px;
}
&__text {
color: var(--text-vanilla-400);
line-height: 18px;
.bold-text {
color: var(--text-vanilla-100);
font-weight: 500;
}
}
&__button-wrapper {
margin-top: 12px;
.configure-alert-rule-button {
padding: 8px 16px;
border-radius: 2px;
background: var(--bg-slate-400);
border-width: 0;
color: var(--text-vanilla-100);
line-height: 24px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
}
}
}
}
.ant-popover-inner:has(.contributor-row-popover-buttons) {
padding: 0 !important;
}
.contributor-row-popover-buttons {
display: flex;
flex-direction: column;
border: 1px solid var(--bg-slate-400);
&__button {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 15px;
color: var(--text-vanilla-400);
font-size: 14px;
letter-spacing: 0.14px;
width: 160px;
cursor: pointer;
&:hover {
background: var(--bg-slate-400);
}
.icon {
display: flex;
}
}
}
.view-all-drawer {
border-radius: 4px;
}
.lightMode {
.ant-table {
background: inherit;
}
.top-contributors-card {
&__header {
border-color: var(--bg-vanilla-300);
.title {
color: var(--text-ink-400);
}
.view-all {
.label {
color: var(--text-ink-400);
}
}
}
&__content {
.contributors-row {
background: inherit;
&:not(:last-of-type) td {
border-bottom: 1px solid var(--bg-vanilla-300) !important;
}
}
}
.empty-content {
border-color: var(--bg-vanilla-300);
&__text {
color: var(--text-ink-400);
.bold-text {
color: var(--text-ink-500);
}
}
&__button-wrapper {
.configure-alert-rule-button {
background: var(--bg-vanilla-300);
color: var(--text-ink-500);
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
export type TopContributorsCardProps = {
topContributorsData: AlertRuleTopContributors[];
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
};

View File

@@ -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 (
<DataStateRenderer
isLoading={isLoading}
isRefetching={isRefetching}
isError={isError || !isValidRuleId || !ruleId}
data={response || null}
>
{(topContributorsData): JSX.Element => (
<TopContributorsCard
topContributorsData={topContributorsData}
totalCurrentTriggers={totalCurrentTriggers}
/>
)}
</DataStateRenderer>
);
}
export default TopContributorsRenderer;

View File

@@ -0,0 +1,26 @@
import { AlertRuleStats } from 'types/api/alerts/def';
import StatsCard from '../StatsCard/StatsCard';
type TotalTriggeredCardProps = {
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
totalPastTriggers: AlertRuleStats['totalPastTriggers'];
timeSeries: AlertRuleStats['currentTriggersSeries']['values'];
};
function TotalTriggeredCard({
totalCurrentTriggers,
totalPastTriggers,
timeSeries,
}: TotalTriggeredCardProps): JSX.Element {
return (
<StatsCard
totalCurrentCount={totalCurrentTriggers}
totalPastCount={totalPastTriggers}
title="Total Triggered"
timeSeries={timeSeries}
/>
);
}
export default TotalTriggeredCard;

View File

@@ -0,0 +1,14 @@
.statistics {
display: flex;
justify-content: space-between;
height: 280px;
border: 1px solid var(--bg-slate-500);
border-radius: 4px;
margin: 0 16px;
}
.lightMode {
.statistics {
border: 1px solid var(--bg-vanilla-300);
}
}

View File

@@ -0,0 +1,100 @@
/* 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 { ALERT_STATUS, TIMELINE_OPTIONS } from './constants';
type Props = { type: string; data: AlertRuleTimelineGraphResponse[] };
function HorizontalTimelineGraph({
width,
isDarkMode,
data,
}: {
width: number;
isDarkMode: boolean;
data: AlertRuleTimelineGraphResponse[];
}): JSX.Element {
const transformedData: AlignedData = useMemo(
() =>
data?.length > 1
? [
data.map((item: AlertRuleTimelineGraphResponse) => item.start / 1000),
data.map(
(item: AlertRuleTimelineGraphResponse) => ALERT_STATUS[item.state],
),
]
: [[], []],
[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 <Uplot data={transformedData} options={options} />;
}
function Graph({ type, data }: Props): JSX.Element | null {
const graphRef = useRef<HTMLDivElement>(null);
const isDarkMode = useIsDarkMode();
const containerDimensions = useResizeObserver(graphRef);
if (type === 'horizontal') {
return (
<div ref={graphRef}>
<HorizontalTimelineGraph
isDarkMode={isDarkMode}
width={containerDimensions.width}
data={data}
/>
</div>
);
}
return null;
}
export default Graph;

View File

@@ -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,
};

View File

@@ -0,0 +1,52 @@
.timeline-graph {
display: flex;
flex-direction: column;
gap: 24px;
background: var(--bg-ink-400);
padding: 12px;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
height: 150px;
&__title {
width: max-content;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid #1d212d;
background: rgba(29, 33, 45, 0.5);
color: #ebebeb;
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
}
&__chart {
.chart-placeholder {
width: 100%;
height: 52px;
background: rgba(255, 255, 255, 0.1215686275);
display: flex;
align-items: center;
justify-content: center;
.chart-icon {
font-size: 2rem;
}
}
}
}
.lightMode {
.timeline-graph {
background: var(--bg-vanilla-200);
border-color: var(--bg-vanilla-300);
&__title {
background: var(--bg-vanilla-100);
color: var(--text-ink-400);
border-color: var(--bg-vanilla-300);
}
&__chart {
.chart-placeholder {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -0,0 +1,73 @@
import '../Graph/graph.styles.scss';
import { BarChartOutlined } from '@ant-design/icons';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { useEffect, useState } from 'react';
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();
const startTime = urlQuery.get(QueryParams.startTime);
const [isPlaceholder, setIsPlaceholder] = useState(false);
useEffect(() => {
if (startTime) {
const startTimeDate = new Date(startTime);
const now = new Date();
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
if (startTimeDate < twentyFourHoursAgo) {
setIsPlaceholder(true);
} else {
setIsPlaceholder(false);
}
}
}, [startTime]);
return (
<div className="timeline-graph">
<div className="timeline-graph__title">
{totalCurrentTriggers} triggers in {relativeTime}
</div>
<div className="timeline-graph__chart">
{isPlaceholder ? (
<div className="chart-placeholder">
<BarChartOutlined className="chart-icon" />
</div>
) : (
<DataStateRenderer
isLoading={isLoading}
isError={isError || !isValidRuleId || !ruleId}
isRefetching={isRefetching}
data={data?.payload?.data || null}
>
{(data): JSX.Element => <Graph type="horizontal" data={data} />}
</DataStateRenderer>
)}
</div>
</div>
);
}
export default GraphWrapper;

View File

@@ -0,0 +1,85 @@
import './table.styles.scss';
import { Table } from 'antd';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
useGetAlertRuleDetailsTimelineTable,
useTimelineTable,
} from 'pages/AlertDetails/hooks';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query';
import { PayloadProps } from 'types/api/alerts/get';
import { timelineTableColumns } from './useTimelineTable';
function TimelineTable(): JSX.Element {
const {
isLoading,
isRefetching,
isError,
data,
isValidRuleId,
ruleId,
} = useGetAlertRuleDetailsTimelineTable();
const { timelineData, totalItems } = useMemo(() => {
const response = data?.payload?.data;
return {
timelineData: response?.items,
totalItems: response?.total,
};
}, [data?.payload?.data]);
const [searchText, setSearchText] = useState('');
const { paginationConfig, onChangeHandler } = useTimelineTable({
totalItems: totalItems ?? 0,
});
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 { currentUnit, targetUnit } = useMemo(() => {
const alertDetailsQuery = queryClient.getQueryData([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
ruleId,
]) as {
payload: PayloadProps;
};
const condition = alertDetailsQuery?.payload?.data?.condition;
const { targetUnit } = condition ?? {};
const { unit: currentUnit } = condition?.compositeQuery ?? {};
return { currentUnit, targetUnit };
}, [queryClient, ruleId]);
const { t } = useTranslation('common');
if (isError || !isValidRuleId || !ruleId) {
return <div>{t('something_went_wrong')}</div>;
}
return (
<div className="timeline-table">
<Table
rowKey={(row): string => `${row.fingerprint}-${row.value}`}
columns={timelineTableColumns(setSearchText, currentUnit, targetUnit)}
dataSource={visibleTimelineData}
pagination={paginationConfig}
size="middle"
onChange={onChangeHandler}
loading={isLoading || isRefetching}
/>
</div>
);
}
export default TimelineTable;

View File

@@ -0,0 +1,127 @@
.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-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;
}
}
&-tbody > tr > td {
border: none;
&:last-of-type,
&:nth-last-of-type(2) {
text-align: right;
}
}
}
.label-filter {
padding: 6px 8px;
border-radius: 4px;
background: var(--text-ink-400);
border-width: 0;
line-height: 18px;
& ::placeholder {
color: var(--text-vanilla-400);
font-size: 12px;
letter-spacing: 0.6px;
text-transform: uppercase;
font-weight: 500;
}
}
.alert-rule {
&-value,
&-created-at {
font-size: 14px;
color: var(--text-vanilla-400);
}
&-value {
font-weight: 500;
line-height: 20px;
}
&-created-at {
line-height: 18px;
letter-spacing: -0.07px;
}
}
.ant-table.ant-table-middle {
border-bottom: 1px solid var(--bg-slate-500);
border-left: 1px solid var(--bg-slate-500);
border-right: 1px solid var(--bg-slate-500);
border-radius: 6px;
}
.ant-pagination-item {
&-active {
display: flex;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 1px 8px;
border-radius: 2px;
background: var(--bg-robin-500);
& > a {
color: var(--text-ink-500);
line-height: 20px;
font-weight: 500;
}
}
}
}
.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);
}
}
}
}

View File

@@ -0,0 +1,9 @@
import {
AlertRuleTimelineTableResponse,
AlertRuleTimelineTableResponsePayload,
} from 'types/api/alerts/def';
export type TimelineTableProps = {
timelineData: AlertRuleTimelineTableResponse[];
totalItems: AlertRuleTimelineTableResponsePayload['data']['total'];
};

View File

@@ -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 (
<Input
className="label-filter"
placeholder="labels"
onChange={handleDebouncedSearch}
suffix={
<Search
size={14}
color={isDarkMode ? 'var(--text-vanilla-100)' : 'var(--text-ink-100)'}
/>
}
/>
);
}
export const timelineTableColumns = (
setSearchText: (text: string) => void,
currentUnit?: string,
targetUnit?: string,
): ColumnsType<AlertRuleTimelineTableResponse> => [
{
title: 'STATE',
dataIndex: 'state',
sorter: true,
width: '12.5%',
render: (value, record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="alert-rule-state">
<AlertState state={value} showLabel />
</div>
</ConditionalAlertPopover>
),
},
{
title: <LabelFilter setSearchText={setSearchText} />,
dataIndex: 'labels',
width: '54.5%',
render: (labels, record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="alert-rule-labels">
<AlertLabels labels={labels} />
</div>
</ConditionalAlertPopover>
),
},
{
title: 'VALUE',
dataIndex: 'value',
width: '14%',
render: (value, record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="alert-rule-value">
{/* convert the value based on y axis and target unit */}
{convertValue(value.toFixed(2), currentUnit, targetUnit)}
</div>
</ConditionalAlertPopover>
),
},
{
title: 'CREATED AT',
dataIndex: 'unixMilli',
width: '32.5%',
render: (value, record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="alert-rule-created-at">{formatEpochTimestamp(value)}</div>
</ConditionalAlertPopover>
),
},
];

View File

@@ -0,0 +1,89 @@
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 (
<div className="coming-soon">
<div className="coming-soon__text">Coming Soon</div>
<div className="coming-soon__icon">
<Info size={10} color="var(--bg-sienna-400)" />
</div>
</div>
);
}
function TimelineTabs(): JSX.Element {
const tabs = [
{
value: TimelineTab.OVERALL_STATUS,
label: 'Overall Status',
},
{
value: TimelineTab.TOP_5_CONTRIBUTORS,
label: (
<div className="top-5-contributors">
Top 5 Contributors
<ComingSoon />
</div>
),
disabled: true,
},
];
return <Tabs2 tabs={tabs} initialSelectedTab={TimelineTab.OVERALL_STATUS} />;
}
function TimelineFilters(): JSX.Element {
const { search } = useLocation();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
const initialSelectedTab = useMemo(
() => searchParams.get('timelineFilter') ?? TimelineFilter.ALL,
[searchParams],
);
const handleFilter = (value: TimelineFilter): void => {
searchParams.set('timelineFilter', value);
history.push({ search: searchParams.toString() });
};
const tabs = [
{
value: TimelineFilter.ALL,
label: 'All',
},
{
value: TimelineFilter.FIRED,
label: 'Fired',
},
{
value: TimelineFilter.RESOLVED,
label: 'Resolved',
},
];
return (
<Tabs2
tabs={tabs}
initialSelectedTab={initialSelectedTab}
onSelectTab={handleFilter}
hasResetButton
/>
);
}
function TabsAndFilters(): JSX.Element {
return (
<div className="timeline-tabs-and-filters">
<TimelineTabs />
<TimelineFilters />
</div>
);
}
export default TabsAndFilters;

View File

@@ -0,0 +1,32 @@
.timeline-tabs-and-filters {
display: flex;
justify-content: space-between;
align-items: center;
.reset-button,
.top-5-contributors {
display: flex;
align-items: center;
gap: 10px;
}
.coming-soon {
display: inline-flex;
padding: 4px 8px;
border-radius: 20px;
border: 1px solid rgba(173, 127, 88, 0.2);
background: rgba(173, 127, 88, 0.1);
justify-content: center;
align-items: center;
gap: 5px;
&__text {
color: var(--text-sienna-400);
font-size: 10px;
font-weight: 500;
letter-spacing: -0.05px;
line-height: normal;
}
&__icon {
display: flex;
}
}
}

View File

@@ -0,0 +1,32 @@
import './timeline.styles.scss';
import GraphWrapper from './GraphWrapper/GraphWrapper';
import TimelineTable from './Table/Table';
import TabsAndFilters from './TabsAndFilters/TabsAndFilters';
function TimelineTableRenderer(): JSX.Element {
return <TimelineTable />;
}
function Timeline({
totalCurrentTriggers,
}: {
totalCurrentTriggers: number;
}): JSX.Element {
return (
<div className="timeline">
<div className="timeline__title">Timeline</div>
<div className="timeline__tabs-and-filters">
<TabsAndFilters />
</div>
<div className="timeline__graph">
<GraphWrapper totalCurrentTriggers={totalCurrentTriggers} />
</div>
<div className="timeline__table">
<TimelineTableRenderer />
</div>
</div>
);
}
export default Timeline;

View File

@@ -0,0 +1,14 @@
.timeline {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0 16px;
&__title {
color: var(--text-vanilla-100);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
}

View File

@@ -0,0 +1,5 @@
.alert-history {
display: flex;
flex-direction: column;
gap: 24px;
}

View File

@@ -0,0 +1 @@
export const TIMELINE_TABLE_PAGE_SIZE = 20;

View File

@@ -0,0 +1,22 @@
import './alertHistory.styles.scss';
import { useState } from 'react';
import Statistics from './Statistics/Statistics';
import Timeline from './Timeline/Timeline';
function AlertHistory(): JSX.Element {
const [totalCurrentTriggers, setTotalCurrentTriggers] = useState(0);
return (
<div className="alert-history">
<Statistics
totalCurrentTriggers={totalCurrentTriggers}
setTotalCurrentTriggers={setTotalCurrentTriggers}
/>
<Timeline totalCurrentTriggers={totalCurrentTriggers} />
</div>
);
}
export default AlertHistory;

View File

@@ -0,0 +1,15 @@
export enum AlertDetailsTab {
OVERVIEW = 'OVERVIEW',
HISTORY = 'HISTORY',
}
export enum TimelineTab {
OVERALL_STATUS = 'OVERALL_STATUS',
TOP_5_CONTRIBUTORS = 'TOP_5_CONTRIBUTORS',
}
export enum TimelineFilter {
ALL = 'ALL',
FIRED = 'FIRED',
RESOLVED = 'RESOLVED',
}

View File

@@ -242,6 +242,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
@@ -329,7 +331,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isTracesView() ||
isDashboardView() ||
isDashboardWidgetView() ||
isDashboardListView()
isDashboardListView() ||
isAlertHistory() ||
isAlertOverview()
? 0
: '0 1rem',
}}

View File

@@ -42,6 +42,10 @@
display: flex;
align-items: center;
}
.ant-tabs-tab-btn {
padding: 0 !important;
}
}
.lightMode {

View File

@@ -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(() => {

View File

@@ -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));

View File

@@ -208,6 +208,8 @@ export const routesToSkip = [
ROUTES.DASHBOARD,
ROUTES.DASHBOARD_WIDGET,
ROUTES.SERVICE_TOP_LEVEL_OPERATIONS,
ROUTES.ALERT_HISTORY,
ROUTES.ALERT_OVERVIEW,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -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;

View File

@@ -0,0 +1,59 @@
.alert-action-buttons {
display: flex;
align-items: center;
gap: 12px;
color: var(--bg-slate-400);
.ant-divider-vertical {
height: 16px;
border-color: var(--bg-slate-400);
margin: 0;
}
.dropdown-icon {
margin-right: 4px;
}
}
.dropdown-menu {
border-radius: 4px;
box-shadow: none;
background: linear-gradient(
138.7deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
);
.delete-button {
border: none;
display: flex;
align-items: center;
width: 100%;
&,
& span {
&:hover {
background: #1d212d;
color: var(--bg-cherry-400);
}
color: var(--bg-cherry-400);
font-size: 14px;
}
}
}
.lightMode {
.alert-action-buttons {
.ant-divider-vertical {
border-color: var(--bg-vanilla-300);
}
}
.dropdown-menu {
background: inherit;
.delete-button {
&,
&span {
&:hover {
background: var(--bg-vanilla-300);
}
}
}
}
}

View File

@@ -0,0 +1,87 @@
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';
const menu: MenuProps['items'] = [
{
key: 'rename-rule',
label: 'Rename',
icon: <PenLine size={16} color="var(--bg-vanilla-400" />,
onClick: (): void => {},
},
{
key: 'duplicate-rule',
label: 'Duplicate',
icon: <Copy size={16} color="var(--bg-vanilla-400" />,
onClick: (): void => {},
},
];
const menuStyle: React.CSSProperties = {
padding: 0,
boxShadow: 'none',
fontSize: 14,
};
function AlertActionButtons({
ruleId,
state,
}: {
ruleId: string;
state: string;
}): JSX.Element {
const {
handleAlertStateToggle,
isAlertRuleEnabled,
} = useAlertRuleStatusToggle({ ruleId, state });
const isDarkMode = useIsDarkMode();
return (
<div className="alert-action-buttons">
<Switch
size="small"
onChange={handleAlertStateToggle}
checked={isAlertRuleEnabled}
/>
<CopyToClipboard textToCopy={window.location.href} />
<Divider type="vertical" />
<Dropdown
trigger={['click']}
menu={{ items: menu }}
// eslint-disable-next-line react/no-unstable-nested-components
dropdownRender={(menu): JSX.Element => (
<div className="dropdown-menu">
{React.cloneElement(menu as React.ReactElement, {
style: menuStyle,
})}
<Divider style={{ margin: 0 }} />
<Button
type="default"
icon={<Trash2 size={16} color="var(--bg-cherry-400" />}
className="delete-button"
>
Delete
</Button>
</div>
)}
>
<Tooltip title="More options">
<Ellipsis
size={16}
color={isDarkMode ? 'var(--bg-vanilla-400)' : 'var(--text-ink-400'}
cursor="pointer"
className="dropdown-icon"
/>
</Tooltip>
</Dropdown>
</div>
);
}
export default AlertActionButtons;

View File

@@ -0,0 +1,47 @@
import './alertHeader.styles.scss';
import AlertActionButtons from './ActionButtons';
import AlertLabels from './AlertLabels/AlertLabels';
import AlertSeverity from './AlertSeverity/AlertSeverity';
import AlertState from './AlertState/AlertState';
type AlertHeaderProps = {
alertDetails: {
state: string;
alert: string;
id: string;
labels: Record<string, string>;
};
};
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const { state, alert, id, labels } = alertDetails;
return (
<div className="alert-info">
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<AlertState state={state} />
<div className="alert-title">{alert}</div>
<div className="alert-id">{id}</div>
</div>
</div>
<div className="bottom-section">
<AlertSeverity severity="warning" />
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
{/* <AlertStatus
status="firing"
timestamp={dayjs().subtract(1, 'd').valueOf()}
/> */}
<AlertLabels labels={labels} />
</div>
</div>
<div className="alert-info__action-buttons">
<AlertActionButtons ruleId={alertDetails.id} state={state} />
</div>
</div>
);
}
export default AlertHeader;

View File

@@ -0,0 +1,31 @@
import './alertLabels.styles.scss';
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<string, any>;
initialCount?: number;
};
function AlertLabels({
labels,
initialCount = 2,
}: AlertLabelsProps): JSX.Element {
return (
<div className="alert-labels">
<SeeMore initialCount={initialCount} moreLabel="More">
{Object.entries(labels).map(([key, value]) => (
<KeyValueLabel key={`label-${key}`} badgeKey={key} badgeValue={value} />
))}
</SeeMore>
</div>
);
}
AlertLabels.defaultProps = {
initialCount: 2,
};
export default AlertLabels;

View File

@@ -0,0 +1,5 @@
.alert-labels {
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
}

View File

@@ -0,0 +1,42 @@
import './alertSeverity.styles.scss';
import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon';
import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon';
import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon';
import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon';
export default function AlertSeverity({
severity,
}: {
severity: string;
}): JSX.Element {
const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
critical: {
text: 'Critical',
className: 'alert-severity--critical',
icon: <SeverityCriticalIcon />,
},
error: {
text: 'Error',
className: 'alert-severity--error',
icon: <SeverityErrorIcon />,
},
warning: {
text: 'Warning',
className: 'alert-severity--warning',
icon: <SeverityWarningIcon />,
},
info: {
text: 'Info',
className: 'alert-severity--info',
icon: <SeverityInfoIcon />,
},
};
const severityDetails = severityConfig[severity];
return (
<div className={`alert-severity ${severityDetails.className}`}>
<div className="alert-severity__icon">{severityDetails.icon}</div>
<div className="alert-severity__text">{severityDetails.text}</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
@mixin severity-styles($background, $text-color) {
.alert-severity__icon {
background: $background;
}
.alert-severity__text {
color: $text-color;
}
}
.alert-severity {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
&__icon {
display: flex;
align-items: center;
justify-content: center;
height: 14px;
width: 14px;
border-radius: 3.5px;
}
&__text {
color: var(--text-sakura-400);
font-size: 14px;
line-height: 18px;
}
&--critical,
&--error {
@include severity-styles(rgba(245, 108, 135, 0.2), var(--text-sakura-400));
}
&--warning {
@include severity-styles(rgba(255, 215, 120, 0.2), var(--text-amber-400));
}
&--info {
@include severity-styles(rgba(113, 144, 249, 0.2), var(--text-robin-400));
}
}

View File

@@ -0,0 +1,71 @@
import './alertState.styles.scss';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { BellOff, CircleCheck, CircleOff, Flame } from 'lucide-react';
type AlertStateProps = {
state: string;
showLabel?: boolean;
};
export default function AlertState({
state,
showLabel,
}: AlertStateProps): JSX.Element {
let icon;
let label;
const isDarkMode = useIsDarkMode();
switch (state) {
case 'no-data':
icon = (
<CircleOff
size={18}
fill="var(--bg-sienna-400)"
color="var(--bg-sienna-400)"
/>
);
label = <span style={{ color: 'var(--bg-sienna-400)' }}>No Data</span>;
break;
case 'disabled':
icon = (
<BellOff
size={18}
fill="var(--bg-vanilla-400)"
color="var(--bg-vanilla-400)"
/>
);
label = <span style={{ color: 'var(--bg-vanilla-400)' }}>Muted</span>;
break;
case 'firing':
icon = (
<Flame size={18} fill="var(--bg-cherry-500)" color="var(--bg-cherry-500)" />
);
label = <span style={{ color: 'var(--bg-cherry-500)' }}>Firing</span>;
break;
case 'normal':
icon = (
<CircleCheck
size={18}
fill="var(--bg-forest-500)"
color={isDarkMode ? 'var(--bg-ink-400)' : 'var(--bg-vanilla-100)'}
/>
);
label = <span style={{ color: 'var(--bg-forest-500)' }}>Resolved</span>;
break;
default:
icon = <div />;
}
return (
<div className="alert-state">
{icon} {showLabel && <div className="alert-state__label">{label}</div>}
</div>
);
}
AlertState.defaultProps = {
showLabel: false,
};

View File

@@ -0,0 +1,10 @@
.alert-state {
display: flex;
align-items: center;
gap: 6px;
&__label {
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
}
}

View File

@@ -0,0 +1,53 @@
import './alertStatus.styles.scss';
import { CircleCheck, Siren } from 'lucide-react';
import { useMemo } from 'react';
import { getDurationFromNow } from 'utils/timeUtils';
import { AlertStatusProps, StatusConfig } from './types';
export default function AlertStatus({
status,
timestamp,
}: AlertStatusProps): JSX.Element {
const statusConfig: StatusConfig = useMemo(
() => ({
firing: {
icon: <Siren size={14} color="var(--text-vanilla-400)" />,
text: 'Firing since',
extraInfo: timestamp ? (
<>
<div></div>
<div className="time">{getDurationFromNow(timestamp)}</div>
</>
) : null,
className: 'alert-status-info--firing',
},
resolved: {
icon: (
<CircleCheck
size={14}
fill="var(--bg-vanilla-400)"
color="var(--bg-ink-400)"
/>
),
text: 'Resolved',
extraInfo: null,
className: 'alert-status-info--resolved',
},
}),
[timestamp],
);
const currentStatus = statusConfig[status];
return (
<div className={`alert-status-info ${currentStatus.className}`}>
<div className="alert-status-info__icon">{currentStatus.icon}</div>
<div className="alert-status-info__details">
<div className="text">{currentStatus.text}</div>
{currentStatus.extraInfo}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
.alert-status-info {
gap: 6px;
color: var(--text-vanilla-400);
&__icon {
display: flex;
align-items: baseline;
}
&,
&__details {
display: flex;
align-items: center;
}
&__details {
gap: 3px;
}
}
.lightMode {
.alert-status-info {
color: var(--text-ink-400);
}
}

View File

@@ -0,0 +1,18 @@
export type AlertStatusProps =
| { status: 'firing'; timestamp: number }
| { status: 'resolved'; timestamp?: number };
export type StatusConfig = {
firing: {
icon: JSX.Element;
text: string;
extraInfo: JSX.Element | null;
className: string;
};
resolved: {
icon: JSX.Element;
text: string;
extraInfo: JSX.Element | null;
className: string;
};
};

View File

@@ -0,0 +1,57 @@
.alert-info {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0 16px;
&__info-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
height: 54px;
.top-section {
display: flex;
align-items: center;
justify-content: space-between;
.alert-title-wrapper {
display: flex;
align-items: center;
gap: 8px;
.alert-title {
font-size: 16px;
font-weight: 500;
color: var(--text-vanilla-100);
line-height: 24px;
letter-spacing: -0.08px;
}
.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;
}
}
}
.lightMode {
.alert-info {
&__info-wrapper {
.top-section {
.alert-title-wrapper {
.alert-title {
color: var(--text-ink-100);
}
}
}
}
}
}

View File

@@ -0,0 +1,189 @@
@mixin flex-center {
display: flex;
justify-content: space-between;
align-items: center;
}
.alert-details-tabs {
.top-level-tab.periscope-tab {
padding: 2px 0;
}
.ant-tabs {
&-nav {
margin-bottom: 0 !important;
&::before {
border-bottom: 1px solid var(--bg-slate-500) !important;
}
}
&-tab {
&[data-node-key='TriggeredAlerts'] {
margin-left: 16px;
}
&:not(:first-of-type) {
margin-left: 24px !important;
}
.periscope-tab {
font-size: 14px;
color: var(--text-vanilla-100);
line-height: 20px;
letter-spacing: -0.07px;
gap: 10px;
}
[aria-selected='false'] {
.periscope-tab {
color: var(--text-vanilla-400);
}
}
}
}
}
.alert-details {
margin-top: 10px;
.divider {
border-color: var(--bg-slate-500);
margin: 16px 0;
}
.breadcrumb-divider {
margin-top: 10px;
}
&__breadcrumb {
ol {
align-items: center;
}
padding-left: 16px;
.breadcrumb-item {
color: var(--text-vanilla-400);
font-size: 14px;
line-height: 20px;
letter-spacing: 0.25px;
padding: 0;
}
.ant-breadcrumb-separator,
.breadcrumb-item--last {
color: var(--var-vanilla-500);
font-family: 'Geist Mono';
}
}
.tabs-and-filters {
margin: 1rem 0;
.ant-tabs {
&-ink-bar {
background-color: transparent;
}
&-nav {
&-wrap {
padding: 0 16px 16px 16px;
}
&::before {
border-bottom: none !important;
}
}
&-tab {
margin-left: 0 !important;
padding: 0;
&-btn {
padding: 6px 17px;
color: var(--text-vanilla-400) !important;
letter-spacing: -0.07px;
font-size: 14px;
&[aria-selected='true'] {
color: var(--text-vanilla-100) !important;
}
}
&-active {
background: var(--bg-slate-400, #1d212d);
}
}
&-extra-content {
padding: 0 16px 16px;
}
&-nav-list {
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-400);
border-radius: 2px;
}
}
.tab-item {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.filters {
@include flex-center;
gap: 16px;
.reset-button {
@include flex-center;
}
}
}
}
.lightMode {
.alert-details {
&-tabs {
.ant-tabs-nav {
&::before {
border-bottom: 1px solid var(--bg-vanilla-300) !important;
}
}
}
&__breadcrumb {
.ant-breadcrumb-link {
color: var(--text-ink-400);
}
.ant-breadcrumb-separator,
span.ant-breadcrumb-link {
color: var(--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 {
&::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);
}
}
}
}
}
}

View File

@@ -0,0 +1,446 @@
import { FilterValue, SorterResult } from 'antd/es/table/interface';
import { TablePaginationConfig, TableProps } from 'antd/lib';
import get from 'api/alerts/get';
import patchAlert from 'api/alerts/patch';
import ruleStats from 'api/alerts/ruleStats';
import timelineGraph from 'api/alerts/timelineGraph';
import timelineTable from 'api/alerts/timelineTable';
import topContributors from 'api/alerts/topContributors';
import { TabRoutes } from 'components/RouteTab/types';
import { QueryParams } from 'constants/query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import AlertHistory from 'container/AlertHistory';
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
import { urlKey } from 'container/AllError/utils';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import GetMinMax from 'lib/getMinMax';
import history from 'lib/history';
import { History, Table } from 'lucide-react';
import EditRules from 'pages/EditRules';
import { OrderPreferenceItems } from 'pages/Logs/config';
import 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();
const getRouteUrl = (tab: AlertDetailsTab): string => {
let route = '';
let params = urlQuery.toString();
const ruleIdKey = QueryParams.ruleId;
const relativeTimeKey = QueryParams.relativeTime;
switch (tab) {
case AlertDetailsTab.OVERVIEW:
route = ROUTES.ALERT_OVERVIEW;
break;
case AlertDetailsTab.HISTORY:
params = `${ruleIdKey}=${urlQuery.get(
ruleIdKey,
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
route = ROUTES.ALERT_HISTORY;
break;
default:
return '';
}
return `${generatePath(route)}?${params}`;
};
const routes = [
{
Component: EditRules,
name: (
<div className="tab-item">
<Table size={14} />
Overview
</div>
),
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
key: ROUTES.ALERT_OVERVIEW,
},
{
Component: AlertHistory,
name: (
<div className="tab-item">
<History size={14} />
History
</div>
),
route: getRouteUrl(AlertDetailsTab.HISTORY),
key: ROUTES.ALERT_HISTORY,
},
];
return { routes };
};
export const useGetAlertRuleDetails = (): {
ruleId: string | null;
data: UseQueryResult;
isValidRuleId: boolean;
} => {
const { search } = useLocation();
const params = new URLSearchParams(search);
const ruleId = params.get(QueryParams.ruleId);
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const data = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], {
queryFn: () =>
get({
id: parseInt(ruleId || '', 10),
}),
enabled: isValidRuleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
return { ruleId, data, isValidRuleId };
};
type GetAlertRuleDetailsApiProps = {
isLoading: boolean;
isRefetching: boolean;
isError: boolean;
isValidRuleId: boolean;
ruleId: string | null;
};
type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & {
data:
| SuccessResponse<AlertRuleStatsPayload, unknown>
| 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<AlertRuleTopContributorsPayload, unknown>
| 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<AlertRuleTimelineTableResponsePayload, unknown>
| 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<AlertRuleTimelineTableResponse>['onChange'] = useCallback(
(
pagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter:
| SorterResult<AlertRuleTimelineTableResponse>[]
| SorterResult<AlertRuleTimelineTableResponse>,
) => {
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<AlertRuleTimelineGraphResponsePayload, unknown>
| 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 };
};

View File

@@ -0,0 +1,122 @@
import './alertDetails.styles.scss';
import { Breadcrumb, Button, Divider } from 'antd';
import { Filters } from 'components/AlertDetailsFilters/Filters';
import NotFound from 'components/NotFound';
import RouteTab from 'components/RouteTab';
import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import AlertHeader from './AlertHeader/AlertHeader';
import {
useGetAlertRuleDetails,
useRouteTabUtils,
useSetStartAndEndTimeFromRelativeTime,
} from './hooks';
import { AlertDetailsStatusRendererProps } from './types';
function AlertDetailsStatusRenderer({
isLoading,
isError,
isRefetching,
data,
}: AlertDetailsStatusRendererProps): JSX.Element {
const alertRuleDetails = useMemo(() => data?.payload?.data, [data]);
const { t } = useTranslation('common');
if (isLoading || isRefetching) {
return <Spinner tip="Loading Rules Details..." />;
}
if (isError) {
return <div>{data?.error || t('something_went_wrong')}</div>;
}
return <AlertHeader alertDetails={alertRuleDetails} />;
}
function BreadCrumbItem({
title,
isLast,
route,
}: {
title: string | null;
isLast?: boolean;
route?: string;
}): JSX.Element {
if (isLast) {
return <div className="breadcrumb-item breadcrumb-item--last">{title}</div>;
}
const handleNavigate = (): void => {
if (!route) {
return;
}
history.push(ROUTES.LIST_ALL_ALERT);
};
return (
<Button type="text" className="breadcrumb-item" onClick={handleNavigate}>
{title}
</Button>
);
}
BreadCrumbItem.defaultProps = {
isLast: false,
route: '',
};
function AlertDetails(): JSX.Element {
const { pathname } = useLocation();
const { routes } = useRouteTabUtils();
useSetStartAndEndTimeFromRelativeTime();
const {
data: { isLoading, data, isRefetching, isError },
ruleId,
isValidRuleId,
} = useGetAlertRuleDetails();
if (isError || !isValidRuleId) {
return <NotFound />;
}
return (
<div className="alert-details">
<Breadcrumb
className="alert-details__breadcrumb"
items={[
{
title: (
<BreadCrumbItem title="Alert Rules" route={ROUTES.LIST_ALL_ALERT} />
),
},
{
title: <BreadCrumbItem title={ruleId} isLast />,
},
]}
/>
<Divider className="divider breadcrumb-divider" />
<AlertDetailsStatusRenderer
{...{ isLoading, isError, isRefetching, data }}
/>
<Divider className="divider" />
<div className="tabs-and-filters">
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
tabBarExtraContent={<Filters />}
/>
</div>
</div>
);
}
export default AlertDetails;

View File

@@ -0,0 +1,6 @@
export type AlertDetailsStatusRendererProps = {
isLoading: boolean;
isError: boolean;
isRefetching: boolean;
data: any;
};

View File

@@ -0,0 +1,3 @@
import AlertHistory from 'container/AlertHistory';
export default AlertHistory;

View File

@@ -1,10 +1,14 @@
import { Tabs } from 'antd';
import { TabsProps } from 'antd/lib';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import ROUTES from 'constants/routes';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import TriggeredAlerts from 'container/TriggeredAlerts';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { GalleryVerticalEnd, Pyramid } from 'lucide-react';
import AlertDetails from 'pages/AlertDetails';
import { useLocation } from 'react-router-dom';
function AllAlertList(): JSX.Element {
@@ -12,15 +16,40 @@ function AllAlertList(): JSX.Element {
const location = useLocation();
const tab = urlQuery.get('tab');
const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY;
const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW;
const search = urlQuery.get('search');
const items: TabsProps['items'] = [
{ label: 'Alert Rules', key: 'AlertRules', children: <AllAlertRules /> },
{
label: 'Triggered Alerts',
label: (
<div className="periscope-tab top-level-tab">
<GalleryVerticalEnd size={16} />
Triggered Alerts
</div>
),
key: 'TriggeredAlerts',
children: <TriggeredAlerts />,
},
{
label: 'Configuration',
label: (
<div className="periscope-tab top-level-tab">
<Pyramid size={16} />
Alert Rules
</div>
),
key: 'AlertRules',
children:
isAlertHistory || isAlertOverview ? <AlertDetails /> : <AllAlertRules />,
},
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon />
Configuration
</div>
),
key: 'Configuration',
children: <PlannedDowntime />,
},
@@ -33,8 +62,16 @@ function AllAlertList(): JSX.Element {
activeKey={tab || 'AlertRules'}
onChange={(tab): void => {
urlQuery.set('tab', tab);
history.replace(`${location.pathname}?${urlQuery.toString()}`);
let params = `tab=${tab}`;
if (search) {
params += `&search=${search}`;
}
history.replace(`/alerts?${params}`);
}}
className={`${
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
}`}
/>
);
}

View File

@@ -1,32 +1,33 @@
.edit-rules-container {
display: flex;
justify-content: center;
align-items: center;
margin-top: 5rem;
padding: 0 16px;
&--error {
display: flex;
justify-content: center;
align-items: center;
margin-top: 5rem;
}
}
.edit-rules-card {
width: 20rem;
padding: 1rem;
width: 20rem;
padding: 1rem;
}
.content {
font-style: normal;
font-style: normal;
font-weight: 300;
font-size: 18px;
line-height: 20px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
justify-content: center;
text-align: center;
margin: 0;
}
.btn-container {
display: flex;
justify-content: center;
align-items: center;
margin-top: 2rem;
display: flex;
justify-content: center;
align-items: center;
margin-top: 2rem;
}

View File

@@ -4,6 +4,7 @@ import { Button, Card } from 'antd';
import get from 'api/alerts/get';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import EditRulesContainer from 'container/EditRules';
import { useNotifications } from 'hooks/useNotifications';
@@ -21,19 +22,21 @@ import {
function EditRules(): JSX.Element {
const params = useUrlQuery();
const ruleId = params.get('ruleId');
const ruleId = params.get(QueryParams.ruleId);
const { t } = useTranslation('common');
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const { isLoading, data, isRefetching, isError } = useQuery(
['ruleId', ruleId],
[REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId],
{
queryFn: () =>
get({
id: parseInt(ruleId || '', 10),
}),
enabled: isValidRuleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
@@ -62,7 +65,7 @@ function EditRules(): JSX.Element {
(data?.payload?.data === undefined && !isLoading)
) {
return (
<div className="edit-rules-container">
<div className="edit-rules-container edit-rules-container--error">
<Card size="small" className="edit-rules-card">
<p className="content">
{data?.message === errorMessageReceivedFromBackend
@@ -84,10 +87,12 @@ function EditRules(): JSX.Element {
}
return (
<EditRulesContainer
ruleId={parseInt(ruleId, 10)}
initialValue={data.payload.data}
/>
<div className="edit-rules-container">
<EditRulesContainer
ruleId={parseInt(ruleId, 10)}
initialValue={data.payload.data}
/>
</div>
);
}

View File

@@ -0,0 +1,39 @@
.copy-to-clipboard {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
padding: 4px 6px;
width: 100px;
&:hover {
background-color: transparent !important;
}
.ant-btn-icon {
margin: 0 !important;
}
& > * {
color: var(--text-vanilla-400);
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
&--success {
& span,
&:hover {
color: var(--bg-forest-400);
}
}
}
.lightMode {
.copy-to-clipboard {
&:not(&--success) {
& > * {
color: var(--text-ink-400);
}
}
}
}

View File

@@ -0,0 +1,54 @@
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';
function CopyToClipboard({ textToCopy }: { textToCopy: string }): JSX.Element {
const [state, copyToClipboard] = useCopyToClipboard();
const [success, setSuccess] = useState(false);
const isDarkMode = useIsDarkMode();
useEffect(() => {
let timer: string | number | NodeJS.Timeout | undefined;
if (state.value) {
setSuccess(true);
timer = setTimeout(() => setSuccess(false), 1000);
}
return (): void => clearTimeout(timer);
}, [state]);
if (success) {
return (
<Button
type="text"
icon={<CircleCheck size={16} color="var(--bg-forest-400)" />}
className="copy-to-clipboard copy-to-clipboard--success"
>
Copied
</Button>
);
}
return (
<Button
type="text"
// eslint-disable-next-line jsx-a11y/anchor-is-valid
icon={
<Link2
size={16}
color={isDarkMode ? 'var(--bg-vanilla-400)' : 'var(--text-ink-400'}
/>
}
onClick={(): void => copyToClipboard(textToCopy)}
className="copy-to-clipboard"
>
Copy link
</Button>
);
}
export default CopyToClipboard;

View File

@@ -0,0 +1,46 @@
import Spinner from 'components/Spinner';
import { useTranslation } from 'react-i18next';
interface DataStateRendererProps<T> {
isLoading: boolean;
isRefetching: boolean;
isError: boolean;
data: T | null;
errorMessage?: string;
loadingMessage?: string;
children: (data: T) => React.ReactNode;
}
/**
* TODO(shaheer): add empty state and optionally accept empty state custom component
* TODO(shaheer): optionally accept custom error state component
* TODO(shaheer): optionally accept custom loading state component
*/
function DataStateRenderer<T>({
isLoading,
isRefetching,
isError,
data,
errorMessage,
loadingMessage,
children,
}: DataStateRendererProps<T>): JSX.Element {
const { t } = useTranslation('common');
if (isLoading || isRefetching || !data) {
return <Spinner tip={loadingMessage} height="100%" />;
}
if (isError || data === null) {
return <div>{errorMessage ?? t('something_went_wrong')}</div>;
}
return <>{children(data)}</>;
}
DataStateRenderer.defaultProps = {
errorMessage: '',
loadingMessage: 'Loading...',
};
export default DataStateRenderer;

View File

@@ -0,0 +1,18 @@
import './keyValueLabel.styles.scss';
type KeyValueLabelProps = { badgeKey: string; badgeValue: string };
export default function KeyValueLabel({
badgeKey,
badgeValue,
}: KeyValueLabelProps): JSX.Element | null {
if (!badgeKey || !badgeValue) {
return null;
}
return (
<div className="key-value-label">
<div className="key-value-label__key">{badgeKey}</div>
<div className="key-value-label__value">{badgeValue}</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
.key-value-label {
display: flex;
align-items: center;
border: 1px solid var(--bg-slate-400);
border-radius: 2px;
flex-wrap: wrap;
&__key,
&__value {
padding: 1px 6px;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.005em;
}
&__key {
background: var(--bg-ink-400);
border-radius: 2px 0 0 2px;
}
&__value {
background: var(--bg-slate-400);
}
color: var(--text-vanilla-400);
}
.lightMode {
.key-value-label {
border-color: var(--bg-vanilla-400);
color: var(--text-ink-400);
&__key {
background: var(--bg-vanilla-300);
}
&__value {
background: var(--bg-vanilla-200);
}
}
}

View File

@@ -0,0 +1,24 @@
import { Typography } from 'antd';
function PaginationInfoText(
total: number,
[start, end]: number[],
): JSX.Element {
return (
<span
style={{
position: 'absolute',
left: 0,
width: 'max-content',
marginLeft: '16px',
}}
>
<Typography.Text className="numbers">
{start} &#8212; {end}
</Typography.Text>
<Typography.Text className="total"> of {total}</Typography.Text>
</span>
);
}
export default PaginationInfoText;

View File

@@ -0,0 +1,47 @@
import './seeMore.styles.scss';
import { Popover } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
type SeeMoreProps = {
children: JSX.Element[];
initialCount?: number;
moreLabel: string;
};
function SeeMore({
children,
initialCount = 2,
moreLabel,
}: SeeMoreProps): JSX.Element {
const remainingCount = children.length - initialCount;
const isDarkMode = useIsDarkMode();
return (
<>
{children.slice(0, initialCount)}
{remainingCount > 0 && (
<Popover
color={isDarkMode ? 'var(--bg-ink-400)' : 'var(--bg-vanilla-100)'}
destroyTooltipOnHide
content={
<div className="see-more-popover-content">
{children.slice(initialCount)}
</div>
}
>
<button
type="button"
className="see-more-button"
>{`+${remainingCount} ${moreLabel}`}</button>
</Popover>
)}
</>
);
}
SeeMore.defaultProps = {
initialCount: 2,
};
export default SeeMore;

View File

@@ -0,0 +1,26 @@
.see-more-button {
background: none;
padding: 2px;
font-size: 14px;
line-height: 18px;
letter-spacing: -0.005em;
color: var(--text-vanilla-400);
border: none;
cursor: pointer;
}
.see-more-popover-content {
display: flex;
gap: 6px;
flex-wrap: wrap;
width: 300px;
}
.lightMode {
.see-more-button {
color: var(--text-ink-400);
}
.see-more-popover-content {
background: var(--bg-vanilla-100);
}
}

View File

@@ -0,0 +1,79 @@
import './tabs2.styles.scss';
import { Button } from 'antd';
import { TimelineFilter } from 'container/AlertHistory/types';
import { Undo } from 'lucide-react';
import { useState } from 'react';
interface Tab {
value: string;
label: string | JSX.Element;
disabled?: boolean;
icon?: string | JSX.Element;
}
interface TimelineTabsProps {
tabs: Tab[];
onSelectTab?: (selectedTab: TimelineFilter) => void;
initialSelectedTab?: string;
hasResetButton?: boolean;
buttonMinWidth?: string;
}
function Tabs2({
tabs,
onSelectTab,
initialSelectedTab,
hasResetButton,
buttonMinWidth = '114px',
}: TimelineTabsProps): JSX.Element {
const [selectedTab, setSelectedTab] = useState<string>(
initialSelectedTab || tabs[0].value,
);
const handleTabClick = (tabValue: string): void => {
setSelectedTab(tabValue);
if (onSelectTab) {
onSelectTab(tabValue as TimelineFilter);
}
};
return (
<div className="tabs-wrapper">
{hasResetButton && selectedTab !== tabs[0].value && (
<Button
value="Reset"
className="tab reset-button"
onClick={(): void => handleTabClick(tabs[0].value)}
icon={<Undo size={14} color="var(--text-vanilla-400)" />}
>
Reset
</Button>
)}
<Button.Group>
{tabs.map((tab) => (
<Button
key={tab.value}
value={tab.value}
className={`tab ${selectedTab === tab.value ? 'selected' : ''}`}
onClick={(): void => handleTabClick(tab.value)}
disabled={tab.disabled}
icon={tab.icon}
style={{ minWidth: buttonMinWidth }}
>
{tab.label}
</Button>
))}
</Button.Group>
</div>
);
}
Tabs2.defaultProps = {
initialSelectedTab: '',
onSelectTab: (): void => {},
hasResetButton: false,
buttonMinWidth: '114px',
};
export default Tabs2;

View File

@@ -0,0 +1,48 @@
.tabs-wrapper {
display: flex;
align-items: center;
gap: 12px;
.tab {
&.ant-btn-default {
box-shadow: none;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-vanilla-400);
background: var(--bg-ink-400);
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
padding: 6px 24px;
border-color: var(--bg-slate-400);
justify-content: center;
}
&.reset-button {
.ant-btn-icon {
margin: 0;
}
padding: 6px 12px;
}
&.selected {
color: var(--text-vanilla-100);
background: var(--bg-slate-400);
}
}
}
.lightMode {
.tabs-wrapper {
.tab {
&.ant-btn-default {
color: var(--text-ink-400);
background: var(--bg-vanilla-300);
border-color: var(--bg-vanilla-300);
}
&.selected {
color: var(--text-robin-500);
background: var(--bg-vanilla-100);
}
}
}
}

View File

@@ -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[];
};

View File

@@ -0,0 +1,7 @@
import { AlertDef } from './def';
export interface RuleStatsProps {
id: AlertDef['id'];
start: number;
end: number;
}

View File

@@ -0,0 +1,11 @@
import { AlertDef } from './def';
export interface Filters {
[k: string]: string | Record<string, unknown>[];
}
export interface GetTimelineGraphRequestProps {
id: AlertDef['id'];
start: number;
end: number;
}

View File

@@ -0,0 +1,16 @@
import { AlertDef } from './def';
export interface Filters {
[k: string]: string | Record<string, unknown>[];
}
export interface GetTimelineTableRequestProps {
id: AlertDef['id'];
start: number;
end: number;
offset: number;
limit: number;
order: string;
filters?: Filters;
state?: string;
}

View File

@@ -0,0 +1,7 @@
import { AlertDef } from './def';
export interface TopContributorsProps {
id: AlertDef['id'];
start: number;
end: number;
}

View File

@@ -0,0 +1,31 @@
export function calculateChange(
totalCurrentTriggers: number | undefined,
totalPastTriggers: number | undefined,
): { changePercentage: number; changeDirection: number } {
if (
totalCurrentTriggers === undefined ||
totalPastTriggers === undefined ||
[0, '0'].includes(totalPastTriggers)
) {
return { changePercentage: 0, changeDirection: 0 };
}
let changePercentage =
((totalCurrentTriggers - totalPastTriggers) / totalPastTriggers) * 100;
let changeDirection = 0;
if (changePercentage < 0) {
changeDirection = -1;
} else if (changePercentage > 0) {
changeDirection = 1;
}
changePercentage = Math.abs(changePercentage);
changePercentage = Math.round(changePercentage);
return {
changePercentage,
changeDirection,
};
}

Some files were not shown because too many files have changed in this diff Show More