Merge branch 'develop' into SIG-5729

This commit is contained in:
rahulkeswani101 2024-09-06 12:22:41 +05:30 committed by GitHub
commit 317c41a166
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
189 changed files with 7753 additions and 635 deletions

View File

@ -51,7 +51,7 @@
"ansi-to-html": "0.7.2", "ansi-to-html": "0.7.2",
"antd": "5.11.0", "antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1", "antd-table-saveas-excel": "2.2.1",
"axios": "1.6.4", "axios": "1.7.4",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^29.6.4", "babel-jest": "^29.6.4",
"babel-loader": "9.1.3", "babel-loader": "9.1.3",
@ -88,7 +88,7 @@
"lucide-react": "0.379.0", "lucide-react": "0.379.0",
"mini-css-extract-plugin": "2.4.5", "mini-css-extract-plugin": "2.4.5",
"papaparse": "5.4.1", "papaparse": "5.4.1",
"posthog-js": "1.142.1", "posthog-js": "1.160.3",
"rc-tween-one": "3.0.6", "rc-tween-one": "3.0.6",
"react": "18.2.0", "react": "18.2.0",
"react-addons-update": "15.6.3", "react-addons-update": "15.6.3",

View File

@ -0,0 +1,30 @@
{
"breadcrumb": "Messaging Queues",
"header": "Kafka / Overview",
"overview": {
"title": "Start sending data in as little as 20 minutes",
"subtitle": "Connect and Monitor Your Data Streams"
},
"configureConsumer": {
"title": "Configure Consumer",
"description": "Add consumer data sources to gain insights and enhance monitoring.",
"button": "Get Started"
},
"configureProducer": {
"title": "Configure Producer",
"description": "Add producer data sources to gain insights and enhance monitoring.",
"button": "Get Started"
},
"monitorKafka": {
"title": "Monitor kafka",
"description": "Add your Kafka source to gain insights and enhance activity tracking.",
"button": "Get Started"
},
"summarySection": {
"viewDetailsButton": "View Details"
},
"confirmModal": {
"content": "Before navigating to the details page, please make sure you have configured all the required setup to ensure correct data monitoring.",
"okText": "Proceed"
}
}

View File

@ -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"
} }

View File

@ -0,0 +1,30 @@
{
"breadcrumb": "Messaging Queues",
"header": "Kafka / Overview",
"overview": {
"title": "Start sending data in as little as 20 minutes",
"subtitle": "Connect and Monitor Your Data Streams"
},
"configureConsumer": {
"title": "Configure Consumer",
"description": "Add consumer data sources to gain insights and enhance monitoring.",
"button": "Get Started"
},
"configureProducer": {
"title": "Configure Producer",
"description": "Add producer data sources to gain insights and enhance monitoring.",
"button": "Get Started"
},
"monitorKafka": {
"title": "Monitor kafka",
"description": "Add your Kafka source to gain insights and enhance activity tracking.",
"button": "Get Started"
},
"summarySection": {
"viewDetailsButton": "View Details"
},
"confirmModal": {
"content": "Before navigating to the details page, please make sure you have configured all the required setup to ensure correct data monitoring.",
"okText": "Proceed"
}
}

View File

@ -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"
} }

View File

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

View File

