Compare commits

..

14 Commits

Author SHA1 Message Date
Vinícius Lourenço
735e9b1ee6 fix(window.open): missing few places 2026-04-21 11:24:05 -03:00
SagarRajput-7
06e521ad97 Merge branch 'base-path-config-setup-1' into base-path-task-phase-2 2026-04-21 11:04:29 +05:30
SagarRajput-7
a9dcad863b Merge branch 'main' into base-path-config-setup-1 2026-04-21 11:04:19 +05:30
SagarRajput-7
72db77b068 feat(base-path): migrate remaining pattern for window.location.origin + path 2026-04-21 10:33:24 +05:30
SagarRajput-7
4f84f07494 feat(base-path): replace window.open with openInNewTab for internal paths 2026-04-21 10:28:57 +05:30
SagarRajput-7
a9e09ee349 feat: code refactor around feedbacks 2026-04-21 10:25:02 +05:30
SagarRajput-7
0a9bb6ba0b feat: applied suggested patch changes 2026-04-21 10:24:51 +05:30
SagarRajput-7
6664a0fae3 feat: code refactor around feedbacks 2026-04-21 10:24:38 +05:30
SagarRajput-7
040dcb9c9b feat: updated base path utils and fixed navigation and translations 2026-04-21 10:24:23 +05:30
SagarRajput-7
4a39453826 feat: updated the html template 2026-04-21 10:24:12 +05:30
SagarRajput-7
ac4db09ec6 feat: removed plugin and serving the index.html only as the template 2026-04-21 10:24:01 +05:30
SagarRajput-7
a691e6a775 feat: refactor the interceptor and added gotmpl into gitignore 2026-04-21 10:23:51 +05:30
SagarRajput-7
d270a3807b feat: changed output path to dir level 2026-04-21 10:23:42 +05:30
SagarRajput-7
c2b553d26c feat: base path config setup and plugin for gotmpl generation at build time 2026-04-21 10:23:33 +05:30
98 changed files with 1784 additions and 1315 deletions

View File

@@ -66,6 +66,8 @@ module.exports = {
rules: {
// Asset migration — base-path safety
'rulesdir/no-unsupported-asset-pattern': 'error',
// Base-path safety — window.open and origin-concat patterns; upgrade to error coming PR
'rulesdir/no-raw-absolute-path': 'warn',
// Code quality rules
'prefer-const': 'error', // Enforces const for variables never reassigned

View File

@@ -0,0 +1,153 @@
'use strict';
/**
* ESLint rule: no-raw-absolute-path
*
* Catches patterns that break at runtime when the app is served from a
* sub-path (e.g. /signoz/):
*
* 1. window.open(path, '_blank')
* → use openInNewTab(path) which calls withBasePath internally
*
* 2. window.location.origin + path / `${window.location.origin}${path}`
* → use getAbsoluteUrl(path)
*
* 3. frontendBaseUrl: window.location.origin (bare origin usage)
* → use getBaseUrl() to include the base path
*
* 4. window.location.href = path
* → use withBasePath(path) or navigate() for internal navigation
*
* External URLs (first arg starts with "http") are explicitly allowed.
*/
function isOriginAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'origin' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isHrefAccess(node) {
return (
node.type === 'MemberExpression' &&
!node.computed &&
node.property.name === 'href' &&
node.object.type === 'MemberExpression' &&
!node.object.computed &&
node.object.property.name === 'location' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'window'
);
}
function isExternalUrl(node) {
if (node.type === 'Literal' && typeof node.value === 'string') {
return node.value.startsWith('http://') || node.value.startsWith('https://');
}
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) {
const raw = node.quasis[0].value.raw;
return raw.startsWith('http://') || raw.startsWith('https://');
}
return false;
}
// window.open(withBasePath(x)) and window.open(getAbsoluteUrl(x)) are already safe.
function isSafeHelperCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(node.callee.name === 'withBasePath' || node.callee.name === 'getAbsoluteUrl')
);
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow raw window.open and origin-concatenation patterns that miss the runtime base path',
category: 'Base Path Safety',
},
schema: [],
messages: {
windowOpen:
'Use openInNewTab(path) instead of window.open(path, "_blank") — openInNewTab prepends the base path automatically.',
originConcat:
'Use getAbsoluteUrl(path) instead of window.location.origin + path — getAbsoluteUrl prepends the base path automatically.',
originDirect:
'Use getBaseUrl() instead of window.location.origin — getBaseUrl includes the base path.',
hrefAssign:
'Use withBasePath(path) or navigate() instead of window.location.href = path — ensures the base path is included.',
},
},
create(context) {
return {
// window.open(path, ...) — allow only external first-arg URLs
CallExpression(node) {
const { callee, arguments: args } = node;
if (
callee.type !== 'MemberExpression' ||
callee.object.type !== 'Identifier' ||
callee.object.name !== 'window' ||
callee.property.name !== 'open'
)
return;
if (args.length < 1) return;
if (isExternalUrl(args[0])) return;
if (isSafeHelperCall(args[0])) return;
context.report({ node, messageId: 'windowOpen' });
},
// window.location.origin + path
BinaryExpression(node) {
if (node.operator !== '+') return;
if (isOriginAccess(node.left) || isOriginAccess(node.right)) {
context.report({ node, messageId: 'originConcat' });
}
},
// `${window.location.origin}${path}`
TemplateLiteral(node) {
if (node.expressions.some(isOriginAccess)) {
context.report({ node, messageId: 'originConcat' });
}
},
// window.location.origin used directly (not in concatenation)
// Catches: frontendBaseUrl: window.location.origin
MemberExpression(node) {
if (!isOriginAccess(node)) return;
const parent = node.parent;
// Skip if parent is BinaryExpression with + (handled by BinaryExpression visitor)
if (parent.type === 'BinaryExpression' && parent.operator === '+') return;
// Skip if inside TemplateLiteral (handled by TemplateLiteral visitor)
if (parent.type === 'TemplateLiteral') return;
context.report({ node, messageId: 'originDirect' });
},
// window.location.href = path
AssignmentExpression(node) {
if (node.operator !== '=') return;
if (!isHrefAccess(node.left)) return;
// Allow external URLs
if (isExternalUrl(node.right)) return;
// Allow safe helper calls
if (isSafeHelperCall(node.right)) return;
context.report({ node, messageId: 'hrefAssign' });
},
};
},
};

View File

@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="[[.BaseHref]]" />
<meta
http-equiv="Cache-Control"
content="no-cache, no-store, must-revalidate, max-age: 0"
@@ -59,7 +60,7 @@
<meta data-react-helmet="true" name="docusaurus_locale" content="en" />
<meta data-react-helmet="true" name="docusaurus_tag" content="default" />
<meta name="robots" content="noindex" />
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
<link data-react-helmet="true" rel="shortcut icon" href="favicon.ico" />
</head>
<body data-theme="default">
<script>
@@ -136,7 +137,7 @@
})(document, 'script');
}
</script>
<link rel="stylesheet" href="/css/uPlot.min.css" />
<link rel="stylesheet" href="css/uPlot.min.css" />
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

View File

