mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-14 05:05:56 +08:00
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:
parent
6019b38da5
commit
e97d0ea51c
@ -38,5 +38,7 @@
|
|||||||
"LIST_LICENSES": "SigNoz | List of Licenses",
|
"LIST_LICENSES": "SigNoz | List of Licenses",
|
||||||
"WORKSPACE_LOCKED": "SigNoz | Workspace Locked",
|
"WORKSPACE_LOCKED": "SigNoz | Workspace Locked",
|
||||||
"SUPPORT": "SigNoz | Support",
|
"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"
|
||||||
}
|
}
|
||||||
|
@ -50,5 +50,7 @@
|
|||||||
"DEFAULT": "Open source Observability Platform | SigNoz",
|
"DEFAULT": "Open source Observability Platform | SigNoz",
|
||||||
"SHORTCUTS": "SigNoz | Shortcuts",
|
"SHORTCUTS": "SigNoz | Shortcuts",
|
||||||
"INTEGRATIONS": "SigNoz | Integrations",
|
"INTEGRATIONS": "SigNoz | Integrations",
|
||||||
|
"ALERT_HISTORY": "SigNoz | Alert Rule History",
|
||||||
|
"ALERT_OVERVIEW": "SigNoz | Alert Rule Overview",
|
||||||
"MESSAGING_QUEUES": "SigNoz | Messaging Queues"
|
"MESSAGING_QUEUES": "SigNoz | Messaging Queues"
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ import { ResourceProvider } from 'hooks/useResourceAttribute';
|
|||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
import { identity, pick, pickBy } from 'lodash-es';
|
import { identity, pick, pickBy } from 'lodash-es';
|
||||||
import posthog from 'posthog-js';
|
import posthog from 'posthog-js';
|
||||||
|
import AlertRuleProvider from 'providers/Alert';
|
||||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
@ -236,22 +237,24 @@ function App(): JSX.Element {
|
|||||||
<QueryBuilderProvider>
|
<QueryBuilderProvider>
|
||||||
<DashboardProvider>
|
<DashboardProvider>
|
||||||
<KeyboardHotkeysProvider>
|
<KeyboardHotkeysProvider>
|
||||||
<AppLayout>
|
<AlertRuleProvider>
|
||||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
<AppLayout>
|
||||||
<Switch>
|
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||||
{routes.map(({ path, component, exact }) => (
|
<Switch>
|
||||||
<Route
|
{routes.map(({ path, component, exact }) => (
|
||||||
key={`${path}`}
|
<Route
|
||||||
exact={exact}
|
key={`${path}`}
|
||||||
path={path}
|
exact={exact}
|
||||||
component={component}
|
path={path}
|
||||||
/>
|
component={component}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
<Route path="*" component={NotFound} />
|
<Route path="*" component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
</AlertRuleProvider>
|
||||||
</KeyboardHotkeysProvider>
|
</KeyboardHotkeysProvider>
|
||||||
</DashboardProvider>
|
</DashboardProvider>
|
||||||
</QueryBuilderProvider>
|
</QueryBuilderProvider>
|
||||||
|
@ -92,6 +92,14 @@ export const CreateNewAlerts = Loadable(
|
|||||||
() => import(/* webpackChunkName: "Create Alerts" */ 'pages/CreateAlert'),
|
() => 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(
|
export const CreateAlertChannelAlerts = Loadable(
|
||||||
() =>
|
() =>
|
||||||
import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'),
|
import(/* webpackChunkName: "Create Channels" */ 'pages/AlertChannelCreate'),
|
||||||
|
@ -2,6 +2,8 @@ import ROUTES from 'constants/routes';
|
|||||||
import { RouteProps } from 'react-router-dom';
|
import { RouteProps } from 'react-router-dom';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AlertHistory,
|
||||||
|
AlertOverview,
|
||||||
AllAlertChannels,
|
AllAlertChannels,
|
||||||
AllErrors,
|
AllErrors,
|
||||||
APIKeys,
|
APIKeys,
|
||||||
@ -171,6 +173,20 @@ const routes: AppRoutes[] = [
|
|||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
key: 'ALERTS_NEW',
|
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,
|
path: ROUTES.TRACE,
|
||||||
exact: true,
|
exact: true,
|
||||||
|
@ -1,26 +1,20 @@
|
|||||||
import axios from 'api';
|
import axios from 'api';
|
||||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
import { PayloadProps, Props } from 'types/api/alerts/create';
|
import { PayloadProps, Props } from 'types/api/alerts/create';
|
||||||
|
|
||||||
const create = async (
|
const create = async (
|
||||||
props: Props,
|
props: Props,
|
||||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||||
try {
|
const response = await axios.post('/rules', {
|
||||||
const response = await axios.post('/rules', {
|
...props.data,
|
||||||
...props.data,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
error: null,
|
error: null,
|
||||||
message: response.data.status,
|
message: response.data.status,
|
||||||
payload: response.data.data,
|
payload: response.data.data,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
return ErrorResponseHandler(error as AxiosError);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default create;
|
export default create;
|
||||||
|
@ -1,24 +1,18 @@
|
|||||||
import axios from 'api';
|
import axios from 'api';
|
||||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
import { PayloadProps, Props } from 'types/api/alerts/delete';
|
import { PayloadProps, Props } from 'types/api/alerts/delete';
|
||||||
|
|
||||||
const deleteAlerts = async (
|
const deleteAlerts = async (
|
||||||
props: Props,
|
props: Props,
|
||||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||||
try {
|
const response = await axios.delete(`/rules/${props.id}`);
|
||||||
const response = await axios.delete(`/rules/${props.id}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
error: null,
|
error: null,
|
||||||
message: response.data.status,
|
message: response.data.status,
|
||||||
payload: response.data.data.rules,
|
payload: response.data.data.rules,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
return ErrorResponseHandler(error as AxiosError);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default deleteAlerts;
|
export default deleteAlerts;
|
||||||
|
@ -1,24 +1,16 @@
|
|||||||
import axios from 'api';
|
import axios from 'api';
|
||||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
import { PayloadProps, Props } from 'types/api/alerts/get';
|
import { PayloadProps, Props } from 'types/api/alerts/get';
|
||||||
|
|
||||||
const get = async (
|
const get = async (
|
||||||
props: Props,
|
props: Props,
|
||||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||||
try {
|
const response = await axios.get(`/rules/${props.id}`);
|
||||||
const response = await axios.get(`/rules/${props.id}`);
|
return {
|
||||||
|
statusCode: 200,
|
||||||
return {
|
error: null,
|
||||||
statusCode: 200,
|
message: response.data.status,
|
||||||
error: null,
|
payload: response.data,
|
||||||
message: response.data.status,
|
};
|
||||||
payload: response.data,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return ErrorResponseHandler(error as AxiosError);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default get;
|
export default get;
|
||||||
|
@ -1,26 +1,20 @@
|
|||||||
import axios from 'api';
|
import axios from 'api';
|
||||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
import { PayloadProps, Props } from 'types/api/alerts/patch';
|
import { PayloadProps, Props } from 'types/api/alerts/patch';
|
||||||
|
|
||||||
const patch = async (
|
const patch = async (
|
||||||
props: Props,
|
props: Props,
|
||||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||||
try {
|
const response = await axios.patch(`/rules/${props.id}`, {
|
||||||
const response = await axios.patch(`/rules/${props.id}`, {
|
...props.data,
|
||||||
...props.data,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
error: null,
|
error: null,
|
||||||
message: response.data.status,
|
message: response.data.status,
|
||||||
payload: response.data.data,
|
payload: response.data.data,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
return ErrorResponseHandler(error as AxiosError);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default patch;
|
export default patch;
|
||||||
|
@ -1,26 +1,20 @@
|
|||||||
import axios from 'api';
|
import axios from 'api';
|
||||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||||
import { PayloadProps, Props } from 'types/api/alerts/save';
|
import { PayloadProps, Props } from 'types/api/alerts/save';
|
||||||
|
|
||||||
const put = async (
|
const put = async (
|
||||||
props: Props,
|
props: Props,
|
||||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||||
try {
|
const response = await axios.put(`/rules/${props.id}`, {
|
||||||
const response = await axios.put(`/rules/${props.id}`, {
|
...props.data,
|
||||||
...props.data,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
error: null,
|
error: null,
|
||||||
message: response.data.status,
|
message: response.data.status,
|
||||||
payload: response.data.data,
|
payload: response.data.data,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
return ErrorResponseHandler(error as AxiosError);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default put;
|
export default put;
|
||||||
|
28
frontend/src/api/alerts/ruleStats.ts
Normal file
28
frontend/src/api/alerts/ruleStats.ts
Normal 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;
|
33
frontend/src/api/alerts/timelineGraph.ts
Normal file
33
frontend/src/api/alerts/timelineGraph.ts
Normal 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;
|
36
frontend/src/api/alerts/timelineTable.ts
Normal file
36
frontend/src/api/alerts/timelineTable.ts
Normal 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;
|
33
frontend/src/api/alerts/topContributors.ts
Normal file
33
frontend/src/api/alerts/topContributors.ts
Normal 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;
|
41
frontend/src/assets/AlertHistory/ConfigureIcon.tsx
Normal file
41
frontend/src/assets/AlertHistory/ConfigureIcon.tsx
Normal 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;
|
65
frontend/src/assets/AlertHistory/LogsIcon.tsx
Normal file
65
frontend/src/assets/AlertHistory/LogsIcon.tsx
Normal 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;
|
39
frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx
Normal file
39
frontend/src/assets/AlertHistory/SeverityCriticalIcon.tsx
Normal 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;
|
42
frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx
Normal file
42
frontend/src/assets/AlertHistory/SeverityErrorIcon.tsx
Normal 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;
|
46
frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx
Normal file
46
frontend/src/assets/AlertHistory/SeverityInfoIcon.tsx
Normal 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;
|
42
frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx
Normal file
42
frontend/src/assets/AlertHistory/SeverityWarningIcon.tsx
Normal 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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
11
frontend/src/components/AlertDetailsFilters/Filters.tsx
Normal file
11
frontend/src/components/AlertDetailsFilters/Filters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
.tab-title {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
41
frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx
Normal file
41
frontend/src/components/TabsAndFilters/Tabs/Tabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
frontend/src/components/TabsAndFilters/TabsAndFilters.tsx
Normal file
16
frontend/src/components/TabsAndFilters/TabsAndFilters.tsx
Normal 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;
|
5
frontend/src/components/TabsAndFilters/constants.ts
Normal file
5
frontend/src/components/TabsAndFilters/constants.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const ALERT_TABS = {
|
||||||
|
OVERVIEW: 'OVERVIEW',
|
||||||
|
HISTORY: 'HISTORY',
|
||||||
|
ACTIVITY: 'ACTIVITY',
|
||||||
|
} as const;
|
@ -1,4 +1,17 @@
|
|||||||
|
import { ManipulateType } from 'dayjs';
|
||||||
|
|
||||||
const MAX_RPS_LIMIT = 100;
|
const MAX_RPS_LIMIT = 100;
|
||||||
export { MAX_RPS_LIMIT };
|
export { MAX_RPS_LIMIT };
|
||||||
|
|
||||||
export const LEGEND = 'legend';
|
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',
|
||||||
|
};
|
||||||
|
@ -8,5 +8,14 @@ export const REACT_QUERY_KEY = {
|
|||||||
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
|
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
|
||||||
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
|
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
|
||||||
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
|
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',
|
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',
|
||||||
};
|
};
|
||||||
|
@ -22,6 +22,8 @@ const ROUTES = {
|
|||||||
EDIT_ALERTS: '/alerts/edit',
|
EDIT_ALERTS: '/alerts/edit',
|
||||||
LIST_ALL_ALERT: '/alerts',
|
LIST_ALL_ALERT: '/alerts',
|
||||||
ALERTS_NEW: '/alerts/new',
|
ALERTS_NEW: '/alerts/new',
|
||||||
|
ALERT_HISTORY: '/alerts/history',
|
||||||
|
ALERT_OVERVIEW: '/alerts/overview',
|
||||||
ALL_CHANNELS: '/settings/channels',
|
ALL_CHANNELS: '/settings/channels',
|
||||||
CHANNELS_NEW: '/settings/channels/new',
|
CHANNELS_NEW: '/settings/channels/new',
|
||||||
CHANNELS_EDIT: '/settings/channels/:id',
|
CHANNELS_EDIT: '/settings/channels/:id',
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
.alert-history {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
22
frontend/src/container/AlertHistory/AlertHistory.tsx
Normal file
22
frontend/src/container/AlertHistory/AlertHistory.tsx
Normal 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;
|
@ -0,0 +1,3 @@
|
|||||||
|
.alert-popover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
@ -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;
|
@ -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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -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;
|
@ -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();
|
||||||
|
};
|
@ -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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -0,0 +1,6 @@
|
|||||||
|
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
|
||||||
|
|
||||||
|
export type TopContributorsCardProps = {
|
||||||
|
topContributorsData: AlertRuleTopContributors[];
|
||||||
|
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
|
||||||
|
};
|
@ -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;
|
@ -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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
184
frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx
Normal file
184
frontend/src/container/AlertHistory/Timeline/Graph/Graph.tsx
Normal 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;
|
@ -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,
|
||||||
|
};
|
@ -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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
56
frontend/src/container/AlertHistory/Timeline/Table/Table.tsx
Normal file
56
frontend/src/container/AlertHistory/Timeline/Table/Table.tsx
Normal 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;
|
@ -0,0 +1,9 @@
|
|||||||
|
import {
|
||||||
|
AlertRuleTimelineTableResponse,
|
||||||
|
AlertRuleTimelineTableResponsePayload,
|
||||||
|
} from 'types/api/alerts/def';
|
||||||
|
|
||||||
|
export type TimelineTableProps = {
|
||||||
|
timelineData: AlertRuleTimelineTableResponse[];
|
||||||
|
totalItems: AlertRuleTimelineTableResponsePayload['data']['total'];
|
||||||
|
};
|
@ -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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
frontend/src/container/AlertHistory/Timeline/Timeline.tsx
Normal file
32
frontend/src/container/AlertHistory/Timeline/Timeline.tsx
Normal 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;
|
@ -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;
|
1
frontend/src/container/AlertHistory/constants.ts
Normal file
1
frontend/src/container/AlertHistory/constants.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const TIMELINE_TABLE_PAGE_SIZE = 20;
|
3
frontend/src/container/AlertHistory/index.tsx
Normal file
3
frontend/src/container/AlertHistory/index.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import AlertHistory from './AlertHistory';
|
||||||
|
|
||||||
|
export default AlertHistory;
|
15
frontend/src/container/AlertHistory/types.ts
Normal file
15
frontend/src/container/AlertHistory/types.ts
Normal 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',
|
||||||
|
}
|
@ -253,6 +253,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
|||||||
routeKey === 'MESSAGING_QUEUES' || routeKey === 'MESSAGING_QUEUES_DETAIL';
|
routeKey === 'MESSAGING_QUEUES' || routeKey === 'MESSAGING_QUEUES_DETAIL';
|
||||||
|
|
||||||
const isDashboardListView = (): boolean => routeKey === 'ALL_DASHBOARD';
|
const isDashboardListView = (): boolean => routeKey === 'ALL_DASHBOARD';
|
||||||
|
const isAlertHistory = (): boolean => routeKey === 'ALERT_HISTORY';
|
||||||
|
const isAlertOverview = (): boolean => routeKey === 'ALERT_OVERVIEW';
|
||||||
const isDashboardView = (): boolean => {
|
const isDashboardView = (): boolean => {
|
||||||
/**
|
/**
|
||||||
* need to match using regex here as the getRoute function will not work for
|
* 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() ||
|
isDashboardView() ||
|
||||||
isDashboardWidgetView() ||
|
isDashboardWidgetView() ||
|
||||||
isDashboardListView() ||
|
isDashboardListView() ||
|
||||||
|
isAlertHistory() ||
|
||||||
|
isAlertOverview() ||
|
||||||
isMessagingQueues()
|
isMessagingQueues()
|
||||||
? 0
|
? 0
|
||||||
: '0 1rem',
|
: '0 1rem',
|
||||||
|
@ -42,6 +42,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-tabs-tab-btn {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lightMode {
|
.lightMode {
|
||||||
|
@ -19,6 +19,7 @@ import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
|||||||
import { FeatureKeys } from 'constants/features';
|
import { FeatureKeys } from 'constants/features';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
import QueryTypeTag from 'container/NewWidget/LeftContainer/QueryTypeTag';
|
||||||
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
|
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
|
||||||
@ -369,7 +370,7 @@ function FormAlertRules({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// invalidate rule in cache
|
// 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
|
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -139,7 +139,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
|||||||
params.set(QueryParams.ruleId, record.id.toString());
|
params.set(QueryParams.ruleId, record.id.toString());
|
||||||
|
|
||||||
setEditLoader(false);
|
setEditLoader(false);
|
||||||
history.push(`${ROUTES.EDIT_ALERTS}?${params.toString()}`);
|
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||||
})
|
})
|
||||||
.catch(handleError)
|
.catch(handleError)
|
||||||
.finally(() => setEditLoader(false));
|
.finally(() => setEditLoader(false));
|
||||||
|
@ -62,6 +62,14 @@
|
|||||||
.shareable-link-popover {
|
.shareable-link-popover {
|
||||||
margin-left: 8px;
|
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 {
|
.share-modal-content {
|
||||||
@ -296,4 +304,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.reset-button {
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
border-color: var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -208,6 +208,8 @@ export const routesToSkip = [
|
|||||||
ROUTES.DASHBOARD,
|
ROUTES.DASHBOARD,
|
||||||
ROUTES.DASHBOARD_WIDGET,
|
ROUTES.DASHBOARD_WIDGET,
|
||||||
ROUTES.SERVICE_TOP_LEVEL_OPERATIONS,
|
ROUTES.SERVICE_TOP_LEVEL_OPERATIONS,
|
||||||
|
ROUTES.ALERT_HISTORY,
|
||||||
|
ROUTES.ALERT_OVERVIEW,
|
||||||
ROUTES.MESSAGING_QUEUES,
|
ROUTES.MESSAGING_QUEUES,
|
||||||
ROUTES.MESSAGING_QUEUES_DETAIL,
|
ROUTES.MESSAGING_QUEUES_DETAIL,
|
||||||
];
|
];
|
||||||
|
@ -27,7 +27,7 @@ import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax';
|
|||||||
import getTimeString from 'lib/getTimeString';
|
import getTimeString from 'lib/getTimeString';
|
||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
import { isObject } from 'lodash-es';
|
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 { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useQueryClient } from 'react-query';
|
import { useQueryClient } from 'react-query';
|
||||||
import { connect, useSelector } from 'react-redux';
|
import { connect, useSelector } from 'react-redux';
|
||||||
@ -44,6 +44,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
|||||||
|
|
||||||
import AutoRefresh from '../AutoRefreshV2';
|
import AutoRefresh from '../AutoRefreshV2';
|
||||||
import { DateTimeRangeType } from '../CustomDateTimeModal';
|
import { DateTimeRangeType } from '../CustomDateTimeModal';
|
||||||
|
import { RelativeTimeMap } from '../DateTimeSelection/config';
|
||||||
import {
|
import {
|
||||||
convertOldTimeToNewValidCustomTimeFormat,
|
convertOldTimeToNewValidCustomTimeFormat,
|
||||||
CustomTimeType,
|
CustomTimeType,
|
||||||
@ -63,7 +64,9 @@ function DateTimeSelection({
|
|||||||
location,
|
location,
|
||||||
updateTimeInterval,
|
updateTimeInterval,
|
||||||
globalTimeLoading,
|
globalTimeLoading,
|
||||||
|
showResetButton = false,
|
||||||
showOldExplorerCTA = false,
|
showOldExplorerCTA = false,
|
||||||
|
defaultRelativeTime = RelativeTimeMap['6hr'] as Time,
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
const [formSelector] = Form.useForm();
|
const [formSelector] = Form.useForm();
|
||||||
|
|
||||||
@ -242,22 +245,25 @@ function DateTimeSelection({
|
|||||||
return defaultSelectedOption;
|
return defaultSelectedOption;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateLocalStorageForRoutes = (value: Time | string): void => {
|
const updateLocalStorageForRoutes = useCallback(
|
||||||
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
(value: Time | string): void => {
|
||||||
if (preRoutes !== null) {
|
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
|
||||||
const preRoutesObject = JSON.parse(preRoutes);
|
if (preRoutes !== null) {
|
||||||
|
const preRoutesObject = JSON.parse(preRoutes);
|
||||||
|
|
||||||
const preRoute = {
|
const preRoute = {
|
||||||
...preRoutesObject,
|
...preRoutesObject,
|
||||||
};
|
};
|
||||||
preRoute[location.pathname] = value;
|
preRoute[location.pathname] = value;
|
||||||
|
|
||||||
setLocalStorageKey(
|
setLocalStorageKey(
|
||||||
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
|
||||||
JSON.stringify(preRoute),
|
JSON.stringify(preRoute),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[location.pathname],
|
||||||
|
);
|
||||||
|
|
||||||
const onLastRefreshHandler = useCallback(() => {
|
const onLastRefreshHandler = useCallback(() => {
|
||||||
const currentTime = dayjs();
|
const currentTime = dayjs();
|
||||||
@ -297,48 +303,65 @@ function DateTimeSelection({
|
|||||||
[location.pathname],
|
[location.pathname],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSelectHandler = (value: Time | CustomTimeType): void => {
|
const onSelectHandler = useCallback(
|
||||||
if (value !== 'custom') {
|
(value: Time | CustomTimeType): void => {
|
||||||
setIsOpen(false);
|
if (value !== 'custom') {
|
||||||
updateTimeInterval(value);
|
setIsOpen(false);
|
||||||
updateLocalStorageForRoutes(value);
|
updateTimeInterval(value);
|
||||||
setIsValidteRelativeTime(true);
|
updateLocalStorageForRoutes(value);
|
||||||
if (refreshButtonHidden) {
|
setIsValidteRelativeTime(true);
|
||||||
setRefreshButtonHidden(false);
|
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.set(QueryParams.relativeTime, value);
|
||||||
urlQuery.delete('startTime');
|
|
||||||
urlQuery.delete('endTime');
|
|
||||||
|
|
||||||
urlQuery.set(QueryParams.relativeTime, value);
|
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||||
|
history.replace(generatedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
// For logs explorer - time range handling is managed in useCopyLogLink.ts:52
|
||||||
history.replace(generatedUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For logs explorer - time range handling is managed in useCopyLogLink.ts:52
|
if (!stagedQuery) {
|
||||||
|
return;
|
||||||
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
|
||||||
// the second boolean param directs the qb about the time change so to merge the query and retain the current state
|
initQueryBuilderData(stagedQuery, true);
|
||||||
// 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 => {
|
const onRefreshHandler = (): void => {
|
||||||
onSelectHandler(selectedTime);
|
onSelectHandler(selectedTime);
|
||||||
onLastRefreshHandler();
|
onLastRefreshHandler();
|
||||||
};
|
};
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
if (defaultRelativeTime) {
|
||||||
|
onSelectHandler(defaultRelativeTime);
|
||||||
|
}
|
||||||
|
}, [defaultRelativeTime, onSelectHandler]);
|
||||||
|
|
||||||
const onCustomDateHandler = (dateTimeRange: DateTimeRangeType): void => {
|
const onCustomDateHandler = (dateTimeRange: DateTimeRangeType): void => {
|
||||||
if (dateTimeRange !== null) {
|
if (dateTimeRange !== null) {
|
||||||
@ -446,6 +469,22 @@ function DateTimeSelection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentRoute = location.pathname;
|
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 time = getDefaultTime(currentRoute);
|
||||||
|
|
||||||
const currentOptions = getOptions(currentRoute);
|
const currentOptions = getOptions(currentRoute);
|
||||||
@ -575,6 +614,19 @@ function DateTimeSelection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="date-time-selector">
|
<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 && (
|
{showOldExplorerCTA && (
|
||||||
<div style={{ marginRight: 12 }}>
|
<div style={{ marginRight: 12 }}>
|
||||||
<NewExplorerCTA />
|
<NewExplorerCTA />
|
||||||
@ -666,11 +718,15 @@ interface DateTimeSelectionV2Props {
|
|||||||
showAutoRefresh: boolean;
|
showAutoRefresh: boolean;
|
||||||
hideShareModal?: boolean;
|
hideShareModal?: boolean;
|
||||||
showOldExplorerCTA?: boolean;
|
showOldExplorerCTA?: boolean;
|
||||||
|
showResetButton?: boolean;
|
||||||
|
defaultRelativeTime?: Time;
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTimeSelection.defaultProps = {
|
DateTimeSelection.defaultProps = {
|
||||||
hideShareModal: false,
|
hideShareModal: false,
|
||||||
showOldExplorerCTA: false,
|
showOldExplorerCTA: false,
|
||||||
|
showResetButton: false,
|
||||||
|
defaultRelativeTime: RelativeTimeMap['6hr'] as Time,
|
||||||
};
|
};
|
||||||
interface DispatchProps {
|
interface DispatchProps {
|
||||||
updateTimeInterval: (
|
updateTimeInterval: (
|
||||||
|
49
frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts
Normal file
49
frontend/src/lib/uPlotLib/plugins/heatmapPlugin.ts
Normal 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;
|
632
frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts
Normal file
632
frontend/src/lib/uPlotLib/plugins/timelinePlugin.ts
Normal 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;
|
189
frontend/src/pages/AlertDetails/AlertDetails.styles.scss
Normal file
189
frontend/src/pages/AlertDetails/AlertDetails.styles.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
123
frontend/src/pages/AlertDetails/AlertDetails.tsx
Normal file
123
frontend/src/pages/AlertDetails/AlertDetails.tsx
Normal 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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
66
frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx
Normal file
66
frontend/src/pages/AlertDetails/AlertHeader/AlertHeader.tsx
Normal 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;
|
@ -0,0 +1,5 @@
|
|||||||
|
.alert-labels {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 6px;
|
||||||
|
}
|
@ -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;
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
.alert-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
&__label {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
};
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
525
frontend/src/pages/AlertDetails/hooks.tsx
Normal file
525
frontend/src/pages/AlertDetails/hooks.tsx
Normal 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 };
|
||||||
|
};
|
3
frontend/src/pages/AlertDetails/index.tsx
Normal file
3
frontend/src/pages/AlertDetails/index.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import AlertDetails from './AlertDetails';
|
||||||
|
|
||||||
|
export default AlertDetails;
|
6
frontend/src/pages/AlertDetails/types.ts
Normal file
6
frontend/src/pages/AlertDetails/types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type AlertDetailsStatusRendererProps = {
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
isRefetching: boolean;
|
||||||
|
data: any;
|
||||||
|
};
|
3
frontend/src/pages/AlertHistory/index.tsx
Normal file
3
frontend/src/pages/AlertHistory/index.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import AlertHistory from 'container/AlertHistory';
|
||||||
|
|
||||||
|
export default AlertHistory;
|
@ -1,10 +1,14 @@
|
|||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import { TabsProps } from 'antd/lib';
|
import { TabsProps } from 'antd/lib';
|
||||||
|
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
import AllAlertRules from 'container/ListAlertRules';
|
import AllAlertRules from 'container/ListAlertRules';
|
||||||
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
|
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
|
||||||
import TriggeredAlerts from 'container/TriggeredAlerts';
|
import TriggeredAlerts from 'container/TriggeredAlerts';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
|
import { GalleryVerticalEnd, Pyramid } from 'lucide-react';
|
||||||
|
import AlertDetails from 'pages/AlertDetails';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
function AllAlertList(): JSX.Element {
|
function AllAlertList(): JSX.Element {
|
||||||
@ -12,15 +16,40 @@ function AllAlertList(): JSX.Element {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const tab = urlQuery.get('tab');
|
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'] = [
|
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',
|
key: 'TriggeredAlerts',
|
||||||
children: <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',
|
key: 'Configuration',
|
||||||
children: <PlannedDowntime />,
|
children: <PlannedDowntime />,
|
||||||
},
|
},
|
||||||
@ -33,8 +62,16 @@ function AllAlertList(): JSX.Element {
|
|||||||
activeKey={tab || 'AlertRules'}
|
activeKey={tab || 'AlertRules'}
|
||||||
onChange={(tab): void => {
|
onChange={(tab): void => {
|
||||||
urlQuery.set('tab', tab);
|
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' : ''
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,33 @@
|
|||||||
.edit-rules-container {
|
.edit-rules-container {
|
||||||
display: flex;
|
padding: 0 16px;
|
||||||
justify-content: center;
|
&--error {
|
||||||
align-items: center;
|
display: flex;
|
||||||
margin-top: 5rem;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.edit-rules-card {
|
.edit-rules-card {
|
||||||
width: 20rem;
|
width: 20rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-container {
|
.btn-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import { Button, Card } from 'antd';
|
|||||||
import get from 'api/alerts/get';
|
import get from 'api/alerts/get';
|
||||||
import Spinner from 'components/Spinner';
|
import Spinner from 'components/Spinner';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import EditRulesContainer from 'container/EditRules';
|
import EditRulesContainer from 'container/EditRules';
|
||||||
import { useNotifications } from 'hooks/useNotifications';
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
@ -21,19 +22,21 @@ import {
|
|||||||
|
|
||||||
function EditRules(): JSX.Element {
|
function EditRules(): JSX.Element {
|
||||||
const params = useUrlQuery();
|
const params = useUrlQuery();
|
||||||
const ruleId = params.get('ruleId');
|
const ruleId = params.get(QueryParams.ruleId);
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
|
|
||||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||||
|
|
||||||
const { isLoading, data, isRefetching, isError } = useQuery(
|
const { isLoading, data, isRefetching, isError } = useQuery(
|
||||||
['ruleId', ruleId],
|
[REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId],
|
||||||
{
|
{
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
get({
|
get({
|
||||||
id: parseInt(ruleId || '', 10),
|
id: parseInt(ruleId || '', 10),
|
||||||
}),
|
}),
|
||||||
enabled: isValidRuleId,
|
enabled: isValidRuleId,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -62,7 +65,7 @@ function EditRules(): JSX.Element {
|
|||||||
(data?.payload?.data === undefined && !isLoading)
|
(data?.payload?.data === undefined && !isLoading)
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="edit-rules-container">
|
<div className="edit-rules-container edit-rules-container--error">
|
||||||
<Card size="small" className="edit-rules-card">
|
<Card size="small" className="edit-rules-card">
|
||||||
<p className="content">
|
<p className="content">
|
||||||
{data?.message === errorMessageReceivedFromBackend
|
{data?.message === errorMessageReceivedFromBackend
|
||||||
@ -84,10 +87,12 @@ function EditRules(): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditRulesContainer
|
<div className="edit-rules-container">
|
||||||
ruleId={parseInt(ruleId, 10)}
|
<EditRulesContainer
|
||||||
initialValue={data.payload.data}
|
ruleId={parseInt(ruleId, 10)}
|
||||||
/>
|
initialValue={data.payload.data}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -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
Loading…
x
Reference in New Issue
Block a user