@ -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'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleStatsPayload } from 'types/api/alerts/def';
import { RuleStatsProps } from 'types/api/alerts/ruleStats';
const ruleStats = async (
props: RuleStatsProps,
): Promise<SuccessResponse<AlertRuleStatsPayload> | ErrorResponse> => {
try {
const response = await axios.post(`/rules/${props.id}/history/stats`, {
start: props.start,
end: props.end,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default ruleStats;

View File

@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def';
import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph';
const timelineGraph = async (
props: GetTimelineGraphRequestProps,
): Promise<
SuccessResponse<AlertRuleTimelineGraphResponsePayload> | ErrorResponse
> => {
try {
const response = await axios.post(
`/rules/${props.id}/history/overall_status`,
{
start: props.start,
end: props.end,
},
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default timelineGraph;

View File

@ -0,0 +1,36 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def';
import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable';
const timelineTable = async (
props: GetTimelineTableRequestProps,
): Promise<
SuccessResponse<AlertRuleTimelineTableResponsePayload> | ErrorResponse
> => {
try {
const response = await axios.post(`/rules/${props.id}/history/timeline`, {
start: props.start,
end: props.end,
offset: props.offset,
limit: props.limit,
order: props.order,
state: props.state,
// TODO(shaheer): implement filters
filters: props.filters,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default timelineTable;

View File

@ -0,0 +1,33 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def';
import { TopContributorsProps } from 'types/api/alerts/topContributors';
const topContributors = async (
props: TopContributorsProps,
): Promise<
SuccessResponse<AlertRuleTopContributorsPayload> | ErrorResponse
> => {
try {
const response = await axios.post(
`/rules/${props.id}/history/top_contributors`,
{
start: props.start,
end: props.end,
},
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default topContributors;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,145 @@
.checkbox-filter {
display: flex;
flex-direction: column;
padding: 12px;
gap: 12px;
border-bottom: 1px solid var(--bg-slate-400);
.filter-header-checkbox {
display: flex;
align-items: center;
justify-content: space-between;
.left-action {
display: flex;
align-items: center;
gap: 6px;
.title {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
text-transform: capitalize;
}
}
.right-action {
display: flex;
align-items: center;
.clear-all {
font-size: 12px;
color: var(--bg-robin-500);
cursor: pointer;
}
}
}
.values {
display: flex;
flex-direction: column;
gap: 8px;
.value {
display: flex;
align-items: center;
gap: 8px;
.checkbox-value-section {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
cursor: pointer;
&.filter-disabled {
cursor: not-allowed;
.value-string {
color: var(--bg-slate-200);
}
.only-btn {
cursor: not-allowed;
color: var(--bg-slate-200);
}
.toggle-btn {
cursor: not-allowed;
color: var(--bg-slate-200);
}
}
.value-string {
}
.only-btn {
display: none;
}
.toggle-btn {
display: none;
}
.toggle-btn:hover {
background-color: unset;
}
.only-btn:hover {
background-color: unset;
}
}
.checkbox-value-section:hover {
.toggle-btn {
display: none;
}
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
}
}
.value:hover {
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
}
}
.no-data {
align-self: center;
}
.show-more {
display: flex;
align-items: center;
justify-content: center;
.show-more-text {
color: var(--bg-robin-500);
cursor: pointer;
}
}
}
.lightMode {
.checkbox-filter {
border-bottom: 1px solid var(--bg-vanilla-300);
.filter-header-checkbox {
.left-action {
.title {
color: var(--bg-ink-400);
}
}
}
}
}

View File

@ -0,0 +1,503 @@
/* eslint-disable no-nested-ternary */
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import './Checkbox.styles.scss';
import { Button, Checkbox, Input, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters';
import { OPERATORS } from 'constants/queryBuilder';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep, isArray, isEmpty, isEqual } from 'lodash-es';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useMemo, useState } from 'react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'nin'];
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
): Record<string, boolean> {
const defaultState: Record<string, boolean> = {};
values.forEach((val) => {
defaultState[val] = trueOrFalse;
});
return defaultState;
}
interface ICheckboxProps {
filter: IQuickFiltersConfig;
}
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const { filter } = props;
const [searchText, setSearchText] = useState<string>('');
const [isOpen, setIsOpen] = useState<boolean>(filter.defaultOpen);
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
const {
lastUsedQuery,
currentQuery,
redirectWithQueryBuilderData,
} = useQueryBuilder();
const { data, isLoading } = useGetAggregateValues(
{
aggregateOperator: 'noop',
dataSource: DataSource.LOGS,
aggregateAttribute: '',
attributeKey: filter.attributeKey.key,
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
tagType: filter.attributeKey.type || '',
searchText: searchText ?? '',
},
{
enabled: isOpen,
keepPreviousData: true,
},
);
const attributeValues: string[] = useMemo(
() =>
((Object.values(data?.payload || {}).find((el) => !!el) ||
[]) as string[]).filter((val) => !isEmpty(val)),
[data?.payload],
);
const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount);
// derive the state of each filter key here in the renderer itself and keep it in sync with staged query
// also we need to keep a note of last focussed query.
// eslint-disable-next-line sonarjs/cognitive-complexity
const currentFilterState = useMemo(() => {
let filterState: Record<string, boolean> = setDefaultValues(
attributeValues,
false,
);
const filterSync = currentQuery?.builder.queryData?.[
lastUsedQuery || 0
]?.filters?.items.find((item) => isEqual(item.key, filter.attributeKey));
if (filterSync) {
if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[val] = true;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = true;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = true;
}
} else if (NON_SELECTED_OPERATORS.includes(filterSync.op)) {
filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => {
filterState[val] = false;
});
} else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false;
} else if (typeof filterSync.value === 'boolean') {
filterState[String(filterSync.value)] = false;
} else if (typeof filterSync.value === 'number') {
filterState[String(filterSync.value)] = false;
}
}
} else {
filterState = setDefaultValues(attributeValues, true);
}
return filterState;
}, [
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
lastUsedQuery,
]);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
lastUsedQuery || 0
]?.filters?.items?.filter((item) => isEqual(item.key, filter.attributeKey))
?.length || 0) > 1,
[currentQuery?.builder?.queryData, lastUsedQuery, filter.attributeKey],
);
// variable to check if the current filter has multiple values to its name in the key op value section
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
const handleClearFilterAttribute = (): void => {
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filters: {
...item.filters,
items:
idx === lastUsedQuery
? item.filters.items.filter(
(fil) => !isEqual(fil.key, filter.attributeKey),
)
: [...item.filters.items],
},
})),
},
};
redirectWithQueryBuilderData(preparedQuery);
};
const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[
lastUsedQuery || 0
]?.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey));
const onChange = (
value: string,
checked: boolean,
isOnlyOrAllClicked: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
const query = cloneDeep(currentQuery.builder.queryData?.[lastUsedQuery || 0]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
if (isOnlyOrAllClicked && query?.filters?.items) {
const isOnlyOrAll = isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only';
query.filters.items = query.filters.items.filter(
(q) => !isEqual(q.key, filter.attributeKey),
);
if (isOnlyOrAll === 'Only') {
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.IN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
} else if (query?.filters?.items) {
if (
query.filters?.items?.some((item) => isEqual(item.key, filter.attributeKey))
) {
// if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) =>
isEqual(q.key, filter.attributeKey),
);
if (currentFilter) {
const runningOperator = currentFilter?.op;
switch (runningOperator) {
case 'in':
if (checked) {
// if it's an IN operator then if we are checking another value it get's added to the
// filter clause. example - key IN [value1, currentSelectedValue]
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key, filter.attributeKey)) {
return newFilter;
}
return item;
});
} else {
// if the current state wasn't an array we make it one and add our value
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key, filter.attributeKey)) {
return newFilter;
}
return item;
});
}
} else if (!checked) {
// if we are removing some value when the running operator is IN we filter.
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key, filter.attributeKey),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key, filter.attributeKey)) {
return newFilter;
}
return item;
});
}
} else {
// if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key, filter.attributeKey),
);
}
}
break;
case 'nin':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
if (!checked) {
// in case of array add the currentUnselectedValue to the list.
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: [...currentFilter.value, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key, filter.attributeKey)) {
return newFilter;
}
return item;
});
} else {
// in case of not an array make it one!
const newFilter = {
...currentFilter,
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key, filter.attributeKey)) {
return newFilter;
}
return item;
});
}
} else if (checked) {
// opposite of above!
if (isArray(currentFilter.value)) {
const newFilter = {
...currentFilter,
value: currentFilter.value.filter((val) => val !== value),
};
if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key, filter.attributeKey),
);
} else {
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key, filter.attributeKey)) {
return newFilter;
}
return item;
});
}
} else {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key, filter.attributeKey),
);
}
}
break;
case '=':
if (checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.IN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key, filter.attributeKey)) {
return newFilter;
}
return item;
});
} else if (!checked) {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key, filter.attributeKey),
);
}
break;
case '!=':
if (!checked) {
const newFilter = {
...currentFilter,
op: getOperatorValue(OPERATORS.NIN),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key, filter.attributeKey)) {
return newFilter;
}
return item;
});
} else if (checked) {
query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key, filter.attributeKey),
);
}
break;
default:
break;
}
}
} else {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getOperatorValue(OPERATORS.NIN),
key: filter.attributeKey,
value,
};
query.filters.items = [...query.filters.items, newFilterItem];
}
}
const finalQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === lastUsedQuery) {
return query;
}
return q;
}),
],
},
};
redirectWithQueryBuilderData(finalQuery);
};
return (
<div className="checkbox-filter">
<section className="filter-header-checkbox">
<section className="left-action">
{isOpen ? (
<ChevronDown
size={13}
cursor="pointer"
onClick={(): void => {
setIsOpen(false);
setVisibleItemsCount(10);
}}
/>
) : (
<ChevronRight
size={13}
onClick={(): void => setIsOpen(true)}
cursor="pointer"
/>
)}
<Typography.Text className="title">{filter.title}</Typography.Text>
</section>
<section className="right-action">
{isOpen && (
<Typography.Text
className="clear-all"
onClick={handleClearFilterAttribute}
>
Clear All
</Typography.Text>
)}
</section>
</section>
{isOpen && isLoading && !attributeValues.length && (
<section className="loading">
<Skeleton paragraph={{ rows: 4 }} />
</section>
)}
{isOpen && !isLoading && (
<>
<section className="search">
<Input
placeholder="Filter values"
onChange={(e): void => setSearchText(e.target.value)}
disabled={isFilterDisabled}
/>
</section>
{attributeValues.length > 0 ? (
<section className="values">
{currentAttributeKeys.map((value: string) => (
<div key={value} className="value">
<Checkbox
onChange={(e): void => onChange(value, e.target.checked, false)}
checked={currentFilterState[value]}
disabled={isFilterDisabled}
rootClassName="check-box"
/>
<div
className={cx(
'checkbox-value-section',
isFilterDisabled ? 'filter-disabled' : '',
)}
onClick={(): void => {
if (isFilterDisabled) {
return;
}
onChange(value, currentFilterState[value], true);
}}
>
{filter.customRendererForValue ? (
filter.customRendererForValue(value)
) : (
<Typography.Text
className="value-string"
ellipsis={{ tooltip: { placement: 'right' } }}
>
{value}
</Typography.Text>
)}
<Button type="text" className="only-btn">
{isSomeFilterPresentForCurrentAttribute
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
? 'All'
: 'Only'
: 'Only'}
</Button>
<Button type="text" className="toggle-btn">
Toggle
</Button>
</div>
</div>
))}
</section>
) : (
<section className="no-data">
<Typography.Text>No values found</Typography.Text>{' '}
</section>
)}
{visibleItemsCount < attributeValues?.length && (
<section className="show-more">
<Typography.Text
className="show-more-text"
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
>
Show More...
</Typography.Text>
</section>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,14 @@
import './Slider.styles.scss';
import { IQuickFiltersConfig } from 'components/QuickFilters/QuickFilters';
interface ISliderProps {
filter: IQuickFiltersConfig;
}
// not needed for now build when required
export default function Slider(props: ISliderProps): JSX.Element {
const { filter } = props;
console.log(filter);
return <div>Slider</div>;
}

View File

@ -0,0 +1,93 @@
.quick-filters {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid var(--bg-slate-400);
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10.5px;
border-bottom: 1px solid var(--bg-slate-400);
.left-actions {
display: flex;
align-items: center;
gap: 6px;
.text {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.sync-tag {
display: flex;
padding: 5px 9px;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 2px;
border: 1px solid rgba(78, 116, 248, 0.2);
background: rgba(78, 116, 248, 0.1);
color: var(--bg-robin-500);
font-family: 'Geist Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
text-transform: uppercase;
}
}
.right-actions {
display: flex;
align-items: center;
gap: 12px;
.divider-filter {
width: 1px;
height: 14px;
background: #161922;
}
.sync-icon {
background-color: var(--bg-ink-500);
border: 0;
box-shadow: none;
}
}
}
}
.lightMode {
.quick-filters {
background-color: var(--bg-vanilla-100);
border-right: 1px solid var(--bg-vanilla-300);
.header {
border-bottom: 1px solid var(--bg-vanilla-300);
.left-actions {
.text {
color: var(--bg-ink-400);
}
.sync-icon {
background-color: var(--bg-vanilla-100);
}
}
.right-actions {
.sync-icon {
background-color: var(--bg-vanilla-100);
}
}
}
}
}

View File

@ -0,0 +1,124 @@
import './QuickFilters.styles.scss';
import {
FilterOutlined,
SyncOutlined,
VerticalAlignTopOutlined,
} from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import Slider from './FilterRenderers/Slider/Slider';
export enum FiltersType {
SLIDER = 'SLIDER',
CHECKBOX = 'CHECKBOX',
}
export enum MinMax {
MIN = 'MIN',
MAX = 'MAX',
}
export enum SpecficFilterOperations {
ALL = 'ALL',
ONLY = 'ONLY',
}
export interface IQuickFiltersConfig {
type: FiltersType;
title: string;
attributeKey: BaseAutocompleteData;
customRendererForValue?: (value: string) => JSX.Element;
defaultOpen: boolean;
}
interface IQuickFiltersProps {
config: IQuickFiltersConfig[];
handleFilterVisibilityChange: () => void;
}
export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const { config, handleFilterVisibilityChange } = props;
const {
currentQuery,
lastUsedQuery,
redirectWithQueryBuilderData,
} = useQueryBuilder();
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
const updatedQuery = cloneDeep(
currentQuery?.builder.queryData?.[lastUsedQuery || 0],
);
if (!updatedQuery) {
return;
}
if (updatedQuery?.filters?.items) {
updatedQuery.filters.items = [];
}
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => ({
...item,
filters: {
...item.filters,
items: idx === lastUsedQuery ? [] : [...item.filters.items],
},
})),
},
};
redirectWithQueryBuilderData(preparedQuery);
};
const lastQueryName =
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
return (
<div className="quick-filters">
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">Filters for</Typography.Text>
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
</Tooltip>
</section>
<section className="right-actions">
<Tooltip title="Reset All">
<SyncOutlined className="sync-icon" onClick={handleReset} />
</Tooltip>
<div className="divider-filter" />
<Tooltip title="Collapse Filters">
<VerticalAlignTopOutlined
rotate={270}
onClick={handleFilterVisibilityChange}
/>
</Tooltip>
</section>
</section>
<section className="filters">
{config.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return <Checkbox filter={filter} />;
case FiltersType.SLIDER:
return <Slider filter={filter} />;
default:
return <Checkbox filter={filter} />;
}
})}
</section>
</div>
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
import './TabsAndFilters.styles.scss';
import { Filters } from 'components/AlertDetailsFilters/Filters';
import { Tabs } from './Tabs/Tabs';
function TabsAndFilters(): JSX.Element {
return (
<div className="tabs-and-filters">
<Tabs />
<Filters />
</div>
);
}
export default TabsAndFilters;

