Feat: alert history (#5774)

* 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

* 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

* feat: 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

* 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

* fix: alert history pr review changes (#5778)

* chore: rename alert history scss files in pascal case

* fix: use proper variables

* chore: use color variable for action button dropdown item

* chore: improve the directory structure for alert history components

* chore: move inline style to scss file and extract dropdown renderer component

* chore: use colors from Color instead of css variables inside tsx files

* chore: return null in default case

* chore: update alert details spinner tip

* chore: timelinePlugin warnings and remove file wide warning disabling

* chore: change Arial to Geist Mono in timeline plugin

* feat: alert history remaining feats (#5825)

* fix: add switch case for inactive state to alert state component

* feat: add API enabled label search similar to Query Builder

* feat: add reset button to date and time picker

* feat: add vertical timeline chart using static data

* chore: use Colors instead of hex + dummy data for 90 days

* fix: label search light mode UI

* fix: remove placeholder logic, and display vertical charts if more than 1 day

* chore: extract dayjs manipulate types to a  constant

* fix: hide the overflow of top contributors card

* fix: throw instead of return error to prevent breaking alert history page in case of error

* chore: temporarily comment alert history vertical charts

* chore: calculate start and end times from relative time and remove query params (#5828)

* chore: calculate start and end times from relative time and remove query params

* fix: hide reset button if selected time is 30m

* feat: alert history dropdown functionality (#5833)

* feat: alert history dropdown actions

* chore: use query keys from react query key constant

* fix: properly handle error states for alert rule APIs

* fix: handle dropdown state using onOpenChange to fix clicking delete not closing the dropdown

* Fix: bugfixes and overall improvements to alert history  (#5841)

* fix: don't display severity label

* chore: remove id from alert header

* chore: add tooltip to enable/disable alert toggle

* chore: update enable/disbale toast message

* fix: set default relative time to 6h if relative time is not provided

* chore: update empty top contributors text and remove configure alert

* chore: temporarily hide value column from timeline column

* fix: use correct links for logs and traces in alert popover

* fix: properly set timeline table offset

* fix: display all values in graph

* fix: resolve conflicts

* chore: remove style for value column in timeline table

* chore: temporarily hide labels search

* fix: incorrect current page in pagination info text

* chore: remove label QB search

* chore: remove value column

* chore: remove commented code

* fix: show traces button when trace link is available

* fix: display horizontal chart even for a single entry

* fix: show inactive state in horizontal similar to normal state

* fix: properly render inactive state in horizontal chart

* fix: properly handle preserving alert toggle between overview and history tabs

* feat: get page size from query param

* chore: remove commented code + minor refactor

* chore: remove tsconfi.tmp

* fix: don't add default relative time if start and times exist in the url

* feat: display date range preview for stat cards

* chore: remove custom dropdown renderer component

* Fix: UI feedback changes (#5852)

* fix: add divider before delete button

* fix: timeline section title color in lightmode

* fix: remove the extra border from alert history tabs

* fix: populate alert rule disabled state on toggling alert state (#5854)

---------

Co-authored-by: Shaheer Kochai <ashaheerki@gmail.com>
This commit is contained in:
Yunus M 2024-09-04 21:26:10 +05:30 committed by GitHub
parent 6019b38da5
commit e97d0ea51c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
121 changed files with 5627 additions and 164 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

@ -50,5 +50,7 @@
"DEFAULT": "Open source Observability Platform | SigNoz",
"SHORTCUTS": "SigNoz | Shortcuts",
"INTEGRATIONS": "SigNoz | Integrations",
"ALERT_HISTORY": "SigNoz | Alert Rule History",
"ALERT_OVERVIEW": "SigNoz | Alert Rule Overview",
"MESSAGING_QUEUES": "SigNoz | Messaging Queues"
}

View File

@ -19,6 +19,7 @@ import { ResourceProvider } from 'hooks/useResourceAttribute';
import history from 'lib/history';
import { identity, pick, pickBy } from 'lodash-es';
import posthog from 'posthog-js';
import AlertRuleProvider from 'providers/Alert';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { Suspense, useEffect, useState } from 'react';
@ -236,22 +237,24 @@ function App(): JSX.Element {
<QueryBuilderProvider>
<DashboardProvider>
<KeyboardHotkeysProvider>
<AppLayout>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<AlertRuleProvider>
<AppLayout>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppLayout>
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</AppLayout>
</AlertRuleProvider>
</KeyboardHotkeysProvider>
</DashboardProvider>
</QueryBuilderProvider>

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,
@ -171,6 +173,20 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'ALERTS_NEW',
},
{
path: ROUTES.ALERT_HISTORY,
exact: true,
component: AlertHistory,
isPrivate: true,
key: 'ALERT_HISTORY',
},
{
path: ROUTES.ALERT_OVERVIEW,
exact: true,
component: AlertOverview,
isPrivate: true,
key: 'ALERT_OVERVIEW',
},
{
path: ROUTES.TRACE,
exact: true,

View File

@ -1,26 +1,20 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/create';
const create = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post('/rules', {
...props.data,
});
const response = await axios.post('/rules', {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default create;

View File

@ -1,24 +1,18 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/delete';
const deleteAlerts = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.delete(`/rules/${props.id}`);
const response = await axios.delete(`/rules/${props.id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data.rules,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data.rules,
};
};
export default deleteAlerts;

View File

@ -1,24 +1,16 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/get';
const get = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/rules/${props.id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
const response = await axios.get(`/rules/${props.id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
};
export default get;

View File

@ -1,26 +1,20 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/patch';
const patch = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.patch(`/rules/${props.id}`, {
...props.data,
});
const response = await axios.patch(`/rules/${props.id}`, {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default patch;

View File

@ -1,26 +1,20 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/save';
const put = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/rules/${props.id}`, {
...props.data,
});
const response = await axios.put(`/rules/${props.id}`, {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default put;

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,14 @@
.reset-button {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
}
.lightMode {
.reset-button {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
}

View File

@ -0,0 +1,11 @@
import './Filters.styles.scss';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
export function Filters(): JSX.Element {
return (
<div className="filters">
<DateTimeSelector showAutoRefresh={false} hideShareModal showResetButton />
</div>
);
}

View File

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

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,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

@ -0,0 +1,16 @@
import './TabsAndFilters.styles.scss';
import { Filters } from 'components/AlertDetailsFilters/Filters';
import { Tabs } from './Tabs/Tabs';
function TabsAndFilters(): JSX.Element {
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

@ -1,4 +1,17 @@
import { ManipulateType } from 'dayjs';
const MAX_RPS_LIMIT = 100;
export { MAX_RPS_LIMIT };
export const LEGEND = 'legend';
export const DAYJS_MANIPULATE_TYPES: { [key: string]: ManipulateType } = {
DAY: 'day',
WEEK: 'week',
MONTH: 'month',
YEAR: 'year',
HOUR: 'hour',
MINUTE: 'minute',
SECOND: 'second',
MILLISECOND: 'millisecond',
};

View File

@ -8,5 +8,14 @@ export const REACT_QUERY_KEY = {
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS',
ALERT_RULE_STATS: 'ALERT_RULE_STATS',
ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS',
ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE',
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS',
TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE',
GET_ALL_ALLERTS: 'GET_ALL_ALLERTS',
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
};

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,5 @@
.alert-history {
display: flex;
flex-direction: column;
gap: 24px;
}

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,3 @@
.alert-popover {
cursor: pointer;
}

View File

@ -0,0 +1,114 @@
import './AlertPopover.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Popover } from 'antd';
import LogsIcon from 'assets/AlertHistory/LogsIcon';
import ROUTES from 'constants/routes';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { DraftingCompass } from 'lucide-react';
import React from 'react';
import { Link } from 'react-router-dom';
type Props = {
children: React.ReactNode;
relatedTracesLink?: string;
relatedLogsLink?: string;
};
function PopoverContent({
relatedTracesLink,
relatedLogsLink,
}: {
relatedTracesLink?: Props['relatedTracesLink'];
relatedLogsLink?: Props['relatedLogsLink'];
}): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<div className="contributor-row-popover-buttons">
{!!relatedLogsLink && (
<Link
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
className="contributor-row-popover-buttons__button"
>
<div className="icon">
<LogsIcon />
</div>
<div className="text">View Logs</div>
</Link>
)}
{!!relatedTracesLink && (
<Link
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
className="contributor-row-popover-buttons__button"
>
<div className="icon">
<DraftingCompass
size={14}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.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,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,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,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,158 @@
import './StatsCard.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { ArrowDownLeft, ArrowUpRight, Calendar } from 'lucide-react';
import { AlertRuleStats } from 'types/api/alerts/def';
import { calculateChange } from 'utils/calculateChange';
import StatsGraph from './StatsGraph/StatsGraph';
import {
convertTimestampToLocaleDateString,
extractDayFromTimestamp,
} from './utils';
type ChangePercentageProps = {
percentage: number;
direction: number;
duration: string | null;
};
function ChangePercentage({
percentage,
direction,
duration,
}: ChangePercentageProps): JSX.Element {
if (direction > 0) {
return (
<div className="change-percentage change-percentage--success">
<div className="change-percentage__icon">
<ArrowDownLeft size={14} color={Color.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={Color.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,
);
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
let displayTime = relativeTime;
if (!displayTime && startTime && endTime) {
const formattedStartDate = extractDayFromTimestamp(startTime);
const formattedEndDate = extractDayFromTimestamp(endTime);
displayTime = `${formattedStartDate} to ${formattedEndDate}`;
}
if (!displayTime) {
displayTime = '';
}
const formattedStartTimeForTooltip = convertTimestampToLocaleDateString(
startTime,
);
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
return (
<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={Color.BG_SLATE_200} />
</div>
{relativeTime ? (
<div className="text">{displayTime}</div>
) : (
<Tooltip
title={`From ${formattedStartTimeForTooltip} to ${formattedEndTimeForTooltip}`}
>
<div className="text">{displayTime}</div>
</Tooltip>
)}
</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,12 @@
export const extractDayFromTimestamp = (timestamp: string | null): string => {
if (!timestamp) return '';
const date = new Date(parseInt(timestamp, 10));
return date.getDate().toString();
};
export const convertTimestampToLocaleDateString = (
timestamp: string | null,
): string => {
if (!timestamp) return '';
return new Date(parseInt(timestamp, 10)).toLocaleString();
};

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

View File

@ -0,0 +1,84 @@
import './TopContributorsCard.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import history from 'lib/history';
import { ArrowRight } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import TopContributorsContent from './TopContributorsContent';
import { TopContributorsCardProps } from './types';
import ViewAllDrawer from './ViewAllDrawer';
function TopContributorsCard({
topContributorsData,
totalCurrentTriggers,
}: TopContributorsCardProps): JSX.Element {
const { search } = useLocation();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
const viewAllTopContributorsParam = searchParams.get('viewAllTopContributors');
const [isViewAllVisible, setIsViewAllVisible] = useState(
!!viewAllTopContributorsParam ?? false,
);
const isDarkMode = useIsDarkMode();
const toggleViewAllParam = (isOpen: boolean): void => {
if (isOpen) {
searchParams.set('viewAllTopContributors', 'true');
} else {
searchParams.delete('viewAllTopContributors');
}
};
const toggleViewAllDrawer = (): void => {
setIsViewAllVisible((prev) => {
const newState = !prev;
toggleViewAllParam(newState);
return newState;
});
history.push({ search: searchParams.toString() });
};
return (
<>
<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 ? Color.BG_VANILLA_400 : Color.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,32 @@
import TopContributorsRows from './TopContributorsRows';
import { TopContributorsCardProps } from './types';
function TopContributorsContent({
topContributorsData,
totalCurrentTriggers,
}: TopContributorsCardProps): JSX.Element {
const isEmpty = !topContributorsData.length;
if (isEmpty) {
return (
<div className="empty-content">
<div className="empty-content__icon"></div>
<div className="empty-content__text">
Top contributors highlight the most frequently triggering group-by
attributes in multi-dimensional alerts
</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,87 @@
import { Color } from '@signozhq/design-tokens';
import { Progress, Table } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
function TopContributorsRows({
topContributors,
totalCurrentTriggers,
}: {
topContributors: AlertRuleTopContributors[];
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
}): JSX.Element {
const columns: ColumnsType<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={Color.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,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,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,184 @@
import { Color } from '@signozhq/design-tokens';
import Uplot from 'components/Uplot';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin';
import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin';
import { useMemo, useRef } from 'react';
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
import uPlot, { AlignedData } from 'uplot';
import { ALERT_STATUS, TIMELINE_OPTIONS } from './constants';
type Props = { type: string; data: AlertRuleTimelineGraphResponse[] };
function HorizontalTimelineGraph({
width,
isDarkMode,
data,
}: {
width: number;
isDarkMode: boolean;
data: AlertRuleTimelineGraphResponse[];
}): JSX.Element {
const transformedData: AlignedData = useMemo(() => {
if (!data?.length) {
return [[], []];
}
// add a first and last entry to make sure the graph displays all the data
const FIVE_MINUTES_IN_SECONDS = 300;
const timestamps = [
data[0].start / 1000 - FIVE_MINUTES_IN_SECONDS, // 5 minutes before the first entry
...data.map((item) => item.start / 1000),
data[data.length - 1].end / 1000, // end value of last entry
];
const states = [
ALERT_STATUS[data[0].state], // Same state as the first entry
...data.map((item) => ALERT_STATUS[item.state]),
ALERT_STATUS[data[data.length - 1].state], // Same state as the last entry
];
return [timestamps, states];
}, [data]);
const options: uPlot.Options = useMemo(
() => ({
width,
height: 85,
cursor: { show: false },
axes: [
{
gap: 10,
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
},
{ show: false },
],
legend: {
show: false,
},
padding: [null, 0, null, 0],
series: [
{
label: 'Time',
},
{
label: 'States',
},
],
plugins:
transformedData?.length > 1
? [
timelinePlugin({
count: transformedData.length - 1,
...TIMELINE_OPTIONS,
}),
]
: [],
}),
[width, isDarkMode, transformedData],
);
return <Uplot data={transformedData} options={options} />;
}
const transformVerticalTimelineGraph = (data: any[]): any => [
data.map((item: { timestamp: any }) => item.timestamp),
Array(data.length).fill(0),
Array(data.length).fill(10),
Array(data.length).fill([0, 1, 2, 3, 4, 5]),
data.map((item: { value: number }) => {
const count = Math.floor(item.value / 10);
return [...Array(count).fill(1), 2];
}),
];
const datatest: any[] = [];
const now = Math.floor(Date.now() / 1000); // current timestamp in seconds
const oneDay = 24 * 60 * 60; // one day in seconds
for (let i = 0; i < 90; i++) {
const timestamp = now - i * oneDay;
const startOfDay = timestamp - (timestamp % oneDay);
datatest.push({
timestamp: startOfDay,
value: Math.floor(Math.random() * 30) + 1,
});
}
function VerticalTimelineGraph({
isDarkMode,
width,
}: {
width: number;
isDarkMode: boolean;
}): JSX.Element {
const transformedData = useMemo(
() => transformVerticalTimelineGraph(datatest),
[],
);
const options: uPlot.Options = useMemo(
() => ({
width,
height: 90,
plugins: [heatmapPlugin()],
cursor: { show: false },
legend: {
show: false,
},
axes: [
{
gap: 10,
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
},
{ show: false },
],
series: [
{},
{
paths: (): null => null,
points: { show: false },
},
{
paths: (): null => null,
points: { show: false },
},
],
}),
[isDarkMode, width],
);
return <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 (
<div ref={graphRef}>
<VerticalTimelineGraph
isDarkMode={isDarkMode}
width={containerDimensions.width}
/>
</div>
);
}
export default Graph;

View File

@ -0,0 +1,33 @@
import { Color } from '@signozhq/design-tokens';
export const ALERT_STATUS: { [key: string]: number } = {
firing: 0,
inactive: 1,
normal: 1,
'no-data': 2,
disabled: 3,
muted: 4,
};
export const STATE_VS_COLOR: {
[key: string]: { stroke: string; fill: string };
}[] = [
{},
{
0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 },
1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 },
2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
},
];
export const TIMELINE_OPTIONS = {
mode: 1,
fill: (seriesIdx: any, _: any, value: any): any =>
STATE_VS_COLOR[seriesIdx][value].fill,
stroke: (seriesIdx: any, _: any, value: any): any =>
STATE_VS_COLOR[seriesIdx][value].stroke,
laneWidthOption: 0.3,
showGrid: false,
};

View File

@ -0,0 +1,67 @@
import '../Graph/Graph.styles.scss';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import Graph from '../Graph/Graph';
function GraphWrapper({
totalCurrentTriggers,
}: {
totalCurrentTriggers: number;
}): JSX.Element {
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const {
isLoading,
isRefetching,
isError,
data,
isValidRuleId,
ruleId,
} = useGetAlertRuleDetailsTimelineGraphData();
// TODO(shaheer): uncomment when the API is ready for
// const { startTime } = useAlertHistoryQueryParams();
// const [isVerticalGraph, setIsVerticalGraph] = useState(false);
// useEffect(() => {
// const checkVerticalGraph = (): void => {
// if (startTime) {
// const startTimeDate = dayjs(Number(startTime));
// const twentyFourHoursAgo = dayjs().subtract(
// HORIZONTAL_GRAPH_HOURS_THRESHOLD,
// DAYJS_MANIPULATE_TYPES.HOUR,
// );
// setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo));
// }
// };
// checkVerticalGraph();
// }, [startTime]);
return (
<div className="timeline-graph">
<div className="timeline-graph__title">
{totalCurrentTriggers} triggers in {relativeTime}
</div>
<div className="timeline-graph__chart">
<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,134 @@
.timeline-table {
border-top: 1px solid var(--text-slate-500);
border-radius: 6px;
overflow: hidden;
margin-top: 4px;
min-height: 600px;
.ant-table {
background: var(--bg-ink-500);
&-cell {
padding: 12px 16px !important;
vertical-align: baseline;
&::before {
display: none;
}
}
&-thead > tr > th {
border-color: var(--bg-slate-500);
background: var(--bg-ink-500);
font-size: 12px;
font-weight: 500;
padding: 12px 16px 8px !important;
&:last-of-type
// TODO(shaheer): uncomment when we display value column
// ,
// &:nth-last-of-type(2)
{
text-align: right;
}
}
&-tbody > tr > td {
border: none;
&:last-of-type,
&:nth-last-of-type(2) {
text-align: right;
}
}
}
.label-filter {
padding: 6px 8px;
border-radius: 4px;
background: var(--text-ink-400);
border-width: 0;
line-height: 18px;
& ::placeholder {
color: var(--text-vanilla-400);
font-size: 12px;
letter-spacing: 0.6px;
text-transform: uppercase;
font-weight: 500;
}
}
.alert-rule {
&-value,
&-created-at {
font-size: 14px;
color: var(--text-vanilla-400);
}
&-value {
font-weight: 500;
line-height: 20px;
}
&-created-at {
line-height: 18px;
letter-spacing: -0.07px;
}
}
.ant-table.ant-table-middle {
border-bottom: 1px solid var(--bg-slate-500);
border-left: 1px solid var(--bg-slate-500);
border-right: 1px solid var(--bg-slate-500);
border-radius: 6px;
}
.ant-pagination-item {
&-active {
display: flex;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 1px 8px;
border-radius: 2px;
background: var(--bg-robin-500);
& > a {
color: var(--text-ink-500);
line-height: 20px;
font-weight: 500;
}
}
}
.alert-history-label-search {
.ant-select-selector {
border: none;
}
}
}
.lightMode {
.timeline-table {
border-color: var(--bg-vanilla-300);
.ant-table {
background: var(--bg-vanilla-100);
&-thead {
& > tr > th {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
}
&.ant-table-middle {
border-color: var(--bg-vanilla-300);
}
}
.alert-history-label-search {
.ant-select-selector {
background: var(--bg-vanilla-200);
}
}
.alert-rule {
&-value,
&-created-at {
color: var(--text-ink-400);
}
}
.ant-pagination-item {
&-active > a {
color: var(--text-vanilla-100);
}
}
}
}

View File

@ -0,0 +1,56 @@
import './Table.styles.scss';
import { Table } from 'antd';
import {
useGetAlertRuleDetailsTimelineTable,
useTimelineTable,
} from 'pages/AlertDetails/hooks';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { timelineTableColumns } from './useTimelineTable';
function TimelineTable(): JSX.Element {
const {
isLoading,
isRefetching,
isError,
data,
isValidRuleId,
ruleId,
} = useGetAlertRuleDetailsTimelineTable();
const { timelineData, totalItems } = useMemo(() => {
const response = data?.payload?.data;
return {
timelineData: response?.items,
totalItems: response?.total,
};
}, [data?.payload?.data]);
const { paginationConfig, onChangeHandler } = useTimelineTable({
totalItems: totalItems ?? 0,
});
const { t } = useTranslation('common');
if (isError || !isValidRuleId || !ruleId) {
return <div>{t('something_went_wrong')}</div>;
}
return (
<div className="timeline-table">
<Table
rowKey={(row): string => `${row.fingerprint}-${row.value}-${row.unixMilli}`}
columns={timelineTableColumns()}
dataSource={timelineData}
pagination={paginationConfig}
size="middle"
onChange={onChangeHandler}
loading={isLoading || isRefetching}
/>
</div>
);
}
export default TimelineTable;

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,53 @@
import { ColumnsType } from 'antd/es/table';
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import { formatEpochTimestamp } from 'utils/timeUtils';
export const timelineTableColumns = (): ColumnsType<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: 'LABELS',
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: '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,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,90 @@
import './TabsAndFilters.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { TimelineFilter, TimelineTab } from 'container/AlertHistory/types';
import history from 'lib/history';
import { Info } from 'lucide-react';
import Tabs2 from 'periscope/components/Tabs2';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
function ComingSoon(): JSX.Element {
return (
<div className="coming-soon">
<div className="coming-soon__text">Coming Soon</div>
<div className="coming-soon__icon">
<Info size={10} color={Color.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,22 @@
.timeline {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0 16px;
&__title {
color: var(--text-vanilla-100);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
}
.lightMode {
.timeline {
&__title {
color: var(--text-ink-400);
}
}
}

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,2 @@
// setting to 25 hours because we want to display the horizontal graph when the user selects 'Last 1 day' from date and time selector
export const HORIZONTAL_GRAPH_HOURS_THRESHOLD = 25;

View File

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

View File

@ -0,0 +1,3 @@
import AlertHistory from './AlertHistory';
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

@ -253,6 +253,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
routeKey === 'MESSAGING_QUEUES' || routeKey === 'MESSAGING_QUEUES_DETAIL';
const isDashboardListView = (): boolean => routeKey === 'ALL_DASHBOARD';
const isAlertHistory = (): boolean => routeKey === 'ALERT_HISTORY';
const isAlertOverview = (): boolean => routeKey === 'ALERT_OVERVIEW';
const isDashboardView = (): boolean => {
/**
* need to match using regex here as the getRoute function will not work for
@ -341,6 +343,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isDashboardView() ||
isDashboardWidgetView() ||
isDashboardListView() ||
isAlertHistory() ||
isAlertOverview() ||
isMessagingQueues()
? 0
: '0 1rem',

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

@ -62,6 +62,14 @@
.shareable-link-popover {
margin-left: 8px;
}
.reset-button {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
margin-right: 16px;
}
}
.share-modal-content {
@ -296,4 +304,8 @@
}
}
}
.reset-button {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
}

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,
ROUTES.MESSAGING_QUEUES,
ROUTES.MESSAGING_QUEUES_DETAIL,
];

View File

@ -27,7 +27,7 @@ import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { isObject } from 'lodash-es';
import { Check, Copy, Info, Send } from 'lucide-react';
import { Check, Copy, Info, Send, Undo } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { connect, useSelector } from 'react-redux';
@ -44,6 +44,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import AutoRefresh from '../AutoRefreshV2';
import { DateTimeRangeType } from '../CustomDateTimeModal';
import { RelativeTimeMap } from '../DateTimeSelection/config';
import {
convertOldTimeToNewValidCustomTimeFormat,
CustomTimeType,
@ -63,7 +64,9 @@ function DateTimeSelection({
location,
updateTimeInterval,
globalTimeLoading,
showResetButton = false,
showOldExplorerCTA = false,
defaultRelativeTime = RelativeTimeMap['6hr'] as Time,
}: Props): JSX.Element {
const [formSelector] = Form.useForm();
@ -242,22 +245,25 @@ function DateTimeSelection({
return defaultSelectedOption;
};
const updateLocalStorageForRoutes = (value: Time | string): void => {
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
if (preRoutes !== null) {
const preRoutesObject = JSON.parse(preRoutes);
const updateLocalStorageForRoutes = useCallback(
(value: Time | string): void => {
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
if (preRoutes !== null) {
const preRoutesObject = JSON.parse(preRoutes);
const preRoute = {
...preRoutesObject,
};
preRoute[location.pathname] = value;
const preRoute = {
...preRoutesObject,
};
preRoute[location.pathname] = value;
setLocalStorageKey(
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
JSON.stringify(preRoute),
);
}
};
setLocalStorageKey(
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
JSON.stringify(preRoute),
);
}
},
[location.pathname],
);
const onLastRefreshHandler = useCallback(() => {
const currentTime = dayjs();
@ -297,48 +303,65 @@ function DateTimeSelection({
[location.pathname],
);
const onSelectHandler = (value: Time | CustomTimeType): void => {
if (value !== 'custom') {
setIsOpen(false);
updateTimeInterval(value);
updateLocalStorageForRoutes(value);
setIsValidteRelativeTime(true);
if (refreshButtonHidden) {
setRefreshButtonHidden(false);
const onSelectHandler = useCallback(
(value: Time | CustomTimeType): void => {
if (value !== 'custom') {
setIsOpen(false);
updateTimeInterval(value);
updateLocalStorageForRoutes(value);
setIsValidteRelativeTime(true);
if (refreshButtonHidden) {
setRefreshButtonHidden(false);
}
} else {
setRefreshButtonHidden(true);
setCustomDTPickerVisible(true);
setIsValidteRelativeTime(false);
setEnableAbsoluteTime(false);
return;
}
} else {
setRefreshButtonHidden(true);
setCustomDTPickerVisible(true);
setIsValidteRelativeTime(false);
setEnableAbsoluteTime(false);
return;
}
if (!isLogsExplorerPage) {
urlQuery.delete('startTime');
urlQuery.delete('endTime');
if (!isLogsExplorerPage) {
urlQuery.delete('startTime');
urlQuery.delete('endTime');
urlQuery.set(QueryParams.relativeTime, value);
urlQuery.set(QueryParams.relativeTime, value);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.replace(generatedUrl);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.replace(generatedUrl);
}
// For logs explorer - time range handling is managed in useCopyLogLink.ts:52
// For logs explorer - time range handling is managed in useCopyLogLink.ts:52
if (!stagedQuery) {
return;
}
// the second boolean param directs the qb about the time change so to merge the query and retain the current state
// we removed update step interval to stop auto updating the value on time change
initQueryBuilderData(stagedQuery, true);
};
if (!stagedQuery) {
return;
}
// the second boolean param directs the qb about the time change so to merge the query and retain the current state
// we removed update step interval to stop auto updating the value on time change
initQueryBuilderData(stagedQuery, true);
},
[
initQueryBuilderData,
isLogsExplorerPage,
location.pathname,
refreshButtonHidden,
stagedQuery,
updateLocalStorageForRoutes,
updateTimeInterval,
urlQuery,
],
);
const onRefreshHandler = (): void => {
onSelectHandler(selectedTime);
onLastRefreshHandler();
};
const handleReset = useCallback(() => {
if (defaultRelativeTime) {
onSelectHandler(defaultRelativeTime);
}
}, [defaultRelativeTime, onSelectHandler]);
const onCustomDateHandler = (dateTimeRange: DateTimeRangeType): void => {
if (dateTimeRange !== null) {
@ -446,6 +469,22 @@ function DateTimeSelection({
}
const currentRoute = location.pathname;
// set the default relative time for alert history and overview pages if relative time is not specified
if (
(!urlQuery.has(QueryParams.startTime) ||
!urlQuery.has(QueryParams.endTime)) &&
!urlQuery.has(QueryParams.relativeTime) &&
(currentRoute === ROUTES.ALERT_OVERVIEW ||
currentRoute === ROUTES.ALERT_HISTORY)
) {
updateTimeInterval(defaultRelativeTime);
urlQuery.set(QueryParams.relativeTime, defaultRelativeTime);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.replace(generatedUrl);
return;
}
const time = getDefaultTime(currentRoute);
const currentOptions = getOptions(currentRoute);
@ -575,6 +614,19 @@ function DateTimeSelection({
return (
<div className="date-time-selector">
{showResetButton && selectedTime !== defaultRelativeTime && (
<FormItem>
<Button
type="default"
className="reset-button"
onClick={handleReset}
title={`Reset to ${defaultRelativeTime}`}
icon={<Undo size={14} />}
>
Reset
</Button>
</FormItem>
)}
{showOldExplorerCTA && (
<div style={{ marginRight: 12 }}>
<NewExplorerCTA />
@ -666,11 +718,15 @@ interface DateTimeSelectionV2Props {
showAutoRefresh: boolean;
hideShareModal?: boolean;
showOldExplorerCTA?: boolean;
showResetButton?: boolean;
defaultRelativeTime?: Time;
}
DateTimeSelection.defaultProps = {
hideShareModal: false,
showOldExplorerCTA: false,
showResetButton: false,
defaultRelativeTime: RelativeTimeMap['6hr'] as Time,
};
interface DispatchProps {
updateTimeInterval: (

View File

@ -0,0 +1,49 @@
import { Color } from '@signozhq/design-tokens';
import uPlot from 'uplot';
const bucketIncr = 5;
function heatmapPlugin(): uPlot.Plugin {
function fillStyle(count: number): string {
const colors = [Color.BG_CHERRY_500, Color.BG_SLATE_400];
return colors[count - 1];
}
return {
hooks: {
draw: (u: uPlot): void => {
const { ctx, data } = u;
const yData = (data[3] as unknown) as number[][];
const yQtys = (data[4] as unknown) as number[][];
const yHgt = Math.floor(
u.valToPos(bucketIncr, 'y', true) - u.valToPos(0, 'y', true),
);
ctx.save();
ctx.beginPath();
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
ctx.clip();
yData.forEach((yVals, xi) => {
const xPos = Math.floor(u.valToPos(data[0][xi], 'x', true));
// const maxCount = yQtys[xi].reduce(
// (acc, val) => Math.max(val, acc),
// -Infinity,
// );
yVals.forEach((yVal, yi) => {
const yPos = Math.floor(u.valToPos(yVal, 'y', true));
ctx.fillStyle = fillStyle(yQtys[xi][yi]);
ctx.fillRect(xPos - 4, yPos, 30, yHgt);
});
});
ctx.restore();
},
},
};
}
export default heatmapPlugin;

View File

@ -0,0 +1,632 @@
import uPlot from 'uplot';
export function pointWithin(
px: number,
py: number,
rlft: number,
rtop: number,
rrgt: number,
rbtm: number,
): boolean {
return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm;
}
const MAX_OBJECTS = 10;
const MAX_LEVELS = 4;
export class Quadtree {
x: number;
y: number;
w: number;
h: number;
l: number;
o: any[];
q: Quadtree[] | null;
constructor(x: number, y: number, w: number, h: number, l?: number) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.l = l || 0;
this.o = [];
this.q = null;
}
split(): void {
const w = this.w / 2;
const h = this.h / 2;
const l = this.l + 1;
this.q = [
// top right
new Quadtree(this.x + w, this.y, w, h, l),
// top left
new Quadtree(this.x, this.y, w, h, l),
// bottom left
new Quadtree(this.x, this.y + h, w, h, l),
// bottom right
new Quadtree(this.x + w, this.y + h, w, h, l),
];
}
quads(
x: number,
y: number,
w: number,
h: number,
cb: (quad: Quadtree) => void,
): void {
const { q } = this;
const hzMid = this.x + this.w / 2;
const vtMid = this.y + this.h / 2;
const startIsNorth = y < vtMid;
const startIsWest = x < hzMid;
const endIsEast = x + w > hzMid;
const endIsSouth = y + h > vtMid;
if (q) {
// top-right quad
if (startIsNorth && endIsEast) {
cb(q[0]);
}
// top-left quad
if (startIsWest && startIsNorth) {
cb(q[1]);
}
// bottom-left quad
if (startIsWest && endIsSouth) {
cb(q[2]);
}
// bottom-right quad
if (endIsEast && endIsSouth) {
cb(q[3]);
}
}
}
add(o: any): void {
if (this.q != null) {
this.quads(o.x, o.y, o.w, o.h, (q) => {
q.add(o);
});
} else {
const os = this.o;
os.push(o);
if (os.length > MAX_OBJECTS && this.l < MAX_LEVELS) {
this.split();
for (let i = 0; i < os.length; i++) {
const oi = os[i];
this.quads(oi.x, oi.y, oi.w, oi.h, (q) => {
q.add(oi);
});
}
this.o.length = 0;
}
}
}
get(x: number, y: number, w: number, h: number, cb: (o: any) => void): void {
const os = this.o;
for (let i = 0; i < os.length; i++) {
cb(os[i]);
}
if (this.q != null) {
this.quads(x, y, w, h, (q) => {
q.get(x, y, w, h, cb);
});
}
}
clear(): void {
this.o.length = 0;
this.q = null;
}
}
Object.assign(Quadtree.prototype, {
split: Quadtree.prototype.split,
quads: Quadtree.prototype.quads,
add: Quadtree.prototype.add,
get: Quadtree.prototype.get,
clear: Quadtree.prototype.clear,
});
const { round, min, ceil } = Math;
function roundDec(val: number, dec: number): number {
return Math.round(val * 10 ** dec) / 10 ** dec;
}
export const SPACE_BETWEEN = 1;
export const SPACE_AROUND = 2;
export const SPACE_EVENLY = 3;
export const inf = Infinity;
const coord = (i: number, offs: number, iwid: number, gap: number): number =>
roundDec(offs + i * (iwid + gap), 6);
export function distr(
numItems: number,
sizeFactor: number,
justify: number,
onlyIdx: number | null,
each: (i: number, offPct: number, dimPct: number) => void,
): void {
const space = 1 - sizeFactor;
let gap = 0;
if (justify === SPACE_BETWEEN) {
gap = space / (numItems - 1);
} else if (justify === SPACE_AROUND) {
gap = space / numItems;
} else if (justify === SPACE_EVENLY) {
gap = space / (numItems + 1);
}
if (Number.isNaN(gap) || gap === Infinity) gap = 0;
let offs = 0;
if (justify === SPACE_AROUND) {
offs = gap / 2;
} else if (justify === SPACE_EVENLY) {
offs = gap;
}
const iwid = sizeFactor / numItems;
const iwidRounded = roundDec(iwid, 6);
if (onlyIdx == null) {
for (let i = 0; i < numItems; i++)
each(i, coord(i, offs, iwid, gap), iwidRounded);
} else each(onlyIdx, coord(onlyIdx, offs, iwid, gap), iwidRounded);
}
function timelinePlugin(opts: any): any {
const { mode, count, fill, stroke, laneWidthOption, showGrid } = opts;
const pxRatio = devicePixelRatio;
const laneWidth = laneWidthOption ?? 0.9;
const laneDistr = SPACE_BETWEEN;
const font = `${round(14 * pxRatio)}px Geist Mono`;
function walk(
yIdx: number | null,
count: number,
dim: number,
draw: (iy: number, y0: number, hgt: number) => void,
): void {
distr(
count,
laneWidth,
laneDistr,
yIdx,
(i: number, offPct: number, dimPct: number) => {
const laneOffPx = dim * offPct;
const laneWidPx = dim * dimPct;
draw(i, laneOffPx, laneWidPx);
},
);
}
const size = opts.size ?? [0.6, Infinity];
const align = opts.align ?? 0;
const gapFactor = 1 - size[0];
const maxWidth = (size[1] ?? inf) * pxRatio;
const fillPaths = new Map();
const strokePaths = new Map();
function drawBoxes(ctx: CanvasRenderingContext2D): void {
fillPaths.forEach((fillPath, fillStyle) => {
ctx.fillStyle = fillStyle;
ctx.fill(fillPath);
});
strokePaths.forEach((strokePath, strokeStyle) => {
ctx.strokeStyle = strokeStyle;
ctx.stroke(strokePath);
});
fillPaths.clear();
strokePaths.clear();
}
let qt: Quadtree;
function putBox(
ctx: CanvasRenderingContext2D,
rect: (path: Path2D, x: number, y: number, w: number, h: number) => void,
xOff: number,
yOff: number,
lft: number,
top: number,
wid: number,
hgt: number,
strokeWidth: number,
iy: number,
ix: number,
value: number | null,
): void {
const fillStyle = fill(iy + 1, ix, value);
let fillPath = fillPaths.get(fillStyle);
if (fillPath == null) fillPaths.set(fillStyle, (fillPath = new Path2D()));
rect(fillPath, lft, top, wid, hgt);
if (strokeWidth) {
const strokeStyle = stroke(iy + 1, ix, value);
let strokePath = strokePaths.get(strokeStyle);
if (strokePath == null)
strokePaths.set(strokeStyle, (strokePath = new Path2D()));
rect(
strokePath,
lft + strokeWidth / 2,
top + strokeWidth / 2,
wid - strokeWidth,
hgt - strokeWidth,
);
}
qt.add({
x: round(lft - xOff),
y: round(top - yOff),
w: wid,
h: hgt,
sidx: iy + 1,
didx: ix,
});
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function drawPaths(u: uPlot, sidx: number, idx0: number, idx1: number): null {
uPlot.orient(
u,
sidx,
(
series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
moveTo,
lineTo,
rect,
) => {
const strokeWidth = round((series.width || 0) * pxRatio);
u.ctx.save();
rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
u.ctx.clip();
walk(sidx - 1, count, yDim, (iy: number, y0: number, hgt: number) => {
// draw spans
if (mode === 1) {
for (let ix = 0; ix < dataY.length; ix++) {
if (dataY[ix] != null) {
const lft = round(valToPosX(dataX[ix], scaleX, xDim, xOff));
let nextIx = ix;
// eslint-disable-next-line no-empty
while (dataY[++nextIx] === undefined && nextIx < dataY.length) {}
// to now (not to end of chart)
const rgt =
nextIx === dataY.length
? xOff + xDim + strokeWidth
: round(valToPosX(dataX[nextIx], scaleX, xDim, xOff));
putBox(
u.ctx,
rect,
xOff,
yOff,
lft,
round(yOff + y0),
rgt - lft,
round(hgt),
strokeWidth,
iy,
ix,
dataY[ix],
);
ix = nextIx - 1;
}
}
}
// draw matrix
else {
const colWid =
valToPosX(dataX[1], scaleX, xDim, xOff) -
valToPosX(dataX[0], scaleX, xDim, xOff);
const gapWid = colWid * gapFactor;
const barWid = round(min(maxWidth, colWid - gapWid) - strokeWidth);
let xShift;
if (align === 1) {
xShift = 0;
} else if (align === -1) {
xShift = barWid;
} else {
xShift = barWid / 2;
}
for (let ix = idx0; ix <= idx1; ix++) {
if (dataY[ix] != null) {
// TODO: all xPos can be pre-computed once for all series in aligned set
const lft = valToPosX(dataX[ix], scaleX, xDim, xOff);
putBox(
u.ctx,
rect,
xOff,
yOff,
round(lft - xShift),
round(yOff + y0),
barWid,
round(hgt),
strokeWidth,
iy,
ix,
dataY[ix],
);
}
}
}
});
// eslint-disable-next-line no-param-reassign
u.ctx.lineWidth = strokeWidth;
drawBoxes(u.ctx);
u.ctx.restore();
},
);
return null;
}
const yMids = Array(count).fill(0);
function drawPoints(u: uPlot, sidx: number): boolean {
u.ctx.save();
u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
u.ctx.clip();
const { ctx } = u;
ctx.font = font;
ctx.fillStyle = 'black';
ctx.textAlign = mode === 1 ? 'left' : 'center';
ctx.textBaseline = 'middle';
uPlot.orient(
u,
sidx,
(
series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
) => {
const strokeWidth = round((series.width || 0) * pxRatio);
const textOffset = mode === 1 ? strokeWidth + 2 : 0;
const y = round(yOff + yMids[sidx - 1]);
if (opts.displayTimelineValue) {
for (let ix = 0; ix < dataY.length; ix++) {
if (dataY[ix] != null) {
const x = valToPosX(dataX[ix], scaleX, xDim, xOff) + textOffset;
u.ctx.fillText(String(dataY[ix]), x, y);
}
}
}
},
);
u.ctx.restore();
return false;
}
const hovered = Array(count).fill(null);
const ySplits = Array(count).fill(0);
const fmtDate = uPlot.fmtDate('{YYYY}-{MM}-{DD} {HH}:{mm}:{ss}');
let legendTimeValueEl: HTMLElement | null = null;
return {
hooks: {
init: (u: uPlot): void => {
legendTimeValueEl = u.root.querySelector('.u-series:first-child .u-value');
},
drawClear: (u: uPlot): void => {
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
qt.clear();
// force-clear the path cache to cause drawBars() to rebuild new quadtree
u.series.forEach((s: any) => {
// eslint-disable-next-line no-param-reassign
s._paths = null;
});
},
setCursor: (u: {
posToVal: (arg0: any, arg1: string) => any;
cursor: { left: any };
scales: { x: { time: any } };
}): any => {
if (mode === 1 && legendTimeValueEl) {
const val = u.posToVal(u.cursor.left, 'x');
legendTimeValueEl.textContent = u.scales.x.time
? fmtDate(new Date(val * 1e3))
: val.toFixed(2);
}
},
},
// eslint-disable-next-line sonarjs/cognitive-complexity
opts: (u: { series: { label: any }[] }, opts: any): any => {
uPlot.assign(opts, {
cursor: {
// x: false,
y: false,
dataIdx: (
u: { cursor: { left: number } },
seriesIdx: number,
closestIdx: any,
) => {
if (seriesIdx === 0) return closestIdx;
const cx = round(u.cursor.left * pxRatio);
if (cx >= 0) {
const cy = yMids[seriesIdx - 1];
hovered[seriesIdx - 1] = null;
qt.get(cx, cy, 1, 1, (o: { x: any; y: any; w: any; h: any }) => {
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h))
hovered[seriesIdx - 1] = o;
});
}
return hovered[seriesIdx - 1]?.didx;
},
points: {
fill: 'rgba(0,0,0,0.3)',
bbox: (u: any, seriesIdx: number) => {
const hRect = hovered[seriesIdx - 1];
return {
left: hRect ? round(hRect.x / devicePixelRatio) : -10,
top: hRect ? round(hRect.y / devicePixelRatio) : -10,
width: hRect ? round(hRect.w / devicePixelRatio) : 0,
height: hRect ? round(hRect.h / devicePixelRatio) : 0,
};
},
},
},
scales: {
x: {
range(u: { data: number[][] }, min: number, max: number) {
if (mode === 2) {
const colWid = u.data[0][1] - u.data[0][0];
const scalePad = colWid / 2;
// eslint-disable-next-line no-param-reassign
if (min <= u.data[0][0]) min = u.data[0][0] - scalePad;
const lastIdx = u.data[0].length - 1;
// eslint-disable-next-line no-param-reassign
if (max >= u.data[0][lastIdx]) max = u.data[0][lastIdx] + scalePad;
}
return [min, max];
},
},
y: {
range: [0, 1],
},
},
});
uPlot.assign(opts.axes[0], {
splits:
mode === 2
? (
u: { data: any[][] },
scaleMin: number,
scaleMax: number,
foundIncr: number,
): any => {
const splits = [];
const dataIncr = u.data[0][1] - u.data[0][0];
const skipFactor = ceil(foundIncr / dataIncr);
for (let i = 0; i < u.data[0].length; i += skipFactor) {
const v = u.data[0][i];
if (v >= scaleMin && v <= scaleMax) splits.push(v);
}
return splits;
}
: null,
grid: {
show: showGrid ?? mode !== 2,
},
});
uPlot.assign(opts.axes[1], {
splits: (u: {
bbox: { height: any };
posToVal: (arg0: number, arg1: string) => any;
}) => {
walk(null, count, u.bbox.height, (iy: any, y0: number, hgt: number) => {
// vertical midpoints of each series' timeline (stored relative to .u-over)
yMids[iy] = round(y0 + hgt / 2);
ySplits[iy] = u.posToVal(yMids[iy] / pxRatio, 'y');
});
return ySplits;
},
values: () =>
Array(count)
.fill(null)
.map((v, i) => u.series[i + 1].label),
gap: 15,
size: 70,
grid: { show: false },
ticks: { show: false },
side: 3,
});
opts.series.forEach((s: any, i: number) => {
if (i > 0) {
uPlot.assign(s, {
// width: 0,
// pxAlign: false,
// stroke: "rgba(255,0,0,0.5)",
paths: drawPaths,
points: {
show: drawPoints,
},
});
}
});
},
};
}
export default timelinePlugin;

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

View File

@ -0,0 +1,123 @@
import './AlertDetails.styles.scss';
import { Breadcrumb, Button, Divider } from 'antd';
import { Filters } from 'components/AlertDetailsFilters/Filters';
import NotFound from 'components/NotFound';
import RouteTab from 'components/RouteTab';
import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import AlertHeader from './AlertHeader/AlertHeader';
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
import { AlertDetailsStatusRendererProps } from './types';
function AlertDetailsStatusRenderer({
isLoading,
isError,
isRefetching,
data,
}: AlertDetailsStatusRendererProps): JSX.Element {
const alertRuleDetails = useMemo(() => data?.payload?.data, [data]);
const { t } = useTranslation('common');
if (isLoading || isRefetching) {
return <Spinner tip="Loading..." />;
}
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();
const {
isLoading,
isRefetching,
isError,
ruleId,
isValidRuleId,
alertDetailsResponse,
} = useGetAlertRuleDetails();
if (
isError ||
!isValidRuleId ||
(alertDetailsResponse && alertDetailsResponse.statusCode !== 200)
) {
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: alertDetailsResponse }}
/>
<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,63 @@
.alert-action-buttons {
display: flex;
align-items: center;
gap: 12px;
color: var(--bg-slate-400);
.ant-divider-vertical {
height: 16px;
border-color: var(--bg-slate-400);
margin: 0;
}
.dropdown-icon {
margin-right: 4px;
}
}
.dropdown-menu {
border-radius: 4px;
box-shadow: none;
background: linear-gradient(
138.7deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
);
.dropdown-divider {
margin: 0;
}
.delete-button {
border: none;
display: flex;
align-items: center;
width: 100%;
&,
& span {
&:hover {
background: var(--bg-slate-400);
color: var(--bg-cherry-400);
}
color: var(--bg-cherry-400);
font-size: 14px;
}
}
}
.lightMode {
.alert-action-buttons {
.ant-divider-vertical {
border-color: var(--bg-vanilla-300);
}
}
.dropdown-menu {
background: inherit;
.delete-button {
&,
&span {
&:hover {
background: var(--bg-vanilla-300);
}
}
}
}
}

View File

@ -0,0 +1,111 @@
import './ActionButtons.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { Copy, Ellipsis, PenLine, Trash2 } from 'lucide-react';
import {
useAlertRuleDelete,
useAlertRuleDuplicate,
useAlertRuleStatusToggle,
} from 'pages/AlertDetails/hooks';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useAlertRule } from 'providers/Alert';
import React from 'react';
import { CSSProperties } from 'styled-components';
import { AlertDef } from 'types/api/alerts/def';
import { AlertHeaderProps } from '../AlertHeader';
const menuItemStyle: CSSProperties = {
fontSize: '14px',
letterSpacing: '0.14px',
};
function AlertActionButtons({
ruleId,
alertDetails,
}: {
ruleId: string;
alertDetails: AlertHeaderProps['alertDetails'];
}): JSX.Element {
const { isAlertRuleDisabled } = useAlertRule();
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
const { handleAlertDuplicate } = useAlertRuleDuplicate({
alertDetails: (alertDetails as unknown) as AlertDef,
});
const { handleAlertDelete } = useAlertRuleDelete({ ruleId: Number(ruleId) });
const params = useUrlQuery();
const handleRename = React.useCallback(() => {
params.set(QueryParams.ruleId, String(ruleId));
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}, [params, ruleId]);
const menu: MenuProps['items'] = React.useMemo(
() => [
{
key: 'rename-rule',
label: 'Rename',
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
onClick: (): void => handleRename(),
style: menuItemStyle,
},
{
key: 'duplicate-rule',
label: 'Duplicate',
icon: <Copy size={16} color={Color.BG_VANILLA_400} />,
onClick: (): void => handleAlertDuplicate(),
style: menuItemStyle,
},
{ type: 'divider' },
{
key: 'delete-rule',
label: 'Delete',
icon: <Trash2 size={16} color={Color.BG_CHERRY_400} />,
onClick: (): void => handleAlertDelete(),
style: {
...menuItemStyle,
color: Color.BG_CHERRY_400,
},
},
],
[handleAlertDelete, handleAlertDuplicate, handleRename],
);
const isDarkMode = useIsDarkMode();
return (
<div className="alert-action-buttons">
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
{isAlertRuleDisabled !== undefined && (
<Switch
size="small"
onChange={handleAlertStateToggle}
checked={!isAlertRuleDisabled}
/>
)}
</Tooltip>
<CopyToClipboard textToCopy={window.location.href} />
<Divider type="vertical" />
<Dropdown trigger={['click']} menu={{ items: menu }}>
<Tooltip title="More options">
<Ellipsis
size={16}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
cursor="pointer"
className="dropdown-icon"
/>
</Tooltip>
</Dropdown>
</div>
);
}
export default AlertActionButtons;

View File

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

View File

@ -0,0 +1,66 @@
import './AlertHeader.styles.scss';
import { useAlertRule } from 'providers/Alert';
import { useEffect, useMemo } from 'react';
import AlertActionButtons from './ActionButtons/ActionButtons';
import AlertLabels from './AlertLabels/AlertLabels';
import AlertSeverity from './AlertSeverity/AlertSeverity';
import AlertState from './AlertState/AlertState';
export type AlertHeaderProps = {
alertDetails: {
state: string;
alert: string;
id: string;
labels: Record<string, string>;
disabled: boolean;
};
};
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const { state, alert, labels, disabled } = alertDetails;
const labelsWithoutSeverity = useMemo(
() =>
Object.fromEntries(
Object.entries(labels).filter(([key]) => key !== 'severity'),
),
[labels],
);
const { isAlertRuleDisabled, setIsAlertRuleDisabled } = useAlertRule();
useEffect(() => {
if (isAlertRuleDisabled === undefined) {
setIsAlertRuleDisabled(disabled);
}
}, [disabled, setIsAlertRuleDisabled, isAlertRuleDisabled]);
return (
<div className="alert-info">
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<AlertState state={isAlertRuleDisabled ? 'disabled' : state} />
<div className="alert-title">{alert}</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={labelsWithoutSeverity} />
</div>
</div>
<div className="alert-info__action-buttons">
<AlertActionButtons alertDetails={alertDetails} ruleId={alertDetails.id} />
</div>
</div>
);
}
export default AlertHeader;

View File

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

View File

@ -0,0 +1,31 @@
import './AlertLabels.styles.scss';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import SeeMore from 'periscope/components/SeeMore';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AlertLabelsProps = {
labels: Record<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,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,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,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,73 @@
import './AlertState.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { BellOff, CircleCheck, CircleOff, Flame } from 'lucide-react';
type AlertStateProps = {
state: string;
showLabel?: boolean;
};
export default function AlertState({
state,
showLabel,
}: AlertStateProps): JSX.Element {
let icon;
let label;
const isDarkMode = useIsDarkMode();
switch (state) {
case 'no-data':
icon = (
<CircleOff
size={18}
fill={Color.BG_SIENNA_400}
color={Color.BG_SIENNA_400}
/>
);
label = <span style={{ color: Color.BG_SIENNA_400 }}>No Data</span>;
break;
case 'disabled':
icon = (
<BellOff
size={18}
fill={Color.BG_VANILLA_400}
color={Color.BG_VANILLA_400}
/>
);
label = <span style={{ color: Color.BG_VANILLA_400 }}>Muted</span>;
break;
case 'firing':
icon = (
<Flame size={18} fill={Color.BG_CHERRY_500} color={Color.BG_CHERRY_500} />
);
label = <span style={{ color: Color.BG_CHERRY_500 }}>Firing</span>;
break;
case 'normal':
case 'inactive':
icon = (
<CircleCheck
size={18}
fill={Color.BG_FOREST_500}
color={isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100}
/>
);
label = <span style={{ color: Color.BG_FOREST_500 }}>Resolved</span>;
break;
default:
icon = null;
}
return (
<div className="alert-state">
{icon} {showLabel && <div className="alert-state__label">{label}</div>}
</div>
);
}
AlertState.defaultProps = {
showLabel: false,
};

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,54 @@
import './AlertStatus.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { CircleCheck, Siren } from 'lucide-react';
import { useMemo } from 'react';
import { getDurationFromNow } from 'utils/timeUtils';
import { AlertStatusProps, StatusConfig } from './types';
export default function AlertStatus({
status,
timestamp,
}: AlertStatusProps): JSX.Element {
const statusConfig: StatusConfig = useMemo(
() => ({
firing: {
icon: <Siren size={14} color={Color.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={Color.BG_VANILLA_400}
color={Color.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,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,525 @@
import { FilterValue, SorterResult } from 'antd/es/table/interface';
import { TablePaginationConfig, TableProps } from 'antd/lib';
import deleteAlerts from 'api/alerts/delete';
import get from 'api/alerts/get';
import getAll from 'api/alerts/getAll';
import patchAlert from 'api/alerts/patch';
import ruleStats from 'api/alerts/ruleStats';
import save from 'api/alerts/save';
import timelineGraph from 'api/alerts/timelineGraph';
import timelineTable from 'api/alerts/timelineTable';
import topContributors from 'api/alerts/topContributors';
import { TabRoutes } from 'components/RouteTab/types';
import { QueryParams } from 'constants/query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import AlertHistory from 'container/AlertHistory';
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
import { urlKey } from 'container/AllError/utils';
import useAxiosError from 'hooks/useAxiosError';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import GetMinMax from 'lib/getMinMax';
import history from 'lib/history';
import { History, Table } from 'lucide-react';
import EditRules from 'pages/EditRules';
import { OrderPreferenceItems } from 'pages/Logs/config';
import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
import { useAlertRule } from 'providers/Alert';
import { useCallback, useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import { generatePath, useLocation } from 'react-router-dom';
import { AppState } from 'store/reducers';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
AlertDef,
AlertRuleStatsPayload,
AlertRuleTimelineGraphResponsePayload,
AlertRuleTimelineTableResponse,
AlertRuleTimelineTableResponsePayload,
AlertRuleTopContributorsPayload,
} from 'types/api/alerts/def';
import { PayloadProps } from 'types/api/alerts/get';
import { GlobalReducer } from 'types/reducer/globalTime';
import { nanoToMilli } from 'utils/timeUtils';
export const useAlertHistoryQueryParams = (): {
ruleId: string | null;
startTime: number;
endTime: number;
hasStartAndEndParams: boolean;
params: URLSearchParams;
} => {
const params = useUrlQuery();
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const startTime = params.get(QueryParams.startTime);
const endTime = params.get(QueryParams.endTime);
const intStartTime = parseInt(startTime || '0', 10);
const intEndTime = parseInt(endTime || '0', 10);
const hasStartAndEndParams = !!intStartTime && !!intEndTime;
const { maxTime, minTime } = useMemo(() => {
if (hasStartAndEndParams)
return GetMinMax('custom', [intStartTime, intEndTime]);
return GetMinMax(globalTime.selectedTime);
}, [hasStartAndEndParams, intStartTime, intEndTime, globalTime.selectedTime]);
const ruleId = params.get(QueryParams.ruleId);
return {
ruleId,
startTime: Math.floor(nanoToMilli(minTime)),
endTime: Math.floor(nanoToMilli(maxTime)),
hasStartAndEndParams,
params,
};
};
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
const urlQuery = useUrlQuery();
const getRouteUrl = (tab: AlertDetailsTab): string => {
let route = '';
let params = urlQuery.toString();
const ruleIdKey = QueryParams.ruleId;
const relativeTimeKey = QueryParams.relativeTime;
switch (tab) {
case AlertDetailsTab.OVERVIEW:
route = ROUTES.ALERT_OVERVIEW;
break;
case AlertDetailsTab.HISTORY:
params = `${ruleIdKey}=${urlQuery.get(
ruleIdKey,
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
route = ROUTES.ALERT_HISTORY;
break;
default:
return '';
}
return `${generatePath(route)}?${params}`;
};
const routes = [
{
Component: EditRules,
name: (
<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 };
};
type Props = {
ruleId: string | null;
isValidRuleId: boolean;
alertDetailsResponse:
| SuccessResponse<PayloadProps, unknown>
| ErrorResponse
| undefined;
isLoading: boolean;
isRefetching: boolean;
isError: boolean;
};
export const useGetAlertRuleDetails = (): Props => {
const { ruleId } = useAlertHistoryQueryParams();
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const {
isLoading,
data: alertDetailsResponse,
isRefetching,
isError,
} = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], {
queryFn: () =>
get({
id: parseInt(ruleId || '', 10),
}),
enabled: isValidRuleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
return {
ruleId,
isLoading,
alertDetailsResponse,
isRefetching,
isError,
isValidRuleId,
};
};
type GetAlertRuleDetailsApiProps = {
isLoading: boolean;
isRefetching: boolean;
isError: boolean;
isValidRuleId: boolean;
ruleId: string | null;
};
type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & {
data:
| SuccessResponse<AlertRuleStatsPayload, unknown>
| ErrorResponse
| undefined;
};
export const useGetAlertRuleDetailsStats = (): GetAlertRuleDetailsStatsProps => {
const { ruleId, startTime, endTime } = useAlertHistoryQueryParams();
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const { isLoading, isRefetching, isError, data } = useQuery(
[REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime],
{
queryFn: () =>
ruleStats({
id: parseInt(ruleId || '', 10),
start: startTime,
end: endTime,
}),
enabled: isValidRuleId && !!startTime && !!endTime,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
};
type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & {
data:
| SuccessResponse<AlertRuleTopContributorsPayload, unknown>
| ErrorResponse
| undefined;
};
export const useGetAlertRuleDetailsTopContributors = (): GetAlertRuleDetailsTopContributorsProps => {
const { ruleId, startTime, endTime } = useAlertHistoryQueryParams();
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const { isLoading, isRefetching, isError, data } = useQuery(
[REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime],
{
queryFn: () =>
topContributors({
id: parseInt(ruleId || '', 10),
start: startTime,
end: endTime,
}),
enabled: isValidRuleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
};
type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & {
data:
| SuccessResponse<AlertRuleTimelineTableResponsePayload, unknown>
| ErrorResponse
| undefined;
};
export const useGetAlertRuleDetailsTimelineTable = (): GetAlertRuleDetailsTimelineTableProps => {
const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams();
const { updatedOrder, offset } = useMemo(
() => ({
updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC,
offset: parseInt(params.get(urlKey.offset) ?? '1', 10),
}),
[params],
);
const timelineFilter = params.get('timelineFilter');
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const hasStartAndEnd = startTime !== null && endTime !== null;
const { isLoading, isRefetching, isError, data } = useQuery(
[
REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE,
ruleId,
startTime,
endTime,
timelineFilter,
updatedOrder,
offset,
],
{
queryFn: () =>
timelineTable({
id: parseInt(ruleId || '', 10),
start: startTime,
end: endTime,
limit: TIMELINE_TABLE_PAGE_SIZE,
order: updatedOrder,
offset,
...(timelineFilter && timelineFilter !== TimelineFilter.ALL
? {
state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal',
}
: {}),
}),
enabled: isValidRuleId && hasStartAndEnd,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
};
export const useTimelineTable = ({
totalItems,
}: {
totalItems: number;
}): {
paginationConfig: TablePaginationConfig;
onChangeHandler: (
pagination: TablePaginationConfig,
sorter: any,
filters: any,
extra: any,
) => void;
} => {
const { pathname } = useLocation();
const { search } = useLocation();
const params = useMemo(() => new URLSearchParams(search), [search]);
const offset = params.get('offset') ?? '0';
const onChangeHandler: TableProps<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 { order } = sorter;
const updatedOrder = order === 'ascend' ? 'asc' : 'desc';
const params = new URLSearchParams(window.location.search);
history.replace(
`${pathname}?${createQueryParams({
...Object.fromEntries(params),
order: updatedOrder,
offset: current * TIMELINE_TABLE_PAGE_SIZE - TIMELINE_TABLE_PAGE_SIZE,
pageSize,
})}`,
);
}
},
[pathname],
);
const offsetInt = parseInt(offset, 10);
const pageSize = params.get('pageSize') ?? String(TIMELINE_TABLE_PAGE_SIZE);
const pageSizeInt = parseInt(pageSize, 10);
const paginationConfig: TablePaginationConfig = {
pageSize: pageSizeInt,
showTotal: PaginationInfoText,
current: offsetInt / TIMELINE_TABLE_PAGE_SIZE + 1,
showSizeChanger: false,
hideOnSinglePage: true,
total: totalItems,
};
return { paginationConfig, onChangeHandler };
};
export const useAlertRuleStatusToggle = ({
ruleId,
}: {
ruleId: string;
}): {
handleAlertStateToggle: (state: boolean) => void;
} => {
const { isAlertRuleDisabled, setIsAlertRuleDisabled } = useAlertRule();
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const handleError = useAxiosError();
const { mutate: toggleAlertState } = useMutation(
[REACT_QUERY_KEY.TOGGLE_ALERT_STATE, ruleId],
patchAlert,
{
onMutate: () => {
setIsAlertRuleDisabled((prev) => !prev);
},
onSuccess: () => {
notifications.success({
message: `Alert has been ${isAlertRuleDisabled ? 'enabled' : 'disabled'}.`,
});
},
onError: (error) => {
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS]);
handleError(error);
},
},
);
const handleAlertStateToggle = (): void => {
const args = {
id: parseInt(ruleId, 10),
data: { disabled: !isAlertRuleDisabled },
};
toggleAlertState(args);
};
return { handleAlertStateToggle };
};
export const useAlertRuleDuplicate = ({
alertDetails,
}: {
alertDetails: AlertDef;
}): {
handleAlertDuplicate: () => void;
} => {
const { notifications } = useNotifications();
const params = useUrlQuery();
const { refetch } = useQuery(REACT_QUERY_KEY.GET_ALL_ALLERTS, {
queryFn: getAll,
cacheTime: 0,
});
const handleError = useAxiosError();
const { mutate: duplicateAlert } = useMutation(
[REACT_QUERY_KEY.DUPLICATE_ALERT_RULE],
save,
{
onSuccess: async () => {
notifications.success({
message: `Success`,
});
const { data: allAlertsData } = await refetch();
if (
allAlertsData &&
allAlertsData.payload &&
allAlertsData.payload.length > 0
) {
const clonedAlert =
allAlertsData.payload[allAlertsData.payload.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}
},
onError: handleError,
},
);
const handleAlertDuplicate = (): void => {
const args = {
data: { ...alertDetails, alert: alertDetails.alert?.concat(' - Copy') },
};
duplicateAlert(args);
};
return { handleAlertDuplicate };
};
export const useAlertRuleDelete = ({
ruleId,
}: {
ruleId: number;
}): {
handleAlertDelete: () => void;
} => {
const { notifications } = useNotifications();
const handleError = useAxiosError();
const { mutate: deleteAlert } = useMutation(
[REACT_QUERY_KEY.REMOVE_ALERT_RULE, ruleId],
deleteAlerts,
{
onSuccess: async () => {
notifications.success({
message: `Success`,
});
history.push(ROUTES.LIST_ALL_ALERT);
},
onError: handleError,
},
);
const handleAlertDelete = (): void => {
const args = { id: ruleId };
deleteAlert(args);
};
return { handleAlertDelete };
};
type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & {
data:
| SuccessResponse<AlertRuleTimelineGraphResponsePayload, unknown>
| ErrorResponse
| undefined;
};
export const useGetAlertRuleDetailsTimelineGraphData = (): GetAlertRuleDetailsTimelineGraphProps => {
const { ruleId, startTime, endTime } = useAlertHistoryQueryParams();
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const hasStartAndEnd = startTime !== null && endTime !== null;
const { isLoading, isRefetching, isError, data } = useQuery(
[REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime],
{
queryFn: () =>
timelineGraph({
id: parseInt(ruleId || '', 10),
start: startTime,
end: endTime,
}),
enabled: isValidRuleId && hasStartAndEnd,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
};

View File

@ -0,0 +1,3 @@
import AlertDetails from './AlertDetails';
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 { Color } from '@signozhq/design-tokens';
import { Button } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { CircleCheck, Link2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
function CopyToClipboard({ textToCopy }: { textToCopy: string }): JSX.Element {
const [state, copyToClipboard] = useCopyToClipboard();
const [success, setSuccess] = useState(false);
const isDarkMode = useIsDarkMode();
useEffect(() => {
let timer: string | number | NodeJS.Timeout | undefined;
if (state.value) {
setSuccess(true);
timer = setTimeout(() => setSuccess(false), 1000);
}
return (): void => clearTimeout(timer);
}, [state]);
if (success) {
return (
<Button
type="text"
icon={<CircleCheck size={16} color={Color.BG_FOREST_400} />}
className="copy-to-clipboard copy-to-clipboard--success"
>
Copied
</Button>
);
}
return (
<Button
type="text"
icon={
<Link2
size={16}
color={isDarkMode ? Color.BG_VANILLA_400 : Color.TEXT_INK_400}
/>
}
onClick={(): void => copyToClipboard(textToCopy)}
className="copy-to-clipboard"
>
Copy link
</Button>
);
}
export default CopyToClipboard;

View File

@ -0,0 +1,3 @@
import CopyToClipboard from './CopyToClipboard';
export default CopyToClipboard;

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