@@ -2,6 +2,7 @@ import { initReactI18next } from 'react-i18next';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getBasePath } from 'utils/basePath';
import cacheBursting from '../../i18n-translations-hash.json';
@@ -24,7 +25,7 @@ i18n
const ns = namespace[0];
const pathkey = `/${language}/${ns}`;
const hash = cacheBursting[pathkey as keyof typeof cacheBursting] || '';
return `/locales/${language}/${namespace}.json?h=${hash}`;
return `${getBasePath()}locales/${language}/${namespace}.json?h=${hash}`;
},
},
react: {

View File

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

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
AlertRuleV2,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
export interface CreateAlertRuleResponse {
data: AlertRuleV2;
status: string;
}
const createAlertRule = async (
props: PostableAlertRuleV2,
): Promise<SuccessResponse<CreateAlertRuleResponse> | ErrorResponse> => {
const response = await axios.post(`/rules`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default createAlertRule;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
import { AxiosAlertManagerInstance } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import convertObjectIntoParams from 'lib/query/convertObjectIntoParams';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/getGroups';
const getGroups = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const queryParams = convertObjectIntoParams(props);
const response = await AxiosAlertManagerInstance.get(
`/alerts/groups?${queryParams}`,
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getGroups;

View File

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

View File

@@ -1,12 +0,0 @@
import { patchRuleByID } from 'api/generated/services/rules';
import type { RuletypesPostableRuleDTO } from 'api/generated/services/sigNoz.schemas';
// why: patchRuleByID's generated body type is the full RuletypesPostableRuleDTO
// because the backend OpenAPI spec currently advertises PostableRule. The
// endpoint itself accepts any subset of fields. Until the backend introduces
// PatchableRule, this wrapper localizes the cast so callers stay typed.
export const patchRulePartial = (
id: string,
patch: Partial<RuletypesPostableRuleDTO>,
): ReturnType<typeof patchRuleByID> =>
patchRuleByID({ id }, patch as RuletypesPostableRuleDTO);

View File

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

View File

@@ -0,0 +1,18 @@
import { isEmpty } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/save';
import create from './create';
import put from './put';
const save = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
if (props.id && !isEmpty(props.id)) {
return put({ ...props });
}
return create({ ...props });
};
export default save;

View File

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

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export interface TestAlertRuleResponse {
data: {
alertCount: number;
message: string;
};
status: string;
}
const testAlertRule = async (
props: PostableAlertRuleV2,
): Promise<SuccessResponse<TestAlertRuleResponse> | ErrorResponse> => {
const response = await axios.post(`/testRule`, {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default testAlertRule;

View File

@@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export interface UpdateAlertRuleResponse {
data: string;
status: string;
}
const updateAlertRule = async (
id: string,
postableAlertRule: PostableAlertRuleV2,
): Promise<SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse> => {
const response = await axios.put(`/rules/${id}`, {
...postableAlertRule,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default updateAlertRule;

View File

@@ -1,5 +1,6 @@
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
@@ -17,6 +18,7 @@ export const GeneratedAPIInstance = <T>(
return generatedAPIAxiosInstance({ ...config }).then(({ data }) => data);
};
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
generatedAPIAxiosInstance.interceptors.response.use(
interceptorsResponse,

View File

@@ -11,6 +11,7 @@ import axios, {
import { ENVIRONMENT } from 'constants/env';
import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getBasePath } from 'utils/basePath';
import { eventEmitter } from 'utils/getEventEmitter';
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
@@ -67,6 +68,39 @@ export const interceptorsRequestResponse = (
return value;
};
// Strips the leading '/' from path and joins with base — idempotent if already prefixed.
// e.g. prependBase('/signoz/', '/api/v1/') → '/signoz/api/v1/'
function prependBase(base: string, path: string): string {
return path.startsWith(base) ? path : base + path.slice(1);
}
// Prepends the runtime base path to outgoing requests so API calls work under
// a URL prefix (e.g. /signoz/api/v1/…). No-op for root deployments and dev
// (dev baseURL is a full http:// URL, not an absolute path).
export const interceptorsRequestBasePath = (
value: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
const basePath = getBasePath();
if (basePath === '/') {
return value;
}
if (value.baseURL?.startsWith('/')) {
// Production relative baseURL: '/api/v1/' → '/signoz/api/v1/'
value.baseURL = prependBase(basePath, value.baseURL);
} else if (value.baseURL?.startsWith('http')) {
// Dev absolute baseURL (VITE_FRONTEND_API_ENDPOINT): 'https://host/api/v1/' → 'https://host/signoz/api/v1/'
const url = new URL(value.baseURL);
url.pathname = prependBase(basePath, url.pathname);
value.baseURL = url.toString();
} else if (!value.baseURL && value.url?.startsWith('/')) {
// Orval-generated client (empty baseURL, path in url): '/api/signoz/v1/rules' → '/signoz/api/signoz/v1/rules'
value.url = prependBase(basePath, value.url);
}
return value;
};
export const interceptorRejected = async (
value: AxiosResponse<any>,
): Promise<AxiosResponse<any>> => {
@@ -133,6 +167,7 @@ const instance = axios.create({
});
instance.interceptors.request.use(interceptorsRequestResponse);
instance.interceptors.request.use(interceptorsRequestBasePath);
instance.interceptors.response.use(interceptorsResponse, interceptorRejected);
export const AxiosAlertManagerInstance = axios.create({
@@ -147,6 +182,7 @@ ApiV2Instance.interceptors.response.use(
interceptorRejected,
);
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
// axios V3
export const ApiV3Instance = axios.create({
@@ -158,6 +194,7 @@ ApiV3Instance.interceptors.response.use(
interceptorRejected,
);
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V4
@@ -170,6 +207,7 @@ ApiV4Instance.interceptors.response.use(
interceptorRejected,
);
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios V5
@@ -182,6 +220,7 @@ ApiV5Instance.interceptors.response.use(
interceptorRejected,
);
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
//
// axios Base
@@ -194,6 +233,7 @@ LogEventAxiosInstance.interceptors.response.use(
interceptorRejectedBase,
);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
//
AxiosAlertManagerInstance.interceptors.response.use(
@@ -201,6 +241,7 @@ AxiosAlertManagerInstance.interceptors.response.use(
interceptorRejected,
);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
export { apiV1 };
export default instance;

View File

@@ -0,0 +1,45 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { Dayjs } from 'dayjs';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { Recurrence } from './getAllDowntimeSchedules';
export interface DowntimeSchedulePayload {
name: string;
description?: string;
alertIds: string[];
schedule: {
timezone?: string;
startTime?: string | Dayjs;
endTime?: string | Dayjs;
recurrence?: Recurrence;
};
}
export interface PayloadProps {
status: string;
data: string;
}
const createDowntimeSchedule = async (
props: DowntimeSchedulePayload,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.post('/downtime_schedules', {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default createDowntimeSchedule;

View File

@@ -0,0 +1,19 @@
import { useMutation, UseMutationResult } from 'react-query';
import axios from 'api';
export interface DeleteDowntimeScheduleProps {
id?: number;
}
export interface DeleteSchedulePayloadProps {
status: string;
data: string;
}
export const useDeleteDowntimeSchedule = (
props: DeleteDowntimeScheduleProps,
): UseMutationResult<DeleteSchedulePayloadProps, Error, number> =>
useMutation({
mutationKey: [props.id],
mutationFn: () => axios.delete(`/downtime_schedules/${props.id}`),
});

View File

@@ -0,0 +1,51 @@
import { useQuery, UseQueryResult } from 'react-query';
import axios from 'api';
import { AxiosError, AxiosResponse } from 'axios';
import { Option } from 'container/PlannedDowntime/PlannedDowntimeutils';
export type Recurrence = {
startTime?: string | null;
endTime?: string | null;
duration?: number | string | null;
repeatType?: string | Option | null;
repeatOn?: string[] | null;
};
type Schedule = {
timezone: string | null;
startTime: string | null;
endTime: string | null;
recurrence: Recurrence | null;
};
export interface DowntimeSchedules {
id: number;
name: string | null;
description: string | null;
schedule: Schedule | null;
alertIds: string[] | null;
createdAt: string | null;
createdBy: string | null;
updatedAt: string | null;
updatedBy: string | null;
kind: string | null;
}
export type PayloadProps = { data: DowntimeSchedules[] };
export const getAllDowntimeSchedules = async (
props?: GetAllDowntimeSchedulesPayloadProps,
): Promise<AxiosResponse<PayloadProps>> =>
axios.get('/downtime_schedules', { params: props });
export interface GetAllDowntimeSchedulesPayloadProps {
active?: boolean;
recurrence?: boolean;
}
export const useGetAllDowntimeSchedules = (
props?: GetAllDowntimeSchedulesPayloadProps,
): UseQueryResult<AxiosResponse<PayloadProps>, AxiosError> =>
useQuery<AxiosResponse<PayloadProps>, AxiosError>({
queryKey: ['getAllDowntimeSchedules', props],
queryFn: () => getAllDowntimeSchedules(props),
});

View File

@@ -0,0 +1,37 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { DowntimeSchedulePayload } from './createDowntimeSchedule';
export interface DowntimeScheduleUpdatePayload {
data: DowntimeSchedulePayload;
id?: number;
}
export interface PayloadProps {
status: string;
data: string;
}
const updateDowntimeSchedule = async (
props: DowntimeScheduleUpdatePayload,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/downtime_schedules/${props.id}`, {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default updateDowntimeSchedule;

View File

@@ -12,6 +12,8 @@ import { AppState } from 'store/reducers';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { withBasePath } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
export interface NavigateToExplorerProps {
filters: TagFilterItem[];
@@ -133,7 +135,11 @@ export function useNavigateToExplorer(): (
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
window.open(newExplorerPath, sameTab ? '_self' : '_blank');
if (sameTab) {
window.location.href = withBasePath(newExplorerPath);
} else {
openInNewTab(newExplorerPath);
}
},
[
prepareQuery,

View File

@@ -13,6 +13,7 @@ import GetMinMax from 'lib/getMinMax';
import { Check, Info, Link2 } from 'lucide-react';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
const routesToBeSharedWithTime = [
ROUTES.LOGS_EXPLORER,
@@ -80,17 +81,13 @@ function ShareURLModal(): JSX.Element {
urlQuery.delete(QueryParams.relativeTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
}
}

View File

@@ -1,4 +1,3 @@
import { RuletypesAlertTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { DataSource } from 'types/common/queryBuilder';
@@ -9,17 +8,3 @@ export const ALERTS_DATA_SOURCE_MAP: Record<AlertTypes, DataSource> = {
[AlertTypes.TRACES_BASED_ALERT]: DataSource.TRACES,
[AlertTypes.EXCEPTIONS_BASED_ALERT]: DataSource.TRACES,
};
export function dataSourceForAlertType(
alertType: RuletypesAlertTypeDTO | undefined,
): DataSource {
switch (alertType) {
case RuletypesAlertTypeDTO.LOGS_BASED_ALERT:
return DataSource.LOGS;
case RuletypesAlertTypeDTO.TRACES_BASED_ALERT:
case RuletypesAlertTypeDTO.EXCEPTIONS_BASED_ALERT:
return DataSource.TRACES;
default:
return DataSource.METRICS;
}
}

View File

@@ -9,6 +9,7 @@ import {
} from 'container/ApiMonitoring/utils';
import { UnfoldVertical } from 'lucide-react';
import { SuccessResponse } from 'types/api';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
@@ -94,20 +95,14 @@ function DependentServices({
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
const url = new URL(
`/services/${
record.serviceData.serviceName &&
record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: ''
}`,
window.location.origin,
);
const serviceName =
record.serviceData.serviceName && record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: '';
const urlQuery = new URLSearchParams();
urlQuery.set(QueryParams.startTime, timeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, timeRange.endTime.toString());
url.search = urlQuery.toString();
window.open(url.toString(), '_blank');
openInNewTab(`/services/${serviceName}?${urlQuery.toString()}`);
},
className: 'clickable-row',
})}

View File

@@ -15,12 +15,8 @@ import {
THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from '../context/types';
import { AlertThresholdMatchType } from '../context/types';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { normalizeMatchType, normalizeOperator } from '../utils';
import ThresholdItem from './ThresholdItem';
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
import {
@@ -136,15 +132,12 @@ function AlertThreshold({
}
};
const normalizedOperator =
normalizeOperator(thresholdState.operator) ?? AlertThresholdOperator.IS_ABOVE;
const matchTypeOptionsWithTooltips = THRESHOLD_MATCH_TYPE_OPTIONS.map(
(option) => ({
...option,
label: (
<Tooltip
title={getMatchTypeTooltip(option.value, normalizedOperator)}
title={getMatchTypeTooltip(option.value, thresholdState.operator)}
placement="left"
overlayClassName="copyable-tooltip"
overlayStyle={{
@@ -239,10 +232,7 @@ function AlertThreshold({
/>
<Typography.Text className="sentence-text">is</Typography.Text>
<Select
value={
(normalizeOperator(thresholdState.operator) ??
thresholdState.operator) as AlertThresholdOperator
}
value={thresholdState.operator}
onChange={(value): void => {
setThresholdState({
type: 'SET_OPERATOR',
@@ -257,10 +247,7 @@ function AlertThreshold({
the threshold(s)
</Typography.Text>
<Select
value={
(normalizeMatchType(thresholdState.matchType) ??
thresholdState.matchType) as AlertThresholdMatchType
}
value={thresholdState.matchType}
onChange={(value): void => {
setThresholdState({
type: 'SET_MATCH_TYPE',

View File

@@ -11,11 +11,6 @@ import {
ANOMALY_THRESHOLD_OPERATOR_OPTIONS,
ANOMALY_TIME_DURATION_OPTIONS,
} from '../context/constants';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from '../context/types';
import { normalizeMatchType, normalizeOperator } from '../utils';
import { AnomalyAndThresholdProps } from './types';
import {
getQueryNames,
@@ -120,10 +115,7 @@ function AnomalyThreshold({
deviations
</Typography.Text>
<Select
value={
(normalizeOperator(thresholdState.operator) ??
thresholdState.operator) as AlertThresholdOperator
}
value={thresholdState.operator}
data-testid="operator-select"
onChange={(value): void => {
setThresholdState({
@@ -140,10 +132,7 @@ function AnomalyThreshold({
the predicted data
</Typography.Text>
<Select
value={
(normalizeMatchType(thresholdState.matchType) ??
thresholdState.matchType) as AlertThresholdMatchType
}
value={thresholdState.matchType}
data-testid="match-type-select"
onChange={(value): void => {
setThresholdState({

View File

@@ -5,7 +5,6 @@ import { useAppContext } from 'providers/App/App';
import { useCreateAlertState } from '../context';
import { AlertThresholdOperator } from '../context/types';
import { normalizeOperator } from '../utils';
import { ThresholdItemProps } from './types';
import { NotificationChannelsNotFoundContent } from './utils';
@@ -55,7 +54,7 @@ function ThresholdItem({
}, [units, threshold.unit, updateThreshold, threshold.id]);
const getOperatorSymbol = (): string => {
switch (normalizeOperator(thresholdState.operator)) {
switch (thresholdState.operator) {
case AlertThresholdOperator.IS_ABOVE:
return '>';
case AlertThresholdOperator.IS_BELOW:

View File

@@ -14,6 +14,7 @@ import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { ROUTING_POLICIES_ROUTE } from './constants';
import { RoutingPolicyBannerProps } from './types';
@@ -387,7 +388,7 @@ export function NotificationChannelsNotFoundContent({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
}}
>
here.

View File

@@ -6,7 +6,9 @@ import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/ut
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as rulesHook from '../../../../api/generated/services/rules';
import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule';
import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule';
import * as useUpdateAlertRuleHook from '../../../../hooks/alerts/useUpdateAlertRule';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
@@ -15,15 +17,15 @@ jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
jest.spyOn(useCreateAlertRuleHook, 'useCreateAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.spyOn(rulesHook, 'useTestRule').mockReturnValue({
jest.spyOn(useTestAlertRuleHook, 'useTestAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.spyOn(rulesHook, 'useUpdateRuleByID').mockReturnValue({
jest.spyOn(useUpdateAlertRuleHook, 'useUpdateAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);

View File

@@ -34,7 +34,6 @@ export const createMockAlertContextState = (
isUpdatingAlertRule: false,
updateAlertRule: jest.fn(),
isEditMode: false,
ruleId: '',
...overrides,
});

View File

@@ -1,15 +1,9 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui';
import { Button, Tooltip, Typography } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { Check, Loader, Send, X } from 'lucide-react';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toPostableRuleDTO } from 'types/api/alerts/convert';
import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { useCreateAlertState } from '../context';
@@ -36,20 +30,9 @@ function Footer(): JSX.Element {
updateAlertRule,
isUpdatingAlertRule,
isEditMode,
ruleId,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const { showErrorModal } = useErrorModal();
const handleApiError = useCallback(
(error: unknown): void => {
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
},
[showErrorModal],
);
const handleDiscard = (e: React.MouseEvent): void => {
discardAlertRule();
@@ -88,21 +71,20 @@ function Footer(): JSX.Element {
notificationSettings,
query: currentQuery,
});
testAlertRule(
{ data: toPostableRuleDTO(payload) },
{
onSuccess: (response) => {
if (response.data?.alertCount === 0) {
toast.error(
'No alerts found during the evaluation. This happens when rule condition is unsatisfied. You may adjust the rule threshold and retry.',
);
return;
}
toast.success('Test notification sent successfully');
},
onError: handleApiError,
testAlertRule(payload, {
onSuccess: (response) => {
if (response.payload?.data?.alertCount === 0) {
toast.error(
'No alerts found during the evaluation. This happens when rule condition is unsatisfied. You may adjust the rule threshold and retry.',
);
return;
}
toast.success('Test notification sent successfully');
},
);
onError: (error) => {
toast.error(error.message);
},
});
}, [
alertType,
basicAlertState,
@@ -125,30 +107,25 @@ function Footer(): JSX.Element {
query: currentQuery,
});
if (isEditMode) {
updateAlertRule(
{
pathParams: { id: ruleId },
data: toPostableRuleDTO(payload),
updateAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');
},
{
onSuccess: () => {
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');
},
onError: handleApiError,
onError: (error) => {
toast.error(error.message);
},
);
});
} else {
createAlertRule(
{ data: toPostableRuleDTO(payload) },
{
onSuccess: () => {
toast.success('Alert rule created successfully');
safeNavigate('/alerts');
},
onError: handleApiError,
createAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule created successfully');
safeNavigate('/alerts');
},
);
onError: (error) => {
toast.error(error.message);
},
});
}
}, [
alertType,
@@ -159,11 +136,9 @@ function Footer(): JSX.Element {
notificationSettings,
currentQuery,
isEditMode,
ruleId,
updateAlertRule,
createAlertRule,
safeNavigate,
handleApiError,
]);
const disableButtons =

View File

@@ -12,11 +12,6 @@ import Footer from '../Footer';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): { showErrorModal: jest.Mock } => ({
showErrorModal: jest.fn(),
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: jest.fn(),

View File

@@ -474,9 +474,9 @@ describe('Footer utils', () => {
spec: [
{
channels: [],
matchType: 'at_least_once',
matchType: '1',
name: 'critical',
op: 'above',
op: '1',
target: 0,
targetUnit: '',
},
@@ -520,33 +520,5 @@ describe('Footer utils', () => {
expect(props.condition.compositeQuery.queryType).toBe('promql');
expect(props.ruleType).toBe('promql_rule');
});
// Backward compatibility: a rule loaded with a legacy op/matchType
// serialization ("1", ">", "eq", "above_or_equal", ...) must round-trip
// back to the backend unchanged when the user hasn't touched those
// fields. If the submit payload silently rewrites them, existing rules
// would drift away from their persisted form on every edit.
it.each([
['numeric', '1', '1'],
['symbol', '>', 'at_least_once'],
['literal', 'above', 'at_least_once'],
['short', 'eq', 'avg'],
['UI-unexposed', 'above_or_equal', 'at_least_once'],
])(
'round-trips %s op/matchType unchanged through the submit payload (%s / %s)',
(_desc, op, matchType) => {
const args: BuildCreateAlertRulePayloadArgs = {
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS,
thresholdState: {
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS.thresholdState,
operator: op,
matchType,
},
};
const props = buildCreateThresholdAlertRulePayload(args);
expect(props.condition.thresholds?.spec[0].op).toBe(op);
expect(props.condition.thresholds?.spec[0].matchType).toBe(matchType);
},
);
});
});

View File

@@ -15,8 +15,6 @@ import {
getEvaluationWindowStateFromAlertDef,
getNotificationSettingsStateFromAlertDef,
getThresholdStateFromAlertDef,
normalizeMatchType,
normalizeOperator,
parseGoTime,
} from '../utils';
@@ -316,137 +314,6 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('normalizeOperator', () => {
it.each([
['1', AlertThresholdOperator.IS_ABOVE],
['above', AlertThresholdOperator.IS_ABOVE],
['>', AlertThresholdOperator.IS_ABOVE],
['2', AlertThresholdOperator.IS_BELOW],
['below', AlertThresholdOperator.IS_BELOW],
['<', AlertThresholdOperator.IS_BELOW],
['3', AlertThresholdOperator.IS_EQUAL_TO],
['equal', AlertThresholdOperator.IS_EQUAL_TO],
['eq', AlertThresholdOperator.IS_EQUAL_TO],
['=', AlertThresholdOperator.IS_EQUAL_TO],
['4', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['not_equal', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['not_eq', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['!=', AlertThresholdOperator.IS_NOT_EQUAL_TO],
['7', AlertThresholdOperator.ABOVE_BELOW],
['outside_bounds', AlertThresholdOperator.ABOVE_BELOW],
])('maps backend alias %s to canonical enum', (alias, expected) => {
expect(normalizeOperator(alias)).toBe(expected);
});
it.each([
['5', 'above_or_equal'],
['above_or_equal', 'above_or_equal'],
['above_or_eq', 'above_or_equal'],
['>=', 'above_or_equal'],
['6', 'below_or_equal'],
['below_or_equal', 'below_or_equal'],
['below_or_eq', 'below_or_equal'],
['<=', 'below_or_equal'],
])('returns undefined for UI-unexposed alias %s (%s family)', (alias) => {
expect(normalizeOperator(alias)).toBeUndefined();
});
it('returns undefined for unknown values', () => {
expect(normalizeOperator('gibberish')).toBeUndefined();
expect(normalizeOperator(undefined)).toBeUndefined();
expect(normalizeOperator('')).toBeUndefined();
});
});
describe('normalizeMatchType', () => {
it.each([
['1', AlertThresholdMatchType.AT_LEAST_ONCE],
['at_least_once', AlertThresholdMatchType.AT_LEAST_ONCE],
['2', AlertThresholdMatchType.ALL_THE_TIME],
['all_the_times', AlertThresholdMatchType.ALL_THE_TIME],
['3', AlertThresholdMatchType.ON_AVERAGE],
['on_average', AlertThresholdMatchType.ON_AVERAGE],
['avg', AlertThresholdMatchType.ON_AVERAGE],
['4', AlertThresholdMatchType.IN_TOTAL],
['in_total', AlertThresholdMatchType.IN_TOTAL],
['sum', AlertThresholdMatchType.IN_TOTAL],
['5', AlertThresholdMatchType.LAST],
['last', AlertThresholdMatchType.LAST],
])('maps backend alias %s to canonical enum', (alias, expected) => {
expect(normalizeMatchType(alias)).toBe(expected);
});
it('returns undefined for unknown values', () => {
expect(normalizeMatchType('gibberish')).toBeUndefined();
expect(normalizeMatchType(undefined)).toBeUndefined();
expect(normalizeMatchType('')).toBeUndefined();
});
});
describe('getThresholdStateFromAlertDef backward compatibility', () => {
const buildDef = (op: string, matchType: string): PostableAlertRuleV2 => ({
...defaultPostableAlertRuleV2,
condition: {
...defaultPostableAlertRuleV2.condition,
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: 1,
targetUnit: UniversalYAxisUnit.MINUTES,
channels: [],
matchType,
op,
},
],
},
},
});
// Each row covers a distinct historical serialization shape the backend
// may have persisted. The frontend must not rewrite these on load —
// otherwise opening and saving an old rule silently changes its op.
it.each([
['numeric', '1', '1'],
['literal', 'above', 'at_least_once'],
['symbol', '>', 'at_least_once'],
['short form', 'eq', 'avg'],
['mixed numeric and literal', '7', 'last'],
['UI-unexposed operator', 'above_or_equal', 'at_least_once'],
['UI-unexposed numeric operator', '5', 'at_least_once'],
])('preserves %s op/matchType verbatim (%s / %s)', (_desc, op, matchType) => {
const state = getThresholdStateFromAlertDef(buildDef(op, matchType));
expect(state.operator).toBe(op);
expect(state.matchType).toBe(matchType);
});
it('falls back to IS_ABOVE / AT_LEAST_ONCE when op and matchType are missing', () => {
const def: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
condition: {
...defaultPostableAlertRuleV2.condition,
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: 1,
targetUnit: UniversalYAxisUnit.MINUTES,
channels: [],
matchType: '',
op: '',
},
],
},
},
};
const state = getThresholdStateFromAlertDef(def);
expect(state.operator).toBe(AlertThresholdOperator.IS_ABOVE);
expect(state.matchType).toBe(AlertThresholdMatchType.AT_LEAST_ONCE);
});
});
describe('getCreateAlertLocalStateFromAlertDef', () => {
it('should return the correct create alert local state for the given alert def', () => {
const args: PostableAlertRuleV2 = {

View File

@@ -10,13 +10,11 @@ import {
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import {
useCreateRule,
useTestRule,
useUpdateRuleByID,
} from 'api/generated/services/rules';
import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule';
import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule';
import { useUpdateAlertRule } from 'hooks/alerts/useUpdateAlertRule';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -217,14 +215,17 @@ export function CreateAlertProvider(
const {
mutate: createAlertRule,
isLoading: isCreatingAlertRule,
} = useCreateRule();
} = useCreateAlertRule();
const { mutate: testAlertRule, isLoading: isTestingAlertRule } = useTestRule();
const {
mutate: testAlertRule,
isLoading: isTestingAlertRule,
} = useTestAlertRule();
const {
mutate: updateAlertRule,
isLoading: isUpdatingAlertRule,
} = useUpdateRuleByID();
} = useUpdateAlertRule(ruleId || '');
const contextValue: ICreateAlertContextProps = useMemo(
() => ({
@@ -248,7 +249,6 @@ export function CreateAlertProvider(
updateAlertRule,
isUpdatingAlertRule,
isEditMode: isEditMode || false,
ruleId: ruleId || '',
}),
[
createAlertState,
@@ -267,7 +267,6 @@ export function CreateAlertProvider(
updateAlertRule,
isUpdatingAlertRule,
isEditMode,
ruleId,
],
);

View File

@@ -1,15 +1,12 @@
import { Dispatch } from 'react';
import { UseMutateFunction } from 'react-query';
import type {
CreateRule201,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
TestRule200,
UpdateRuleByIDPathParameters,
} from 'api/generated/services/sigNoz.schemas';
import type { BodyType, ErrorType } from 'api/generatedAPIInstance';
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
import { UpdateAlertRuleResponse } from 'api/alerts/updateAlertRule';
import { Dayjs } from 'dayjs';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { Labels } from 'types/api/alerts/def';
export interface ICreateAlertContextProps {
@@ -27,33 +24,27 @@ export interface ICreateAlertContextProps {
setNotificationSettings: Dispatch<NotificationSettingsAction>;
isCreatingAlertRule: boolean;
createAlertRule: UseMutateFunction<
CreateRule201,
ErrorType<unknown>,
{ data: BodyType<RuletypesPostableRuleDTO> },
SuccessResponse<CreateAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
unknown
>;
isTestingAlertRule: boolean;
testAlertRule: UseMutateFunction<
TestRule200,
ErrorType<unknown>,
{ data: BodyType<RuletypesPostableRuleDTO> },
SuccessResponse<TestAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
unknown
>;
discardAlertRule: () => void;
isUpdatingAlertRule: boolean;
updateAlertRule: UseMutateFunction<
Awaited<
ReturnType<typeof import('api/generated/services/rules').updateRuleByID>
>,
ErrorType<RenderErrorResponseDTO>,
{
pathParams: UpdateRuleByIDPathParameters;
data: BodyType<RuletypesPostableRuleDTO>;
},
SuccessResponse<UpdateAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
unknown
>;
isEditMode: boolean;
ruleId: string;
}
export interface ICreateAlertProviderProps {
@@ -95,28 +86,25 @@ export interface Threshold {
}
export enum AlertThresholdOperator {
IS_ABOVE = 'above',
IS_BELOW = 'below',
IS_EQUAL_TO = 'equal',
IS_NOT_EQUAL_TO = 'not_equal',
ABOVE_BELOW = 'outside_bounds',
IS_ABOVE = '1',
IS_BELOW = '2',
IS_EQUAL_TO = '3',
IS_NOT_EQUAL_TO = '4',
ABOVE_BELOW = '7',
}
export enum AlertThresholdMatchType {
AT_LEAST_ONCE = 'at_least_once',
ALL_THE_TIME = 'all_the_times',
ON_AVERAGE = 'on_average',
IN_TOTAL = 'in_total',
LAST = 'last',
AT_LEAST_ONCE = '1',
ALL_THE_TIME = '2',
ON_AVERAGE = '3',
IN_TOTAL = '4',
LAST = '5',
}
export interface AlertThresholdState {
selectedQuery: string;
// Stored as a raw string so backend aliases ("1", ">", "above_or_eq", ...)
// survive a load/save round-trip. User edits from the UI write the
// canonical enum value.
operator: AlertThresholdOperator | string;
matchType: AlertThresholdMatchType | string;
operator: AlertThresholdOperator;
matchType: AlertThresholdMatchType;
evaluationWindow: string;
algorithm: string;
seasonality: string;

View File

@@ -237,68 +237,6 @@ export function getAdvancedOptionsStateFromAlertDef(
};
}
// Mirrors the backend's CompareOperator.Normalize() in
// pkg/types/ruletypes/compare.go. Maps any accepted alias to the enum value
// the dropdown understands. Returns undefined for aliases the UI does not
// expose (e.g. above_or_equal, below_or_equal) so callers can keep the raw
// value on screen instead of silently rewriting it.
export function normalizeOperator(
raw: string | undefined,
): AlertThresholdOperator | undefined {
switch (raw) {
case '1':
case 'above':
case '>':
return AlertThresholdOperator.IS_ABOVE;
case '2':
case 'below':
case '<':
return AlertThresholdOperator.IS_BELOW;
case '3':
case 'equal':
case 'eq':
case '=':
return AlertThresholdOperator.IS_EQUAL_TO;
case '4':
case 'not_equal':
case 'not_eq':
case '!=':
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
case '7':
case 'outside_bounds':
return AlertThresholdOperator.ABOVE_BELOW;
default:
return undefined;
}
}
// Mirrors the backend's MatchType.Normalize() in pkg/types/ruletypes/match.go.
export function normalizeMatchType(
raw: string | undefined,
): AlertThresholdMatchType | undefined {
switch (raw) {
case '1':
case 'at_least_once':
return AlertThresholdMatchType.AT_LEAST_ONCE;
case '2':
case 'all_the_times':
return AlertThresholdMatchType.ALL_THE_TIME;
case '3':
case 'on_average':
case 'avg':
return AlertThresholdMatchType.ON_AVERAGE;
case '4':
case 'in_total':
case 'sum':
return AlertThresholdMatchType.IN_TOTAL;
case '5':
case 'last':
return AlertThresholdMatchType.LAST;
default:
return undefined;
}
}
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
@@ -316,9 +254,11 @@ export function getThresholdStateFromAlertDef(
})) || [],
selectedQuery: alertDef.condition.selectedQueryName || '',
operator:
alertDef.condition.thresholds?.spec[0].op || AlertThresholdOperator.IS_ABOVE,
(alertDef.condition.thresholds?.spec[0].op as AlertThresholdOperator) ||
AlertThresholdOperator.IS_ABOVE,
matchType:
alertDef.condition.thresholds?.spec[0].matchType ||
(alertDef.condition.thresholds?.spec[0]
.matchType as AlertThresholdMatchType) ||
AlertThresholdMatchType.AT_LEAST_ONCE,
};
}

View File

@@ -1,14 +1,15 @@
import { RuletypesAlertTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { dataSourceForAlertType } from 'constants/alerts';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export function sanitizeDefaultAlertQuery(
query: Query,
alertType: RuletypesAlertTypeDTO | undefined,
alertType: AlertTypes,
): Query {
// If there are no queries, add a default one based on the alert type
if (query.builder.queryData.length === 0) {
const dataSource = dataSourceForAlertType(alertType);
const dataSource = ALERTS_DATA_SOURCE_MAP[alertType];
query.builder.queryData.push(initialQueryBuilderFormValuesMap[dataSource]);
}
return query;

View File

@@ -15,6 +15,7 @@ import { AlertDef, Labels } from 'types/api/alerts/def';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
import { popupContainer } from 'utils/selectPopupContainer';
import ChannelSelect from './ChannelSelect';
@@ -87,7 +88,7 @@ function BasicInfo({
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
ruleId: isNewRule ? 0 : alertDef?.id,
});
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasLoggedEvent = useRef(false);

View File

@@ -6,15 +6,9 @@ import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { ExclamationCircleOutlined, SaveOutlined } from '@ant-design/icons';
import { Button, FormInstance, Modal, SelectProps, Typography } from 'antd';
import saveAlertApi from 'api/alerts/save';
import testAlertApi from 'api/alerts/testAlert';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
testRule,
updateRuleByID,
} from 'api/generated/services/rules';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
@@ -38,16 +32,13 @@ import { isEmpty, isEqual } from 'lodash-es';
import { BellDot, ExternalLink } from 'lucide-react';
import Tabs2 from 'periscope/components/Tabs2';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { AppState } from 'store/reducers';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
import {
AlertDef,
defaultEvalWindow,
defaultMatchType,
} from 'types/api/alerts/def';
import APIError from 'types/api/error';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryFunction } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
@@ -377,7 +368,6 @@ function FormAlertRules({
redirectWithQueryBuilderData(query);
};
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const validatePromParams = useCallback((): boolean => {
let retval = true;
@@ -543,47 +533,59 @@ function FormAlertRules({
};
try {
if (ruleId && !isEmpty(ruleId)) {
await updateRuleByID(
{ id: ruleId },
toPostableRuleDTOFromAlertDef(postableAlert),
);
const apiReq =
ruleId && !isEmpty(ruleId)
? { data: postableAlert, id: ruleId }
: { data: postableAlert };
const response = await saveAlertApi(apiReq);
if (response.statusCode === 200) {
logData = {
status: 'success',
statusMessage: isNewRule ? t('rule_created') : t('rule_edited'),
};
notifications.success({
message: 'Success',
description: logData.statusMessage,
});
// invalidate rule in cache
ruleCache.invalidateQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
`${ruleId}`,
]);
// eslint-disable-next-line sonarjs/no-identical-functions
setTimeout(() => {
urlQuery.delete(QueryParams.compositeQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
}, 2000);
} else {
await createRule(toPostableRuleDTOFromAlertDef(postableAlert));
logData = {
status: 'error',
statusMessage: response.error || t('unexpected_error'),
};
notifications.error({
message: 'Error',
description: logData.statusMessage,
});
}
logData = {
status: 'success',
statusMessage: isNewRule ? t('rule_created') : t('rule_edited'),
};
notifications.success({
message: 'Success',
description: logData.statusMessage,
});
// invalidate rule in cache
ruleCache.invalidateQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
`${ruleId}`,
]);
// eslint-disable-next-line sonarjs/no-identical-functions
setTimeout(() => {
urlQuery.delete(QueryParams.compositeQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
}, 2000);
} catch (e) {
const apiError = convertToApiError(e as AxiosError<RenderErrorResponseDTO>);
logData = {
status: 'error',
statusMessage: apiError?.getErrorMessage() || t('unexpected_error'),
statusMessage: t('unexpected_error'),
};
showErrorModal(apiError as APIError);
notifications.error({
message: 'Error',
description: logData.statusMessage,
});
}
setLoading(false);
@@ -639,30 +641,39 @@ function FormAlertRules({
let statusResponse = { status: 'failed', message: '' };
setLoading(true);
try {
const response = await testRule(
toPostableRuleDTOFromAlertDef(postableAlert),
);
const response = await testAlertApi({ data: postableAlert });
if (response.data?.alertCount === 0) {
if (response.statusCode === 200) {
const { payload } = response;
if (payload?.alertCount === 0) {
notifications.error({
message: 'Error',
description: t('no_alerts_found'),
});
statusResponse = { status: 'failed', message: t('no_alerts_found') };
} else {
notifications.success({
message: 'Success',
description: t('rule_test_fired'),
});
statusResponse = { status: 'success', message: t('rule_test_fired') };
}
} else {
notifications.error({
message: 'Error',
description: t('no_alerts_found'),
description: response.error || t('unexpected_error'),
});
statusResponse = { status: 'failed', message: t('no_alerts_found') };
} else {
notifications.success({
message: 'Success',
description: t('rule_test_fired'),
});
statusResponse = { status: 'success', message: t('rule_test_fired') };
statusResponse = {
status: 'failed',
message: response.error || t('unexpected_error'),
};
}
} catch (e) {
const apiError = convertToApiError(e as AxiosError<RenderErrorResponseDTO>);
statusResponse = {
status: 'failed',
message: apiError?.getErrorMessage() || t('unexpected_error'),
};
showErrorModal(apiError as APIError);
notifications.error({
message: 'Error',
description: t('unexpected_error'),
});
statusResponse = { status: 'failed', message: t('unexpected_error') };
}
setLoading(false);
logEvent('Alert: Test notification', {

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { Link, useLocation } from 'react-router-dom';
import { Button, Skeleton, Tag } from 'antd';
import getAll from 'api/alerts/getAll';
import logEvent from 'api/common/logEvent';
import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import history from 'lib/history';
@@ -11,7 +11,7 @@ import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/map
import { ArrowRight, ArrowUpRight, Plus } from 'lucide-react';
import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { GettableAlert } from 'types/api/alerts/get';
import { USER_ROLES } from 'types/roles';
import beaconUrl from '@/assets/Icons/beacon.svg';
@@ -28,23 +28,22 @@ export default function AlertRules({
const { user } = useAppContext();
const [rulesExist, setRulesExist] = useState(false);
const [sortedAlertRules, setSortedAlertRules] = useState<RuletypesRuleDTO[]>(
[],
);
const [sortedAlertRules, setSortedAlertRules] = useState<GettableAlert[]>([]);
const location = useLocation();
const params = new URLSearchParams(location.search);
// Fetch Alerts
const { data: alerts, isError, isLoading } = useListRules({
query: { cacheTime: 0 },
const { data: alerts, isError, isLoading } = useQuery('allAlerts', {
queryFn: getAll,
cacheTime: 0,
});
useEffect(() => {
const rules = alerts?.data ?? [];
const rules = alerts?.payload || [];
setRulesExist(rules.length > 0);
const sortedRules = [...rules].sort((a, b) => {
const sortedRules = rules.sort((a, b) => {
// First, prioritize firing alerts
if (a.state === 'firing' && b.state !== 'firing') {
return -1;
@@ -53,10 +52,10 @@ export default function AlertRules({
return 1;
}
// Then sort by updatedAt timestamp
return (
new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
);
// Then sort by updateAt timestamp
const aUpdateAt = new Date(a.updateAt).getTime();
const bUpdateAt = new Date(b.updateAt).getTime();
return bUpdateAt - aUpdateAt;
});
if (sortedRules.length > 0 && !loadingUserPreferences) {
@@ -119,27 +118,22 @@ export default function AlertRules({
</div>
);
const onEditHandler = (record: RuletypesRuleDTO) => (): void => {
const onEditHandler = (record: GettableAlert) => (): void => {
logEvent('Homepage: Alert clicked', {
ruleId: record.id,
ruleName: record.alert,
ruleState: record.state,
});
const compositeQuery = mapQueryDataFromApi(
toCompositeMetricQuery(record.condition.compositeQuery),
);
const compositeQuery = mapQueryDataFromApi(record.condition.compositeQuery);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.panelTypes, record.condition.compositeQuery.panelType);
params.set(QueryParams.ruleId, record.id);
params.set(QueryParams.ruleId, record.id.toString());
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
};
@@ -175,9 +169,9 @@ export default function AlertRules({
<div className="alert-rule-item-description home-data-item-tag">
<Tag color={rule?.labels?.severity}>{rule?.labels?.severity}</Tag>
{rule.state === 'firing' && (
{rule?.state === 'firing' && (
<Tag color="red" className="firing-tag">
{rule.state}
{rule?.state}
</Tag>
)}
</div>

View File

@@ -10,6 +10,7 @@ import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { Dashboard } from 'types/api/dashboard/getAll';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import dialsUrl from '@/assets/Icons/dials.svg';
@@ -114,7 +115,7 @@ export default function Dashboards({
dashboardName: dashboard.data.title,
});
if (event.metaKey || event.ctrlKey) {
window.open(getLink(), '_blank');
openInNewTab(getLink());
} else {
safeNavigate(getLink());
}

View File

@@ -51,6 +51,7 @@ import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuidv4 } from 'uuid';
import { filterDuplicateFilters } from '../commonUtils';
@@ -569,10 +570,7 @@ function K8sBaseDetails<T>({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
const compositeQuery = {
...initialQueryState,
@@ -591,10 +589,7 @@ function K8sBaseDetails<T>({
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}
};

View File

@@ -4,6 +4,7 @@ import {
CloudintegrationtypesServiceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { openInNewTab } from 'utils/navigation';
import './ServiceDashboards.styles.scss';
@@ -44,7 +45,7 @@ function ServiceDashboards({
return;
}
if (event.metaKey || event.ctrlKey) {
window.open(dashboardUrl, '_blank', 'noopener,noreferrer');
openInNewTab(dashboardUrl);
return;
}
safeNavigate(dashboardUrl);
@@ -54,7 +55,7 @@ function ServiceDashboards({
return;
}
if (event.button === 1) {
window.open(dashboardUrl, '_blank', 'noopener,noreferrer');
openInNewTab(dashboardUrl);
}
}}
onKeyDown={(event): void => {

View File

@@ -1,16 +1,9 @@
import { Dispatch, SetStateAction, useState } from 'react';
import type { NotificationInstance } from 'antd/es/notification/interface';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { deleteRuleByID } from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import deleteAlerts from 'api/alerts/delete';
import { State } from 'hooks/useFetch';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { PayloadProps as DeleteAlertPayloadProps } from 'types/api/alerts/delete';
import APIError from 'types/api/error';
import { GettableAlert } from 'types/api/alerts/get';
import { ColumnButton } from './styles';
@@ -29,31 +22,48 @@ function DeleteAlert({
payload: undefined,
});
const { showErrorModal } = useErrorModal();
const defaultErrorMessage = 'Something went wrong';
const onDeleteHandler = async (id: string): Promise<void> => {
try {
await deleteRuleByID({ id });
setData((state) => state.filter((alert) => alert.id !== id));
setDeleteAlertState((state) => ({
...state,
loading: false,
}));
notifications.success({
message: 'Success',
const response = await deleteAlerts({
id,
});
if (response.statusCode === 200) {
setData((state) => state.filter((alert) => alert.id !== id));
setDeleteAlertState((state) => ({
...state,
loading: false,
payload: response.payload,
}));
notifications.success({
message: 'Success',
});
} else {
setDeleteAlertState((state) => ({
...state,
loading: false,
error: true,
errorMessage: response.error || defaultErrorMessage,
}));
notifications.error({
message: response.error || defaultErrorMessage,
});
}
} catch (error) {
setDeleteAlertState((state) => ({
...state,
loading: false,
error: true,
errorMessage: defaultErrorMessage,
}));
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
notifications.error({
message: defaultErrorMessage,
});
}
};
@@ -78,8 +88,8 @@ function DeleteAlert({
}
interface DeleteAlertProps {
id: string;
setData: Dispatch<SetStateAction<RuletypesRuleDTO[]>>;
id: GettableAlert['id'];
setData: Dispatch<SetStateAction<GettableAlert[]>>;
notifications: NotificationInstance;
}

View File

@@ -4,16 +4,8 @@ import { UseQueryResult } from 'react-query';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Flex, Input, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import saveAlertApi from 'api/alerts/save';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { createRule } from 'api/generated/services/rules';
import type {
ListRules200,
RenderErrorResponseDTO,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
import DropDown from 'components/DropDown/DropDown';
import {
DynamicColumnsKey,
@@ -35,9 +27,9 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import APIError from 'types/api/error';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { GettableAlert } from 'types/api/alerts/get';
import { isModifierKeyPressed } from 'utils/app';
import DeleteAlert from './DeleteAlert';
@@ -66,7 +58,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
const paginationParam = params.get('page');
const searchParams = params.get('search');
const [searchString, setSearchString] = useState<string>(searchParams || '');
const [data, setData] = useState<RuletypesRuleDTO[]>(() => {
const [data, setData] = useState<GettableAlert[]>(() => {
const value = searchString.toLowerCase();
const filteredData = filterAlerts(allAlertRules, value);
return filteredData || [];
@@ -78,7 +70,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
? orderQueryParam
: null;
const { sortedInfo, handleChange } = useSortableTable<RuletypesRuleDTO>(
const { sortedInfo, handleChange } = useSortableTable<GettableAlert>(
sortingOrder,
orderColumnParam || '',
searchString,
@@ -91,7 +83,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
const { data: refetchData, status } = await refetch();
if (status === 'success') {
const value = searchString.toLowerCase();
const filteredData = filterAlerts(refetchData?.data ?? [], value);
const filteredData = filterAlerts(refetchData.payload || [], value);
setData(filteredData || []);
}
if (status === 'error') {
@@ -102,7 +94,11 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
})();
}, 30000);
const { showErrorModal } = useErrorModal();
const handleError = useCallback((): void => {
notificationsApi.error({
message: t('something_went_wrong'),
});
}, [notificationsApi, t]);
const onClickNewAlertHandler = useCallback(
(e: React.MouseEvent): void => {
@@ -119,24 +115,21 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
);
const onEditHandler = (
record: RuletypesRuleDTO,
record: GettableAlert,
options?: { newTab?: boolean },
): void => {
const compositeQuery = sanitizeDefaultAlertQuery(
mapQueryDataFromApi(toCompositeMetricQuery(record.condition.compositeQuery)),
record.alertType,
mapQueryDataFromApi(record.condition.compositeQuery),
record.alertType as AlertTypes,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.panelTypes, record.condition.compositeQuery.panelType);
params.set(QueryParams.ruleId, record.id);
params.set(QueryParams.ruleId, record.id.toString());
setEditLoader(false);
@@ -146,41 +139,47 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
};
const onCloneHandler = (
originalAlert: RuletypesRuleDTO,
originalAlert: GettableAlert,
) => async (): Promise<void> => {
const copyAlert: RuletypesRuleDTO = {
const copyAlert = {
...originalAlert,
alert: `${originalAlert.alert} - Copy`,
alert: originalAlert.alert.concat(' - Copy'),
};
const apiReq = { data: copyAlert };
try {
setCloneLoader(true);
await createRule(copyAlert);
const response = await saveAlertApi(apiReq);
notificationsApi.success({
message: 'Success',
description: 'Alert cloned successfully',
});
if (response.statusCode === 200) {
notificationsApi.success({
message: 'Success',
description: 'Alert cloned successfully',
});
const { data: refetchData, status } = await refetch();
const rules = refetchData?.data;
if (status === 'success' && rules) {
setData(rules);
setTimeout(() => {
const clonedAlert = rules[rules.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
safeNavigate(`${ROUTES.EDIT_ALERTS}?${params.toString()}`);
}, 2000);
}
if (status === 'error') {
const { data: refetchData, status } = await refetch();
if (status === 'success' && refetchData.payload) {
setData(refetchData.payload || []);
setTimeout(() => {
const clonedAlert = refetchData.payload[refetchData.payload.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
safeNavigate(`${ROUTES.EDIT_ALERTS}?${params.toString()}`);
}, 2000);
}
if (status === 'error') {
notificationsApi.error({
message: t('something_went_wrong'),
});
}
} else {
notificationsApi.error({
message: t('something_went_wrong'),
message: 'Error',
description: response.error || t('something_went_wrong'),
});
}
} catch (error) {
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
handleError();
console.error(error);
} finally {
setCloneLoader(false);
}
@@ -193,16 +192,16 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
setData(filteredData);
});
const dynamicColumns: ColumnsType<RuletypesRuleDTO> = [
const dynamicColumns: ColumnsType<GettableAlert> = [
{
title: 'Created At',
dataIndex: 'createdAt',
dataIndex: 'createAt',
width: 80,
key: DynamicColumnsKey.CreatedAt,
align: 'center',
sorter: (a: RuletypesRuleDTO, b: RuletypesRuleDTO): number => {
const prev = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const next = b.createdAt ? new Date(b.createdAt).getTime() : 0;
sorter: (a: GettableAlert, b: GettableAlert): number => {
const prev = new Date(a.createAt).getTime();
const next = new Date(b.createAt).getTime();
return prev - next;
},
@@ -214,20 +213,20 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
},
{
title: 'Created By',
dataIndex: 'createdBy',
dataIndex: 'createBy',
width: 80,
key: DynamicColumnsKey.CreatedBy,
align: 'center',
},
{
title: 'Updated At',
dataIndex: 'updatedAt',
dataIndex: 'updateAt',
width: 80,
key: DynamicColumnsKey.UpdatedAt,
align: 'center',
sorter: (a: RuletypesRuleDTO, b: RuletypesRuleDTO): number => {
const prev = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const next = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
sorter: (a: GettableAlert, b: GettableAlert): number => {
const prev = new Date(a.updateAt).getTime();
const next = new Date(b.updateAt).getTime();
return prev - next;
},
@@ -239,14 +238,14 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
},
{
title: 'Updated By',
dataIndex: 'updatedBy',
dataIndex: 'updateBy',
width: 80,
key: DynamicColumnsKey.UpdatedBy,
align: 'center',
},
];
const columns: ColumnsType<RuletypesRuleDTO> = [
const columns: ColumnsType<GettableAlert> = [
{
title: 'Status',
dataIndex: 'state',
@@ -323,7 +322,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
dataIndex: 'id',
key: 'action',
width: 10,
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => (
render: (id: GettableAlert['id'], record): JSX.Element => (
<div data-testid="alert-actions">
<DropDown
onDropDownItemClick={(item): void =>
@@ -332,9 +331,9 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
element={[
<ToggleAlertState
key="1"
disabled={record.disabled ?? false}
disabled={record.disabled}
setData={setData}
id={id ?? ''}
id={id}
/>,
<ColumnButton
key="2"
@@ -366,7 +365,7 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
key="4"
notifications={notificationsApi}
setData={setData}
id={id ?? ''}
id={id}
/>,
]}
/>
@@ -421,10 +420,9 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
}
interface ListAlertProps {
allAlertRules: RuletypesRuleDTO[];
allAlertRules: GettableAlert[];
refetch: UseQueryResult<
ListRules200,
ErrorType<RenderErrorResponseDTO>
ErrorResponse | SuccessResponse<GettableAlert[]>
>['refetch'];
}

View File

@@ -1,5 +1,5 @@
import { Tag } from 'antd';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { GettableAlert } from 'types/api/alerts/get';
function Status({ status }: StatusProps): JSX.Element {
switch (status) {
@@ -26,7 +26,7 @@ function Status({ status }: StatusProps): JSX.Element {
}
interface StatusProps {
status: RuletypesRuleDTO['state'];
status: GettableAlert['state'];
}
export default Status;

View File

@@ -1,15 +1,9 @@
import { Dispatch, SetStateAction, useState } from 'react';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type {
RenderErrorResponseDTO,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import patchAlert from 'api/alerts/patch';
import { State } from 'hooks/useFetch';
import { useNotifications } from 'hooks/useNotifications';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { GettableAlert } from 'types/api/alerts/get';
import { PayloadProps as PatchPayloadProps } from 'types/api/alerts/patch';
import { ColumnButton } from './styles';
@@ -18,7 +12,7 @@ function ToggleAlertState({
disabled,
setData,
}: ToggleAlertStateProps): JSX.Element {
const [apiStatus, setAPIStatus] = useState<State<RuletypesRuleDTO>>({
const [apiStatus, setAPIStatus] = useState<State<PatchPayloadProps>>({
error: false,
errorMessage: '',
loading: false,
@@ -27,7 +21,8 @@ function ToggleAlertState({
});
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const defaultErrorMessage = 'Something went wrong';
const onToggleHandler = async (
id: string,
@@ -39,40 +34,58 @@ function ToggleAlertState({
loading: true,
}));
const response = await patchRulePartial(id, { disabled });
const { data: updatedRule } = response;
setData((state) =>
state.map((alert) => {
if (alert.id === id) {
return {
...alert,
disabled: updatedRule.disabled,
state: updatedRule.state,
};
}
return alert;
}),
);
setAPIStatus((state) => ({
...state,
loading: false,
payload: updatedRule,
}));
notifications.success({
message: 'Success',
const response = await patchAlert({
id,
data: {
disabled,
},
});
if (response.statusCode === 200) {
setData((state) =>
state.map((alert) => {
if (alert.id === id) {
return {
...alert,
disabled: response.payload.disabled,
state: response.payload.state,
};
}
return alert;
}),
);
setAPIStatus((state) => ({
...state,
loading: false,
payload: response.payload,
}));
notifications.success({
message: 'Success',
});
} else {
setAPIStatus((state) => ({
...state,
loading: false,
error: true,
errorMessage: response.error || defaultErrorMessage,
}));
notifications.error({
message: response.error || defaultErrorMessage,
});
}
} catch (error) {
setAPIStatus((state) => ({
...state,
loading: false,
error: true,
errorMessage: defaultErrorMessage,
}));
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
notifications.error({
message: defaultErrorMessage,
});
}
};
@@ -89,9 +102,9 @@ function ToggleAlertState({
}
interface ToggleAlertStateProps {
id: string;
id: GettableAlert['id'];
disabled: boolean;
setData: Dispatch<SetStateAction<RuletypesRuleDTO[]>>;
setData: Dispatch<SetStateAction<GettableAlert[]>>;
}
export default ToggleAlertState;

View File

@@ -1,69 +1,52 @@
import type {
RuletypesAlertStateDTO,
RuletypesCompareOperatorDTO,
RuletypesMatchTypeDTO,
RuletypesPanelTypeDTO,
RuletypesQueryTypeDTO,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GettableAlert } from 'types/api/alerts/get';
import { filterAlerts } from '../utils';
describe('filterAlerts', () => {
const mockAlertBase: Partial<RuletypesRuleDTO> = {
state: 'active' as RuletypesAlertStateDTO,
const mockAlertBase: Partial<GettableAlert> = {
state: 'active',
disabled: false,
createdAt: new Date('2024-01-01T00:00:00Z'),
createdBy: 'test-user',
updatedAt: new Date('2024-01-01T00:00:00Z'),
updatedBy: 'test-user',
createAt: '2024-01-01T00:00:00Z',
createBy: 'test-user',
updateAt: '2024-01-01T00:00:00Z',
updateBy: 'test-user',
version: '1',
condition: {
compositeQuery: {
queries: [],
panelType: 'graph' as RuletypesPanelTypeDTO,
queryType: 'builder' as RuletypesQueryTypeDTO,
},
matchType: 'at_least_once' as RuletypesMatchTypeDTO,
op: 'above' as RuletypesCompareOperatorDTO,
},
ruleType: 'threshold_rule' as RuletypesRuleDTO['ruleType'],
};
const mockAlerts: RuletypesRuleDTO[] = [
const mockAlerts: GettableAlert[] = [
{
...mockAlertBase,
id: '1',
alert: 'High CPU Usage',
alertType: 'METRIC_BASED_ALERT',
alertType: 'metrics',
labels: {
severity: 'warning',
status: 'ok',
environment: 'production',
},
} as RuletypesRuleDTO,
} as GettableAlert,
{
...mockAlertBase,
id: '2',
alert: 'Memory Leak Detected',
alertType: 'METRIC_BASED_ALERT',
alertType: 'metrics',
labels: {
severity: 'critical',
status: 'firing',
environment: 'staging',
},
} as RuletypesRuleDTO,
} as GettableAlert,
{
...mockAlertBase,
id: '3',
alert: 'Database Connection Error',
alertType: 'METRIC_BASED_ALERT',
alertType: 'metrics',
labels: {
severity: 'error',
status: 'pending',
environment: 'production',
},
} as RuletypesRuleDTO,
} as GettableAlert,
];
it('should return all alerts when filter is empty', () => {
@@ -114,14 +97,14 @@ describe('filterAlerts', () => {
});
it('should handle alerts with missing labels', () => {
const alertsWithMissingLabels: RuletypesRuleDTO[] = [
const alertsWithMissingLabels: GettableAlert[] = [
{
...mockAlertBase,
id: '4',
alert: 'Test Alert',
alertType: 'METRIC_BASED_ALERT',
alertType: 'metrics',
labels: undefined,
} as RuletypesRuleDTO,
} as GettableAlert,
];
const result = filterAlerts(alertsWithMissingLabels, 'test');
expect(result).toHaveLength(1);
@@ -129,16 +112,16 @@ describe('filterAlerts', () => {
});
it('should handle alerts with missing alert name', () => {
const alertsWithMissingName: RuletypesRuleDTO[] = [
const alertsWithMissingName: GettableAlert[] = [
{
...mockAlertBase,
id: '5',
alert: '',
alertType: 'METRIC_BASED_ALERT',
alertType: 'metrics',
labels: {
severity: 'warning',
},
} as RuletypesRuleDTO,
} as GettableAlert,
];
const result = filterAlerts(alertsWithMissingName, 'warning');
expect(result).toHaveLength(1);

View File

@@ -1,66 +1,78 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Space } from 'antd';
import getAll from 'api/alerts/getAll';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRules } from 'api/generated/services/rules';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { useNotifications } from 'hooks/useNotifications';
import { isUndefined } from 'lodash-es';
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
import ListAlert from './ListAlert';
function ListAlertRules(): JSX.Element {
const { t } = useTranslation('common');
const { data, isError, isLoading, refetch, error } = useListRules({
query: { cacheTime: 0 },
const { data, isError, isLoading, refetch, status } = useQuery('allAlerts', {
queryFn: getAll,
cacheTime: 0,
});
const rules = data?.data ?? [];
const hasLoaded = !isLoading && data !== undefined;
const logEventCalledRef = useRef(false);
const { notifications } = useNotifications();
const apiError = useMemo(
() => convertToApiError(error as AxiosError<RenderErrorResponseDTO> | null),
[error],
);
useEffect(() => {
if (!logEventCalledRef.current && hasLoaded) {
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {
logEvent('Alert: List page visited', {
number: rules.length,
number: data?.payload?.length,
});
logEventCalledRef.current = true;
}
}, [hasLoaded, rules.length]);
}, [data?.payload]);
useEffect(() => {
if (isError) {
if (status === 'error' || (status === 'success' && data.statusCode >= 400)) {
notifications.error({
message: apiError?.getErrorMessage() || t('something_went_wrong'),
message: data?.error || t('something_went_wrong'),
});
}
}, [isError, apiError, t, notifications]);
}, [data?.error, data?.statusCode, status, t, notifications]);
// api failed to load the data
if (isError) {
return <div>{apiError?.getErrorMessage() || t('something_went_wrong')}</div>;
return <div>{data?.error || t('something_went_wrong')}</div>;
}
if (isLoading || !data) {
return <Spinner height="75vh" tip="Loading Rules..." />;
// api is successful but error is present
if (status === 'success' && data.statusCode >= 400) {
return (
<ListAlert
{...{
allAlertRules: [],
refetch,
}}
/>
);
}
if (rules.length === 0) {
if (status === 'success' && !data.payload?.length) {
return <AlertsEmptyState />;
}
// in case of loading
if (isLoading || !data?.payload) {
return <Spinner height="75vh" tip="Loading Rules..." />;
}
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<ListAlert allAlertRules={rules} refetch={refetch} />
<ListAlert
{...{
allAlertRules: data.payload,
refetch,
}}
/>
</Space>
);
}

View File

@@ -1,18 +1,19 @@
import logEvent from 'api/common/logEvent';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { dataSourceForAlertType } from 'constants/alerts';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { GettableAlert } from 'types/api/alerts/get';
export const filterAlerts = (
allAlertRules: RuletypesRuleDTO[],
allAlertRules: GettableAlert[],
filter: string,
): RuletypesRuleDTO[] => {
): GettableAlert[] => {
if (!filter.trim()) {
return allAlertRules;
}
const value = filter.trim().toLowerCase();
return allAlertRules.filter((alert) => {
const alertName = alert.alert.toLowerCase();
const alertName = alert.alert?.toLowerCase();
const severity = alert.labels?.severity?.toLowerCase();
// Create a string of all label keys and values for searching
@@ -22,7 +23,7 @@ export const filterAlerts = (
.toLowerCase();
return (
alertName.includes(value) ||
alertName?.includes(value) ||
severity?.includes(value) ||
labelSearchString.includes(value)
);
@@ -31,7 +32,7 @@ export const filterAlerts = (
export const alertActionLogEvent = (
action: string,
record: RuletypesRuleDTO,
record: GettableAlert,
): void => {
let actionValue = '';
switch (action) {
@@ -51,9 +52,9 @@ export const alertActionLogEvent = (
break;
}
logEvent('Alert: Action', {
ruleId: record.id,
dataSource: dataSourceForAlertType(record.alertType),
name: record.alert,
ruleId: record?.id,
dataSource: ALERTS_DATA_SOURCE_MAP[record.alertType as AlertTypes],
name: record?.alert,
action: actionValue,
});
};

View File

@@ -83,6 +83,8 @@ import {
} from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { getAbsoluteUrl } from 'utils/basePath';
import { openInNewTab } from 'utils/navigation';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import dashboardsUrl from '@/assets/Icons/dashboards.svg';
@@ -457,7 +459,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
window.open(getLink(), '_blank');
openInNewTab(getLink());
}}
>
Open in New Tab
@@ -469,7 +471,7 @@ function DashboardsList(): JSX.Element {
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(`${window.location.origin}${getLink()}`);
setCopy(getAbsoluteUrl(getLink()));
}}
>
Copy Link

View File

@@ -1,6 +1,7 @@
import { LockFilled } from '@ant-design/icons';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
import { Data } from '../DashboardsList';
import { TableLinkText } from './styles';
@@ -12,7 +13,7 @@ function Name(name: Data['name'], data: Data): JSX.Element {
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
if (event.metaKey || event.ctrlKey) {
window.open(getLink(), '_blank');
openInNewTab(getLink());
} else {
history.push(getLink());
}

View File

@@ -17,6 +17,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { useContextLogData } from './useContextLogData';
@@ -116,7 +117,7 @@ function ContextLogRenderer({
);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(link, '_blank', 'noopener,noreferrer');
openInNewTab(link);
},
[query, urlQuery],
);

View File

@@ -34,6 +34,7 @@ import { SET_DETAILED_LOG_DATA } from 'types/actions/logs';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { openInNewTab } from 'utils/navigation';
import { ActionItemProps } from './ActionItem';
import FieldRenderer from './FieldRenderer';
@@ -191,7 +192,7 @@ function TableView({
if (event.ctrlKey || event.metaKey) {
// open the trace in new tab
window.open(route, '_blank');
openInNewTab(route);
} else {
history.push(route);
}

View File

@@ -34,6 +34,7 @@ import ROUTES from 'constants/routes';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDragColumns from 'hooks/useDragColumns';
import { getAbsoluteUrl } from 'utils/basePath';
import { infinityDefaultStyles } from '../InfinityTableView/config';
import { TanStackTableStyled } from '../InfinityTableView/styles';
@@ -239,7 +240,7 @@ const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });

View File

@@ -1,5 +1,6 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { openInNewTab as openInNewTabUtil } from 'utils/navigation';
import { TopOperationList } from './TopOperationsTable';
import { NavigateToTraceProps } from './types';
@@ -37,7 +38,7 @@ export const navigateToTrace = ({
}=${JSONCompositeQuery}`;
if (openInNewTab) {
window.open(newTraceExplorerPath, '_blank');
openInNewTabUtil(newTraceExplorerPath);
} else {
safeNavigate(newTraceExplorerPath);
}

View File

@@ -9,6 +9,7 @@ import {
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { Bell, Grid } from 'lucide-react';
import { openInNewTab } from 'utils/navigation';
import { pluralize } from 'utils/pluralize';
import { DashboardsAndAlertsPopoverProps } from './types';
@@ -67,9 +68,8 @@ function DashboardsAndAlertsPopover({
<Typography.Link
key={alert.alertId}
onClick={(): void => {
window.open(
openInNewTab(
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=${alert.alertId}`,
'_blank',
);
}}
className="dashboards-popover-content-item"
@@ -90,11 +90,10 @@ function DashboardsAndAlertsPopover({
<Typography.Link
key={dashboard.dashboardId}
onClick={(): void => {
window.open(
openInNewTab(
generatePath(ROUTES.DASHBOARD, {
dashboardId: dashboard.dashboardId,
}),
'_blank',
);
}}
className="dashboards-popover-content-item"

View File

@@ -1,21 +1,21 @@
import React, { ChangeEvent, useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import { PlusOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Flex, Form, Input, Tooltip, Typography } from 'antd';
import getAll from 'api/alerts/getAll';
import { useDeleteDowntimeSchedule } from 'api/plannedDowntime/deleteDowntimeSchedule';
import {
useDeleteDowntimeScheduleByID,
useListDowntimeSchedules,
} from 'api/generated/services/downtimeschedules';
import { useListRules } from 'api/generated/services/rules';
import type { RuletypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
DowntimeSchedules,
useGetAllDowntimeSchedules,
} from 'api/plannedDowntime/getAllDowntimeSchedules';
import dayjs from 'dayjs';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { Search } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { USER_ROLES } from 'types/roles';
import 'dayjs/locale/en';
@@ -33,28 +33,28 @@ import './PlannedDowntime.styles.scss';
dayjs.locale('en');
export function PlannedDowntime(): JSX.Element {
const { data: alertsData, isError, isLoading } = useListRules({
query: { cacheTime: 0 },
const { data, isError, isLoading } = useQuery('allAlerts', {
queryFn: getAll,
cacheTime: 0,
});
const [isOpen, setIsOpen] = React.useState(false);
const [form] = Form.useForm();
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const history = useHistory();
const urlQuery = useUrlQuery();
const [initialValues, setInitialValues] = useState<
Partial<RuletypesPlannedMaintenanceDTO & { editMode: boolean }>
Partial<DowntimeSchedules & { editMode: boolean }>
>(defautlInitialValues);
const downtimeSchedules = useListDowntimeSchedules();
const downtimeSchedules = useGetAllDowntimeSchedules();
const alertOptions = React.useMemo(
() =>
alertsData?.data?.map((i) => ({
data?.payload?.map((i) => ({
label: i.alert,
value: i.id,
})),
[alertsData],
[data],
);
useEffect(() => {
@@ -66,7 +66,7 @@ export function PlannedDowntime(): JSX.Element {
const [searchValue, setSearchValue] = React.useState<string | number>(
urlQuery.get('search') || '',
);
const [deleteData, setDeleteData] = useState<{ id: string; name: string }>();
const [deleteData, setDeleteData] = useState<{ id: number; name: string }>();
const [isEditMode, setEditMode] = useState<boolean>(false);
const updateUrlWithSearch = useDebouncedFn((value) => {
@@ -105,13 +105,12 @@ export function PlannedDowntime(): JSX.Element {
const {
mutateAsync: deleteDowntimeScheduleAsync,
isLoading: isDeleteLoading,
} = useDeleteDowntimeScheduleByID();
} = useDeleteDowntimeSchedule({ id: deleteData?.id });
const onDeleteHandler = (): void => {
deleteDowntimeHandler({
deleteDowntimeScheduleAsync,
notifications,
showErrorModal,
refetchAllSchedules,
deleteId: deleteData?.id,
hideDeleteDowntimeScheduleModal,

View File

@@ -14,18 +14,11 @@ import {
Typography,
} from 'antd';
import type { DefaultOptionType } from 'antd/es/select';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createDowntimeSchedule,
updateDowntimeScheduleByID,
} from 'api/generated/services/downtimeschedules';
import type {
RuletypesPlannedMaintenanceDTO,
RuletypesPostablePlannedMaintenanceDTO,
RuletypesRecurrenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
DowntimeSchedules,
Recurrence,
} from 'api/plannedDowntime/getAllDowntimeSchedules';
import { DowntimeScheduleUpdatePayload } from 'api/plannedDowntime/updateDowntimeSchedule';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import {
ModalButtonWrapper,
@@ -36,14 +29,15 @@ import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { useNotifications } from 'hooks/useNotifications';
import { defaultTo, isEmpty } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ALL_TIME_ZONES } from 'utils/timeZoneUtil';
import 'dayjs/locale/en';
import { SOMETHING_WENT_WRONG } from '../../constants/api';
import { showErrorNotification } from '../../utils/error';
import { AlertRuleTags } from './PlannedDowntimeList';
import {
createEditDowntimeSchedule,
getAlertOptionsFromIds,
getDurationInfo,
getEndTime,
@@ -68,9 +62,9 @@ interface PlannedDowntimeFormData {
name: string;
startTime: dayjs.Dayjs | string;
endTime: dayjs.Dayjs | string;
recurrence?: RuletypesRecurrenceDTO | null;
recurrence?: Recurrence | null;
alertRules: DefaultOptionType[];
recurrenceSelect?: RuletypesRecurrenceDTO;
recurrenceSelect?: Recurrence;
timezone?: string;
}
@@ -78,7 +72,7 @@ const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
interface PlannedDowntimeFormProps {
initialValues: Partial<
RuletypesPlannedMaintenanceDTO & {
DowntimeSchedules & {
editMode: boolean;
}
>;
@@ -117,9 +111,9 @@ export function PlannedDowntimeForm(
?.unit || 'm',
);
const [formData, setFormData] = useState<Partial<PlannedDowntimeFormData>>({
timezone: initialValues.schedule?.timezone,
});
const [formData, setFormData] = useState<PlannedDowntimeFormData>(
initialValues?.schedule as PlannedDowntimeFormData,
);
const [recurrenceType, setRecurrenceType] = useState<string | null>(
(initialValues.schedule?.recurrence?.repeatType as string) ||
@@ -131,7 +125,6 @@ export function PlannedDowntimeForm(
: undefined;
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const datePickerFooter = (mode: any): any =>
mode === 'time' ? (
@@ -141,54 +134,57 @@ export function PlannedDowntimeForm(
const saveHanlder = useCallback(
async (values: PlannedDowntimeFormData) => {
const shouldKeepLocalTime = !isEditMode;
const data: RuletypesPostablePlannedMaintenanceDTO = {
alertIds: values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
name: values.name,
schedule: {
startTime: new Date(
handleTimeConversion(
const createEditProps: DowntimeScheduleUpdatePayload = {
data: {
alertIds: values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
name: values.name,
schedule: {
startTime: handleTimeConversion(
values.startTime,
timezoneInitialValue,
values.timezone,
shouldKeepLocalTime,
),
),
timezone: values.timezone as string,
endTime: values.endTime
? new Date(
handleTimeConversion(
timezone: values.timezone,
endTime: values.endTime
? handleTimeConversion(
values.endTime,
timezoneInitialValue,
values.timezone,
shouldKeepLocalTime,
),
)
: undefined,
recurrence: values.recurrence as RuletypesRecurrenceDTO,
)
: undefined,
recurrence: values.recurrence as Recurrence,
},
},
id: isEditMode ? initialValues.id : undefined,
};
setSaveLoading(true);
try {
if (isEditMode && initialValues.id) {
await updateDowntimeScheduleByID({ id: initialValues.id }, data);
const response = await createEditDowntimeSchedule({ ...createEditProps });
if (response.message === 'success') {
setIsOpen(false);
notifications.success({
message: 'Success',
description: isEditMode
? 'Schedule updated successfully'
: 'Schedule created successfully',
});
refetchAllSchedules();
} else {
await createDowntimeSchedule(data);
notifications.error({
message: 'Error',
description:
typeof response.error === 'string'
? response.error
: response.error?.message || SOMETHING_WENT_WRONG,
});
}
setIsOpen(false);
notifications.success({
message: 'Success',
description: isEditMode
? 'Schedule updated successfully'
: 'Schedule created successfully',
});
refetchAllSchedules();
} catch (e: unknown) {
showErrorModal(
convertToApiError(e as AxiosError<RenderErrorResponseDTO>) as APIError,
);
showErrorNotification(notifications, e as Error);
}
setSaveLoading(false);
},
@@ -199,11 +195,10 @@ export function PlannedDowntimeForm(
refetchAllSchedules,
setIsOpen,
timezoneInitialValue,
showErrorModal,
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const recurrenceData =
const recurrenceData: Recurrence | undefined =
values?.recurrence?.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
@@ -230,10 +225,7 @@ export function PlannedDowntimeForm(
repeatType: values.recurrence?.repeatType,
};
const payloadValues = {
...values,
recurrence: recurrenceData as RuletypesRecurrenceDTO | undefined,
};
const payloadValues = { ...values, recurrence: recurrenceData };
await saveHanlder(payloadValues);
};
@@ -244,9 +236,11 @@ export function PlannedDowntimeForm(
];
const handleOk = async (): Promise<void> => {
await form.validateFields().catch(() => {
// antd renders inline field-level errors; nothing more to do here.
});
try {
await form.validateFields();
} catch (error) {
console.error(error);
}
};
const handleCancel = (): void => {
@@ -287,19 +281,18 @@ export function PlannedDowntimeForm(
: '',
recurrence: {
...initialValues.schedule?.recurrence,
repeatType: (!isScheduleRecurring(initialValues?.schedule)
repeatType: !isScheduleRecurring(initialValues?.schedule)
? recurrenceOptions.doesNotRepeat.value
: initialValues.schedule?.recurrence
?.repeatType) as RuletypesRecurrenceDTO['repeatType'],
duration: String(
getDurationInfo(initialValues.schedule?.recurrence?.duration as string)
?.value ?? '',
),
} as RuletypesRecurrenceDTO,
: (initialValues.schedule?.recurrence?.repeatType as string),
duration: getDurationInfo(
initialValues.schedule?.recurrence?.duration as string,
)?.value,
},
timezone: initialValues.schedule?.timezone as string,
};
return formData;
}, [initialValues, alertOptions]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValues]);
useEffect(() => {
setSelectedTags(formatedInitialValues.alertRules);
@@ -332,12 +325,7 @@ export function PlannedDowntimeForm(
const startTimeText = useMemo((): string => {
let startTime = formData?.startTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
startTime =
(formData?.recurrence?.startTime
? dayjs(formData.recurrence.startTime).toISOString()
: '') ||
formData?.startTime ||
'';
startTime = formData?.recurrence?.startTime || formData?.startTime || '';
}
if (!startTime) {
@@ -393,10 +381,7 @@ export function PlannedDowntimeForm(
const endTimeText = useMemo((): string => {
let endTime = formData?.endTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
endTime =
(formData?.recurrence?.endTime
? dayjs(formData.recurrence.endTime).toISOString()
: '') || '';
endTime = formData?.recurrence?.endTime || '';
if (!isEditMode && !endTime) {
endTime = formData?.endTime || '';

View File

@@ -12,15 +12,13 @@ import {
Typography,
} from 'antd';
import type { DefaultOptionType } from 'antd/es/select';
import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
RuletypesPlannedMaintenanceDTO,
RuletypesRecurrenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import {
DowntimeSchedules,
PayloadProps,
Recurrence,
} from 'api/plannedDowntime/getAllDowntimeSchedules';
import { AxiosError, AxiosResponse } from 'axios';
import cx from 'classnames';
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications';
import { defaultTo } from 'lodash-es';
import { CalendarClock, PenLine, Trash2 } from 'lucide-react';
@@ -145,7 +143,7 @@ export function CollapseListContent({
created_by_name?: string;
created_by_email?: string;
timeframe: [string | undefined | null, string | undefined | null];
repeats?: RuletypesRecurrenceDTO | null;
repeats?: Recurrence | null;
updated_at?: string;
updated_by_name?: string;
alertOptions?: DefaultOptionType[];
@@ -220,10 +218,10 @@ export function CollapseListContent({
export function CustomCollapseList(
props: DowntimeSchedulesTableData & {
setInitialValues: React.Dispatch<
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
React.SetStateAction<Partial<DowntimeSchedules>>
>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteDowntime: (id: string, name: string) => void;
handleDeleteDowntime: (id: number, name: string) => void;
setEditMode: React.Dispatch<React.SetStateAction<boolean>>;
},
): JSX.Element {
@@ -243,19 +241,12 @@ export function CustomCollapseList(
kind,
} = props;
const scheduleTime = schedule?.startTime
? dayjs(schedule.startTime).toISOString()
: createdAt
? dayjs(createdAt).toISOString()
: '';
const scheduleTime = schedule?.startTime ? schedule.startTime : createdAt;
// Combine time and date
const formattedDateAndTime = `Start time ⎯ ${formatDateTime(
defaultTo(scheduleTime, ''),
)} ${schedule?.timezone}`;
const endTime = getEndTime({
kind,
schedule,
} as Partial<RuletypesPlannedMaintenanceDTO>);
const endTime = getEndTime({ kind, schedule });
return (
<>
@@ -266,10 +257,7 @@ export function CustomCollapseList(
duration={
schedule?.recurrence?.duration
? (schedule?.recurrence?.duration as string)
: getDuration(
schedule?.startTime ? dayjs(schedule.startTime).toISOString() : '',
schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '',
)
: getDuration(schedule?.startTime, schedule?.endTime)
}
name={defaultTo(name, '')}
handleEdit={(): void => {
@@ -278,23 +266,21 @@ export function CustomCollapseList(
setEditMode(true);
}}
handleDelete={(): void => {
handleDeleteDowntime(id ?? '', name || '');
handleDeleteDowntime(id, name || '');
}}
/>
}
key={id ?? ''}
key={id}
>
<CollapseListContent
created_at={createdAt ? dayjs(createdAt).toISOString() : ''}
created_at={defaultTo(createdAt, '')}
created_by_name={defaultTo(createdBy, '')}
timeframe={[
schedule?.startTime?.toString(),
typeof endTime === 'string' ? endTime : endTime?.toString(),
]}
repeats={
schedule?.recurrence as RuletypesRecurrenceDTO | null | undefined
}
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
repeats={schedule?.recurrence}
updated_at={defaultTo(updatedAt, '')}
updated_by_name={defaultTo(updatedBy, '')}
alertOptions={alertOptions}
timezone={defaultTo(schedule?.timezone, '')}
@@ -309,7 +295,7 @@ export function CustomCollapseList(
);
}
export type DowntimeSchedulesTableData = RuletypesPlannedMaintenanceDTO & {
export type DowntimeSchedulesTableData = DowntimeSchedules & {
alertOptions: DefaultOptionType[];
};
@@ -323,15 +309,15 @@ export function PlannedDowntimeList({
searchValue,
}: {
downtimeSchedules: UseQueryResult<
ListDowntimeSchedules200,
ErrorType<RenderErrorResponseDTO>
AxiosResponse<PayloadProps, any>,
AxiosError<unknown, any>
>;
alertOptions: DefaultOptionType[];
setInitialValues: React.Dispatch<
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
React.SetStateAction<Partial<DowntimeSchedules>>
>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteDowntime: (id: string, name: string) => void;
handleDeleteDowntime: (id: number, name: string) => void;
setEditMode: React.Dispatch<React.SetStateAction<boolean>>;
searchValue: string | number;
}): JSX.Element {
@@ -351,19 +337,19 @@ export function PlannedDowntimeList({
];
const { notifications } = useNotifications();
const tableData = [...(downtimeSchedules.data?.data || [])]
const tableData = (downtimeSchedules.data?.data?.data || [])
.sort((a, b): number => {
if (a?.updatedAt && b?.updatedAt) {
return dayjs(b.updatedAt).diff(dayjs(a.updatedAt));
return b.updatedAt.localeCompare(a.updatedAt);
}
return 0;
})
.filter(
?.filter(
(data) =>
data.name.includes(searchValue.toLocaleString()) ||
data.id === searchValue.toLocaleString(),
data?.name?.includes(searchValue.toLocaleString()) ||
data?.id.toLocaleString() === searchValue.toLocaleString(),
)
.map((data) => {
.map?.((data) => {
const specificAlertOptions = getAlertOptionsFromIds(
data.alertIds || [],
alertOptions,

View File

@@ -1,19 +1,21 @@
import { UseMutateAsyncFunction } from 'react-query';
import type { NotificationInstance } from 'antd/es/notification/interface';
import type { DefaultOptionType } from 'antd/es/select';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type {
DeleteDowntimeScheduleByIDPathParameters,
RenderErrorResponseDTO,
RuletypesPlannedMaintenanceDTO,
RuletypesRecurrenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
import createDowntimeSchedule from 'api/plannedDowntime/createDowntimeSchedule';
import { DeleteSchedulePayloadProps } from 'api/plannedDowntime/deleteDowntimeSchedule';
import {
DowntimeSchedules,
Recurrence,
} from 'api/plannedDowntime/getAllDowntimeSchedules';
import updateDowntimeSchedule, {
DowntimeScheduleUpdatePayload,
PayloadProps,
} from 'api/plannedDowntime/updateDowntimeSchedule';
import { showErrorNotification } from 'components/ExplorerCard/utils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { isEmpty, isEqual } from 'lodash-es';
import APIError from 'types/api/error';
import { ErrorResponse, SuccessResponse } from 'types/api';
type DateTimeString = string | null | undefined;
@@ -59,21 +61,15 @@ export const getAlertOptionsFromIds = (
alertIds?.includes(alert.value as string),
);
export const recurrenceInfo = (
recurrence?: RuletypesRecurrenceDTO | null,
): string => {
export const recurrenceInfo = (recurrence?: Recurrence | null): string => {
if (!recurrence) {
return 'No';
}
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const formattedStartTime = startTime
? formatDateTime(dayjs(startTime).toISOString())
: '';
const formattedEndTime = endTime
? `to ${formatDateTime(dayjs(endTime).toISOString())}`
: '';
const formattedStartTime = startTime ? formatDateTime(startTime) : '';
const formattedEndTime = endTime ? `to ${formatDateTime(endTime)}` : '';
const weeklyRepeatString = repeatOn ? `on ${repeatOn.join(', ')}` : '';
const durationString = duration ? `- Duration: ${duration}` : '';
@@ -81,32 +77,31 @@ export const recurrenceInfo = (
};
export const defautlInitialValues: Partial<
RuletypesPlannedMaintenanceDTO & { editMode: boolean }
DowntimeSchedules & { editMode: boolean }
> = {
name: '',
description: '',
schedule: {
timezone: '',
endTime: undefined,
recurrence: undefined,
startTime: undefined,
endTime: '',
recurrence: null,
startTime: '',
},
alertIds: [],
createdAt: undefined,
createdBy: undefined,
createdAt: '',
createdBy: '',
editMode: false,
};
type DeleteDowntimeScheduleProps = {
deleteDowntimeScheduleAsync: UseMutateAsyncFunction<
void,
ErrorType<RenderErrorResponseDTO>,
{ pathParams: DeleteDowntimeScheduleByIDPathParameters }
DeleteSchedulePayloadProps,
Error,
number
>;
notifications: NotificationInstance;
showErrorModal: (error: APIError) => void;
refetchAllSchedules: VoidFunction;
deleteId?: string;
deleteId?: number;
hideDeleteDowntimeScheduleModal: () => void;
clearSearch: () => void;
};
@@ -118,33 +113,40 @@ export const deleteDowntimeHandler = ({
hideDeleteDowntimeScheduleModal,
clearSearch,
notifications,
showErrorModal,
}: DeleteDowntimeScheduleProps): void => {
if (!deleteId) {
const errorMsg = new Error('Something went wrong');
console.error('Unable to delete, please provide correct deleteId');
notifications.error({ message: 'Something went wrong' });
showErrorNotification(notifications, errorMsg);
} else {
deleteDowntimeScheduleAsync(
{ pathParams: { id: String(deleteId) } },
{
onSuccess: () => {
hideDeleteDowntimeScheduleModal();
clearSearch();
notifications.success({
message: 'Downtime schedule Deleted Successfully',
});
refetchAllSchedules();
},
onError: (err) => {
showErrorModal(
convertToApiError(err as AxiosError<RenderErrorResponseDTO>) as APIError,
);
},
deleteDowntimeScheduleAsync(deleteId, {
onSuccess: () => {
hideDeleteDowntimeScheduleModal();
clearSearch();
notifications.success({
message: 'Downtime schedule Deleted Successfully',
});
refetchAllSchedules();
},
);
onError: (err) => {
showErrorNotification(notifications, err);
},
});
}
};
export const createEditDowntimeSchedule = async (
props: DowntimeScheduleUpdatePayload,
): Promise<
| SuccessResponse<PayloadProps>
| ErrorResponse<{ code: string; message: string } | string>
> => {
if (props.id) {
return updateDowntimeSchedule({ ...props });
}
return createDowntimeSchedule({ ...props.data });
};
export const recurrenceOptions = {
doesNotRepeat: {
label: 'Does not repeat',
@@ -228,21 +230,19 @@ export const getEndTime = ({
kind,
schedule,
}: Partial<
RuletypesPlannedMaintenanceDTO & {
DowntimeSchedules & {
editMode: boolean;
}
>): string | dayjs.Dayjs => {
if (kind === 'fixed') {
return schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
return schedule?.endTime || '';
}
return schedule?.recurrence?.endTime
? dayjs(schedule.recurrence.endTime).toISOString()
: '';
return schedule?.recurrence?.endTime || '';
};
export const isScheduleRecurring = (
schedule?: RuletypesPlannedMaintenanceDTO['schedule'] | null,
schedule?: DowntimeSchedules['schedule'],
): boolean => (schedule ? !isEmpty(schedule?.recurrence) : false);
function convertUtcOffsetToTimezoneOffset(offsetMinutes: number): string {

View File

@@ -1,10 +1,7 @@
import { UseQueryResult } from 'react-query';
import { fireEvent, screen } from '@testing-library/react';
import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { PayloadProps } from 'api/plannedDowntime/getAllDowntimeSchedules';
import { AxiosError, AxiosResponse } from 'axios';
import {
mockLocation,
mockQueryParams,
@@ -25,53 +22,45 @@ const MOCK_DATE_2 = '2024-01-02';
const MOCK_DATE_3 = '2024-01-03';
const MOCK_DOWNTIME_1 = createMockDowntime({
id: '1',
id: 1,
name: MOCK_DOWNTIME_1_NAME,
createdAt: new Date(MOCK_DATE_1),
updatedAt: new Date(MOCK_DATE_1),
schedule: buildSchedule({
startTime: new Date(MOCK_DATE_1),
timezone: 'UTC',
}),
createdAt: MOCK_DATE_1,
updatedAt: MOCK_DATE_1,
schedule: buildSchedule({ startTime: MOCK_DATE_1, timezone: 'UTC' }),
alertIds: [],
});
const MOCK_DOWNTIME_2 = createMockDowntime({
id: '2',
id: 2,
name: MOCK_DOWNTIME_2_NAME,
createdAt: new Date(MOCK_DATE_2),
updatedAt: new Date(MOCK_DATE_2),
schedule: buildSchedule({
startTime: new Date(MOCK_DATE_2),
timezone: 'UTC',
}),
createdAt: MOCK_DATE_2,
updatedAt: MOCK_DATE_2,
schedule: buildSchedule({ startTime: MOCK_DATE_2, timezone: 'UTC' }),
alertIds: [],
});
const MOCK_DOWNTIME_3 = createMockDowntime({
id: '3',
id: 3,
name: MOCK_DOWNTIME_3_NAME,
createdAt: new Date(MOCK_DATE_3),
updatedAt: new Date(MOCK_DATE_3),
schedule: buildSchedule({
startTime: new Date(MOCK_DATE_3),
timezone: 'UTC',
}),
createdAt: MOCK_DATE_3,
updatedAt: MOCK_DATE_3,
schedule: buildSchedule({ startTime: MOCK_DATE_3, timezone: 'UTC' }),
alertIds: [],
});
const MOCK_DOWNTIME_RESPONSE: ListDowntimeSchedules200 = {
data: [MOCK_DOWNTIME_1, MOCK_DOWNTIME_2, MOCK_DOWNTIME_3],
status: 'success',
const MOCK_DOWNTIME_RESPONSE: Partial<AxiosResponse<PayloadProps>> = {
data: {
data: [MOCK_DOWNTIME_1, MOCK_DOWNTIME_2, MOCK_DOWNTIME_3],
},
};
type DowntimeQueryResult = UseQueryResult<
ListDowntimeSchedules200,
ErrorType<RenderErrorResponseDTO>
AxiosResponse<PayloadProps>,
AxiosError
>;
const mockDowntimeQueryResult: Partial<DowntimeQueryResult> = {
data: MOCK_DOWNTIME_RESPONSE,
data: MOCK_DOWNTIME_RESPONSE as AxiosResponse<PayloadProps>,
isLoading: false,
isFetching: false,
isError: false,
@@ -100,27 +89,13 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock('api/generated/services/downtimeschedules', () => ({
useListDowntimeSchedules: (): DowntimeQueryResult =>
jest.mock('api/plannedDowntime/getAllDowntimeSchedules', () => ({
useGetAllDowntimeSchedules: (): DowntimeQueryResult =>
mockDowntimeQueryResult as DowntimeQueryResult,
useDeleteDowntimeScheduleByID: (): {
mutateAsync: jest.Mock;
isLoading: false;
} => ({
mutateAsync: jest.fn(),
isLoading: false,
}),
}));
jest.mock('api/generated/services/rules', () => ({
useListRules: (): {
data: { data: [] };
isError: false;
isLoading: false;
} => ({
data: { data: [] },
isError: false,
isLoading: false,
}),
jest.mock('api/alerts/getAll', () => ({
__esModule: true,
default: (): Promise<{ payload: [] }> => Promise.resolve({ payload: [] }),
}));
describe('PlannedDowntime Component', () => {

View File

@@ -1,37 +1,29 @@
import type {
RuletypesPlannedMaintenanceDTO,
RuletypesScheduleDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
RuletypesMaintenanceKindDTO,
RuletypesMaintenanceStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DowntimeSchedules } from 'api/plannedDowntime/getAllDowntimeSchedules';
export const buildSchedule = (
schedule: Partial<RuletypesScheduleDTO>,
): RuletypesScheduleDTO => ({
timezone: schedule?.timezone ?? '',
startTime: schedule?.startTime,
endTime: schedule?.endTime,
recurrence: schedule?.recurrence,
schedule: Partial<DowntimeSchedules['schedule']>,
): DowntimeSchedules['schedule'] => ({
timezone: schedule?.timezone ?? null,
startTime: schedule?.startTime ?? null,
endTime: schedule?.endTime ?? null,
recurrence: schedule?.recurrence ?? null,
});
export const createMockDowntime = (
overrides: Partial<RuletypesPlannedMaintenanceDTO>,
): RuletypesPlannedMaintenanceDTO => ({
id: overrides.id ?? '0',
name: overrides.name ?? '',
description: overrides.description ?? '',
overrides: Partial<DowntimeSchedules>,
): DowntimeSchedules => ({
id: overrides.id ?? 0,
name: overrides.name ?? null,
description: overrides.description ?? null,
schedule: buildSchedule({
timezone: 'UTC',
startTime: new Date('2024-01-01'),
startTime: '2024-01-01',
...overrides.schedule,
}),
alertIds: overrides.alertIds ?? [],
createdAt: overrides.createdAt,
createdBy: overrides.createdBy ?? '',
updatedAt: overrides.updatedAt,
updatedBy: overrides.updatedBy ?? '',
kind: overrides.kind ?? RuletypesMaintenanceKindDTO.recurring,
status: overrides.status ?? RuletypesMaintenanceStatusDTO.active,
alertIds: overrides.alertIds ?? null,
createdAt: overrides.createdAt ?? null,
createdBy: overrides.createdBy ?? null,
updatedAt: overrides.updatedAt ?? null,
updatedBy: overrides.updatedBy ?? null,
kind: overrides.kind ?? null,
});

View File

@@ -14,6 +14,7 @@ import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { openInNewTab } from 'utils/navigation';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
@@ -115,7 +116,7 @@ const useBaseAggregateOptions = ({
key={id}
icon={<LinkOutlined />}
onClick={(): void => {
window.open(url, '_blank');
openInNewTab(url);
onClose?.();
}}
>

View File

@@ -14,6 +14,7 @@ import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';
import { Check, Loader, X } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
import { INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE } from './constants';
import {
@@ -76,7 +77,7 @@ function RoutingPolicyDetails({
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
openInNewTab(ROUTES.CHANNELS_NEW);
}}
>
here.

View File

@@ -28,6 +28,7 @@ import {
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuid } from 'uuid';
import noDataUrl from '@/assets/Icons/no-data.svg';
@@ -143,7 +144,7 @@ function SpanLogs({
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
window.open(url, '_blank');
openInNewTab(url);
},
[
isLogSpanRelated,

View File

@@ -17,6 +17,7 @@ import { BarChart2, Compass, X } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
@@ -157,13 +158,7 @@ function SpanRelatedSignals({
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
`${window.location.origin}${
ROUTES.LOGS_EXPLORER
}?${searchParams.toString()}`,
'_blank',
'noopener,noreferrer',
);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
const emptyStateConfig = useMemo(

View File

@@ -31,6 +31,7 @@ import {
UPDATE_SPANS_AGGREGATE_PAGE_SIZE,
} from 'types/actions/trace';
import { TraceReducer } from 'types/reducer/trace';
import { openInNewTab } from 'utils/navigation';
import { v4 } from 'uuid';
dayjs.extend(duration);
@@ -214,7 +215,7 @@ function TraceTable(): JSX.Element {
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
window.open(getLink(record), '_blank');
openInNewTab(getLink(record));
} else {
history.push(getLink(record));
}

View File

@@ -28,6 +28,7 @@ import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { openInNewTab } from 'utils/navigation';
import './TracesTableComponent.styles.scss';
@@ -86,7 +87,7 @@ function TracesTableComponent({
event.preventDefault();
event.stopPropagation();
if (event.metaKey || event.ctrlKey) {
window.open(getTraceLink(record), '_blank');
openInNewTab(getTraceLink(record));
} else {
history.push(getTraceLink(record));
}

View File

@@ -0,0 +1,20 @@
import { useMutation, UseMutationResult } from 'react-query';
import createAlertRule, {
CreateAlertRuleResponse,
} from 'api/alerts/createAlertRule';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export function useCreateAlertRule(): UseMutationResult<
SuccessResponse<CreateAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
> {
return useMutation<
SuccessResponse<CreateAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
>({
mutationFn: (alertData) => createAlertRule(alertData),
});
}

View File

@@ -0,0 +1,18 @@
import { useMutation, UseMutationResult } from 'react-query';
import testAlertRule, { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export function useTestAlertRule(): UseMutationResult<
SuccessResponse<TestAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
> {
return useMutation<
SuccessResponse<TestAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
>({
mutationFn: (alertData) => testAlertRule(alertData),
});
}

View File

@@ -0,0 +1,22 @@
import { useMutation, UseMutationResult } from 'react-query';
import updateAlertRule, {
UpdateAlertRuleResponse,
} from 'api/alerts/updateAlertRule';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export function useUpdateAlertRule(
id: string,
): UseMutationResult<
SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
> {
return useMutation<
SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
>({
mutationFn: (alertData) => updateAlertRule(id, alertData),
});
}

View File

@@ -17,6 +17,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getAbsoluteUrl } from 'utils/basePath';
import { HIGHLIGHTED_DELAY } from './configs';
import { UseCopyLogLink } from './types';
@@ -60,7 +61,7 @@ export const useCopyLogLink = (logId?: string): UseCopyLogLink => {
urlQuery.set(QueryParams.startTime, minTime?.toString() || '');
urlQuery.set(QueryParams.endTime, maxTime?.toString() || '');
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);

View File

@@ -22,6 +22,7 @@ import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
import { openInNewTab } from 'utils/navigation';
const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const queryRangeMutation = useMutation(getSubstituteVars);
@@ -92,7 +93,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const url = `${ROUTES.ALERTS_NEW}?${params.toString()}`;
window.open(url, '_blank', 'noreferrer');
openInNewTab(url);
},
onError: () => {
notifications.error({

View File

@@ -4,6 +4,7 @@ import { useCopyToClipboard } from 'react-use';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { Span } from 'types/api/trace/getTraceV2';
import { getAbsoluteUrl } from 'utils/basePath';
export const useCopySpanLink = (
span?: Span,
@@ -28,7 +29,7 @@ export const useCopySpanLink = (
urlQuery.set('spanId', span?.spanId);
}
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
notifications.success({

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
replace?: boolean;
@@ -130,7 +131,7 @@ export const useSafeNavigate = (
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
window.open(withBasePath(targetPath), '_blank');
return;
}

View File

@@ -1,3 +1,4 @@
import { createBrowserHistory } from 'history';
import { getBasePath } from 'utils/basePath';
export default createBrowserHistory();
export default createBrowserHistory({ basename: getBasePath() });

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { Breadcrumb, Button, Divider } from 'antd';
import logEvent from 'api/common/logEvent';
@@ -14,16 +15,39 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { isModifierKeyPressed } from 'utils/app';
import AlertHeader from './AlertHeader/AlertHeader';
import AlertNotFound from './AlertNotFound';
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
import { AlertDetailsStatusRendererProps } from './types';
import './AlertDetails.styles.scss';
function AlertDetailsStatusRenderer({
isLoading,
isError,
isRefetching,
data,
}: AlertDetailsStatusRendererProps): JSX.Element {
const alertRuleDetails = useMemo(() => data?.payload?.data, [data]);
const { t } = useTranslation('common');
if (isLoading || isRefetching) {
return <Spinner tip="Loading..." />;
}
if (isError) {
return <div>{data?.error || t('something_went_wrong')}</div>;
}
return <AlertHeader alertDetails={alertRuleDetails} />;
}
function BreadCrumbItem({
title,
isLast,
@@ -63,6 +87,7 @@ function AlertDetails(): JSX.Element {
const {
isLoading,
isRefetching,
isError,
ruleId,
isValidRuleId,
@@ -74,7 +99,7 @@ function AlertDetails(): JSX.Element {
}, [params]);
const getDocumentTitle = useMemo(() => {
const alertTitle = alertDetailsResponse?.data?.alert;
const alertTitle = alertDetailsResponse?.payload?.data?.alert;
if (alertTitle) {
return alertTitle;
}
@@ -85,17 +110,14 @@ function AlertDetails(): JSX.Element {
return document.title;
}
return 'Alert Not Found';
}, [alertDetailsResponse?.data?.alert, isTestAlert, isLoading]);
}, [alertDetailsResponse?.payload?.data?.alert, isTestAlert, isLoading]);
useEffect(() => {
document.title = getDocumentTitle;
}, [getDocumentTitle]);
const alertRuleDetails = useMemo(
() =>
alertDetailsResponse?.data
? fromRuleDTOToPostableRuleV2(alertDetailsResponse.data)
: undefined,
() => alertDetailsResponse?.payload?.data as PostableAlertRuleV2 | undefined,
[alertDetailsResponse],
);
@@ -104,7 +126,12 @@ function AlertDetails(): JSX.Element {
[alertRuleDetails],
);
if (isError || !isValidRuleId || (!isLoading && !alertRuleDetails)) {
if (
isError ||
!isValidRuleId ||
(alertDetailsResponse && alertDetailsResponse.statusCode !== 200) ||
(!isLoading && !alertRuleDetails)
) {
return <AlertNotFound isTestAlert={isTestAlert} />;
}
@@ -146,7 +173,9 @@ function AlertDetails(): JSX.Element {
/>
<Divider className="divider breadcrumb-divider" />
{alertRuleDetails && <AlertHeader alertDetails={alertRuleDetails} />}
<AlertDetailsStatusRenderer
{...{ isLoading, isError, isRefetching, data: alertDetailsResponse }}
/>
<Divider className="divider" />
<div className="tabs-and-filters">
<RouteTab

View File

@@ -1,5 +1,4 @@
import { useMemo, useState } from 'react';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import CreateAlertV2Header from 'container/CreateAlertV2/CreateAlertHeader';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useAlertRule } from 'providers/Alert';
@@ -7,6 +6,7 @@ import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { GettableAlert } from 'types/api/alerts/get';
import AlertActionButtons from './ActionButtons/ActionButtons';
import AlertLabels from './AlertLabels/AlertLabels';
@@ -16,7 +16,7 @@ import AlertState from './AlertState/AlertState';
import './AlertHeader.styles.scss';
export type AlertHeaderProps = {
alertDetails: RuletypesRuleDTO | PostableAlertRuleV2;
alertDetails: GettableAlert | PostableAlertRuleV2;
};
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const { state, alert: alertName, labels } = alertDetails;

View File

@@ -3,25 +3,15 @@ import { useMutation, useQuery, useQueryClient } from 'react-query';
import { generatePath, useLocation } from 'react-router-dom';
import { TablePaginationConfig, TableProps } from 'antd';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { patchRulePartial } from 'api/alerts/patchRulePartial';
import deleteAlerts from 'api/alerts/delete';
import get from 'api/alerts/get';
import getAll from 'api/alerts/getAll';
import patchAlert from 'api/alerts/patch';
import ruleStats from 'api/alerts/ruleStats';
import save from 'api/alerts/save';
import timelineGraph from 'api/alerts/timelineGraph';
import timelineTable from 'api/alerts/timelineTable';
import topContributors from 'api/alerts/topContributors';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createRule,
deleteRuleByID,
updateRuleByID,
useGetRuleByID,
useListRules,
} from 'api/generated/services/rules';
import type {
GetRuleByID200,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { TabRoutes } from 'components/RouteTab/types';
import { QueryParams } from 'constants/query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -31,6 +21,7 @@ import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
import { urlKey } from 'container/AllError/utils';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import useAxiosError from 'hooks/useAxiosError';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -43,9 +34,7 @@ import { OrderPreferenceItems } from 'pages/Logs/config';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
import { useAlertRule } from 'providers/Alert';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
import {
AlertDef,
AlertRuleStatsPayload,
@@ -54,7 +43,7 @@ import {
AlertRuleTimelineTableResponsePayload,
AlertRuleTopContributorsPayload,
} from 'types/api/alerts/def';
import APIError from 'types/api/error';
import { PayloadProps } from 'types/api/alerts/get';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { nanoToMilli } from 'utils/timeUtils';
@@ -153,7 +142,10 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
type Props = {
ruleId: string | null;
isValidRuleId: boolean;
alertDetailsResponse: GetRuleByID200 | undefined;
alertDetailsResponse:
| SuccessResponse<PayloadProps, unknown>
| ErrorResponse
| undefined;
isLoading: boolean;
isRefetching: boolean;
isError: boolean;
@@ -169,15 +161,14 @@ export const useGetAlertRuleDetails = (): Props => {
data: alertDetailsResponse,
isRefetching,
isError,
} = useGetRuleByID(
{ id: ruleId || '' },
{
query: {
enabled: isValidRuleId,
refetchOnWindowFocus: false,
},
},
);
} = useQuery([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId], {
queryFn: () =>
get({
id: ruleId || '',
}),
enabled: isValidRuleId,
refetchOnWindowFocus: false,
});
return {
ruleId,
@@ -396,27 +387,24 @@ export const useAlertRuleStatusToggle = ({
const { notifications } = useNotifications();
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const handleError = useAxiosError();
const { mutate: toggleAlertState } = useMutation(
[REACT_QUERY_KEY.TOGGLE_ALERT_STATE, ruleId],
(args: { id: string; data: Partial<RuletypesPostableRuleDTO> }) =>
patchRulePartial(args.id, args.data),
patchAlert,
{
onSuccess: (data) => {
setAlertRuleState(data.data.state);
setAlertRuleState(data?.payload?.state);
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
notifications.success({
message: `Alert has been ${
data.data.state === 'disabled' ? 'disabled' : 'enabled'
data?.payload?.state === 'disabled' ? 'disabled' : 'enabled'
}.`,
});
},
onError: (error) => {
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
handleError(error);
},
},
);
@@ -443,14 +431,14 @@ export const useAlertRuleDuplicate = ({
const params = useUrlQuery();
const { refetch } = useListRules({
query: { cacheTime: 0 },
const { refetch } = useQuery(REACT_QUERY_KEY.GET_ALL_ALLERTS, {
queryFn: getAll,
cacheTime: 0,
});
const { showErrorModal } = useErrorModal();
const handleError = useAxiosError();
const { mutate: duplicateAlert } = useMutation(
[REACT_QUERY_KEY.DUPLICATE_ALERT_RULE],
(args: { data: AlertDef }) =>
createRule(toPostableRuleDTOFromAlertDef(args.data)),
save,
{
onSuccess: async () => {
notifications.success({
@@ -459,17 +447,18 @@ export const useAlertRuleDuplicate = ({
const { data: allAlertsData } = await refetch();
const rules = allAlertsData?.data;
if (rules && rules.length > 0) {
const clonedAlert = rules[rules.length - 1];
if (
allAlertsData &&
allAlertsData.payload &&
allAlertsData.payload.length > 0
) {
const clonedAlert =
allAlertsData.payload[allAlertsData.payload.length - 1];
params.set(QueryParams.ruleId, String(clonedAlert.id));
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}
},
onError: (error) =>
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
),
onError: handleError,
},
);
@@ -495,21 +484,18 @@ export const useAlertRuleUpdate = ({
isLoading: boolean;
} => {
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const handleError = useAxiosError();
const { mutate: updateAlertRule, isLoading } = useMutation(
[REACT_QUERY_KEY.UPDATE_ALERT_RULE, alertDetails.id],
(args: { data: AlertDef; id: string }) =>
updateRuleByID({ id: args.id }, toPostableRuleDTOFromAlertDef(args.data)),
save,
{
onMutate: () => setUpdatedName(intermediateName),
onSuccess: () =>
notifications.success({ message: 'Alert renamed successfully' }),
onError: (error) => {
setUpdatedName(alertDetails.alert);
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);
handleError(error);
},
},
);
@@ -517,7 +503,7 @@ export const useAlertRuleUpdate = ({
const handleAlertUpdate = (): void => {
updateAlertRule({
data: { ...alertDetails, alert: intermediateName },
id: alertDetails.id || '',
id: alertDetails.id,
});
};
@@ -532,11 +518,11 @@ export const useAlertRuleDelete = ({
handleAlertDelete: () => void;
} => {
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const handleError = useAxiosError();
const { mutate: deleteAlert } = useMutation(
[REACT_QUERY_KEY.REMOVE_ALERT_RULE, ruleId],
(args: { id: string }) => deleteRuleByID({ id: args.id }),
deleteAlerts,
{
onSuccess: async () => {
notifications.success({
@@ -545,11 +531,7 @@ export const useAlertRuleDelete = ({
history.push(ROUTES.LIST_ALL_ALERT);
},
// eslint-disable-next-line sonarjs/no-identical-functions
onError: (error) =>
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
),
onError: handleError,
},
);

View File

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

View File

@@ -0,0 +1,6 @@
export const returnToAlertsPage = 'Return to Alerts Page';
export const errorMessageReceivedFromBackend = 'sql: no rows in result set';
export const improvedErrorMessage =
'The Alert that you are trying to access does not exist.';

View File

@@ -1,15 +1,11 @@
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Button, Card } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetRuleByID } from 'api/generated/services/rules';
import type {
RenderErrorResponseDTO,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import get from 'api/alerts/get';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import EditRulesContainer from 'container/EditRules';
import { useNotifications } from 'hooks/useNotifications';
@@ -20,10 +16,12 @@ import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import {
fromRuleDTOToAlertDef,
fromRuleDTOToPostableRuleV2,
} from 'types/api/alerts/convert';
errorMessageReceivedFromBackend,
improvedErrorMessage,
returnToAlertsPage,
} from './constants';
import './EditRules.styles.scss';
@@ -35,14 +33,16 @@ function EditRules(): JSX.Element {
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
const { isLoading, data, isRefetching, isError, error } = useGetRuleByID(
{ id: ruleId || '' },
const { isLoading, data, isRefetching, isError } = useQuery(
[REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId],
{
query: {
enabled: isValidRuleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
queryFn: () =>
get({
id: ruleId || '',
}),
enabled: isValidRuleId,
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
@@ -65,26 +65,22 @@ function EditRules(): JSX.Element {
}
}, [isValidRuleId, ruleId, notifications, safeNavigate]);
const ruleData: RuletypesRuleDTO | undefined = data?.data;
const apiError = useMemo(
() => convertToApiError(error as AxiosError<RenderErrorResponseDTO> | null),
[error],
);
if (
(isError && !isValidRuleId) ||
ruleId == null ||
(ruleData === undefined && !isLoading)
(data?.payload?.data === undefined && !isLoading)
) {
const errorMsg = apiError?.getErrorMessage() || '';
return (
<div className="edit-rules-container edit-rules-container--error">
<Card size="small" className="edit-rules-card">
<p className="content">{errorMsg || t('something_went_wrong')}</p>
<p className="content">
{data?.message === errorMessageReceivedFromBackend
? improvedErrorMessage
: data?.error || t('something_went_wrong')}
</p>
<div className="btn-container">
<Button type="default" size="large" onClick={clickHandler}>
Return to Alerts Page
{returnToAlertsPage}
</Button>
</div>
</Card>
@@ -92,20 +88,20 @@ function EditRules(): JSX.Element {
);
}
if (isLoading || isRefetching || !ruleData) {
if (isLoading || isRefetching || !data?.payload) {
return <Spinner tip="Loading Rules..." />;
}
let initialV2AlertValue: PostableAlertRuleV2 | null = null;
if (ruleData.schemaVersion === NEW_ALERT_SCHEMA_VERSION) {
initialV2AlertValue = fromRuleDTOToPostableRuleV2(ruleData);
if (data.payload.data.schemaVersion === NEW_ALERT_SCHEMA_VERSION) {
initialV2AlertValue = data.payload.data as PostableAlertRuleV2;
}
return (
<div className="edit-rules-container">
<EditRulesContainer
ruleId={ruleId || ''}
initialValue={fromRuleDTOToAlertDef(ruleData)}
initialValue={data.payload.data}
initialV2AlertValue={initialV2AlertValue}
/>
</div>

View File

@@ -4,6 +4,7 @@ import ROUTES from 'constants/routes';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Home, LifeBuoy } from 'lucide-react';
import { withBasePath } from 'utils/basePath';
import cloudUrl from '@/assets/Images/cloud.svg';
@@ -11,8 +12,8 @@ import './ErrorBoundaryFallback.styles.scss';
function ErrorBoundaryFallback(): JSX.Element {
const handleReload = (): void => {
// Go to home page
window.location.href = ROUTES.HOME;
// Hard reload resets Sentry.ErrorBoundary state; withBasePath preserves any /signoz/ prefix.
window.location.href = withBasePath(ROUTES.HOME);
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();

View File

@@ -19,6 +19,7 @@ import {
} from 'pages/MessagingQueues/MessagingQueuesUtils';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { openInNewTab } from 'utils/navigation';
import {
convertToMilliseconds,
@@ -93,7 +94,7 @@ export function getColumns(
key={item}
className="traceid-text"
onClick={(): void => {
window.open(`${ROUTES.TRACE}/${item}`, '_blank');
openInNewTab(`${ROUTES.TRACE}/${item}`);
logEvent(`MQ Kafka: Drop Rate - traceid navigation`, {
item,
});
@@ -123,7 +124,7 @@ export function getColumns(
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
window.open(`/services/${encodeURIComponent(text)}`, '_blank');
openInNewTab(`/services/${encodeURIComponent(text)}`);
}}
>
{text}

View File

@@ -1,153 +0,0 @@
import type {
RuletypesAlertCompositeQueryDTO,
RuletypesPostableRuleDTO,
RuletypesPostableRuleDTOLabels,
RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
RuletypesAlertTypeDTO,
RuletypesPanelTypeDTO,
RuletypesQueryTypeDTO,
RuletypesRuleTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { AlertTypes } from './alertTypes';
import { PostableAlertRuleV2 } from './alertTypesV2';
import { ICompositeMetricQuery } from './compositeQuery';
import { AlertDef, Labels } from './def';
function toRuleTypeDTO(ruleType: string | undefined): RuletypesRuleTypeDTO {
switch (ruleType) {
case RuletypesRuleTypeDTO.promql_rule:
return RuletypesRuleTypeDTO.promql_rule;
case RuletypesRuleTypeDTO.anomaly_rule:
return RuletypesRuleTypeDTO.anomaly_rule;
default:
return RuletypesRuleTypeDTO.threshold_rule;
}
}
function toAlertTypeDTO(
alertType: AlertTypes | string | undefined,
): RuletypesAlertTypeDTO | undefined {
switch (alertType) {
case AlertTypes.METRICS_BASED_ALERT:
return RuletypesAlertTypeDTO.METRIC_BASED_ALERT;
case AlertTypes.LOGS_BASED_ALERT:
return RuletypesAlertTypeDTO.LOGS_BASED_ALERT;
case AlertTypes.TRACES_BASED_ALERT:
return RuletypesAlertTypeDTO.TRACES_BASED_ALERT;
case AlertTypes.EXCEPTIONS_BASED_ALERT:
return RuletypesAlertTypeDTO.EXCEPTIONS_BASED_ALERT;
default:
return undefined;
}
}
function stripUndefinedLabels(
labels: Labels | undefined,
): RuletypesPostableRuleDTOLabels | undefined {
if (!labels) {
return undefined;
}
const out: RuletypesPostableRuleDTOLabels = {};
Object.entries(labels).forEach(([key, value]) => {
if (typeof value === 'string') {
out[key] = value;
}
});
return out;
}
// why: local PostableAlertRuleV2/AlertDef diverge from RuletypesPostableRuleDTO
// in several spots that match by string value but not by nominal TS type —
// condition.{op,matchType}, evaluation.kind, notificationSettings.renotify.alertStates.
// The backend accepts the local runtime shape, so one boundary cast encapsulates
// the type-surface gap rather than leaking it to call sites.
export function toPostableRuleDTO(
local: PostableAlertRuleV2,
): RuletypesPostableRuleDTO {
const payload = {
alert: local.alert,
alertType: toAlertTypeDTO(local.alertType),
ruleType: toRuleTypeDTO(local.ruleType),
condition: local.condition,
annotations: local.annotations,
labels: stripUndefinedLabels(local.labels),
notificationSettings: local.notificationSettings,
evaluation: local.evaluation,
schemaVersion: local.schemaVersion,
source: local.source,
version: local.version,
disabled: local.disabled,
};
return (payload as unknown) as RuletypesPostableRuleDTO;
}
export function toPostableRuleDTOFromAlertDef(
local: AlertDef,
): RuletypesPostableRuleDTO {
const payload = {
alert: local.alert,
alertType: toAlertTypeDTO(local.alertType),
ruleType: toRuleTypeDTO(local.ruleType),
condition: local.condition,
annotations: local.annotations,
labels: stripUndefinedLabels(local.labels),
evalWindow: local.evalWindow,
frequency: local.frequency,
preferredChannels: local.preferredChannels,
source: local.source,
version: local.version,
disabled: local.disabled,
};
return (payload as unknown) as RuletypesPostableRuleDTO;
}
export function fromRuleDTOToPostableRuleV2(
dto: RuletypesRuleDTO,
): PostableAlertRuleV2 {
return (dto as unknown) as PostableAlertRuleV2;
}
export function fromRuleDTOToAlertDef(dto: RuletypesRuleDTO): AlertDef {
return (dto as unknown) as AlertDef;
}
function toEQueryType(queryType: RuletypesQueryTypeDTO): EQueryType {
switch (queryType) {
case RuletypesQueryTypeDTO.builder:
return EQueryType.QUERY_BUILDER;
case RuletypesQueryTypeDTO.clickhouse_sql:
return EQueryType.CLICKHOUSE;
case RuletypesQueryTypeDTO.promql:
return EQueryType.PROM;
default:
return EQueryType.QUERY_BUILDER;
}
}
function toPanelType(panelType: RuletypesPanelTypeDTO): PANEL_TYPES {
switch (panelType) {
case RuletypesPanelTypeDTO.value:
return PANEL_TYPES.VALUE;
case RuletypesPanelTypeDTO.table:
return PANEL_TYPES.TABLE;
case RuletypesPanelTypeDTO.graph:
default:
return PANEL_TYPES.TIME_SERIES;
}
}
export function toCompositeMetricQuery(
dto: RuletypesAlertCompositeQueryDTO,
): ICompositeMetricQuery {
return {
queryType: toEQueryType(dto.queryType),
panelType: toPanelType(dto.panelType),
unit: dto.unit,
queries: (dto.queries ?? undefined) as ICompositeMetricQuery['queries'],
};
}

View File

@@ -0,0 +1,21 @@
import { AlertDef } from './def';
export interface Props {
id: AlertDef['id'];
}
export interface GettableAlert extends AlertDef {
id: string;
alert: string;
state: string;
disabled: boolean;
createAt: string;
createBy: string;
updateAt: string;
updateBy: string;
schemaVersion: string;
}
export type PayloadProps = {
data: GettableAlert;
};

View File

@@ -0,0 +1,3 @@
import { GettableAlert } from './get';
export type PayloadProps = GettableAlert[];

View File

@@ -0,0 +1,12 @@
import { GettableAlert } from './get';
export type PayloadProps = GettableAlert;
export interface PatchProps {
disabled?: boolean;
}
export interface Props {
id?: string;
data: PatchProps;
}

View File

@@ -0,0 +1,118 @@
/**
* basePath is memoized at module init, so each describe block isolates the
* module with a fresh DOM state using jest.isolateModules + require.
*/
type BasePath = typeof import('../basePath');
function loadModule(href?: string): BasePath {
if (href !== undefined) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.appendChild(base);
}
let mod!: BasePath;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
mod = require('../basePath');
});
return mod;
}
afterEach(() => {
document.head.querySelectorAll('base').forEach((el) => el.remove());
});
describe('at basePath="/"', () => {
let m: BasePath;
beforeEach(() => {
m = loadModule('/');
});
it('getBasePath returns "/"', () => {
expect(m.getBasePath()).toBe('/');
});
it('withBasePath is a no-op for any internal path', () => {
expect(m.withBasePath('/logs')).toBe('/logs');
expect(m.withBasePath('/logs/explorer')).toBe('/logs/explorer');
});
it('withBasePath passes through external URLs', () => {
expect(m.withBasePath('https://example.com/foo')).toBe(
'https://example.com/foo',
);
});
it('getAbsoluteUrl returns origin + path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(`${window.location.origin}/logs`);
});
it('getBaseUrl returns bare origin', () => {
expect(m.getBaseUrl()).toBe(window.location.origin);
});
});
describe('at basePath="/signoz/"', () => {
let m: BasePath;
beforeEach(() => {
m = loadModule('/signoz/');
});
it('getBasePath returns "/signoz/"', () => {
expect(m.getBasePath()).toBe('/signoz/');
});
it('withBasePath prepends the prefix', () => {
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
expect(m.withBasePath('/logs/explorer')).toBe('/signoz/logs/explorer');
});
it('withBasePath is idempotent — safe to call twice', () => {
expect(m.withBasePath('/signoz/logs')).toBe('/signoz/logs');
});
it('withBasePath is idempotent when path equals the prefix without trailing slash', () => {
expect(m.withBasePath('/signoz')).toBe('/signoz');
});
it('withBasePath passes through external URLs', () => {
expect(m.withBasePath('https://example.com/foo')).toBe(
'https://example.com/foo',
);
});
it('getAbsoluteUrl returns origin + prefixed path', () => {
expect(m.getAbsoluteUrl('/logs')).toBe(
`${window.location.origin}/signoz/logs`,
);
});
it('getBaseUrl returns origin + prefix without trailing slash', () => {
expect(m.getBaseUrl()).toBe(`${window.location.origin}/signoz`);
});
});
describe('no <base> tag', () => {
it('getBasePath falls back to "/"', () => {
const m = loadModule();
expect(m.getBasePath()).toBe('/');
});
});
describe('href without trailing slash', () => {
it('normalises to trailing slash', () => {
const m = loadModule('/signoz');
expect(m.getBasePath()).toBe('/signoz/');
expect(m.withBasePath('/logs')).toBe('/signoz/logs');
});
});
describe('nested prefix "/a/b/prefix/"', () => {
it('withBasePath handles arbitrary depth', () => {
const m = loadModule('/a/b/prefix/');
expect(m.withBasePath('/logs')).toBe('/a/b/prefix/logs');
expect(m.withBasePath('/a/b/prefix/logs')).toBe('/a/b/prefix/logs');
});
});

View File

@@ -1,15 +1,27 @@
import { isModifierKeyPressed } from '../app';
import { openInNewTab } from '../navigation';
type NavigationModule = typeof import('../navigation');
function loadNavigationModule(href?: string): NavigationModule {
if (href !== undefined) {
const base = document.createElement('base');
base.setAttribute('href', href);
document.head.appendChild(base);
}
let mod!: NavigationModule;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
mod = require('../navigation');
});
return mod;
}
describe('navigation utilities', () => {
const originalWindowOpen = window.open;
beforeEach(() => {
window.open = jest.fn();
});
afterEach(() => {
window.open = originalWindowOpen;
document.head.querySelectorAll('base').forEach((el) => el.remove());
});
describe('isModifierKeyPressed', () => {
@@ -56,25 +68,59 @@ describe('navigation utilities', () => {
});
describe('openInNewTab', () => {
it('calls window.open with the given path and _blank target', () => {
openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
describe('at basePath="/"', () => {
let m: NavigationModule;
beforeEach(() => {
window.open = jest.fn();
m = loadNavigationModule('/');
});
it('passes internal path through unchanged', () => {
m.openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/dashboard', '_blank');
});
it('passes through external URLs unchanged', () => {
m.openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('handles paths with query strings', () => {
m.openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
expect(window.open).toHaveBeenCalledWith(
'/alerts?tab=AlertRules&relativeTime=30m',
'_blank',
);
});
});
it('handles full URLs', () => {
openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
describe('at basePath="/signoz/"', () => {
let m: NavigationModule;
beforeEach(() => {
window.open = jest.fn();
m = loadNavigationModule('/signoz/');
});
it('handles paths with query strings', () => {
openInNewTab('/alerts?tab=AlertRules&relativeTime=30m');
expect(window.open).toHaveBeenCalledWith(
'/alerts?tab=AlertRules&relativeTime=30m',
'_blank',
);
it('prepends base path to internal paths', () => {
m.openInNewTab('/dashboard');
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
});
it('passes through external URLs unchanged', () => {
m.openInNewTab('https://example.com/page');
expect(window.open).toHaveBeenCalledWith(
'https://example.com/page',
'_blank',
);
});
it('is idempotent — does not double-prefix an already-prefixed path', () => {
m.openInNewTab('/signoz/dashboard');
expect(window.open).toHaveBeenCalledWith('/signoz/dashboard', '_blank');
});
});
});
});

View File

@@ -0,0 +1,50 @@
// Read once at module init — avoids a DOM query on every axios request.
const _basePath: string = ((): string => {
const href = document.querySelector('base')?.getAttribute('href') ?? '/';
return href.endsWith('/') ? href : `${href}/`;
})();
/** Returns the runtime base path — always trailing-slashed. e.g. "/" or "/signoz/" */
export function getBasePath(): string {
return _basePath;
}
/**
* Prepends the base path to an internal absolute path.
* Idempotent and safe to call on any value.
*
* withBasePath('/logs') → '/signoz/logs'
* withBasePath('/signoz/logs') → '/signoz/logs' (already prefixed)
* withBasePath('https://x.com') → 'https://x.com' (external, passthrough)
*/
export function withBasePath(path: string): string {
if (!path.startsWith('/')) {
return path;
}
if (_basePath === '/') {
return path;
}
if (path.startsWith(_basePath) || path === _basePath.slice(0, -1)) {
return path;
}
return _basePath + path.slice(1);
}
/**
* Full absolute URL — for copy-to-clipboard and window.open calls.
* getAbsoluteUrl(ROUTES.LOGS_EXPLORER) → 'https://host/signoz/logs/logs-explorer'
*/
export function getAbsoluteUrl(path: string): string {
return window.location.origin + withBasePath(path);
}
/**
* Origin + base path without trailing slash — for sending to the backend
* as frontendBaseUrl in invite / password-reset email flows.
* getBaseUrl() → 'https://host/signoz'
*/
export function getBaseUrl(): string {
return (
window.location.origin + (_basePath === '/' ? '' : _basePath.slice(0, -1))
);
}

View File

@@ -1,6 +1,5 @@
/**
* Opens the given path in a new browser tab.
*/
import { withBasePath } from 'utils/basePath';
export const openInNewTab = (path: string): void => {
window.open(path, '_blank');
window.open(withBasePath(path), '_blank');
};

View File

@@ -10,6 +10,18 @@ import { createHtmlPlugin } from 'vite-plugin-html';
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
import tsconfigPaths from 'vite-tsconfig-paths';
// In dev the Go backend is not involved, so replace the [[.BaseHref]] placeholder
// with "/" so relative assets resolve correctly from the Vite dev server.
function devBasePathPlugin(): Plugin {
return {
name: 'dev-base-path',
apply: 'serve',
transformIndexHtml(html): string {
return html.replaceAll('[[.BaseHref]]', '/');
},
};
}
function rawMarkdownPlugin(): Plugin {
return {
name: 'raw-markdown',
@@ -32,6 +44,7 @@ export default defineConfig(
const plugins = [
tsconfigPaths(),
rawMarkdownPlugin(),
devBasePathPlugin(),
react(),
createHtmlPlugin({
inject: {
@@ -124,6 +137,7 @@ export default defineConfig(
'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN),
'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL),
},
base: './',
build: {
sourcemap: true,
outDir: 'build',