View File

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

View File

@ -1,4 +1,17 @@
import { ManipulateType } from 'dayjs';
const MAX_RPS_LIMIT = 100; 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',
};

View File

@ -19,4 +19,5 @@ export enum LOCALSTORAGE {
SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR', SHOW_EXPLORER_TOOLBAR = 'SHOW_EXPLORER_TOOLBAR',
PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES', PINNED_ATTRIBUTES = 'PINNED_ATTRIBUTES',
THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1', THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1',
SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS',
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,158 @@
import './StatsCard.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { ArrowDownLeft, ArrowUpRight, Calendar } from 'lucide-react';
import { AlertRuleStats } from 'types/api/alerts/def';
import { calculateChange } from 'utils/calculateChange';
import StatsGraph from './StatsGraph/StatsGraph';
import {
convertTimestampToLocaleDateString,
extractDayFromTimestamp,
} from './utils';
type ChangePercentageProps = {
percentage: number;
direction: number;
duration: string | null;
};
function ChangePercentage({
percentage,
direction,
duration,
}: ChangePercentageProps): JSX.Element {
if (direction > 0) {
return (
<div className="change-percentage change-percentage--success">
<div className="change-percentage__icon">
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
</div>
<div className="change-percentage__label">
{percentage}% vs Last {duration}
</div>
</div>
);
}
if (direction < 0) {
return (
<div className="change-percentage change-percentage--error">
<div className="change-percentage__icon">
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
</div>
<div className="change-percentage__label">
{percentage}% vs Last {duration}
</div>
</div>
);
}
return (
<div className="change-percentage change-percentage--no-previous-data">
<div className="change-percentage__label">no previous data</div>
</div>
);
}
type StatsCardProps = {
totalCurrentCount?: number;
totalPastCount?: number;
title: string;
isEmpty?: boolean;
emptyMessage?: string;
displayValue?: string | number;
timeSeries?: AlertRuleStats['currentTriggersSeries']['values'];
};
function StatsCard({
displayValue,
totalCurrentCount,
totalPastCount,
title,
isEmpty,
emptyMessage,
timeSeries = [],
}: StatsCardProps): JSX.Element {
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const { changePercentage, changeDirection } = calculateChange(
totalCurrentCount,
totalPastCount,
);
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
let displayTime = relativeTime;
if (!displayTime && startTime && endTime) {
const formattedStartDate = extractDayFromTimestamp(startTime);
const formattedEndDate = extractDayFromTimestamp(endTime);
displayTime = `${formattedStartDate} to ${formattedEndDate}`;
}
if (!displayTime) {
displayTime = '';
}
const formattedStartTimeForTooltip = convertTimestampToLocaleDateString(
startTime,
);
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
return (
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
<div className="stats-card__title-wrapper">
<div className="title">{title}</div>
<div className="duration-indicator">
<div className="icon">
<Calendar size={14} color={Color.BG_SLATE_200} />
</div>
{relativeTime ? (
<div className="text">{displayTime}</div>
) : (
<Tooltip
title={`From ${formattedStartTimeForTooltip} to ${formattedEndTimeForTooltip}`}
>
<div className="text">{displayTime}</div>
</Tooltip>
)}
</div>
</div>
<div className="stats-card__stats">
<div className="count-label">
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
</div>
<ChangePercentage
direction={changeDirection}
percentage={changePercentage}
duration={relativeTime}
/>
</div>
<div className="stats-card__graph">
<div className="graph">
{!isEmpty && timeSeries.length > 1 && (
<StatsGraph timeSeries={timeSeries} changeDirection={changeDirection} />
)}
</div>
</div>
</div>
);
}
StatsCard.defaultProps = {
totalCurrentCount: 0,
totalPastCount: 0,
isEmpty: false,
emptyMessage: 'No Data',
displayValue: '',
timeSeries: [],
};
export default StatsCard;

View File

@ -0,0 +1,90 @@
import { Color } from '@signozhq/design-tokens';
import Uplot from 'components/Uplot';
import { useResizeObserver } from 'hooks/useDimensions';
import { useMemo, useRef } from 'react';
import { AlertRuleStats } from 'types/api/alerts/def';
type Props = {
timeSeries: AlertRuleStats['currentTriggersSeries']['values'];
changeDirection: number;
};
const getStyle = (
changeDirection: number,
): { stroke: string; fill: string } => {
if (changeDirection === 0) {
return {
stroke: Color.BG_ROBIN_500,
fill: 'rgba(78, 116, 248, 0.20)',
};
}
if (changeDirection > 0) {
return {
stroke: Color.BG_FOREST_500,
fill: 'rgba(37, 225, 146, 0.20)',
};
}
return {
stroke: Color.BG_CHERRY_500,
fill: ' rgba(229, 72, 77, 0.20)',
};
};
function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
const { xData, yData } = useMemo(
() => ({
xData: timeSeries.map((item) => item.timestamp),
yData: timeSeries.map((item) => Number(item.value)),
}),
[timeSeries],
);
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const options: uPlot.Options = useMemo(
() => ({
width: containerDimensions.width,
height: containerDimensions.height,
legend: {
show: false,
},
cursor: {
x: false,
y: false,
drag: {
x: false,
y: false,
},
},
padding: [0, 0, 2, 0],
series: [
{},
{
...getStyle(changeDirection),
points: {
show: false,
},
width: 1.4,
},
],
axes: [
{ show: false },
{
show: false,
},
],
}),
[changeDirection, containerDimensions.height, containerDimensions.width],
);
return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
<Uplot data={[xData, yData]} options={options} />
</div>
);
}
export default StatsGraph;

View File

@ -0,0 +1,12 @@
export const extractDayFromTimestamp = (timestamp: string | null): string => {
if (!timestamp) return '';
const date = new Date(parseInt(timestamp, 10));
return date.getDate().toString();
};
export const convertTimestampToLocaleDateString = (
timestamp: string | null,
): string => {
if (!timestamp) return '';
return new Date(parseInt(timestamp, 10)).toLocaleString();
};

View File

@ -0,0 +1,102 @@
import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { useEffect } from 'react';
import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard';
import StatsCard from '../StatsCard/StatsCard';
import TotalTriggeredCard from '../TotalTriggeredCard/TotalTriggeredCard';
const hasTotalTriggeredStats = (
totalCurrentTriggers: number | string,
totalPastTriggers: number | string,
): boolean =>
(Number(totalCurrentTriggers) > 0 && Number(totalPastTriggers) > 0) ||
Number(totalCurrentTriggers) > 0;
const hasAvgResolutionTimeStats = (
currentAvgResolutionTime: number | string,
pastAvgResolutionTime: number | string,
): boolean =>
(Number(currentAvgResolutionTime) > 0 && Number(pastAvgResolutionTime) > 0) ||
Number(currentAvgResolutionTime) > 0;
type StatsCardsRendererProps = {
setTotalCurrentTriggers: (value: number) => void;
};
// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title
function StatsCardsRenderer({
setTotalCurrentTriggers,
}: StatsCardsRendererProps): JSX.Element {
const {
isLoading,
isRefetching,
isError,
data,
isValidRuleId,
ruleId,
} = useGetAlertRuleDetailsStats();
useEffect(() => {
if (data?.payload?.data?.totalCurrentTriggers !== undefined) {
setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers);
}
}, [data, setTotalCurrentTriggers]);
return (
<DataStateRenderer
isLoading={isLoading}
isRefetching={isRefetching}
isError={isError || !isValidRuleId || !ruleId}
data={data?.payload?.data || null}
>
{(data): JSX.Element => {
const {
currentAvgResolutionTime,
pastAvgResolutionTime,
totalCurrentTriggers,
totalPastTriggers,
currentAvgResolutionTimeSeries,
currentTriggersSeries,
} = data;
return (
<>
{hasTotalTriggeredStats(totalCurrentTriggers, totalPastTriggers) ? (
<TotalTriggeredCard
totalCurrentTriggers={totalCurrentTriggers}
totalPastTriggers={totalPastTriggers}
timeSeries={currentTriggersSeries?.values}
/>
) : (
<StatsCard
title="Total Triggered"
isEmpty
emptyMessage="None Triggered."
/>
)}
{hasAvgResolutionTimeStats(
currentAvgResolutionTime,
pastAvgResolutionTime,
) ? (
<AverageResolutionCard
currentAvgResolutionTime={currentAvgResolutionTime}
pastAvgResolutionTime={pastAvgResolutionTime}
timeSeries={currentAvgResolutionTimeSeries?.values}
/>
) : (
<StatsCard
title="Avg. Resolution Time"
isEmpty
emptyMessage="No Resolutions."
/>
)}
</>
);
}}
</DataStateRenderer>
);
}
export default StatsCardsRenderer;

View File

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

View File

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

View File

@ -0,0 +1,32 @@
import TopContributorsRows from './TopContributorsRows';
import { TopContributorsCardProps } from './types';
function TopContributorsContent({
topContributorsData,
totalCurrentTriggers,
}: TopContributorsCardProps): JSX.Element {
const isEmpty = !topContributorsData.length;
if (isEmpty) {
return (
<div className="empty-content">
<div className="empty-content__icon"></div>
<div className="empty-content__text">
Top contributors highlight the most frequently triggering group-by
attributes in multi-dimensional alerts
</div>
</div>
);
}
return (
<div className="top-contributors-card__content">
<TopContributorsRows
topContributors={topContributorsData.slice(0, 3)}
totalCurrentTriggers={totalCurrentTriggers}
/>
</div>
);
}
export default TopContributorsContent;

View File

@ -0,0 +1,87 @@
import { Color } from '@signozhq/design-tokens';
import { Progress, Table } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
function TopContributorsRows({
topContributors,
totalCurrentTriggers,
}: {
topContributors: AlertRuleTopContributors[];
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
}): JSX.Element {
const columns: ColumnsType<AlertRuleTopContributors> = [
{
title: 'labels',
dataIndex: 'labels',
key: 'labels',
width: '51%',
render: (
labels: AlertRuleTopContributors['labels'],
record,
): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div>
<AlertLabels labels={labels} />
</div>
</ConditionalAlertPopover>
),
},
{
title: 'progressBar',
dataIndex: 'count',
key: 'progressBar',
width: '39%',
render: (count: AlertRuleTopContributors['count'], record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<Progress
percent={(count / totalCurrentTriggers) * 100}
showInfo={false}
trailColor="rgba(255, 255, 255, 0)"
strokeColor={Color.BG_ROBIN_500}
/>
</ConditionalAlertPopover>
),
},
{
title: 'count',
dataIndex: 'count',
key: 'count',
width: '10%',
render: (count: AlertRuleTopContributors['count'], record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="total-contribution">
{count}/{totalCurrentTriggers}
</div>
</ConditionalAlertPopover>
),
},
];
return (
<Table
rowClassName="contributors-row"
rowKey={(row): string => `top-contributor-${row.fingerprint}`}
columns={columns}
showHeader={false}
dataSource={topContributors}
pagination={
topContributors.length > 10 ? { showTotal: PaginationInfoText } : false
}
/>
);
}
export default TopContributorsRows;

View File

@ -0,0 +1,46 @@
import { Color } from '@signozhq/design-tokens';
import { Drawer } from 'antd';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
import TopContributorsRows from './TopContributorsRows';
function ViewAllDrawer({
isViewAllVisible,
toggleViewAllDrawer,
totalCurrentTriggers,
topContributorsData,
}: {
isViewAllVisible: boolean;
toggleViewAllDrawer: () => void;
topContributorsData: AlertRuleTopContributors[];
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
}): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<Drawer
open={isViewAllVisible}
destroyOnClose
onClose={toggleViewAllDrawer}
placement="right"
width="50%"
className="view-all-drawer"
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
title="Viewing All Contributors"
>
<div className="top-contributors-card--view-all">
<div className="top-contributors-card__content">
<TopContributorsRows
topContributors={topContributorsData}
totalCurrentTriggers={totalCurrentTriggers}
/>
</div>
</div>
</Drawer>
);
}
export default ViewAllDrawer;

View File

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

View File

@ -0,0 +1,42 @@
import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { AlertRuleStats } from 'types/api/alerts/def';
import TopContributorsCard from '../TopContributorsCard/TopContributorsCard';
type TopContributorsRendererProps = {
totalCurrentTriggers: AlertRuleStats['totalCurrentTriggers'];
};
function TopContributorsRenderer({
totalCurrentTriggers,
}: TopContributorsRendererProps): JSX.Element {
const {
isLoading,
isRefetching,
isError,
data,
isValidRuleId,
ruleId,
} = useGetAlertRuleDetailsTopContributors();
const response = data?.payload?.data;
// TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all
return (
<DataStateRenderer
isLoading={isLoading}
isRefetching={isRefetching}
isError={isError || !isValidRuleId || !ruleId}
data={response || null}
>
{(topContributorsData): JSX.Element => (
<TopContributorsCard
topContributorsData={topContributorsData}
totalCurrentTriggers={totalCurrentTriggers}
/>
)}
</DataStateRenderer>
);
}
export default TopContributorsRenderer;

View File

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

View File

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

View File

@ -0,0 +1,184 @@
import { Color } from '@signozhq/design-tokens';
import Uplot from 'components/Uplot';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin';
import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin';
import { useMemo, useRef } from 'react';
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
import uPlot, { AlignedData } from 'uplot';
import { ALERT_STATUS, TIMELINE_OPTIONS } from './constants';
type Props = { type: string; data: AlertRuleTimelineGraphResponse[] };
function HorizontalTimelineGraph({
width,
isDarkMode,
data,
}: {
width: number;
isDarkMode: boolean;
data: AlertRuleTimelineGraphResponse[];
}): JSX.Element {
const transformedData: AlignedData = useMemo(() => {
if (!data?.length) {
return [[], []];
}
// add a first and last entry to make sure the graph displays all the data
const FIVE_MINUTES_IN_SECONDS = 300;
const timestamps = [
data[0].start / 1000 - FIVE_MINUTES_IN_SECONDS, // 5 minutes before the first entry
...data.map((item) => item.start / 1000),
data[data.length - 1].end / 1000, // end value of last entry
];
const states = [
ALERT_STATUS[data[0].state], // Same state as the first entry
...data.map((item) => ALERT_STATUS[item.state]),
ALERT_STATUS[data[data.length - 1].state], // Same state as the last entry
];
return [timestamps, states];
}, [data]);
const options: uPlot.Options = useMemo(
() => ({
width,
height: 85,
cursor: { show: false },
axes: [
{
gap: 10,
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
},
{ show: false },
],
legend: {
show: false,
},
padding: [null, 0, null, 0],
series: [
{
label: 'Time',
},
{
label: 'States',
},
],
plugins:
transformedData?.length > 1
? [
timelinePlugin({
count: transformedData.length - 1,
...TIMELINE_OPTIONS,
}),
]
: [],
}),
[width, isDarkMode, transformedData],
);
return <Uplot data={transformedData} options={options} />;
}
const transformVerticalTimelineGraph = (data: any[]): any => [
data.map((item: { timestamp: any }) => item.timestamp),
Array(data.length).fill(0),
Array(data.length).fill(10),
Array(data.length).fill([0, 1, 2, 3, 4, 5]),
data.map((item: { value: number }) => {
const count = Math.floor(item.value / 10);
return [...Array(count).fill(1), 2];
}),
];
const datatest: any[] = [];
const now = Math.floor(Date.now() / 1000); // current timestamp in seconds
const oneDay = 24 * 60 * 60; // one day in seconds
for (let i = 0; i < 90; i++) {
const timestamp = now - i * oneDay;
const startOfDay = timestamp - (timestamp % oneDay);
datatest.push({
timestamp: startOfDay,
value: Math.floor(Math.random() * 30) + 1,
});
}
function VerticalTimelineGraph({
isDarkMode,
width,
}: {
width: number;
isDarkMode: boolean;
}): JSX.Element {
const transformedData = useMemo(
() => transformVerticalTimelineGraph(datatest),
[],
);
const options: uPlot.Options = useMemo(
() => ({
width,
height: 90,
plugins: [heatmapPlugin()],
cursor: { show: false },
legend: {
show: false,
},
axes: [
{
gap: 10,
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
},
{ show: false },
],
series: [
{},
{
paths: (): null => null,
points: { show: false },
},
{
paths: (): null => null,
points: { show: false },
},
],
}),
[isDarkMode, width],
);
return <Uplot data={transformedData} options={options} />;
}
function Graph({ type, data }: Props): JSX.Element | null {
const graphRef = useRef<HTMLDivElement>(null);
const isDarkMode = useIsDarkMode();
const containerDimensions = useResizeObserver(graphRef);
if (type === 'horizontal') {
return (
<div ref={graphRef}>
<HorizontalTimelineGraph
isDarkMode={isDarkMode}
width={containerDimensions.width}
data={data}
/>
</div>
);
}
return (
<div ref={graphRef}>
<VerticalTimelineGraph
isDarkMode={isDarkMode}
width={containerDimensions.width}
/>
</div>
);
}
export default Graph;

View File

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

View File

@ -0,0 +1,67 @@
import '../Graph/Graph.styles.scss';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import Graph from '../Graph/Graph';
function GraphWrapper({
totalCurrentTriggers,
}: {
totalCurrentTriggers: number;
}): JSX.Element {
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const {
isLoading,
isRefetching,
isError,
data,
isValidRuleId,
ruleId,
} = useGetAlertRuleDetailsTimelineGraphData();
// TODO(shaheer): uncomment when the API is ready for
// const { startTime } = useAlertHistoryQueryParams();
// const [isVerticalGraph, setIsVerticalGraph] = useState(false);
// useEffect(() => {
// const checkVerticalGraph = (): void => {
// if (startTime) {
// const startTimeDate = dayjs(Number(startTime));
// const twentyFourHoursAgo = dayjs().subtract(
// HORIZONTAL_GRAPH_HOURS_THRESHOLD,
// DAYJS_MANIPULATE_TYPES.HOUR,
// );
// setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo));
// }
// };
// checkVerticalGraph();
// }, [startTime]);
return (
<div className="timeline-graph">
<div className="timeline-graph__title">
{totalCurrentTriggers} triggers in {relativeTime}
</div>
<div className="timeline-graph__chart">
<DataStateRenderer
isLoading={isLoading}
isError={isError || !isValidRuleId || !ruleId}
isRefetching={isRefetching}
data={data?.payload?.data || null}
>
{(data): JSX.Element => <Graph type="horizontal" data={data} />}
</DataStateRenderer>
</div>
</div>
);
}
export default GraphWrapper;

View File

@ -0,0 +1,134 @@
.timeline-table {
border-top: 1px solid var(--text-slate-500);
border-radius: 6px;
overflow: hidden;
margin-top: 4px;
min-height: 600px;
.ant-table {
background: var(--bg-ink-500);
&-cell {
padding: 12px 16px !important;
vertical-align: baseline;
&::before {
display: none;
}
}
&-thead > tr > th {
border-color: var(--bg-slate-500);
background: var(--bg-ink-500);
font-size: 12px;
font-weight: 500;
padding: 12px 16px 8px !important;
&:last-of-type
// TODO(shaheer): uncomment when we display value column
// ,
// &:nth-last-of-type(2)
{
text-align: right;
}
}
&-tbody > tr > td {
border: none;
&:last-of-type,
&:nth-last-of-type(2) {
text-align: right;
}
}
}
.label-filter {
padding: 6px 8px;
border-radius: 4px;
background: var(--text-ink-400);
border-width: 0;
line-height: 18px;
& ::placeholder {
color: var(--text-vanilla-400);
font-size: 12px;
letter-spacing: 0.6px;
text-transform: uppercase;
font-weight: 500;
}
}
.alert-rule {
&-value,
&-created-at {
font-size: 14px;
color: var(--text-vanilla-400);
}
&-value {
font-weight: 500;
line-height: 20px;
}
&-created-at {
line-height: 18px;
letter-spacing: -0.07px;
}
}
.ant-table.ant-table-middle {
border-bottom: 1px solid var(--bg-slate-500);
border-left: 1px solid var(--bg-slate-500);
border-right: 1px solid var(--bg-slate-500);
border-radius: 6px;
}
.ant-pagination-item {
&-active {
display: flex;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 1px 8px;
border-radius: 2px;
background: var(--bg-robin-500);
& > a {
color: var(--text-ink-500);
line-height: 20px;
font-weight: 500;
}
}
}
.alert-history-label-search {
.ant-select-selector {
border: none;
}
}
}
.lightMode {
.timeline-table {
border-color: var(--bg-vanilla-300);
.ant-table {
background: var(--bg-vanilla-100);
&-thead {
& > tr > th {
background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300);
}
}
&.ant-table-middle {
border-color: var(--bg-vanilla-300);
}
}
.alert-history-label-search {
.ant-select-selector {
background: var(--bg-vanilla-200);
}
}
.alert-rule {
&-value,
&-created-at {
color: var(--text-ink-400);
}
}
.ant-pagination-item {
&-active > a {
color: var(--text-vanilla-100);
}
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,53 @@
import { ColumnsType } from 'antd/es/table';
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import { formatEpochTimestamp } from 'utils/timeUtils';
export const timelineTableColumns = (): ColumnsType<AlertRuleTimelineTableResponse> => [
{
title: 'STATE',
dataIndex: 'state',
sorter: true,
width: '12.5%',
render: (value, record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="alert-rule-state">
<AlertState state={value} showLabel />
</div>
</ConditionalAlertPopover>
),
},
{
title: 'LABELS',
dataIndex: 'labels',
width: '54.5%',
render: (labels, record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="alert-rule-labels">
<AlertLabels labels={labels} />
</div>
</ConditionalAlertPopover>
),
},
{
title: 'CREATED AT',
dataIndex: 'unixMilli',
width: '32.5%',
render: (value, record): JSX.Element => (
<ConditionalAlertPopover
relatedTracesLink={record.relatedTracesLink}
relatedLogsLink={record.relatedLogsLink}
>
<div className="alert-rule-created-at">{formatEpochTimestamp(value)}</div>
</ConditionalAlertPopover>
),
},
];

View File

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

View File

@ -0,0 +1,90 @@
import './TabsAndFilters.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { TimelineFilter, TimelineTab } from 'container/AlertHistory/types';
import history from 'lib/history';
import { Info } from 'lucide-react';
import Tabs2 from 'periscope/components/Tabs2';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
function ComingSoon(): JSX.Element {
return (
<div className="coming-soon">
<div className="coming-soon__text">Coming Soon</div>
<div className="coming-soon__icon">
<Info size={10} color={Color.BG_SIENNA_400} />
</div>
</div>
);
}
function TimelineTabs(): JSX.Element {
const tabs = [
{
value: TimelineTab.OVERALL_STATUS,
label: 'Overall Status',
},
{
value: TimelineTab.TOP_5_CONTRIBUTORS,
label: (
<div className="top-5-contributors">
Top 5 Contributors
<ComingSoon />
</div>
),
disabled: true,
},
];
return <Tabs2 tabs={tabs} initialSelectedTab={TimelineTab.OVERALL_STATUS} />;
}
function TimelineFilters(): JSX.Element {
const { search } = useLocation();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
const initialSelectedTab = useMemo(
() => searchParams.get('timelineFilter') ?? TimelineFilter.ALL,
[searchParams],
);
const handleFilter = (value: TimelineFilter): void => {
searchParams.set('timelineFilter', value);
history.push({ search: searchParams.toString() });
};
const tabs = [
{
value: TimelineFilter.ALL,
label: 'All',
},
{
value: TimelineFilter.FIRED,
label: 'Fired',
},
{
value: TimelineFilter.RESOLVED,
label: 'Resolved',
},
];
return (
<Tabs2
tabs={tabs}
initialSelectedTab={initialSelectedTab}
onSelectTab={handleFilter}
hasResetButton
/>
);
}
function TabsAndFilters(): JSX.Element {
return (
<div className="timeline-tabs-and-filters">
<TimelineTabs />
<TimelineFilters />
</div>
);
}
export default TabsAndFilters;

View File

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

View File

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

View File

@ -0,0 +1,2 @@
// setting to 25 hours because we want to display the horizontal graph when the user selects 'Last 1 day' from date and time selector
export const HORIZONTAL_GRAPH_HOURS_THRESHOLD = 25;

View File

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

View File

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

View File

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

View File

@ -78,6 +78,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isCloudUserVal = isCloudUser(); const isCloudUserVal = isCloudUser();
const showAddCreditCardModal = const showAddCreditCardModal =
isLoggedIn &&
isChatSupportEnabled && isChatSupportEnabled &&
isCloudUserVal && isCloudUserVal &&
!isPremiumChatSupportEnabled && !isPremiumChatSupportEnabled &&
@ -253,6 +254,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 +344,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isDashboardView() || isDashboardView() ||
isDashboardWidgetView() || isDashboardWidgetView() ||
isDashboardListView() || isDashboardListView() ||
isAlertHistory() ||
isAlertOverview() ||
isMessagingQueues() isMessagingQueues()
? 0 ? 0
: '0 1rem', : '0 1rem',

View File

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

View File

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

View File

@ -22,6 +22,7 @@ import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import EmptyWidget from '../EmptyWidget'; import EmptyWidget from '../EmptyWidget';
import { MenuItemKeys } from '../WidgetHeader/contants'; import { MenuItemKeys } from '../WidgetHeader/contants';
import { GridCardGraphProps } from './types'; import { GridCardGraphProps } from './types';
import { isDataAvailableByPanelType } from './utils';
import WidgetGraphComponent from './WidgetGraphComponent'; import WidgetGraphComponent from './WidgetGraphComponent';
function GridCardGraph({ function GridCardGraph({
@ -182,7 +183,9 @@ function GridCardGraph({
setErrorMessage(error.message); setErrorMessage(error.message);
}, },
onSettled: (data) => { onSettled: (data) => {
dataAvailable?.(Boolean(data?.payload?.data?.result?.length)); dataAvailable?.(
isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes),
);
}, },
}, },
); );

View File

@ -1,6 +1,8 @@
/* eslint-disable sonarjs/cognitive-complexity */ /* eslint-disable sonarjs/cognitive-complexity */
import { LOCALSTORAGE } from 'constants/localStorage'; import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import getLabelName from 'lib/getLabelName'; import getLabelName from 'lib/getLabelName';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery'; import { QueryData } from 'types/api/widgets/getQuery';
import { LegendEntryProps } from './FullView/types'; import { LegendEntryProps } from './FullView/types';
@ -131,3 +133,21 @@ export const toggleGraphsVisibilityInChart = ({
lineChartRef?.current?.toggleGraph(index, showLegendData); lineChartRef?.current?.toggleGraph(index, showLegendData);
}); });
}; };
export const isDataAvailableByPanelType = (
data?: MetricRangePayloadProps['data'],
panelType?: string,
): boolean => {
const getPanelData = (): any[] | undefined => {
switch (panelType) {
case PANEL_TYPES.TABLE:
return (data?.result?.[0] as any)?.table?.rows;
case PANEL_TYPES.LIST:
return data?.newResult?.data?.result?.[0]?.list as any[];
default:
return data?.result;
}
};
return Boolean(getPanelData()?.length);
};

View File

@ -438,6 +438,10 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
: true, : true,
[selectedDashboard], [selectedDashboard],
); );
let isDataAvailableInAnyWidget = false;
const isLogEventCalled = useRef<boolean>(false);
return isDashboardEmpty ? ( return isDashboardEmpty ? (
<DashboardEmptyState /> <DashboardEmptyState />
) : ( ) : (
@ -516,6 +520,18 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
); );
} }
const checkIfDataExists = (isDataAvailable: boolean): void => {
if (!isDataAvailableInAnyWidget && isDataAvailable) {
isDataAvailableInAnyWidget = true;
}
if (!isLogEventCalled.current && isDataAvailableInAnyWidget) {
isLogEventCalled.current = true;
logEvent('Dashboard Detail: Panel data fetched', {
isDataAvailableInAnyWidget,
});
}
};
return ( return (
<CardContainer <CardContainer
className={isDashboardLocked ? '' : 'enable-resize'} className={isDashboardLocked ? '' : 'enable-resize'}
@ -534,6 +550,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
variables={variables} variables={variables}
version={selectedDashboard?.data?.version} version={selectedDashboard?.data?.version}
onDragSelect={onDragSelect} onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}
/> />
</Card> </Card>
</CardContainer> </CardContainer>

View File

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

View File

@ -67,7 +67,6 @@ export function TableViewActions(
); );
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const textToCopy = fieldData.value;
if (record.field === 'body') { if (record.field === 'body') {
const parsedBody = recursiveParseJSON(fieldData.value); const parsedBody = recursiveParseJSON(fieldData.value);
@ -89,6 +88,17 @@ export function TableViewActions(
: { __html: '' }; : { __html: '' };
const fieldFilterKey = filterKeyForField(fieldData.field); const fieldFilterKey = filterKeyForField(fieldData.field);
let textToCopy = fieldData.value;
// remove starting and ending quotes from the value
try {
textToCopy = textToCopy.replace(/^"|"$/g, '');
} catch (error) {
console.error(
'Failed to remove starting and ending quotes from the value',
error,
);
}
return ( return (
<div className={cx('value-field', isOpen ? 'open-popover' : '')}> <div className={cx('value-field', isOpen ? 'open-popover' : '')}>

View File

@ -1,6 +1,6 @@
### Step 1: Download/Copy this hostmetrics JSON file ### Step 1: Download/Copy this hostmetrics JSON file
Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)
&nbsp; &nbsp;
&nbsp; &nbsp;

View File

@ -1,6 +1,6 @@
### Step 1: Download/Copy this hostmetrics JSON file ### Step 1: Download/Copy this hostmetrics JSON file
Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)
&nbsp; &nbsp;
&nbsp; &nbsp;

View File

@ -1,6 +1,6 @@
### Step 1: Download/Copy this hostmetrics JSON file ### Step 1: Download/Copy this hostmetrics JSON file
Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)
&nbsp; &nbsp;
&nbsp; &nbsp;

View File

@ -1,6 +1,6 @@
### Step 1: Download/Copy this hostmetrics JSON file ### Step 1: Download/Copy this hostmetrics JSON file
Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)
&nbsp; &nbsp;
&nbsp; &nbsp;

View File

@ -51,7 +51,7 @@ aws ecs list-tasks --cluster ${CLUSTER_NAME} --region ${REGION}
To verify that the data is being sent to SigNoz Cloud, you can go to the dashboard section of SigNoz and import one of the following dashboards below: To verify that the data is being sent to SigNoz Cloud, you can go to the dashboard section of SigNoz and import one of the following dashboards below:
- [instancemetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/chore/ecs-dashboards/ecs-infra-metrics/instance-metrics.json) - [instancemetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/chore/ecs-dashboards/ecs-infra-metrics/instance-metrics.json)
- [hostmetrics-with-variable.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics-with-variable.json) - [hostmetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics.json)
&nbsp; &nbsp;

View File

@ -51,7 +51,7 @@ aws ecs list-tasks --cluster ${CLUSTER_NAME} --region ${REGION}
To verify that the data is being sent to SigNoz Cloud, you can go to the dashboard section of SigNoz and import one of the following dashboards below: To verify that the data is being sent to SigNoz Cloud, you can go to the dashboard section of SigNoz and import one of the following dashboards below:
- [instancemetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/chore/ecs-dashboards/ecs-infra-metrics/instance-metrics.json) - [instancemetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/chore/ecs-dashboards/ecs-infra-metrics/instance-metrics.json)
- [hostmetrics-with-variable.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics-with-variable.json) - [hostmetrics.json](https://raw.githubusercontent.com/SigNoz/dashboards/main/hostmetrics/hostmetrics.json)
&nbsp; &nbsp;

View File

@ -1,9 +1,8 @@
## Monitor using Dashboards ## Monitor using Dashboards
To visualize the Kubernetes Metrics, you can use one of the following pre-built Dashboards: To visualize the Kubernetes Metrics, you can use following pre-built Dashboards:
- [K8s Node-Level Metrics](https://github.com/SigNoz/dashboards/blob/main/k8s-node-%26-pod-metrics/k8s-node-level-metrics.json) - [K8s Infra Metrics](https://github.com/SigNoz/dashboards/tree/main/k8s-infra-metrics)
- [K8s Pod_level Metrics](https://github.com/SigNoz/dashboards/blob/main/k8s-node-%26-pod-metrics/k8s-pod-level-metrics.json)
You should copy the JSON data in these files and create a New Dashboard in the Dashboard Tab of SigNoz. You should copy the JSON data in these files and create a New Dashboard in the Dashboard Tab of SigNoz.

View File

@ -1,6 +1,6 @@
### Step 1: Download/Copy this hostmetrics JSON file ### Step 1: Download/Copy this hostmetrics JSON file
Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)
### Step 2: Import hostmetrics JSON file to SigNoz Cloud ### Step 2: Import hostmetrics JSON file to SigNoz Cloud

View File

@ -1,6 +1,6 @@
### Step 1: Download/Copy this hostmetrics JSON file ### Step 1: Download/Copy this hostmetrics JSON file
Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)
### Step 2: Import hostmetrics JSON file to SigNoz Cloud ### Step 2: Import hostmetrics JSON file to SigNoz Cloud

View File

@ -1,6 +1,6 @@
### Step 1: Download/Copy this hostmetrics JSON file ### Step 1: Download/Copy this hostmetrics JSON file
Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)
### Step 2: Import hostmetrics JSON file to SigNoz Cloud ### Step 2: Import hostmetrics JSON file to SigNoz Cloud

View File

@ -1,6 +1,6 @@
### Step 1: Download/Copy this hostmetrics JSON file ### Step 1: Download/Copy this hostmetrics JSON file
Download/Copy the `hostmetrics-with-variable.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics-with-variable.json) Download/Copy the `hostmetrics.json` from [here](https://github.com/SigNoz/dashboards/blob/main/hostmetrics/hostmetrics.json)
### Step 2: Import hostmetrics JSON file to SigNoz Cloud ### Step 2: Import hostmetrics JSON file to SigNoz Cloud

View File

@ -7,7 +7,7 @@ export const URL_OPTIONS = 'options';
export const defaultOptionsQuery: OptionsQuery = { export const defaultOptionsQuery: OptionsQuery = {
selectColumns: [], selectColumns: [],
maxLines: 2, maxLines: 2,
format: 'list', format: 'raw',
fontSize: FontSize.SMALL, fontSize: FontSize.SMALL,
}; };

View File

@ -140,6 +140,11 @@ const useOptionsMenu = ({
return col; return col;
}) })
.filter(Boolean) as BaseAutocompleteData[]; .filter(Boolean) as BaseAutocompleteData[];
// this is the last point where we can set the default columns and if uptil now also we have an empty array then we will set the default columns
if (!initialSelected || !initialSelected?.length) {
initialSelected = defaultTraceSelectedColumns;
}
} }
return initialSelected || []; return initialSelected || [];

View File

@ -77,6 +77,18 @@
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
} }
} }
.formItemWithBullet {
margin-bottom: 0;
}
.scheduleTimeInfoText {
margin-top: 8px;
margin-bottom: 20px;
font-size: 12px;
font-weight: 400;
color: var(--bg-vanilla-400);
}
} }
.alert-rule-tags { .alert-rule-tags {
@ -543,5 +555,13 @@
background: var(--bg-vanilla-100); background: var(--bg-vanilla-100);
} }
} }
.scheduleTimeInfoText {
color: var(--bg-slate-300);
}
.alert-rule-info {
color: var(--bg-slate-300);
}
} }
} }

View File

@ -41,7 +41,7 @@ import {
getAlertOptionsFromIds, getAlertOptionsFromIds,
getDurationInfo, getDurationInfo,
getEndTime, getEndTime,
handleTimeConvertion, handleTimeConversion,
isScheduleRecurring, isScheduleRecurring,
recurrenceOptions, recurrenceOptions,
recurrenceOptionWithSubmenu, recurrenceOptionWithSubmenu,
@ -52,6 +52,10 @@ dayjs.locale('en');
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
const TIME_FORMAT = 'HH:mm';
const DATE_FORMAT = 'Do MMM YYYY';
const ORDINAL_FORMAT = 'Do';
interface PlannedDowntimeFormData { interface PlannedDowntimeFormData {
name: string; name: string;
startTime: dayjs.Dayjs | string; startTime: dayjs.Dayjs | string;
@ -105,6 +109,10 @@ export function PlannedDowntimeForm(
?.unit || 'm', ?.unit || 'm',
); );
const [formData, setFormData] = useState<PlannedDowntimeFormData>(
initialValues?.schedule as PlannedDowntimeFormData,
);
const [recurrenceType, setRecurrenceType] = useState<string | null>( const [recurrenceType, setRecurrenceType] = useState<string | null>(
(initialValues.schedule?.recurrence?.repeatType as string) || (initialValues.schedule?.recurrence?.repeatType as string) ||
recurrenceOptions.doesNotRepeat.value, recurrenceOptions.doesNotRepeat.value,
@ -131,7 +139,7 @@ export function PlannedDowntimeForm(
.filter((alert) => alert !== undefined) as string[], .filter((alert) => alert !== undefined) as string[],
name: values.name, name: values.name,
schedule: { schedule: {
startTime: handleTimeConvertion( startTime: handleTimeConversion(
values.startTime, values.startTime,
timezoneInitialValue, timezoneInitialValue,
values.timezone, values.timezone,
@ -139,7 +147,7 @@ export function PlannedDowntimeForm(
), ),
timezone: values.timezone, timezone: values.timezone,
endTime: values.endTime endTime: values.endTime
? handleTimeConvertion( ? handleTimeConversion(
values.endTime, values.endTime,
timezoneInitialValue, timezoneInitialValue,
values.timezone, values.timezone,
@ -196,14 +204,14 @@ export function PlannedDowntimeForm(
? `${values.recurrence?.duration}${durationUnit}` ? `${values.recurrence?.duration}${durationUnit}`
: undefined, : undefined,
endTime: !isEmpty(values.endTime) endTime: !isEmpty(values.endTime)
? handleTimeConvertion( ? handleTimeConversion(
values.endTime, values.endTime,
timezoneInitialValue, timezoneInitialValue,
values.timezone, values.timezone,
!isEditMode, !isEditMode,
) )
: undefined, : undefined,
startTime: handleTimeConvertion( startTime: handleTimeConversion(
values.startTime, values.startTime,
timezoneInitialValue, timezoneInitialValue,
values.timezone, values.timezone,
@ -300,6 +308,116 @@ export function PlannedDowntimeForm(
}), }),
); );
const getTimezoneFormattedTime = (
time: string | dayjs.Dayjs,
timeZone?: string,
isEditMode?: boolean,
format?: string,
): string => {
if (!time) {
return '';
}
if (!timeZone) {
return dayjs(time).format(format);
}
return dayjs(time).tz(timeZone, isEditMode).format(format);
};
const startTimeText = useMemo((): string => {
let startTime = formData?.startTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
startTime = formData?.recurrence?.startTime || formData?.startTime || '';
}
if (!startTime) {
return '';
}
if (formData.timezone) {
startTime = handleTimeConversion(
startTime,
timezoneInitialValue,
formData?.timezone,
!isEditMode,
);
}
const daysOfWeek = formData?.recurrence?.repeatOn;
const formattedStartTime = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
TIME_FORMAT,
);
const formattedStartDate = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
DATE_FORMAT,
);
const ordinalFormat = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
ORDINAL_FORMAT,
);
const formattedDaysOfWeek = daysOfWeek?.join(', ');
switch (recurrenceType) {
case 'daily':
return `Scheduled from ${formattedStartDate}, daily starting at ${formattedStartTime}.`;
case 'monthly':
return `Scheduled from ${formattedStartDate}, monthly on the ${ordinalFormat} starting at ${formattedStartTime}.`;
case 'weekly':
return `Scheduled from ${formattedStartDate}, weekly ${
formattedDaysOfWeek ? `on [${formattedDaysOfWeek}]` : ''
} starting at ${formattedStartTime}`;
default:
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
}
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
const endTimeText = useMemo((): string => {
let endTime = formData?.endTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
endTime = formData?.recurrence?.endTime || '';
if (!isEditMode && !endTime) {
endTime = formData?.endTime || '';
}
}
if (!endTime) {
return '';
}
if (formData.timezone) {
endTime = handleTimeConversion(
endTime,
timezoneInitialValue,
formData?.timezone,
!isEditMode,
);
}
const formattedEndTime = getTimezoneFormattedTime(
endTime,
formData.timezone,
!isEditMode,
TIME_FORMAT,
);
const formattedEndDate = getTimezoneFormattedTime(
endTime,
formData.timezone,
!isEditMode,
DATE_FORMAT,
);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
return ( return (
<Modal <Modal
title={ title={
@ -323,6 +441,7 @@ export function PlannedDowntimeForm(
onFinish={onFinish} onFinish={onFinish}
onValuesChange={(): void => { onValuesChange={(): void => {
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string); setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
setFormData(form.getFieldsValue());
}} }}
autoComplete="off" autoComplete="off"
> >
@ -333,7 +452,7 @@ export function PlannedDowntimeForm(
label="Starts from" label="Starts from"
name="startTime" name="startTime"
rules={formValidationRules} rules={formValidationRules}
className="formItemWithBullet" className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value): any => ({ getValueProps={(value): any => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined, value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})} })}
@ -348,6 +467,9 @@ export function PlannedDowntimeForm(
popupClassName="datePicker" popupClassName="datePicker"
/> />
</Form.Item> </Form.Item>
{!isEmpty(startTimeText) && (
<div className="scheduleTimeInfoText">{startTimeText}</div>
)}
<Form.Item <Form.Item
label="Repeats every" label="Repeats every"
name={['recurrence', 'repeatType']} name={['recurrence', 'repeatType']}
@ -411,7 +533,7 @@ export function PlannedDowntimeForm(
required: recurrenceType === recurrenceOptions.doesNotRepeat.value, required: recurrenceType === recurrenceOptions.doesNotRepeat.value,
}, },
]} ]}
className="formItemWithBullet" className={!isEmpty(endTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value): any => ({ getValueProps={(value): any => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined, value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})} })}
@ -426,6 +548,9 @@ export function PlannedDowntimeForm(
popupClassName="datePicker" popupClassName="datePicker"
/> />
</Form.Item> </Form.Item>
{!isEmpty(endTimeText) && (
<div className="scheduleTimeInfoText">{endTimeText}</div>
)}
<div> <div>
<div className="alert-rule-form"> <div className="alert-rule-form">
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography> <Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>

View File

@ -262,7 +262,7 @@ export function formatWithTimezone(
return `${parsedDate?.substring(0, 19)}${targetOffset}`; return `${parsedDate?.substring(0, 19)}${targetOffset}`;
} }
export function handleTimeConvertion( export function handleTimeConversion(
dateValue: string | dayjs.Dayjs, dateValue: string | dayjs.Dayjs,
timezoneInit?: string, timezoneInit?: string,
timezone?: string, timezone?: string,

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