Merge branch 'main' into thirdpartylistapifix

This commit is contained in:
Ekansh Gupta 2025-09-28 22:11:29 +05:30 committed by GitHub
commit 2f1bd624e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 6080 additions and 209 deletions

4
.gitignore vendored
View File

@ -230,6 +230,6 @@ poetry.toml
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python
frontend/.cursor/rules/
# cursor files
frontend/.cursor/

View File

@ -325,17 +325,7 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
func makeRulesManager(
ch baseint.Reader,
cache cache.Cache,
alertmanager alertmanager.Alertmanager,
sqlstore sqlstore.SQLStore,
telemetryStore telemetrystore.TelemetryStore,
prometheus prometheus.Prometheus,
orgGetter organization.Getter,
querier querier.Querier,
logger *slog.Logger,
) (*baserules.Manager, error) {
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, querier querier.Querier, logger *slog.Logger) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts

View File

@ -387,6 +387,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
}
if smpl.IsMissing {
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(labels.NoDataLabel, "true")
}
lbs := lb.Labels()

View File

@ -3,6 +3,7 @@ import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const config: Config.InitialOptions = {
silent: true,
clearMocks: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],

View File

@ -0,0 +1,81 @@
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.11 16.8483L14.0369 17.7304C14.0369 17.7304 12.3481 22.0324 12.2437 22.3235C12.1392 22.6146 12.1437 23.0746 12.6037 23.0746C13.1881 23.0746 16.1546 23.0591 16.1546 23.0591C16.1546 23.0591 15.4346 26.5322 15.3924 26.8433C15.3502 27.1544 15.6835 27.4277 15.9768 27.1144C16.2701 26.8011 20.3121 21.6058 20.4877 21.3525C20.801 20.8992 20.4988 20.4814 20.1433 20.4614C19.7877 20.4414 17.4056 20.4925 17.4056 20.4925L19.11 16.8483Z"
fill="#FECA18"
/>
<path
d="M17.7589 17.4527C17.7589 17.4527 16.6279 19.9548 16.5856 20.097C16.4612 20.5192 17.0078 20.6903 17.1634 20.3481C17.3189 20.0037 18.6655 17.2194 18.6655 17.2194L17.7589 17.4527Z"
fill="#FDB900"
/>
<path
d="M12.8859 22.2592C13.1836 22.2503 15.7968 22.2436 16.0146 22.2281C16.4213 22.1969 16.4835 22.7591 16.0146 22.7591C15.528 22.7591 12.9637 22.768 12.8081 22.7747C12.4481 22.7925 12.3992 22.2747 12.8859 22.2592Z"
fill="#FDB900"
/>
<path
d="M14.9813 17.1127C14.9813 17.1127 13.6481 20.4592 13.5725 20.7103C13.2592 21.7591 14.3858 21.728 14.6836 21.1325C14.8302 20.837 16.2635 17.8016 16.3257 17.2527C16.3879 16.7061 14.9813 17.1127 14.9813 17.1127Z"
fill="#FFE36A"
/>
<path
d="M15.3347 21.0148C15.1436 20.8837 14.9125 20.9992 14.7725 21.2192C14.6325 21.4392 14.428 21.797 14.7414 21.9858C15.0236 22.1547 15.2724 21.8147 15.3835 21.657C15.4924 21.4992 15.6324 21.217 15.3347 21.0148Z"
fill="#FFE36A"
/>
<path
d="M17.6301 21.7326C17.1212 21.6237 16.9568 22.0459 16.8479 22.5459C16.739 23.0459 16.3302 24.6636 16.2546 25.0635C16.1457 25.6257 16.6612 25.7057 16.8812 25.188C17.0457 24.8013 17.759 23.057 17.8501 22.7792C17.9901 22.3592 18.1456 21.8437 17.6301 21.7326Z"
fill="#FFE36A"
/>
<path
d="M25.7585 12.0573C25.7585 12.0573 26.3363 4.28441 19.69 3.2978C13.7147 2.41118 12.5415 8.08421 12.5415 8.08421C12.5415 8.08421 10.2838 7.55757 8.66166 9.00639C7.05064 10.4463 7.17508 12.1507 7.17508 12.1507C7.17508 12.1507 3.20195 11.524 2.79531 14.935C2.41533 18.1215 7.11286 17.3282 7.11286 17.3282L29.4183 15.686C29.4183 15.686 29.9472 14.0106 28.2606 12.7462C27.2585 11.9929 25.7585 12.0573 25.7585 12.0573Z"
fill="#E4EAEE"
/>
<path
d="M13.7347 13.8196C13.9213 13.7574 14.9857 14.6662 18.0522 14.6951C22.2653 14.7373 25.4563 12.2552 25.4563 12.2552C25.4563 12.2552 25.5629 13.0108 25.2274 13.5485C24.8096 14.2151 24.163 14.3418 24.163 14.3418C24.163 14.3418 25.3318 15.1551 26.9362 15.0307C28.3828 14.9173 29.4294 14.4262 29.4294 14.4262C29.4294 14.4262 29.5694 15.1395 29.4916 15.8351C29.3361 17.2217 28.5228 17.7861 27.6251 17.8883C26.9651 17.9638 21.3276 17.9905 19.0122 18.0127C16.9256 18.0327 6.29285 18.2994 5.20624 18.1794C4.07963 18.0549 3.22412 17.3772 2.91303 16.4061C2.67526 15.6706 2.78859 15.1973 2.78859 15.1973C2.78859 15.1973 4.85959 15.7462 6.02175 15.6151C7.48167 15.4484 8.46162 14.5307 8.46162 14.5307C8.46162 14.5307 9.33713 15.0307 11.277 14.864C12.9458 14.7173 13.7347 13.8196 13.7347 13.8196Z"
fill="url(#paint0_radial_811_5475)"
/>
<path
d="M24.8653 18.2661C24.6386 18.1394 23.832 18.8927 23.3787 19.346C23.1009 19.6238 22.2165 20.3504 22.0965 21.0504C21.7988 22.7703 23.7698 23.1614 24.552 22.3637C25.143 21.7615 25.0364 20.586 25.0364 20.1904C25.0386 19.6416 25.1475 18.4216 24.8653 18.2661Z"
fill="#52C0EE"
/>
<path
d="M8.67058 19.1904C8.45504 19.0659 7.68174 19.7948 7.24621 20.237C6.97956 20.5058 6.13294 21.2103 6.01294 21.8947C5.71962 23.5723 7.5973 23.9657 8.34837 23.1924C8.91501 22.608 8.82168 21.4591 8.82391 21.0725C8.82613 20.537 8.93723 19.3459 8.67058 19.1904Z"
fill="#52C0EE"
/>
<path
d="M12.2548 24.1634C12.0126 24.0723 11.1971 24.8145 10.7438 25.2678C10.466 25.5456 9.64386 26.2566 9.52386 26.9566C9.2261 28.6765 11.1349 29.0832 11.9171 28.2854C12.5081 27.6832 12.4104 26.5077 12.4015 26.1122C12.3793 25.0812 12.4215 24.2256 12.2548 24.1634Z"
fill="#52C0EE"
/>
<path
d="M23.5054 20.4926C23.1943 20.3304 22.8165 20.3993 22.5765 20.8992C22.3365 21.3992 22.5343 21.797 22.7854 21.9103C23.0743 22.0436 23.432 21.9214 23.672 21.5147C23.912 21.1081 23.7654 20.6281 23.5054 20.4926Z"
fill="#B2E6FE"
/>
<path
d="M10.6682 26.4279C10.3482 26.3168 9.99715 26.4345 9.83716 26.9478C9.67717 27.4611 9.92382 27.8122 10.1794 27.8878C10.4749 27.9744 10.7993 27.8078 10.9727 27.3834C11.1438 26.9589 10.9349 26.5212 10.6682 26.4279Z"
fill="#B2E6FE"
/>
<path
d="M7.12613 21.3258C6.78837 21.2325 6.43283 21.3769 6.30395 21.9169C6.17507 22.4569 6.45061 22.8035 6.71948 22.8613C7.03058 22.9302 7.35278 22.7369 7.50389 22.288C7.65277 21.8436 7.41056 21.4036 7.12613 21.3258Z"
fill="#B2E6FE"
/>
<defs>
<radialGradient
id="paint0_radial_811_5475"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(0.18837 -6.53799 9.79456 0.282554 16.401 18.5051)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.1934" stopColor="#FFE366" />
<stop offset="0.3305" stopColor="#EDDD82" />
<stop offset="0.5709" stopColor="#D0D4AD" />
<stop offset="0.7589" stopColor="#BFCFC7" />
<stop offset="0.8699" stopColor="#B8CDD1" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -0,0 +1,36 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface CreateRoutingPolicyBody {
name: string;
expression: string;
actions: {
channels: string[];
};
description?: string;
}
export interface CreateRoutingPolicyResponse {
success: boolean;
message: string;
}
const createRoutingPolicy = async (
props: CreateRoutingPolicyBody,
): Promise<
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.post(`/notification-policy`, props);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default createRoutingPolicy;

View File

@ -0,0 +1,30 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface DeleteRoutingPolicyResponse {
success: boolean;
message: string;
}
const deleteRoutingPolicy = async (
routingPolicyId: string,
): Promise<
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.delete(
`/notification-policy/${routingPolicyId}`,
);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteRoutingPolicy;

View File

@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface ApiRoutingPolicy {
id: string;
name: string;
expression: string;
description: string;
channels: string[];
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
}
export interface GetRoutingPoliciesResponse {
status: string;
data?: ApiRoutingPolicy[];
}
export const getRoutingPolicies = async (
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2> => {
try {
const response = await axios.get('/notification-policy', {
signal,
headers,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};

View File

@ -0,0 +1,40 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface UpdateRoutingPolicyBody {
name: string;
expression: string;
actions: {
channels: string[];
};
description: string;
}
export interface UpdateRoutingPolicyResponse {
success: boolean;
message: string;
}
const updateRoutingPolicy = async (
id: string,
props: UpdateRoutingPolicyBody,
): Promise<
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.put(`/notification-policy/${id}`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
return ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updateRoutingPolicy;

View File

@ -0,0 +1,117 @@
# withErrorBoundary HOC
A Higher-Order Component (HOC) that wraps React components with ErrorBoundary to provide error handling and recovery.
## Features
- **Automatic Error Catching**: Catches JavaScript errors in any component tree
- **Integration**: Automatically reports errors with context
- **Custom Fallback UI**: Supports custom error fallback components
- **Error Logging**: Optional custom error handlers for additional logging
- **TypeScript Support**: Fully typed with proper generics
- **Component Context**: Automatically adds component name to tags
## Basic Usage
```tsx
import { withErrorBoundary } from 'components/HOC';
// Wrap any component
const SafeComponent = withErrorBoundary(MyComponent);
// Use it like any other component
<SafeComponent prop1="value1" prop2="value2" />
```
## Advanced Usage
### Custom Fallback Component
```tsx
const CustomFallback = () => (
<div>
<h3>Oops! Something went wrong</h3>
<button onClick={() => window.location.reload()}>Reload</button>
</div>
);
const SafeComponent = withErrorBoundary(MyComponent, {
fallback: <CustomFallback />
});
```
### Custom Error Handler
```tsx
const SafeComponent = withErrorBoundary(MyComponent, {
onError: (error, componentStack, eventId) => {
console.error('Component error:', error);
// Send to analytics, logging service, etc.
}
});
```
### Sentry Configuration
```tsx
const SafeComponent = withErrorBoundary(MyComponent, {
sentryOptions: {
tags: {
section: 'dashboard',
priority: 'high',
feature: 'metrics'
},
level: 'error'
}
});
```
## API Reference
### `withErrorBoundary<P>(component, options?)`
#### Parameters
- `component: ComponentType<P>` - The React component to wrap
- `options?: WithErrorBoundaryOptions` - Configuration options
#### Options
```tsx
interface WithErrorBoundaryOptions {
/** Custom fallback component to render when an error occurs */
fallback?: ReactElement;
/** Custom error handler function */
onError?: (
error: unknown,
componentStack: string | undefined,
eventId: string
) => void;
/** Additional props to pass to the Sentry ErrorBoundary */
sentryOptions?: {
tags?: Record<string, string>;
level?: Sentry.SeverityLevel;
};
}
```
## When to Use
- **Critical Components**: Wrap important UI components that shouldn't crash the entire app
- **Third-party Integrations**: Wrap components that use external libraries
- **Data-heavy Components**: Wrap components that process complex data
- **Route Components**: Wrap page-level components to prevent navigation issues
## Best Practices
1. **Use Sparingly**: Don't wrap every component - focus on critical ones
2. **Meaningful Fallbacks**: Provide helpful fallback UI that guides users
3. **Log Errors**: Always implement error logging for debugging
4. **Component Names**: Ensure components have proper `displayName` for debugging
5. **Test Error Scenarios**: Test that your error boundaries work as expected
## Examples
See `withErrorBoundary.example.tsx` for complete usage examples.

View File

@ -0,0 +1,211 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import withErrorBoundary, {
WithErrorBoundaryOptions,
} from '../withErrorBoundary';
// Mock dependencies before imports
jest.mock('@sentry/react', () => {
const ReactMock = jest.requireActual('react');
class MockErrorBoundary extends ReactMock.Component<
{
children: React.ReactNode;
fallback: React.ReactElement;
onError?: (error: Error, componentStack: string, eventId: string) => void;
beforeCapture?: (scope: {
setTag: (key: string, value: string) => void;
setLevel: (level: string) => void;
}) => void;
},
{ hasError: boolean }
> {
constructor(props: MockErrorBoundary['props']) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: { componentStack: string }): void {
const { beforeCapture, onError } = this.props;
if (beforeCapture) {
const mockScope = {
setTag: jest.fn(),
setLevel: jest.fn(),
};
beforeCapture(mockScope);
}
if (onError) {
onError(error, errorInfo.componentStack, 'mock-event-id');
}
}
render(): React.ReactNode {
const { hasError } = this.state;
const { fallback, children } = this.props;
if (hasError) {
return <div data-testid="error-boundary-fallback">{fallback}</div>;
}
return <div data-testid="app-error-boundary">{children}</div>;
}
}
return {
ErrorBoundary: MockErrorBoundary,
SeverityLevel: {
error: 'error',
warning: 'warning',
info: 'info',
},
};
});
jest.mock(
'../../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback',
() =>
function MockErrorBoundaryFallback(): JSX.Element {
return (
<div data-testid="default-error-fallback">Default Error Fallback</div>
);
},
);
// Test component that can throw errors
interface TestComponentProps {
shouldThrow?: boolean;
message?: string;
}
function TestComponent({
shouldThrow = false,
message = 'Test Component',
}: TestComponentProps): JSX.Element {
if (shouldThrow) {
throw new Error('Test error');
}
return <div data-testid="test-component">{message}</div>;
}
TestComponent.defaultProps = {
shouldThrow: false,
message: 'Test Component',
};
// Test component with display name
function NamedComponent(): JSX.Element {
return <div data-testid="named-component">Named Component</div>;
}
NamedComponent.displayName = 'NamedComponent';
describe('withErrorBoundary', () => {
// Suppress console errors for cleaner test output
const originalError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should wrap component with ErrorBoundary and render successfully', () => {
// Arrange
const SafeComponent = withErrorBoundary(TestComponent);
// Act
render(<SafeComponent message="Hello World" />);
// Assert
expect(screen.getByTestId('app-error-boundary')).toBeInTheDocument();
expect(screen.getByTestId('test-component')).toBeInTheDocument();
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('should render fallback UI when component throws error', () => {
// Arrange
const SafeComponent = withErrorBoundary(TestComponent);
// Act
render(<SafeComponent shouldThrow />);
// Assert
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
expect(screen.getByTestId('default-error-fallback')).toBeInTheDocument();
});
it('should render custom fallback component when provided', () => {
// Arrange
const customFallback = (
<div data-testid="custom-fallback">Custom Error UI</div>
);
const options: WithErrorBoundaryOptions = {
fallback: customFallback,
};
const SafeComponent = withErrorBoundary(TestComponent, options);
// Act
render(<SafeComponent shouldThrow />);
// Assert
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
expect(screen.getByTestId('custom-fallback')).toBeInTheDocument();
expect(screen.getByText('Custom Error UI')).toBeInTheDocument();
});
it('should call custom error handler when error occurs', () => {
// Arrange
const mockErrorHandler = jest.fn();
const options: WithErrorBoundaryOptions = {
onError: mockErrorHandler,
};
const SafeComponent = withErrorBoundary(TestComponent, options);
// Act
render(<SafeComponent shouldThrow />);
// Assert
expect(mockErrorHandler).toHaveBeenCalledWith(
expect.any(Error),
expect.any(String),
'mock-event-id',
);
expect(mockErrorHandler).toHaveBeenCalledTimes(1);
});
it('should set correct display name for debugging', () => {
// Arrange & Act
const SafeTestComponent = withErrorBoundary(TestComponent);
const SafeNamedComponent = withErrorBoundary(NamedComponent);
// Assert
expect(SafeTestComponent.displayName).toBe(
'withErrorBoundary(TestComponent)',
);
expect(SafeNamedComponent.displayName).toBe(
'withErrorBoundary(NamedComponent)',
);
});
it('should handle component without display name', () => {
// Arrange
function AnonymousComponent(): JSX.Element {
return <div>Anonymous</div>;
}
// Act
const SafeAnonymousComponent = withErrorBoundary(AnonymousComponent);
// Assert
expect(SafeAnonymousComponent.displayName).toBe(
'withErrorBoundary(AnonymousComponent)',
);
});
});

View File

@ -0,0 +1,2 @@
export type { WithErrorBoundaryOptions } from './withErrorBoundary';
export { default as withErrorBoundary } from './withErrorBoundary';

View File

@ -0,0 +1,143 @@
import { Button } from 'antd';
import { useState } from 'react';
import { withErrorBoundary } from './index';
/**
* Example component that can throw errors
*/
function ProblematicComponent(): JSX.Element {
const [shouldThrow, setShouldThrow] = useState(false);
if (shouldThrow) {
throw new Error('This is a test error from ProblematicComponent!');
}
return (
<div style={{ padding: '20px' }}>
<h3>Problematic Component</h3>
<p>This component can throw errors when the button is clicked.</p>
<Button type="primary" onClick={(): void => setShouldThrow(true)} danger>
Trigger Error
</Button>
</div>
);
}
/**
* Basic usage - wraps component with default error boundary
*/
export const SafeProblematicComponent = withErrorBoundary(ProblematicComponent);
/**
* Usage with custom fallback component
*/
function CustomErrorFallback(): JSX.Element {
return (
<div
style={{ padding: '20px', border: '1px solid red', borderRadius: '4px' }}
>
<h4 style={{ color: 'red' }}>Custom Error Fallback</h4>
<p>Something went wrong in this specific component!</p>
<Button onClick={(): void => window.location.reload()}>Reload Page</Button>
</div>
);
}
export const SafeProblematicComponentWithCustomFallback = withErrorBoundary(
ProblematicComponent,
{
fallback: <CustomErrorFallback />,
},
);
/**
* Usage with custom error handler
*/
export const SafeProblematicComponentWithErrorHandler = withErrorBoundary(
ProblematicComponent,
{
onError: (error, errorInfo) => {
console.error('Custom error handler:', error);
console.error('Error info:', errorInfo);
// You could also send to analytics, logging service, etc.
},
sentryOptions: {
tags: {
section: 'dashboard',
priority: 'high',
},
level: 'error',
},
},
);
/**
* Example of wrapping an existing component from the codebase
*/
function ExistingComponent({
title,
data,
}: {
title: string;
data: any[];
}): JSX.Element {
// This could be any existing component that might throw errors
return (
<div>
<h4>{title}</h4>
<ul>
{data.map((item, index) => (
// eslint-disable-next-line react/no-array-index-key
<li key={index}>{item.name}</li>
))}
</ul>
</div>
);
}
export const SafeExistingComponent = withErrorBoundary(ExistingComponent, {
sentryOptions: {
tags: {
component: 'ExistingComponent',
feature: 'data-display',
},
},
});
/**
* Usage examples in a container component
*/
export function ErrorBoundaryExamples(): JSX.Element {
const sampleData = [
{ name: 'Item 1' },
{ name: 'Item 2' },
{ name: 'Item 3' },
];
return (
<div style={{ padding: '20px' }}>
<h2>Error Boundary HOC Examples</h2>
<div style={{ marginBottom: '20px' }}>
<h3>1. Basic Usage</h3>
<SafeProblematicComponent />
</div>
<div style={{ marginBottom: '20px' }}>
<h3>2. With Custom Fallback</h3>
<SafeProblematicComponentWithCustomFallback />
</div>
<div style={{ marginBottom: '20px' }}>
<h3>3. With Custom Error Handler</h3>
<SafeProblematicComponentWithErrorHandler />
</div>
<div style={{ marginBottom: '20px' }}>
<h3>4. Wrapped Existing Component</h3>
<SafeExistingComponent title="Sample Data" data={sampleData} />
</div>
</div>
);
}

View File

@ -0,0 +1,99 @@
import * as Sentry from '@sentry/react';
import { ComponentType, ReactElement } from 'react';
import ErrorBoundaryFallback from '../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
/**
* Configuration options for the ErrorBoundary HOC
*/
interface WithErrorBoundaryOptions {
/** Custom fallback component to render when an error occurs */
fallback?: ReactElement;
/** Custom error handler function */
onError?: (
error: unknown,
componentStack: string | undefined,
eventId: string,
) => void;
/** Additional props to pass to the ErrorBoundary */
sentryOptions?: {
tags?: Record<string, string>;
level?: Sentry.SeverityLevel;
};
}
/**
* Higher-Order Component that wraps a component with ErrorBoundary
*
* @param WrappedComponent - The component to wrap with error boundary
* @param options - Configuration options for the error boundary
*
* @example
* // Basic usage
* const SafeComponent = withErrorBoundary(MyComponent);
*
* @example
* // With custom fallback
* const SafeComponent = withErrorBoundary(MyComponent, {
* fallback: <div>Something went wrong!</div>
* });
*
* @example
* // With custom error handler
* const SafeComponent = withErrorBoundary(MyComponent, {
* onError: (error, errorInfo) => {
* console.error('Component error:', error, errorInfo);
* }
* });
*/
function withErrorBoundary<P extends Record<string, unknown>>(
WrappedComponent: ComponentType<P>,
options: WithErrorBoundaryOptions = {},
): ComponentType<P> {
const {
fallback = <ErrorBoundaryFallback />,
onError,
sentryOptions = {},
} = options;
function WithErrorBoundaryComponent(props: P): JSX.Element {
return (
<Sentry.ErrorBoundary
fallback={fallback}
beforeCapture={(scope): void => {
// Add component name to context
scope.setTag(
'component',
WrappedComponent.displayName || WrappedComponent.name || 'Unknown',
);
// Add any custom tags
if (sentryOptions.tags) {
Object.entries(sentryOptions.tags).forEach(([key, value]) => {
scope.setTag(key, value);
});
}
// Set severity level if provided
if (sentryOptions.level) {
scope.setLevel(sentryOptions.level);
}
}}
onError={onError}
>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<WrappedComponent {...props} />
</Sentry.ErrorBoundary>
);
}
// Set display name for debugging purposes
WithErrorBoundaryComponent.displayName = `withErrorBoundary(${
WrappedComponent.displayName || WrappedComponent.name || 'Component'
})`;
return WithErrorBoundaryComponent;
}
export default withErrorBoundary;
export type { WithErrorBoundaryOptions };

View File

@ -86,4 +86,7 @@ export const REACT_QUERY_KEY = {
SPAN_LOGS: 'SPAN_LOGS',
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
// Routing Policies Query Keys
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',
} as const;

View File

@ -482,7 +482,7 @@ function AllErrors(): JSX.Element {
pagination={{
pageSize: getUpdatedPageSize,
responsive: true,
current: getUpdatedOffset / 10 + 1,
current: Math.floor(getUpdatedOffset / getUpdatedPageSize) + 1,
position: ['bottomLeft'],
total: errorCountResponse.data?.payload || 0,
}}

View File

@ -1,6 +1,6 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
@ -137,4 +137,70 @@ describe('Exceptions - All Errors', () => {
}),
);
});
describe('pagination edge cases', () => {
it('should navigate to page 2 when pageSize=100 and clicking next', async () => {
// Arrange: start with pageSize=100 and offset=0
render(
<Exceptions
initUrl={[
`/exceptions?pageSize=100&offset=0&order=ascending&orderParam=serviceName`,
]}
/>,
);
// Wait for initial load
await screen.findByText(/redis timeout/i);
const nextPageItem = screen.getByTitle('Next Page');
const nextPageButton = nextPageItem.querySelector(
'button',
) as HTMLButtonElement;
fireEvent.click(nextPageButton);
await waitFor(() => {
const qp = new URLSearchParams(window.location.search);
expect(qp.get('offset')).toBe('100');
});
const queryParams = new URLSearchParams(window.location.search);
expect(queryParams.get('pageSize')).toBe('100');
expect(queryParams.get('offset')).toBe('100');
});
it('initializes current page from URL (offset/pageSize)', async () => {
// offset=100, pageSize=100 => current page should be 2
render(
<Exceptions
initUrl={[
`/exceptions?pageSize=100&offset=100&order=ascending&orderParam=serviceName`,
]}
/>,
);
await screen.findByText(/redis timeout/i);
const activeItem = document.querySelector('.ant-pagination-item-active');
expect(activeItem?.textContent).toBe('2');
const qp = new URLSearchParams(window.location.search);
expect(qp.get('pageSize')).toBe('100');
expect(qp.get('offset')).toBe('100');
});
it('clicking a numbered page updates offset correctly', async () => {
// pageSize=100, click page 3 => offset = 200
render(
<Exceptions
initUrl={[
`/exceptions?pageSize=100&offset=0&order=ascending&orderParam=serviceName`,
]}
/>,
);
await screen.findByText(/redis timeout/i);
const page3Item = screen.getByTitle('3');
const page3Anchor = page3Item.querySelector('a') as HTMLAnchorElement;
fireEvent.click(page3Anchor);
await waitFor(() => {
const qp = new URLSearchParams(window.location.search);
expect(qp.get('offset')).toBe('200');
});
});
});
});

View File

@ -1,6 +1,7 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Switch, Table, Tooltip, Typography } from 'antd';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
@ -248,4 +249,4 @@ function TopErrors({
);
}
export default TopErrors;
export default withErrorBoundary(TopErrors);

View File

@ -1,4 +1,3 @@
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import ROUTES from 'constants/routes';
import * as useGetMetricsListHooks from 'hooks/metricsExplorer/useGetMetricsList';
@ -7,6 +6,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import store from 'store';
import { render, screen } from 'tests/test-utils';
import Summary from '../Summary';
import { TreemapViewType } from '../types';

View File

@ -0,0 +1,47 @@
import { Button, Modal, Typography } from 'antd';
import { Trash2, X } from 'lucide-react';
import { DeleteRoutingPolicyProps } from './types';
function DeleteRoutingPolicy({
handleClose,
handleDelete,
routingPolicy,
isDeletingRoutingPolicy,
}: DeleteRoutingPolicyProps): JSX.Element {
return (
<Modal
className="delete-policy-modal"
title={<span className="title">Delete Routing Policy</span>}
open
closable={false}
onCancel={handleClose}
footer={[
<Button
key="cancel"
onClick={handleClose}
className="cancel-btn"
icon={<X size={16} />}
disabled={isDeletingRoutingPolicy}
>
Cancel
</Button>,
<Button
key="submit"
icon={<Trash2 size={16} />}
onClick={handleDelete}
className="delete-btn"
disabled={isDeletingRoutingPolicy}
>
Delete Routing Policy
</Button>,
]}
>
<Typography.Text className="delete-text">
{`Are you sure you want to delete ${routingPolicy?.name} routing policy? Deleting a routing policy is irreversible and cannot be undone.`}
</Typography.Text>
</Modal>
);
}
export default DeleteRoutingPolicy;

View File

@ -0,0 +1,118 @@
import './styles.scss';
import { PlusOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Flex, Input, Tooltip, Typography } from 'antd';
import { Search } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { ChangeEvent, useMemo } from 'react';
import { USER_ROLES } from 'types/roles';
import DeleteRoutingPolicy from './DeleteRoutingPolicy';
import RoutingPolicyDetails from './RoutingPolicyDetails';
import RoutingPolicyList from './RoutingPolicyList';
import useRoutingPolicies from './useRoutingPolicies';
function RoutingPolicies(): JSX.Element {
const { user } = useAppContext();
const {
// Routing Policies
selectedRoutingPolicy,
routingPoliciesData,
isLoadingRoutingPolicies,
isErrorRoutingPolicies,
// Channels
channels,
isLoadingChannels,
isErrorChannels,
refreshChannels,
// Search
searchTerm,
setSearchTerm,
// Delete Modal
isDeleteModalOpen,
handleDeleteModalOpen,
handleDeleteModalClose,
handleDeleteRoutingPolicy,
isDeletingRoutingPolicy,
// Policy Details Modal
policyDetailsModalState,
handlePolicyDetailsModalClose,
handlePolicyDetailsModalOpen,
handlePolicyDetailsModalAction,
isPolicyDetailsModalActionLoading,
} = useRoutingPolicies();
const disableCreateButton = user?.role === USER_ROLES.VIEWER;
const tooltipTitle = useMemo(() => {
if (user?.role === USER_ROLES.VIEWER) {
return 'You need edit permissions to create a routing policy';
}
return '';
}, [user?.role]);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchTerm(e.target.value || '');
};
return (
<div className="routing-policies-container">
<div className="routing-policies-content">
<Typography.Title className="title">Routing Policies</Typography.Title>
<Typography.Text className="subtitle">
Create and manage routing policies.
</Typography.Text>
<Flex className="toolbar">
<Input
placeholder="Search for a routing policy..."
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
value={searchTerm}
onChange={handleSearch}
/>
<Tooltip title={tooltipTitle}>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={(): void => handlePolicyDetailsModalOpen('create', null)}
disabled={disableCreateButton}
>
New routing policy
</Button>
</Tooltip>
</Flex>
<br />
<RoutingPolicyList
routingPolicies={routingPoliciesData}
isRoutingPoliciesLoading={isLoadingRoutingPolicies}
isRoutingPoliciesError={isErrorRoutingPolicies}
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
handleDeleteModalOpen={handleDeleteModalOpen}
/>
{policyDetailsModalState.isOpen && (
<RoutingPolicyDetails
routingPolicy={selectedRoutingPolicy}
closeModal={handlePolicyDetailsModalClose}
mode={policyDetailsModalState.mode}
channels={channels}
isErrorChannels={isErrorChannels}
isLoadingChannels={isLoadingChannels}
handlePolicyDetailsModalAction={handlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={isPolicyDetailsModalActionLoading}
refreshChannels={refreshChannels}
/>
)}
{isDeleteModalOpen && (
<DeleteRoutingPolicy
isDeletingRoutingPolicy={isDeletingRoutingPolicy}
handleDelete={handleDeleteRoutingPolicy}
handleClose={handleDeleteModalClose}
routingPolicy={selectedRoutingPolicy}
/>
)}
</div>
</div>
);
}
export default RoutingPolicies;

View File

@ -0,0 +1,208 @@
import {
Button,
Divider,
Flex,
Form,
Input,
Modal,
Select,
Typography,
} from 'antd';
import { useForm } from 'antd/lib/form/Form';
import ROUTES from 'constants/routes';
import { ModalTitle } from 'container/PipelinePage/PipelineListsView/styles';
import { useAppContext } from 'providers/App/App';
import { useMemo } from 'react';
import { USER_ROLES } from 'types/roles';
import { INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE } from './constants';
import {
RoutingPolicyDetailsFormState,
RoutingPolicyDetailsProps,
} from './types';
function RoutingPolicyDetails({
closeModal,
mode,
channels,
isErrorChannels,
isLoadingChannels,
routingPolicy,
handlePolicyDetailsModalAction,
isPolicyDetailsModalActionLoading,
refreshChannels,
}: RoutingPolicyDetailsProps): JSX.Element {
const [form] = useForm();
const { user } = useAppContext();
const initialFormState = useMemo(() => {
if (mode === 'edit') {
return {
name: routingPolicy?.name || '',
expression: routingPolicy?.expression || '',
channels: routingPolicy?.channels || [],
description: routingPolicy?.description || '',
};
}
return INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE;
}, [routingPolicy, mode]);
const modalTitle =
mode === 'edit' ? 'Edit routing policy' : 'Create routing policy';
const handleSave = (): void => {
handlePolicyDetailsModalAction(mode, {
name: form.getFieldValue('name'),
expression: form.getFieldValue('expression'),
channels: form.getFieldValue('channels'),
description: form.getFieldValue('description'),
});
};
const notificationChannelsNotFoundContent = (
<Flex justify="space-between">
<Flex gap={4} align="center">
<Typography.Text>No channels yet.</Typography.Text>
{user?.role === USER_ROLES.ADMIN ? (
<Typography.Text>
Create one
<Button
style={{ padding: '0 4px' }}
type="link"
onClick={(): void => {
window.open(ROUTES.CHANNELS_NEW, '_blank');
}}
>
here.
</Button>
</Typography.Text>
) : (
<Typography.Text>Please ask your admin to create one.</Typography.Text>
)}
</Flex>
<Button type="text" onClick={refreshChannels}>
Refresh
</Button>
</Flex>
);
return (
<Modal
title={<ModalTitle level={4}>{modalTitle}</ModalTitle>}
centered
open
className="create-policy-modal"
width={600}
onCancel={closeModal}
footer={null}
maskClosable={false}
>
<Divider plain />
<Form<RoutingPolicyDetailsFormState>
form={form}
initialValues={initialFormState}
onFinish={handleSave}
>
<div className="create-policy-container">
<div className="input-group">
<Typography.Text>Routing Policy Name</Typography.Text>
<Form.Item
name="name"
rules={[
{
required: true,
message: 'Please provide a name for the routing policy',
},
]}
>
<Input placeholder="e.g. Base routing policy..." />
</Form.Item>
</div>
<div className="input-group">
<Typography.Text>Description</Typography.Text>
<Form.Item
name="description"
rules={[
{
required: false,
},
]}
>
<Input.TextArea
placeholder="e.g. This is a routing policy that..."
autoSize={{ minRows: 1, maxRows: 6 }}
style={{ resize: 'none' }}
/>
</Form.Item>
</div>
<div className="input-group">
<Typography.Text>Expression</Typography.Text>
<Form.Item
name="expression"
rules={[
{
required: true,
message: 'Please provide an expression for the routing policy',
},
]}
>
<Input.TextArea
placeholder='e.g. service.name == "payment" && threshold.name == "critical"'
autoSize={{ minRows: 1, maxRows: 6 }}
style={{ resize: 'none' }}
/>
</Form.Item>
</div>
<div className="input-group">
<Typography.Text>Notification Channels</Typography.Text>
<Form.Item
name="channels"
rules={[
{
required: true,
message: 'Please select at least one notification channel',
},
]}
>
<Select
options={channels.map((channel) => ({
value: channel.name,
label: channel.name,
}))}
mode="multiple"
placeholder="Select notification channels"
showSearch
maxTagCount={3}
maxTagPlaceholder={(omittedValues): string =>
`+${omittedValues.length} more`
}
maxTagTextLength={10}
filterOption={(input, option): boolean =>
option?.label?.toLowerCase().includes(input.toLowerCase()) || false
}
status={isErrorChannels ? 'error' : undefined}
disabled={isLoadingChannels}
notFoundContent={notificationChannelsNotFoundContent}
/>
</Form.Item>
</div>
</div>
<Flex className="create-policy-footer" justify="space-between">
<Button onClick={closeModal} disabled={isPolicyDetailsModalActionLoading}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
loading={isPolicyDetailsModalActionLoading}
disabled={isPolicyDetailsModalActionLoading}
>
Save Routing Policy
</Button>
</Flex>
</Form>
</Modal>
);
}
export default RoutingPolicyDetails;

View File

@ -0,0 +1,73 @@
import { Table, TableProps, Typography } from 'antd';
import { useMemo } from 'react';
import RoutingPolicyListItem from './RoutingPolicyListItem';
import { RoutingPolicy, RoutingPolicyListProps } from './types';
function RoutingPolicyList({
routingPolicies,
isRoutingPoliciesLoading,
isRoutingPoliciesError,
handlePolicyDetailsModalOpen,
handleDeleteModalOpen,
}: RoutingPolicyListProps): JSX.Element {
const columns: TableProps<RoutingPolicy>['columns'] = [
{
title: 'Routing Policy',
key: 'routingPolicy',
render: (data: RoutingPolicy): JSX.Element => (
<RoutingPolicyListItem
routingPolicy={data}
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
handleDeleteModalOpen={handleDeleteModalOpen}
/>
),
},
];
const localeEmptyState = useMemo(
() => (
<div className="no-routing-policies-message-container">
{isRoutingPoliciesError ? (
<img src="/Icons/awwSnap.svg" alt="aww-snap" className="error-state-svg" />
) : (
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
)}
{isRoutingPoliciesError ? (
<Typography.Text>
Something went wrong while fetching routing policies.
</Typography.Text>
) : (
<Typography.Text>No routing policies found.</Typography.Text>
)}
</div>
),
[isRoutingPoliciesError],
);
return (
<Table<RoutingPolicy>
columns={columns}
className="routing-policies-table"
bordered={false}
dataSource={routingPolicies}
loading={isRoutingPoliciesLoading}
showHeader={false}
rowKey="id"
pagination={{
pageSize: 5,
showSizeChanger: false,
hideOnSinglePage: true,
}}
locale={{
emptyText: isRoutingPoliciesLoading ? null : localeEmptyState,
}}
/>
);
}
export default RoutingPolicyList;

View File

@ -0,0 +1,137 @@
import { Color } from '@signozhq/design-tokens';
import { Collapse, Flex, Tag, Typography } from 'antd';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { PenLine, Trash2 } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { USER_ROLES } from 'types/roles';
import {
PolicyListItemContentProps,
PolicyListItemHeaderProps,
RoutingPolicyListItemProps,
} from './types';
function PolicyListItemHeader({
name,
handleEdit,
handleDelete,
}: PolicyListItemHeaderProps): JSX.Element {
const { user } = useAppContext();
const isEditEnabled = user?.role !== USER_ROLES.VIEWER;
return (
<Flex className="policy-list-item-header" justify="space-between">
<Typography>{name}</Typography>
{isEditEnabled && (
<div className="action-btn">
<PenLine
size={14}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
handleEdit();
}}
data-testid="edit-routing-policy"
/>
<Trash2
size={14}
color={Color.BG_CHERRY_500}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
handleDelete();
}}
data-testid="delete-routing-policy"
/>
</div>
)}
</Flex>
);
}
function PolicyListItemContent({
routingPolicy,
}: PolicyListItemContentProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return (
<div className="policy-list-item-content">
<div className="policy-list-item-content-row">
<Typography>Created by</Typography>
<Typography>{routingPolicy.createdBy}</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Created on</Typography>
<Typography>
{routingPolicy.createdAt
? formatTimezoneAdjustedTimestamp(
routingPolicy.createdAt,
DATE_TIME_FORMATS.MONTH_DATETIME,
)
: '-'}
</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Updated by</Typography>
<Typography>{routingPolicy.updatedBy || '-'}</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Updated on</Typography>
<Typography>
{routingPolicy.updatedAt
? formatTimezoneAdjustedTimestamp(
routingPolicy.updatedAt,
DATE_TIME_FORMATS.MONTH_DATETIME,
)
: '-'}
</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Expression</Typography>
<Typography>{routingPolicy.expression}</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Description</Typography>
<Typography>{routingPolicy.description || '-'}</Typography>
</div>
<div className="policy-list-item-content-row">
<Typography>Channels</Typography>
<div>
{routingPolicy.channels.map((channel) => (
<Tag key={channel}>{channel}</Tag>
))}
</div>
</div>
</div>
);
}
function RoutingPolicyListItem({
routingPolicy,
handlePolicyDetailsModalOpen,
handleDeleteModalOpen,
}: RoutingPolicyListItemProps): JSX.Element {
return (
<Collapse accordion className="policy-list-item">
<Collapse.Panel
header={
<PolicyListItemHeader
name={routingPolicy.name}
handleEdit={(): void =>
handlePolicyDetailsModalOpen('edit', routingPolicy)
}
handleDelete={(): void => handleDeleteModalOpen(routingPolicy)}
/>
}
key={routingPolicy.id}
>
<PolicyListItemContent routingPolicy={routingPolicy} />
</Collapse.Panel>
</Collapse>
);
}
export default RoutingPolicyListItem;

View File

@ -0,0 +1,81 @@
import { fireEvent, render, screen } from '@testing-library/react';
import DeleteRoutingPolicy from '../DeleteRoutingPolicy';
import { MOCK_ROUTING_POLICY_1 } from './testUtils';
const mockRoutingPolicy = MOCK_ROUTING_POLICY_1;
const mockHandleDelete = jest.fn();
const mockHandleClose = jest.fn();
const DELETE_BUTTON_TEXT = 'Delete Routing Policy';
const CANCEL_BUTTON_TEXT = 'Cancel';
describe('DeleteRoutingPolicy', () => {
it('renders base layout with routing policy', () => {
render(
<DeleteRoutingPolicy
routingPolicy={mockRoutingPolicy}
isDeletingRoutingPolicy={false}
handleDelete={mockHandleDelete}
handleClose={mockHandleClose}
/>,
);
expect(
screen.getByRole('dialog', { name: DELETE_BUTTON_TEXT }),
).toBeInTheDocument();
expect(
screen.getByText(
`Are you sure you want to delete ${mockRoutingPolicy.name} routing policy? Deleting a routing policy is irreversible and cannot be undone.`,
),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: CANCEL_BUTTON_TEXT }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: DELETE_BUTTON_TEXT }),
).toBeInTheDocument();
});
it('should call handleDelete when delete button is clicked', () => {
render(
<DeleteRoutingPolicy
routingPolicy={mockRoutingPolicy}
isDeletingRoutingPolicy={false}
handleDelete={mockHandleDelete}
handleClose={mockHandleClose}
/>,
);
fireEvent.click(screen.getByRole('button', { name: DELETE_BUTTON_TEXT }));
expect(mockHandleDelete).toHaveBeenCalled();
});
it('should call handleClose when cancel button is clicked', () => {
render(
<DeleteRoutingPolicy
routingPolicy={mockRoutingPolicy}
isDeletingRoutingPolicy={false}
handleDelete={mockHandleDelete}
handleClose={mockHandleClose}
/>,
);
fireEvent.click(screen.getByRole('button', { name: CANCEL_BUTTON_TEXT }));
expect(mockHandleClose).toHaveBeenCalled();
});
it('should be disabled when deleting routing policy', () => {
render(
<DeleteRoutingPolicy
routingPolicy={mockRoutingPolicy}
isDeletingRoutingPolicy
handleDelete={mockHandleDelete}
handleClose={mockHandleClose}
/>,
);
expect(
screen.getByRole('button', { name: DELETE_BUTTON_TEXT }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: CANCEL_BUTTON_TEXT }),
).toBeDisabled();
});
});

View File

@ -0,0 +1,126 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as appHooks from 'providers/App/App';
import RoutingPolicies from '../RoutingPolicies';
import * as routingPoliciesHooks from '../useRoutingPolicies';
import {
getAppContextMockState,
getUseRoutingPoliciesMockData,
MOCK_ROUTING_POLICY_1,
} from './testUtils';
const ROUTING_POLICY_DETAILS_TEST_ID = 'routing-policy-details';
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
jest.mock('../RoutingPolicyList', () => ({
__esModule: true,
default: jest.fn(() => (
<div data-testid="routing-policy-list">RoutingPolicyList</div>
)),
}));
jest.mock('../RoutingPolicyDetails', () => ({
__esModule: true,
default: jest.fn(() => (
<div data-testid="routing-policy-details">RoutingPolicyDetails</div>
)),
}));
jest.mock('../DeleteRoutingPolicy', () => ({
__esModule: true,
default: jest.fn(() => (
<div data-testid="delete-routing-policy">DeleteRoutingPolicy</div>
)),
}));
const mockHandleSearch = jest.fn();
const mockHandlePolicyDetailsModalOpen = jest.fn();
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
getUseRoutingPoliciesMockData({
setSearchTerm: mockHandleSearch,
handlePolicyDetailsModalOpen: mockHandlePolicyDetailsModalOpen,
}),
);
describe('RoutingPolicies', () => {
it('should render components properly', () => {
render(<RoutingPolicies />);
expect(screen.getByText('Routing Policies')).toBeInTheDocument();
expect(
screen.getByText('Create and manage routing policies.'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search for a routing policy...'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /New routing policy/ }),
).toBeInTheDocument();
expect(screen.getByTestId('routing-policy-list')).toBeInTheDocument();
expect(
screen.queryByTestId(ROUTING_POLICY_DETAILS_TEST_ID),
).not.toBeInTheDocument();
expect(screen.queryByTestId('delete-routing-policy')).not.toBeInTheDocument();
});
it('should enable the "New routing policy" button for users with ADMIN role', () => {
render(<RoutingPolicies />);
expect(
screen.getByRole('button', { name: /New routing policy/ }),
).toBeEnabled();
});
it('should disable the "New routing policy" button for users with VIEWER role', () => {
jest
.spyOn(appHooks, 'useAppContext')
.mockReturnValueOnce(getAppContextMockState({ role: 'VIEWER' }));
render(<RoutingPolicies />);
expect(
screen.getByRole('button', { name: /New routing policy/ }),
).toBeDisabled();
});
it('filters routing policies by search term', () => {
render(<RoutingPolicies />);
const searchInput = screen.getByPlaceholderText(
'Search for a routing policy...',
);
fireEvent.change(searchInput, {
target: { value: MOCK_ROUTING_POLICY_1.name },
});
expect(mockHandleSearch).toHaveBeenCalledWith(MOCK_ROUTING_POLICY_1.name);
});
it('clicking on the "New routing policy" button opens the policy details modal', () => {
render(<RoutingPolicies />);
const newRoutingPolicyButton = screen.getByRole('button', {
name: /New routing policy/,
});
fireEvent.click(newRoutingPolicyButton);
expect(mockHandlePolicyDetailsModalOpen).toHaveBeenCalledWith('create', null);
});
it('policy details modal is open based on modal state', () => {
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
getUseRoutingPoliciesMockData({
policyDetailsModalState: {
mode: 'create',
isOpen: true,
},
}),
);
render(<RoutingPolicies />);
expect(
screen.getByTestId(ROUTING_POLICY_DETAILS_TEST_ID),
).toBeInTheDocument();
});
it('delete modal is open based on modal state', () => {
jest.spyOn(routingPoliciesHooks, 'default').mockReturnValue(
getUseRoutingPoliciesMockData({
isDeleteModalOpen: true,
}),
);
render(<RoutingPolicies />);
expect(screen.getByTestId('delete-routing-policy')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react';
import RoutingPoliciesList from '../RoutingPolicyList';
import { RoutingPolicyListItemProps } from '../types';
import { getUseRoutingPoliciesMockData } from './testUtils';
const useRoutingPolicesMockData = getUseRoutingPoliciesMockData();
const mockHandlePolicyDetailsModalOpen = jest.fn();
const mockHandleDeleteModalOpen = jest.fn();
jest.mock('../RoutingPolicyListItem', () => ({
__esModule: true,
default: jest.fn(({ routingPolicy }: RoutingPolicyListItemProps) => (
<div data-testid="routing-policy-list-item">{routingPolicy.name}</div>
)),
}));
const ROUTING_POLICY_LIST_ITEM_TEST_ID = 'routing-policy-list-item';
describe('RoutingPoliciesList', () => {
it('renders base layout with routing policies', () => {
render(
<RoutingPoliciesList
routingPolicies={useRoutingPolicesMockData.routingPoliciesData}
isRoutingPoliciesLoading={
useRoutingPolicesMockData.isLoadingRoutingPolicies
}
isRoutingPoliciesError={useRoutingPolicesMockData.isErrorRoutingPolicies}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
const routingPolicyItems = screen.getAllByTestId(
ROUTING_POLICY_LIST_ITEM_TEST_ID,
);
expect(routingPolicyItems).toHaveLength(2);
expect(routingPolicyItems[0]).toHaveTextContent(
useRoutingPolicesMockData.routingPoliciesData[0].name,
);
expect(routingPolicyItems[1]).toHaveTextContent(
useRoutingPolicesMockData.routingPoliciesData[1].name,
);
});
it('renders loading state', () => {
render(
<RoutingPoliciesList
routingPolicies={useRoutingPolicesMockData.routingPoliciesData}
isRoutingPoliciesLoading
isRoutingPoliciesError={false}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
// Check for loading spinner by class name
expect(document.querySelector('.ant-spin-spinning')).toBeInTheDocument();
// Check that the table is in loading state (blurred)
expect(document.querySelector('.ant-spin-blur')).toBeInTheDocument();
});
it('renders error state', () => {
render(
<RoutingPoliciesList
routingPolicies={[]}
isRoutingPoliciesLoading={false}
isRoutingPoliciesError
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
expect(
screen.getByText('Something went wrong while fetching routing policies.'),
).toBeInTheDocument();
});
it('renders empty state', () => {
render(
<RoutingPoliciesList
routingPolicies={[]}
isRoutingPoliciesLoading={false}
isRoutingPoliciesError={false}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
expect(screen.getByText('No routing policies found.')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,423 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import * as appHooks from 'providers/App/App';
import RoutingPolicyDetails from '../RoutingPolicyDetails';
import {
getAppContextMockState,
MOCK_CHANNEL_1,
MOCK_CHANNEL_2,
MOCK_ROUTING_POLICY_1,
} from './testUtils';
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
const mockHandlePolicyDetailsModalAction = jest.fn();
const mockCloseModal = jest.fn();
const mockChannels = [MOCK_CHANNEL_1, MOCK_CHANNEL_2];
const mockRoutingPolicy = MOCK_ROUTING_POLICY_1;
const mockRefreshChannels = jest.fn();
const NEW_NAME = 'New Name';
const NEW_EXPRESSION = 'New Expression';
const NEW_DESCRIPTION = 'New Description';
const SAVE_BUTTON_TEXT = 'Save Routing Policy';
const NO_CHANNELS_FOUND_TEXT = 'No channels yet.';
describe('RoutingPolicyDetails', () => {
it('renders base create layout with header, 3 inputs and footer', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Create routing policy')).toBeInTheDocument();
expect(screen.getByText('Routing Policy Name')).toBeInTheDocument();
expect(screen.getByText('Expression')).toBeInTheDocument();
expect(screen.getByText('Notification Channels')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: SAVE_BUTTON_TEXT }),
).toBeInTheDocument();
});
it('renders base edit layout with header, 3 inputs and footer', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="edit"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Edit routing policy')).toBeInTheDocument();
expect(screen.getByText('Routing Policy Name')).toBeInTheDocument();
expect(screen.getByText('Expression')).toBeInTheDocument();
expect(screen.getByText('Notification Channels')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: SAVE_BUTTON_TEXT }),
).toBeInTheDocument();
});
it('prefills inputs with existing policy values in edit mode', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="edit"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const nameInput = screen.getByDisplayValue(mockRoutingPolicy.name);
expect(nameInput).toBeInTheDocument();
const expressionTextarea = screen.getByDisplayValue(
mockRoutingPolicy.expression,
);
expect(expressionTextarea).toBeInTheDocument();
expect(screen.getByText(MOCK_CHANNEL_1.name)).toBeInTheDocument();
});
it('creating and saving the routing policy works correctly', async () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const nameInput = screen.getByPlaceholderText('e.g. Base routing policy...');
expect(nameInput).toBeInTheDocument();
const expressionTextarea = screen.getByPlaceholderText(
'e.g. service.name == "payment" && threshold.name == "critical"',
);
expect(expressionTextarea).toBeInTheDocument();
const descriptionTextarea = screen.getByPlaceholderText(
'e.g. This is a routing policy that...',
);
expect(descriptionTextarea).toBeInTheDocument();
fireEvent.change(nameInput, { target: { value: NEW_NAME } });
fireEvent.change(expressionTextarea, { target: { value: NEW_EXPRESSION } });
fireEvent.change(descriptionTextarea, { target: { value: NEW_DESCRIPTION } });
const channelSelect = screen.getByRole('combobox');
fireEvent.mouseDown(channelSelect);
const channelOptions = await screen.findAllByText('Channel 1');
fireEvent.click(channelOptions[1]);
// Wait for the form to be valid before submitting
await waitFor(() => {
expect(screen.getByDisplayValue(NEW_NAME)).toBeInTheDocument();
});
const saveButton = screen.getByRole('button', {
name: 'Save Routing Policy',
});
fireEvent.click(saveButton);
// Wait for the form submission to complete
await waitFor(() => {
expect(mockHandlePolicyDetailsModalAction).toHaveBeenCalled();
});
expect(mockHandlePolicyDetailsModalAction).toHaveBeenCalledWith('create', {
name: NEW_NAME,
expression: NEW_EXPRESSION,
description: NEW_DESCRIPTION,
channels: ['Channel 1'],
});
});
it('editing and saving the routing policy works correctly', async () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="edit"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const nameInput = screen.getByDisplayValue(mockRoutingPolicy.name);
expect(nameInput).toBeInTheDocument();
const expressionTextarea = screen.getByDisplayValue(
mockRoutingPolicy.expression,
);
expect(expressionTextarea).toBeInTheDocument();
const descriptionTextarea = screen.getByDisplayValue(
mockRoutingPolicy.description || 'description 1',
);
expect(descriptionTextarea).toBeInTheDocument();
fireEvent.change(nameInput, { target: { value: NEW_NAME } });
fireEvent.change(expressionTextarea, { target: { value: NEW_EXPRESSION } });
fireEvent.change(descriptionTextarea, { target: { value: NEW_DESCRIPTION } });
// Wait for the form to be valid before submitting
await waitFor(() => {
expect(screen.getByDisplayValue(NEW_NAME)).toBeInTheDocument();
});
const saveButton = screen.getByRole('button', {
name: SAVE_BUTTON_TEXT,
});
fireEvent.click(saveButton);
// Wait for the form submission to complete
await waitFor(() => {
expect(mockHandlePolicyDetailsModalAction).toHaveBeenCalled();
});
expect(mockHandlePolicyDetailsModalAction).toHaveBeenCalledWith('edit', {
name: NEW_NAME,
expression: NEW_EXPRESSION,
description: NEW_DESCRIPTION,
channels: ['Channel 1'],
});
});
it('should close modal when cancel button is clicked', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="edit"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelButton);
expect(mockCloseModal).toHaveBeenCalled();
});
it('buttons should be disabled when loading', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="edit"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
expect(cancelButton).toBeDisabled();
const saveButton = screen.getByRole('button', {
name: new RegExp(SAVE_BUTTON_TEXT, 'i'),
});
expect(saveButton).toBeDisabled();
});
it('submit should not be called when inputs are invalid', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={mockChannels}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const saveButton = screen.getByRole('button', {
name: SAVE_BUTTON_TEXT,
});
fireEvent.click(saveButton);
expect(mockHandlePolicyDetailsModalAction).not.toHaveBeenCalled();
});
it('notification channels select should be disabled when channels are loading', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
expect(channelSelect).toBeDisabled();
});
it('should show error state when channels fail to load', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
const selectContainer = channelSelect.closest('.ant-select');
expect(selectContainer).toHaveClass('ant-select-status-error');
});
it('should show empty state when no channels are available', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
fireEvent.mouseDown(channelSelect);
expect(screen.getByText(NO_CHANNELS_FOUND_TEXT)).toBeInTheDocument();
});
it('should show create channel button for admin users in empty state', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
fireEvent.mouseDown(channelSelect);
expect(screen.getByText(NO_CHANNELS_FOUND_TEXT)).toBeInTheDocument();
expect(screen.getByText('Create one')).toBeInTheDocument();
});
it('should show admin message for non-admin users in empty state', () => {
jest
.spyOn(appHooks, 'useAppContext')
.mockReturnValue(getAppContextMockState({ role: 'VIEWER' }));
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
fireEvent.mouseDown(channelSelect);
expect(screen.getByText(NO_CHANNELS_FOUND_TEXT)).toBeInTheDocument();
expect(
screen.getByText('Please ask your admin to create one.'),
).toBeInTheDocument();
expect(screen.queryByText('Create one')).not.toBeInTheDocument();
});
it('should call refreshChannels when refresh button is clicked in empty state', () => {
render(
<RoutingPolicyDetails
routingPolicy={mockRoutingPolicy}
closeModal={mockCloseModal}
mode="create"
channels={[]}
handlePolicyDetailsModalAction={mockHandlePolicyDetailsModalAction}
isPolicyDetailsModalActionLoading={false}
isErrorChannels={false}
isLoadingChannels={false}
refreshChannels={mockRefreshChannels}
/>,
);
const channelSelect = screen.getByRole('combobox');
fireEvent.mouseDown(channelSelect);
const refreshButton = screen.getByText('Refresh');
expect(refreshButton).toBeInTheDocument();
fireEvent.click(refreshButton);
expect(mockRefreshChannels).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,126 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as appHooks from 'providers/App/App';
import { ROLES, USER_ROLES } from 'types/roles';
import RoutingPolicyListItem from '../RoutingPolicyListItem';
import { getAppContextMockState, MOCK_ROUTING_POLICY_1 } from './testUtils';
const mockFormatTimezoneAdjustedTimestamp = jest.fn();
jest.mock('providers/Timezone', () => ({
useTimezone: (): any => ({
formatTimezoneAdjustedTimestamp: mockFormatTimezoneAdjustedTimestamp,
}),
}));
jest.spyOn(appHooks, 'useAppContext').mockReturnValue(getAppContextMockState());
const mockRoutingPolicy = MOCK_ROUTING_POLICY_1;
const mockHandlePolicyDetailsModalOpen = jest.fn();
const mockHandleDeleteModalOpen = jest.fn();
const EDIT_ROUTING_POLICY_TEST_ID = 'edit-routing-policy';
const DELETE_ROUTING_POLICY_TEST_ID = 'delete-routing-policy';
describe('RoutingPolicyListItem', () => {
it('should render properly in collapsed state', () => {
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
expect(screen.getByText(mockRoutingPolicy.name)).toBeInTheDocument();
expect(screen.getByTestId(EDIT_ROUTING_POLICY_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(DELETE_ROUTING_POLICY_TEST_ID)).toBeInTheDocument();
});
it('should render properly in expanded state', () => {
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
expect(screen.getByText(mockRoutingPolicy.name)).toBeInTheDocument();
fireEvent.click(screen.getByText(mockRoutingPolicy.name));
expect(screen.getByText(mockRoutingPolicy.expression)).toBeInTheDocument();
expect(screen.getByText(mockRoutingPolicy.channels[0])).toBeInTheDocument();
expect(
screen.getByText(mockRoutingPolicy.createdBy || 'user1@signoz.io'),
).toBeInTheDocument();
expect(
screen.getByText(mockRoutingPolicy.description || 'description 1'),
).toBeInTheDocument();
});
it('should call handlePolicyDetailsModalOpen when edit button is clicked', () => {
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
fireEvent.click(screen.getByTestId(EDIT_ROUTING_POLICY_TEST_ID));
expect(mockHandlePolicyDetailsModalOpen).toHaveBeenCalledWith(
'edit',
mockRoutingPolicy,
);
});
it('should call handleDeleteModalOpen when delete button is clicked', () => {
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
fireEvent.click(screen.getByTestId(DELETE_ROUTING_POLICY_TEST_ID));
expect(mockHandleDeleteModalOpen).toHaveBeenCalledWith(mockRoutingPolicy);
});
it('edit and delete buttons should not be rendered for viewer role', () => {
jest
.spyOn(appHooks, 'useAppContext')
.mockReturnValue(
getAppContextMockState({ role: USER_ROLES.VIEWER as ROLES }),
);
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
expect(
screen.queryByTestId(EDIT_ROUTING_POLICY_TEST_ID),
).not.toBeInTheDocument();
expect(
screen.queryByTestId(DELETE_ROUTING_POLICY_TEST_ID),
).not.toBeInTheDocument();
});
it('in details panel, show "-" for undefined values', () => {
render(
<RoutingPolicyListItem
routingPolicy={mockRoutingPolicy}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
/>,
);
// Expand the details panel
fireEvent.click(screen.getByText(mockRoutingPolicy.name));
const updatedByRow = screen.getByText('Updated by').parentElement;
expect(updatedByRow).toHaveTextContent('-');
const updatedOnRow = screen.getByText('Updated on').parentElement;
expect(updatedOnRow).toHaveTextContent('-');
});
});

View File

@ -0,0 +1,121 @@
import { IAppContext, IUser } from 'providers/App/types';
import { Channels } from 'types/api/channels/getAll';
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
export const MOCK_ROUTING_POLICY_1: RoutingPolicy = {
id: '1',
name: 'Routing Policy 1',
expression: 'expression 1',
description: 'description 1',
channels: ['Channel 1'],
createdAt: '2021-01-04',
updatedAt: undefined,
createdBy: 'user1@signoz.io',
updatedBy: undefined,
};
export const MOCK_ROUTING_POLICY_2: RoutingPolicy = {
id: '2',
name: 'Routing Policy 2',
expression: 'expression 2',
description: 'description 2',
channels: ['Channel 2'],
createdAt: '2021-01-05',
updatedAt: '2021-01-05',
createdBy: 'user2@signoz.io',
updatedBy: 'user2@signoz.io',
};
export const MOCK_CHANNEL_1: Channels = {
name: 'Channel 1',
created_at: '2021-01-01',
data: 'data 1',
id: '1',
type: 'type 1',
updated_at: '2021-01-01',
};
export const MOCK_CHANNEL_2: Channels = {
name: 'Channel 2',
created_at: '2021-01-02',
data: 'data 2',
id: '2',
type: 'type 2',
updated_at: '2021-01-02',
};
export function getUseRoutingPoliciesMockData(
overrides?: Partial<UseRoutingPoliciesReturn>,
): UseRoutingPoliciesReturn {
return {
selectedRoutingPolicy: MOCK_ROUTING_POLICY_1,
routingPoliciesData: [MOCK_ROUTING_POLICY_1, MOCK_ROUTING_POLICY_2],
isLoadingRoutingPolicies: false,
isErrorRoutingPolicies: false,
channels: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
isLoadingChannels: false,
searchTerm: '',
setSearchTerm: jest.fn(),
isDeleteModalOpen: false,
handleDeleteModalOpen: jest.fn(),
handleDeleteModalClose: jest.fn(),
handleDeleteRoutingPolicy: jest.fn(),
isDeletingRoutingPolicy: false,
policyDetailsModalState: {
mode: null,
isOpen: false,
},
handlePolicyDetailsModalClose: jest.fn(),
handlePolicyDetailsModalOpen: jest.fn(),
handlePolicyDetailsModalAction: jest.fn(),
isPolicyDetailsModalActionLoading: false,
isErrorChannels: false,
refreshChannels: jest.fn(),
...overrides,
};
}
export function getAppContextMockState(
overrides?: Partial<IUser>,
): IAppContext {
return {
user: {
accessJwt: 'some-token',
refreshJwt: 'some-refresh-token',
id: 'some-user-id',
email: 'user@signoz.io',
displayName: 'John Doe',
createdAt: 1732544623,
organization: 'Nightswatch',
orgId: 'does-not-matter-id',
role: 'ADMIN',
...overrides,
},
activeLicense: null,
trialInfo: null,
featureFlags: null,
orgPreferences: null,
userPreferences: null,
isLoggedIn: false,
org: null,
isFetchingUser: false,
isFetchingActiveLicense: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
userFetchError: undefined,
activeLicenseFetchError: null,
featureFlagsFetchError: undefined,
orgPreferencesFetchError: undefined,
changelog: null,
showChangelogModal: false,
activeLicenseRefetch: jest.fn(),
updateUser: jest.fn(),
updateOrgPreferences: jest.fn(),
updateUserPreferenceInContext: jest.fn(),
updateOrg: jest.fn(),
updateChangelog: jest.fn(),
toggleChangelogModal: jest.fn(),
versionData: null,
hasEditPermission: false,
};
}

View File

@ -0,0 +1,8 @@
import { RoutingPolicyDetailsFormState } from './types';
export const INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE: RoutingPolicyDetailsFormState = {
name: '',
expression: '',
channels: [],
description: '',
};

View File

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

View File

@ -0,0 +1,452 @@
.routing-policies-container {
display: flex;
justify-content: center;
width: 100%;
margin-top: 8px;
.routing-policies-content {
width: calc(100% - 30px);
max-width: 736px;
.title {
color: var(--bg-vanilla-100);
font-size: var(--font-size-lg);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 28px;
letter-spacing: -0.09px;
}
.subtitle {
color: var(--bg-vanilla-400);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
}
.ant-input-affix-wrapper {
margin-top: 16px;
margin-bottom: 8px;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
.ant-btn {
margin-top: 8px;
}
}
.routing-policies-table {
.no-routing-policies-message-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
min-height: 200px;
.empty-state-svg {
width: 40px;
height: 40px;
}
.ant-typography {
color: var(--bg-vanilla-400);
}
}
}
}
}
.routing-policies-table {
.ant-table {
background: none !important;
}
.ant-table-cell {
padding: 0 !important;
border: 0 !important;
width: 736px;
}
.ant-table-tbody {
display: flex;
flex-direction: column;
gap: 16px;
.policy-list-item {
border: 1px solid var(--bg-slate-500);
background-color: var(--bg-ink-400);
border-radius: 6px;
.ant-collapse-header {
height: 56px;
align-items: center;
}
.policy-list-item-header {
.ant-typography {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 18px;
}
.action-btn {
display: flex;
align-items: center;
gap: 20px;
cursor: pointer;
}
}
.policy-list-item-content {
.policy-list-item-content-row {
display: grid;
grid-template-columns: 128px 1fr;
margin-bottom: 13px;
.ant-typography:first-child {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.ant-typography:last-child,
div .ant-typography {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 18px;
}
}
}
.ant-collapse-content-box {
padding: 12px 20px 12px 38px;
}
.ant-collapse-content-active {
border-top: none;
}
.ant-collapse-item {
border: none;
}
}
}
}
.create-policy-modal {
.ant-modal-content {
padding: 16px;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
background: var(--bg-ink-400);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
.ant-modal-header {
background-color: var(--bg-ink-400);
.ant-typography {
margin: 0;
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
}
.ant-modal-body {
.ant-divider {
margin: 16px 0;
border: 0.5px solid var(--bg-slate-500);
}
}
}
.create-policy-container {
display: flex;
flex-direction: column;
margin-bottom: 32px;
.input-group {
display: flex;
flex-direction: column;
gap: 4px;
.ant-typography {
color: var(--bg-vanilla-500);
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
padding-bottom: 6px;
}
.ant-input {
width: 100%;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
}
.ant-select {
width: 100%;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
}
&.ant-select-focused .ant-select-selector {
border-color: var(--bg-slate-400) !important;
box-shadow: none !important;
}
&:hover .ant-select-selector {
border-color: var(--bg-slate-400) !important;
}
}
}
}
.create-policy-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
.ant-btn {
border-radius: 2px;
}
}
}
.delete-policy-modal {
width: calc(100% - 30px) !important; /* Adjust the 20px as needed */
max-width: 384px;
.ant-modal-content {
padding: 0;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
background: var(--bg-ink-400);
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
.ant-modal-header {
padding: 16px;
background: var(--bg-ink-400);
}
.ant-modal-body {
padding: 0px 16px 28px 16px;
.ant-typography {
color: var(--bg-vanilla-400);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
}
.save-view-input {
margin-top: 8px;
display: flex;
gap: 8px;
}
.ant-color-picker-trigger {
padding: 6px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
width: 32px;
height: 32px;
.ant-color-picker-color-block {
border-radius: 50px;
width: 16px;
height: 16px;
flex-shrink: 0;
.ant-color-picker-color-block-inner {
display: flex;
justify-content: center;
align-items: center;
}
}
}
}
.ant-modal-footer {
display: flex;
justify-content: flex-end;
padding: 16px 16px;
margin: 0;
.cancel-btn {
display: flex;
align-items: center;
border: none;
border-radius: 2px;
background: var(--bg-slate-500);
}
.delete-btn {
display: flex;
align-items: center;
border: none;
border-radius: 2px;
background: var(--bg-cherry-500);
margin-left: 12px;
}
.delete-btn:hover {
color: var(--bg-vanilla-100);
background: var(--bg-cherry-600);
}
}
}
.title {
color: var(--bg-vanilla-100);
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 20px; /* 142.857% */
}
}
.lightMode {
.routing-policies-container {
.routing-policies-content {
.title {
color: var(--bg-ink-500);
}
}
}
.routing-policies-table {
.ant-table-tbody {
.policy-list-item {
border: 1px solid var(--bg-vanilla-300);
background-color: var(--bg-vanilla-100);
.policy-list-item-header {
.ant-typography {
color: var(--bg-slate-400);
}
}
.policy-list-item-content {
.policy-list-item-content-row {
.ant-typography:first-child {
color: var(--bg-slate-400);
}
.ant-typography:last-child,
div .ant-typography {
color: var(--bg-slate-100);
}
}
}
}
}
}
.create-policy-modal {
.ant-modal-content {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.ant-modal-header {
background-color: var(--bg-vanilla-100);
.ant-typography {
color: var(--bg-slate-100);
}
}
.ant-modal-body {
.ant-divider {
border: 0.5px solid var(--bg-vanilla-300);
}
}
}
.create-policy-container {
.input-group {
.ant-typography {
color: var(--bg-slate-100);
}
.ant-input {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
&.ant-select-focused .ant-select-selector {
border-color: var(--bg-vanilla-300) !important;
box-shadow: none !important;
}
&:hover .ant-select-selector {
border-color: var(--bg-vanilla-300) !important;
}
}
}
}
}
.delete-policy-modal {
.ant-modal-content {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.ant-modal-header {
background: var(--bg-vanilla-100);
}
.ant-modal-body {
.ant-typography {
color: var(--bg-slate-400);
}
}
.ant-modal-footer {
.cancel-btn {
background: var(--bg-vanilla-300);
color: var(--bg-slate-100);
}
.delete-btn {
background: var(--bg-cherry-500);
color: var(--bg-vanilla-100);
}
.delete-btn:hover {
color: var(--bg-vanilla-100);
background: var(--bg-cherry-600);
}
}
}
.title {
color: var(--bg-slate-100);
}
}
}

View File

@ -0,0 +1,115 @@
import { Channels } from 'types/api/channels/getAll';
export interface RoutingPolicy {
id: string;
name: string;
expression: string;
channels: string[];
description: string | undefined;
createdAt: string | undefined;
updatedAt: string | undefined;
createdBy: string | undefined;
updatedBy: string | undefined;
}
type HandlePolicyDetailsModalOpen = (
mode: PolicyDetailsModalMode,
routingPolicy: RoutingPolicy | null,
) => void;
type HandlePolicyDetailsModalAction = (
mode: PolicyDetailsModalMode,
routingPolicyData: {
name: string;
expression: string;
channels: string[];
description: string;
},
) => void;
type HandleDeleteModalOpen = (routingPolicy: RoutingPolicy) => void;
export type PolicyDetailsModalMode = 'create' | 'edit' | null;
export interface RoutingPolicyListProps {
routingPolicies: RoutingPolicy[];
isRoutingPoliciesLoading: boolean;
isRoutingPoliciesError: boolean;
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
handleDeleteModalOpen: HandleDeleteModalOpen;
}
export interface RoutingPolicyListItemProps {
routingPolicy: RoutingPolicy;
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
handleDeleteModalOpen: HandleDeleteModalOpen;
}
export interface PolicyListItemHeaderProps {
name: string;
handleEdit: () => void;
handleDelete: () => void;
}
export interface PolicyListItemContentProps {
routingPolicy: RoutingPolicy;
}
export interface RoutingPolicyDetailsProps {
routingPolicy: RoutingPolicy | null;
closeModal: () => void;
mode: PolicyDetailsModalMode;
channels: Channels[];
isErrorChannels: boolean;
isLoadingChannels: boolean;
handlePolicyDetailsModalAction: HandlePolicyDetailsModalAction;
isPolicyDetailsModalActionLoading: boolean;
refreshChannels: () => void;
}
export interface DeleteRoutingPolicyProps {
routingPolicy: RoutingPolicy | null;
isDeletingRoutingPolicy: boolean;
handleDelete: () => void;
handleClose: () => void;
}
export interface UseRoutingPoliciesReturn {
// Routing Policies
selectedRoutingPolicy: RoutingPolicy | null;
routingPoliciesData: RoutingPolicy[];
isLoadingRoutingPolicies: boolean;
isErrorRoutingPolicies: boolean;
// Channels
channels: Channels[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;
// Search
searchTerm: string;
setSearchTerm: (searchTerm: string) => void;
// Delete Modal
isDeleteModalOpen: boolean;
handleDeleteModalOpen: (routingPolicy: RoutingPolicy) => void;
handleDeleteModalClose: () => void;
handleDeleteRoutingPolicy: () => void;
isDeletingRoutingPolicy: boolean;
// Policy Details Modal
policyDetailsModalState: PolicyDetailsModalState;
handlePolicyDetailsModalClose: () => void;
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
handlePolicyDetailsModalAction: HandlePolicyDetailsModalAction;
isPolicyDetailsModalActionLoading: boolean;
}
export interface PolicyDetailsModalState {
mode: PolicyDetailsModalMode;
isOpen: boolean;
}
export interface RoutingPolicyDetailsFormState {
name: string;
expression: string;
channels: string[];
description: string;
}

View File

@ -0,0 +1,240 @@
import './styles.scss';
import { toast } from '@signozhq/sonner';
import getAllChannels from 'api/channels/getAll';
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useCreateRoutingPolicy } from 'hooks/routingPolicies/useCreateRoutingPolicy';
import { useDeleteRoutingPolicy } from 'hooks/routingPolicies/useDeleteRoutingPolicy';
import { useGetRoutingPolicies } from 'hooks/routingPolicies/useGetRoutingPolicies';
import { useUpdateRoutingPolicy } from 'hooks/routingPolicies/useUpdateRoutingPolicy';
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import {
PolicyDetailsModalMode,
PolicyDetailsModalState,
RoutingPolicy,
UseRoutingPoliciesReturn,
} from './types';
import {
mapApiResponseToRoutingPolicies,
mapRoutingPolicyToCreateApiPayload,
mapRoutingPolicyToUpdateApiPayload,
} from './utils';
function useRoutingPolicies(): UseRoutingPoliciesReturn {
const queryClient = useQueryClient();
// Local state
const [searchTerm, setSearchTerm] = useState('');
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [
policyDetailsModalState,
setPolicyDetailsModalState,
] = useState<PolicyDetailsModalState>({
mode: null,
isOpen: false,
});
const [
selectedRoutingPolicy,
setSelectedRoutingPolicy,
] = useState<RoutingPolicy | null>(null);
// Routing Policies list
const {
data: routingPolicies,
isLoading: isLoadingRoutingPolicies,
isError: isErrorRoutingPolicies,
} = useGetRoutingPolicies();
const routingPoliciesData = useMemo(() => {
const unfilteredRoutingPolicies = mapApiResponseToRoutingPolicies(
routingPolicies as SuccessResponseV2<GetRoutingPoliciesResponse>,
);
return unfilteredRoutingPolicies.filter((routingPolicy) =>
routingPolicy.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [routingPolicies, searchTerm]);
// Channels list
const {
data,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refetchChannels,
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
const refreshChannels = (): void => {
refetchChannels();
};
// Handlers
const handlePolicyDetailsModalOpen = (
mode: PolicyDetailsModalMode,
routingPolicy: RoutingPolicy | null,
): void => {
if (routingPolicy) {
setSelectedRoutingPolicy(routingPolicy);
}
setPolicyDetailsModalState({
isOpen: true,
mode,
});
};
const handlePolicyDetailsModalClose = (): void => {
setSelectedRoutingPolicy(null);
setPolicyDetailsModalState({
isOpen: false,
mode: null,
});
};
const handleDeleteModalOpen = (routingPolicy: RoutingPolicy): void => {
setSelectedRoutingPolicy(routingPolicy);
setIsDeleteModalOpen(true);
};
const handleDeleteModalClose = (): void => {
setSelectedRoutingPolicy(null);
setIsDeleteModalOpen(false);
};
// Create Routing Policy
const {
mutate: createRoutingPolicy,
isLoading: isCreating,
} = useCreateRoutingPolicy();
// Update Routing Policy
const {
mutate: updateRoutingPolicy,
isLoading: isUpdating,
} = useUpdateRoutingPolicy();
// Policy Details Modal Action (Create or Update)
const handlePolicyDetailsModalAction = (
mode: PolicyDetailsModalMode,
routingPolicyData: {
name: string;
expression: string;
channels: string[];
description: string;
},
): void => {
if (mode === 'create') {
createRoutingPolicy(
{
payload: mapRoutingPolicyToCreateApiPayload(
routingPolicyData.name,
routingPolicyData.expression,
routingPolicyData.channels,
routingPolicyData.description,
),
},
{
onSuccess: () => {
toast.success('Routing policy created successfully');
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
handlePolicyDetailsModalClose();
},
onError: (error) => {
toast.error(`Error: ${error.message}`);
},
},
);
} else if (mode === 'edit' && selectedRoutingPolicy) {
updateRoutingPolicy(
{
id: selectedRoutingPolicy.id,
payload: mapRoutingPolicyToUpdateApiPayload(
routingPolicyData.name,
routingPolicyData.expression,
routingPolicyData.channels,
routingPolicyData.description,
),
},
{
onSuccess: () => {
toast.success('Routing policy updated successfully');
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
handlePolicyDetailsModalClose();
},
onError: () => {
toast.error('Failed to update routing policy');
},
},
);
}
};
// Policy Details Modal Action Loading (Creating or Updating)
const isPolicyDetailsModalActionLoading = useMemo(() => {
if (policyDetailsModalState.mode === 'create') {
return isCreating;
}
if (policyDetailsModalState.mode === 'edit') {
return isUpdating;
}
return false;
}, [policyDetailsModalState.mode, isCreating, isUpdating]);
// Delete Routing Policy
const {
mutate: deleteRoutingPolicy,
isLoading: isDeletingRoutingPolicy,
} = useDeleteRoutingPolicy();
const handleDeleteRoutingPolicy = (): void => {
if (!selectedRoutingPolicy) {
return;
}
deleteRoutingPolicy(selectedRoutingPolicy.id, {
onSuccess: () => {
toast.success('Routing policy deleted successfully');
queryClient.invalidateQueries(REACT_QUERY_KEY.GET_ROUTING_POLICIES);
handleDeleteModalClose();
},
onError: () => {
toast.error('Failed to delete routing policy');
},
});
};
return {
// Routing Policies
selectedRoutingPolicy,
routingPoliciesData,
isLoadingRoutingPolicies,
isErrorRoutingPolicies,
// Channels
channels,
isLoadingChannels,
isErrorChannels,
refreshChannels,
// Search
searchTerm,
setSearchTerm,
// Delete Modal
isDeleteModalOpen,
handleDeleteModalOpen,
handleDeleteModalClose,
handleDeleteRoutingPolicy,
isDeletingRoutingPolicy,
// Policy Details Modal
policyDetailsModalState,
isPolicyDetailsModalActionLoading,
handlePolicyDetailsModalAction,
handlePolicyDetailsModalOpen,
handlePolicyDetailsModalClose,
};
}
export default useRoutingPolicies;

View File

@ -0,0 +1,61 @@
import { CreateRoutingPolicyBody } from 'api/routingPolicies/createRoutingPolicy';
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
import { UpdateRoutingPolicyBody } from 'api/routingPolicies/updateRoutingPolicy';
import { SuccessResponseV2 } from 'types/api';
import { RoutingPolicy } from './types';
export function showRoutingPoliciesPage(): boolean {
return localStorage.getItem('showRoutingPoliciesPage') === 'true';
}
export function mapApiResponseToRoutingPolicies(
response: SuccessResponseV2<GetRoutingPoliciesResponse>,
): RoutingPolicy[] {
return (
response?.data?.data?.map((policyData) => ({
id: policyData.id,
name: policyData.name,
expression: policyData.expression,
description: policyData.description,
channels: policyData.channels,
createdAt: policyData.createdAt,
updatedAt: policyData.updatedAt,
createdBy: policyData.createdBy,
updatedBy: policyData.updatedBy,
})) || []
);
}
export function mapRoutingPolicyToCreateApiPayload(
name: string,
expression: string,
channels: string[],
description: string,
): CreateRoutingPolicyBody {
return {
name,
expression,
actions: {
channels,
},
description,
};
}
// eslint-disable-next-line sonarjs/no-identical-functions
export function mapRoutingPolicyToUpdateApiPayload(
name: string,
expression: string,
channels: string[],
description: string,
): UpdateRoutingPolicyBody {
return {
name,
expression,
actions: {
channels,
},
description,
};
}

View File

@ -0,0 +1,24 @@
import createRoutingPolicy, {
CreateRoutingPolicyBody,
CreateRoutingPolicyResponse,
} from 'api/routingPolicies/createRoutingPolicy';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
interface UseCreateRoutingPolicyProps {
payload: CreateRoutingPolicyBody;
}
export function useCreateRoutingPolicy(): UseMutationResult<
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2,
Error,
UseCreateRoutingPolicyProps
> {
return useMutation<
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2,
Error,
UseCreateRoutingPolicyProps
>({
mutationFn: ({ payload }) => createRoutingPolicy(payload),
});
}

View File

@ -0,0 +1,19 @@
import deleteRoutingPolicy, {
DeleteRoutingPolicyResponse,
} from 'api/routingPolicies/deleteRoutingPolicy';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
export function useDeleteRoutingPolicy(): UseMutationResult<
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2,
Error,
string
> {
return useMutation<
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2,
Error,
string
>({
mutationFn: (policyId) => deleteRoutingPolicy(policyId),
});
}

View File

@ -0,0 +1,39 @@
import {
getRoutingPolicies,
GetRoutingPoliciesResponse,
} from 'api/routingPolicies/getRoutingPolicies';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useMemo } from 'react';
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
type UseGetRoutingPolicies = (
options?: UseQueryOptions<
SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2,
Error
>,
headers?: Record<string, string>,
) => UseQueryResult<
SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2,
Error
>;
export const useGetRoutingPolicies: UseGetRoutingPolicies = (
options,
headers,
) => {
const queryKey = useMemo(
() => options?.queryKey || [REACT_QUERY_KEY.GET_ROUTING_POLICIES],
[options?.queryKey],
);
return useQuery<
SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2,
Error
>({
queryFn: ({ signal }) => getRoutingPolicies(signal, headers),
...options,
queryKey,
});
};

View File

@ -0,0 +1,25 @@
import updateRoutingPolicy, {
UpdateRoutingPolicyBody,
UpdateRoutingPolicyResponse,
} from 'api/routingPolicies/updateRoutingPolicy';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponseV2, SuccessResponseV2 } from 'types/api';
interface UseUpdateRoutingPolicyProps {
id: string;
payload: UpdateRoutingPolicyBody;
}
export function useUpdateRoutingPolicy(): UseMutationResult<
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2,
Error,
UseUpdateRoutingPolicyProps
> {
return useMutation<
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2,
Error,
UseUpdateRoutingPolicyProps
>({
mutationFn: ({ id, payload }) => updateRoutingPolicy(id, payload),
});
}

View File

@ -2,4 +2,14 @@
.ant-tabs-nav {
padding: 0 8px;
}
.configuration-tabs {
margin-top: -16px;
.ant-tabs-nav {
.ant-tabs-nav-wrap {
padding: 0 8px;
}
}
}
}

View File

@ -7,11 +7,14 @@ import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection
import ROUTES from 'constants/routes';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import RoutingPolicies from 'container/RoutingPolicies';
import { showRoutingPoliciesPage } from 'container/RoutingPolicies/utils';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { GalleryVerticalEnd, Pyramid } from 'lucide-react';
import AlertDetails from 'pages/AlertDetails';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
function AllAlertList(): JSX.Element {
@ -25,6 +28,37 @@ function AllAlertList(): JSX.Element {
const search = urlQuery.get('search');
const showRoutingPoliciesPageFlag = showRoutingPoliciesPage();
const configurationTab = useMemo(() => {
if (showRoutingPoliciesPageFlag) {
const tabs = [
{
label: 'Planned Downtime',
key: 'planned-downtime',
children: <PlannedDowntime />,
},
{
label: 'Routing Policies',
key: 'routing-policies',
children: <RoutingPolicies />,
},
];
return (
<Tabs
className="configuration-tabs"
defaultActiveKey="planned-downtime"
items={tabs}
/>
);
}
return (
<div className="planned-downtime-container">
<PlannedDowntime />
</div>
);
}, [showRoutingPoliciesPageFlag]);
const items: TabsProps['items'] = [
{
label: (
@ -58,11 +92,7 @@ function AllAlertList(): JSX.Element {
</div>
),
key: 'Configuration',
children: (
<div className="planned-downtime-container">
<PlannedDowntime />
</div>
),
children: configurationTab,
},
];

View File

@ -9,9 +9,11 @@
color: var(--bg-vanilla-100);
.error-icon {
margin-bottom: 16px;
}
.error-boundary-fallback-content {
display: flex;
flex-direction: column;
max-width: 520px;
gap: 8px;
.title,
.actions {
@ -20,7 +22,26 @@
gap: 8px;
}
.title {
color: var(--bg-vanilla-100);
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.description {
color: var(--bg-vanilla-400);
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.actions {
margin-top: 16px;
}
}
}

View File

@ -1,36 +1,36 @@
import './ErrorBoundaryFallback.styles.scss';
import { BugOutlined } from '@ant-design/icons';
import { Button, Typography } from 'antd';
import { Button } from 'antd';
import ROUTES from 'constants/routes';
import Slack from 'container/SideNav/Slack';
import { Home, TriangleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Home, LifeBuoy } from 'lucide-react';
import { handleContactSupport } from 'pages/Integrations/utils';
import { useCallback } from 'react';
function ErrorBoundaryFallback(): JSX.Element {
const { t } = useTranslation(['errorDetails']);
const onClickSlackHandler = (): void => {
window.open('https://signoz.io/slack', '_blank');
};
const handleReload = (): void => {
// Go to home page
window.location.href = ROUTES.HOME;
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const handleSupport = useCallback(() => {
handleContactSupport(isCloudUserVal);
}, [isCloudUserVal]);
return (
<div className="error-boundary-fallback-container">
<div className="error-boundary-fallback-content">
<div className="error-icon">
<TriangleAlert size={48} />
</div>
<div className="title">
<BugOutlined />
<Typography.Title type="danger" level={4} style={{ margin: 0 }}>
{t('something_went_wrong')}
</Typography.Title>
<img src="/Images/cloud.svg" alt="error-cloud-icon" />
</div>
<div className="title">Something went wrong :/</div>
<p>{t('contact_if_issue_exists')}</p>
<div className="description">
Our team is getting on top to resolve this. Please reach out to support if
the issue persists.
</div>
<div className="actions">
<Button
@ -39,19 +39,20 @@ function ErrorBoundaryFallback(): JSX.Element {
icon={<Home size={16} />}
className="periscope-btn primary"
>
Go Home
Go to Home
</Button>
<Button
className="periscope-btn secondary"
type="default"
onClick={onClickSlackHandler}
icon={<Slack />}
onClick={handleSupport}
icon={<LifeBuoy size={16} />}
>
Slack Support
Contact Support
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,10 +1,49 @@
.support-page-container {
color: white;
padding-left: 48px;
padding-right: 48px;
max-height: 100vh;
overflow: hidden;
max-width: 1400px;
margin: 64px auto;
.support-page-header {
border-bottom: 1px solid var(--bg-slate-500);
background: rgba(11, 12, 14, 0.7);
backdrop-filter: blur(20px);
.support-page-header-title {
color: var(--bg-vanilla-100);
text-align: center;
font-family: Inter;
font-size: 13px;
font-style: normal;
line-height: 14px;
letter-spacing: 0.4px;
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
}
}
.support-page-content {
padding: 16px;
.support-page-content-description {
color: var(--bg-vanilla-100);
text-align: left;
font-family: Inter;
font-size: 16px;
font-style: normal;
line-height: 24px;
letter-spacing: 0.4px;
display: flex;
align-items: center;
gap: 8px;
}
.support-channels {
margin: 24px 0;
}
}
}
.support-channels {
@ -21,6 +60,16 @@
position: relative;
border: none !important;
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.support-channel-title {
width: 100%;
display: flex;
@ -37,6 +86,21 @@
button {
max-width: 100%;
padding: 4px 16px;
.ant-typography {
font-size: 11px;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
}
.support-channel-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
}
}
@ -47,8 +111,50 @@
}
}
@media screen and (min-width: 1440px) {
.lightMode {
.support-page-container {
width: 80%;
.support-page-header {
border-bottom: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.support-page-header-title {
color: var(--bg-ink-400);
}
}
}
.support-page-content {
.support-page-content-description {
color: var(--bg-ink-400);
}
.support-channels {
.support-channel {
border: 1px solid var(--bg-vanilla-300);
background: linear-gradient(
139deg,
rgba(255, 255, 255, 0.8) 0%,
rgba(255, 255, 255, 0.9) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
backdrop-filter: blur(20px);
}
.support-channel-title {
color: var(--bg-ink-400);
}
.support-channel-action {
button {
.ant-typography {
color: var(--bg-ink-400);
}
}
.support-channel-btn {
color: var(--bg-ink-400);
}
}
}
}
}

View File

@ -6,9 +6,11 @@ import updateCreditCardApi from 'api/v1/checkout/create';
import { FeatureKeys } from 'constants/features';
import { useNotifications } from 'hooks/useNotifications';
import {
ArrowUpRight,
Book,
CreditCard,
Github,
LifeBuoy,
MessageSquare,
Slack,
X,
@ -45,34 +47,38 @@ const supportChannels = [
{
key: 'documentation',
name: 'Documentation',
icon: <Book />,
icon: <Book size={16} />,
title: 'Find answers in the documentation.',
url: 'https://signoz.io/docs/',
btnText: 'Visit docs',
isExternal: true,
},
{
key: 'github',
name: 'Github',
icon: <Github />,
icon: <Github size={16} />,
title: 'Create an issue on GitHub to report bugs or request new features.',
url: 'https://github.com/SigNoz/signoz/issues',
btnText: 'Create issue',
isExternal: true,
},
{
key: 'slack_community',
name: 'Slack Community',
icon: <Slack />,
icon: <Slack size={16} />,
title: 'Get support from the SigNoz community on Slack.',
url: 'https://signoz.io/slack',
btnText: 'Join Slack',
isExternal: true,
},
{
key: 'chat',
name: 'Chat',
icon: <MessageSquare />,
icon: <MessageSquare size={16} />,
title: 'Get quick support directly from the team.',
url: '',
btnText: 'Launch chat',
isExternal: false,
},
];
@ -182,12 +188,17 @@ export default function Support(): JSX.Element {
return (
<div className="support-page-container">
<div className="support-page-header">
<Title level={3}> Help & Support </Title>
<Text style={{ fontSize: 14 }}>
<header className="support-page-header">
<div className="support-page-header-title" data-testid="support-page-title">
<LifeBuoy size={16} />
Support
</div>
</header>
<div className="support-page-content">
<div className="support-page-content-description">
We are here to help in case of questions or issues. Pick the channel that
is most convenient for you.
</Text>
</div>
<div className="support-channels">
@ -204,17 +215,19 @@ export default function Support(): JSX.Element {
<div className="support-channel-action">
<Button
className="periscope-btn secondary support-channel-btn"
type="default"
className="periscope-btn secondary"
onClick={(): void => handleChannelClick(channel)}
>
<Text ellipsis>{channel.btnText} </Text>
{channel.isExternal && <ArrowUpRight size={14} />}
</Button>
</div>
</Card>
),
)}
</div>
</div>
{/* Add Credit Card Modal */}
<Modal

View File

@ -55,6 +55,10 @@ type Alertmanager interface {
// SetDefaultConfig sets the default config for the organization.
SetDefaultConfig(context.Context, string) error
SetNotificationConfig(ctx context.Context, orgID valuer.UUID, ruleId string, config *alertmanagertypes.NotificationConfig) error
DeleteNotificationConfig(ctx context.Context, orgID valuer.UUID, ruleId string) error
// Collects stats for the organization.
statsreporter.StatsCollector
}

View File

@ -0,0 +1,563 @@
package alertmanagerserver
import (
"context"
"fmt"
"log/slog"
"sort"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/dispatch"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/provider"
"github.com/prometheus/alertmanager/store"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
const (
noDataLabel = model.LabelName("nodata")
)
// Dispatcher sorts incoming alerts into aggregation groups and
// assigns the correct notifiers to each.
type Dispatcher struct {
route *dispatch.Route
alerts provider.Alerts
stage notify.Stage
marker types.GroupMarker
metrics *DispatcherMetrics
limits Limits
timeout func(time.Duration) time.Duration
mtx sync.RWMutex
aggrGroupsPerRoute map[*dispatch.Route]map[model.Fingerprint]*aggrGroup
aggrGroupsNum int
done chan struct{}
ctx context.Context
cancel func()
logger *slog.Logger
notificationManager nfmanager.NotificationManager
orgID string
}
// We use the upstream Limits interface from Prometheus
type Limits = dispatch.Limits
// NewDispatcher returns a new Dispatcher.
func NewDispatcher(
ap provider.Alerts,
r *dispatch.Route,
s notify.Stage,
mk types.GroupMarker,
to func(time.Duration) time.Duration,
lim Limits,
l *slog.Logger,
m *DispatcherMetrics,
n nfmanager.NotificationManager,
orgID string,
) *Dispatcher {
if lim == nil {
// Use a simple implementation when no limits are provided
lim = &unlimitedLimits{}
}
disp := &Dispatcher{
alerts: ap,
stage: s,
route: r,
marker: mk,
timeout: to,
logger: l.With("component", "signoz-dispatcher"),
metrics: m,
limits: lim,
notificationManager: n,
orgID: orgID,
}
return disp
}
// Run starts dispatching alerts incoming via the updates channel.
func (d *Dispatcher) Run() {
d.done = make(chan struct{})
d.mtx.Lock()
d.aggrGroupsPerRoute = map[*dispatch.Route]map[model.Fingerprint]*aggrGroup{}
d.aggrGroupsNum = 0
d.metrics.aggrGroups.Set(0)
d.ctx, d.cancel = context.WithCancel(context.Background())
d.mtx.Unlock()
d.run(d.alerts.Subscribe())
close(d.done)
}
func (d *Dispatcher) run(it provider.AlertIterator) {
maintenance := time.NewTicker(30 * time.Second)
defer maintenance.Stop()
defer it.Close()
for {
select {
case alert, ok := <-it.Next():
if !ok {
// Iterator exhausted for some reason.
if err := it.Err(); err != nil {
d.logger.ErrorContext(d.ctx, "Error on alert update", "err", err)
}
return
}
d.logger.DebugContext(d.ctx, "SigNoz Custom Dispatcher: Received alert", "alert", alert)
// Log errors but keep trying.
if err := it.Err(); err != nil {
d.logger.ErrorContext(d.ctx, "Error on alert update", "err", err)
continue
}
now := time.Now()
for _, r := range d.route.Match(alert.Labels) {
d.processAlert(alert, r)
}
d.metrics.processingDuration.Observe(time.Since(now).Seconds())
case <-maintenance.C:
d.doMaintenance()
case <-d.ctx.Done():
return
}
}
}
func (d *Dispatcher) doMaintenance() {
d.mtx.Lock()
defer d.mtx.Unlock()
for _, groups := range d.aggrGroupsPerRoute {
for _, ag := range groups {
if ag.empty() {
ag.stop()
d.marker.DeleteByGroupKey(ag.routeID, ag.GroupKey())
delete(groups, ag.fingerprint())
d.aggrGroupsNum--
d.metrics.aggrGroups.Dec()
}
}
}
}
// AlertGroup represents how alerts exist within an aggrGroup.
type AlertGroup struct {
Alerts types.AlertSlice
Labels model.LabelSet
Receiver string
GroupKey string
RouteID string
Renotify time.Duration
}
type AlertGroups []*AlertGroup
func (ag AlertGroups) Swap(i, j int) { ag[i], ag[j] = ag[j], ag[i] }
func (ag AlertGroups) Less(i, j int) bool {
if ag[i].Labels.Equal(ag[j].Labels) {
return ag[i].Receiver < ag[j].Receiver
}
return ag[i].Labels.Before(ag[j].Labels)
}
func (ag AlertGroups) Len() int { return len(ag) }
// Groups returns a slice of AlertGroups from the dispatcher's internal state.
func (d *Dispatcher) Groups(routeFilter func(*dispatch.Route) bool, alertFilter func(*types.Alert, time.Time) bool) (AlertGroups, map[model.Fingerprint][]string) {
groups := AlertGroups{}
d.mtx.RLock()
defer d.mtx.RUnlock()
// Keep a list of receivers for an alert to prevent checking each alert
// again against all routes. The alert has already matched against this
// route on ingestion.
receivers := map[model.Fingerprint][]string{}
now := time.Now()
for route, ags := range d.aggrGroupsPerRoute {
if !routeFilter(route) {
continue
}
for _, ag := range ags {
receiver := route.RouteOpts.Receiver
alertGroup := &AlertGroup{
Labels: ag.labels,
Receiver: receiver,
GroupKey: ag.GroupKey(),
RouteID: ag.routeID,
Renotify: ag.opts.RepeatInterval,
}
alerts := ag.alerts.List()
filteredAlerts := make([]*types.Alert, 0, len(alerts))
for _, a := range alerts {
if !alertFilter(a, now) {
continue
}
fp := a.Fingerprint()
if r, ok := receivers[fp]; ok {
// Receivers slice already exists. Add
// the current receiver to the slice.
receivers[fp] = append(r, receiver)
} else {
// First time we've seen this alert fingerprint.
// Initialize a new receivers slice.
receivers[fp] = []string{receiver}
}
filteredAlerts = append(filteredAlerts, a)
}
if len(filteredAlerts) == 0 {
continue
}
alertGroup.Alerts = filteredAlerts
groups = append(groups, alertGroup)
}
}
sort.Sort(groups)
for i := range groups {
sort.Sort(groups[i].Alerts)
}
for i := range receivers {
sort.Strings(receivers[i])
}
return groups, receivers
}
// Stop the dispatcher.
func (d *Dispatcher) Stop() {
if d == nil {
return
}
d.mtx.Lock()
if d.cancel == nil {
d.mtx.Unlock()
return
}
d.cancel()
d.cancel = nil
d.mtx.Unlock()
<-d.done
}
// notifyFunc is a function that performs notification for the alert
// with the given fingerprint. It aborts on context cancelation.
// Returns false iff notifying failed.
type notifyFunc func(context.Context, ...*types.Alert) bool
// processAlert determines in which aggregation group the alert falls
// and inserts it.
func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
ruleId := getRuleIDFromAlert(alert)
config, err := d.notificationManager.GetNotificationConfig(d.orgID, ruleId)
if err != nil {
d.logger.ErrorContext(d.ctx, "error getting alert notification config", "rule_id", ruleId, "error", err)
return
}
groupLabels := getGroupLabels(alert, config.NotificationGroup)
fp := groupLabels.Fingerprint()
d.mtx.Lock()
defer d.mtx.Unlock()
routeGroups, ok := d.aggrGroupsPerRoute[route]
if !ok {
routeGroups = map[model.Fingerprint]*aggrGroup{}
d.aggrGroupsPerRoute[route] = routeGroups
}
ag, ok := routeGroups[fp]
if ok {
ag.insert(alert)
return
}
// If the group does not exist, create it. But check the limit first.
if limit := d.limits.MaxNumberOfAggregationGroups(); limit > 0 && d.aggrGroupsNum >= limit {
d.metrics.aggrGroupLimitReached.Inc()
d.logger.ErrorContext(d.ctx, "Too many aggregation groups, cannot create new group for alert", "groups", d.aggrGroupsNum, "limit", limit, "alert", alert.Name())
return
}
renotifyInterval := config.Renotify.RenotifyInterval
if noDataAlert(alert) {
renotifyInterval = config.Renotify.NoDataInterval
groupLabels[noDataLabel] = alert.Labels[noDataLabel]
}
ag = newAggrGroup(d.ctx, groupLabels, route, d.timeout, d.logger, renotifyInterval)
routeGroups[fp] = ag
d.aggrGroupsNum++
d.metrics.aggrGroups.Inc()
// Insert the 1st alert in the group before starting the group's run()
// function, to make sure that when the run() will be executed the 1st
// alert is already there.
ag.insert(alert)
go ag.run(func(ctx context.Context, alerts ...*types.Alert) bool {
_, _, err := d.stage.Exec(ctx, d.logger, alerts...)
if err != nil {
logger := d.logger.With("num_alerts", len(alerts), "err", err)
if errors.Is(ctx.Err(), context.Canceled) {
// It is expected for the context to be canceled on
// configuration reload or shutdown. In this case, the
// message should only be logged at the debug level.
logger.DebugContext(ctx, "Notify for alerts failed")
} else {
logger.ErrorContext(ctx, "Notify for alerts failed")
}
}
return err == nil
})
}
// aggrGroup aggregates alert fingerprints into groups to which a
// common set of routing options applies.
// It emits notifications in the specified intervals.
type aggrGroup struct {
labels model.LabelSet
opts *dispatch.RouteOpts
logger *slog.Logger
routeID string
routeKey string
alerts *store.Alerts
ctx context.Context
cancel func()
done chan struct{}
next *time.Timer
timeout func(time.Duration) time.Duration
mtx sync.RWMutex
hasFlushed bool
}
// newAggrGroup returns a new aggregation group.
func newAggrGroup(ctx context.Context, labels model.LabelSet, r *dispatch.Route, to func(time.Duration) time.Duration, logger *slog.Logger, renotify time.Duration) *aggrGroup {
if to == nil {
to = func(d time.Duration) time.Duration { return d }
}
opts := deepCopyRouteOpts(r.RouteOpts, renotify)
ag := &aggrGroup{
labels: labels,
routeID: r.ID(),
routeKey: r.Key(),
opts: &opts,
timeout: to,
alerts: store.NewAlerts(),
done: make(chan struct{}),
}
ag.ctx, ag.cancel = context.WithCancel(ctx)
ag.logger = logger.With("aggr_group", ag)
// Set an initial one-time wait before flushing
// the first batch of notifications.
ag.next = time.NewTimer(ag.opts.GroupWait)
return ag
}
func (ag *aggrGroup) fingerprint() model.Fingerprint {
return ag.labels.Fingerprint()
}
func (ag *aggrGroup) GroupKey() string {
return fmt.Sprintf("%s:%s", ag.routeKey, ag.labels)
}
func (ag *aggrGroup) String() string {
return ag.GroupKey()
}
func (ag *aggrGroup) run(nf notifyFunc) {
defer close(ag.done)
defer ag.next.Stop()
for {
select {
case now := <-ag.next.C:
// Give the notifications time until the next flush to
// finish before terminating them.
ctx, cancel := context.WithTimeout(ag.ctx, ag.timeout(ag.opts.GroupInterval))
// The now time we retrieve from the ticker is the only reliable
// point of time reference for the subsequent notification pipeline.
// Calculating the current time directly is prone to flaky behavior,
// which usually only becomes apparent in tests.
ctx = notify.WithNow(ctx, now)
// Populate context with information needed along the pipeline.
ctx = notify.WithGroupKey(ctx, ag.GroupKey())
ctx = notify.WithGroupLabels(ctx, ag.labels)
ctx = notify.WithReceiverName(ctx, ag.opts.Receiver)
ctx = notify.WithRepeatInterval(ctx, ag.opts.RepeatInterval)
ctx = notify.WithMuteTimeIntervals(ctx, ag.opts.MuteTimeIntervals)
ctx = notify.WithActiveTimeIntervals(ctx, ag.opts.ActiveTimeIntervals)
ctx = notify.WithRouteID(ctx, ag.routeID)
// Wait the configured interval before calling flush again.
ag.mtx.Lock()
ag.next.Reset(ag.opts.GroupInterval)
ag.hasFlushed = true
ag.mtx.Unlock()
ag.flush(func(alerts ...*types.Alert) bool {
return nf(ctx, alerts...)
})
cancel()
case <-ag.ctx.Done():
return
}
}
}
func (ag *aggrGroup) stop() {
// Calling cancel will terminate all in-process notifications
// and the run() loop.
ag.cancel()
<-ag.done
}
// insert inserts the alert into the aggregation group.
func (ag *aggrGroup) insert(alert *types.Alert) {
if err := ag.alerts.Set(alert); err != nil {
ag.logger.ErrorContext(ag.ctx, "error on set alert", "err", err)
}
// Immediately trigger a flush if the wait duration for this
// alert is already over.
ag.mtx.Lock()
defer ag.mtx.Unlock()
if !ag.hasFlushed && alert.StartsAt.Add(ag.opts.GroupWait).Before(time.Now()) {
ag.next.Reset(0)
}
}
func (ag *aggrGroup) empty() bool {
return ag.alerts.Empty()
}
// flush sends notifications for all new alerts.
func (ag *aggrGroup) flush(notify func(...*types.Alert) bool) {
if ag.empty() {
return
}
var (
alerts = ag.alerts.List()
alertsSlice = make(types.AlertSlice, 0, len(alerts))
resolvedSlice = make(types.AlertSlice, 0, len(alerts))
now = time.Now()
)
for _, alert := range alerts {
a := *alert
// Ensure that alerts don't resolve as time move forwards.
if a.ResolvedAt(now) {
resolvedSlice = append(resolvedSlice, &a)
} else {
a.EndsAt = time.Time{}
}
alertsSlice = append(alertsSlice, &a)
}
sort.Stable(alertsSlice)
ag.logger.DebugContext(ag.ctx, "flushing", "alerts", fmt.Sprintf("%v", alertsSlice))
if notify(alertsSlice...) {
// Delete all resolved alerts as we just sent a notification for them,
// and we don't want to send another one. However, we need to make sure
// that each resolved alert has not fired again during the flush as then
// we would delete an active alert thinking it was resolved.
if err := ag.alerts.DeleteIfNotModified(resolvedSlice); err != nil {
ag.logger.ErrorContext(ag.ctx, "error on delete alerts", "err", err)
}
}
}
// unlimitedLimits provides unlimited aggregation groups for SigNoz
type unlimitedLimits struct{}
func (u *unlimitedLimits) MaxNumberOfAggregationGroups() int { return 0 }
func getRuleIDFromAlert(alert *types.Alert) string {
for name, value := range alert.Labels {
if string(name) == "ruleId" {
return string(value)
}
}
return ""
}
func deepCopyRouteOpts(opts dispatch.RouteOpts, renotify time.Duration) dispatch.RouteOpts {
newOpts := opts
if opts.GroupBy != nil {
newOpts.GroupBy = make(map[model.LabelName]struct{}, len(opts.GroupBy))
for k, v := range opts.GroupBy {
newOpts.GroupBy[k] = v
}
}
if opts.MuteTimeIntervals != nil {
newOpts.MuteTimeIntervals = make([]string, len(opts.MuteTimeIntervals))
copy(newOpts.MuteTimeIntervals, opts.MuteTimeIntervals)
}
if opts.ActiveTimeIntervals != nil {
newOpts.ActiveTimeIntervals = make([]string, len(opts.ActiveTimeIntervals))
copy(newOpts.ActiveTimeIntervals, opts.ActiveTimeIntervals)
}
if renotify > 0 {
newOpts.RepeatInterval = renotify
}
return newOpts
}
func getGroupLabels(alert *types.Alert, groups map[model.LabelName]struct{}) model.LabelSet {
groupLabels := model.LabelSet{}
for ln, lv := range alert.Labels {
if _, ok := groups[ln]; ok {
groupLabels[ln] = lv
}
}
return groupLabels
}
func noDataAlert(alert *types.Alert) bool {
if _, ok := alert.Labels[noDataLabel]; ok {
return true
} else {
return false
}
}

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/dispatch"
@ -52,8 +53,8 @@ type Server struct {
// alertmanager primitives from upstream alertmanager
alerts *mem.Alerts
nflog *nflog.Log
dispatcher *dispatch.Dispatcher
dispatcherMetrics *dispatch.DispatcherMetrics
dispatcher *Dispatcher
dispatcherMetrics *DispatcherMetrics
inhibitor *inhibit.Inhibitor
silencer *silence.Silencer
silences *silence.Silences
@ -63,9 +64,10 @@ type Server struct {
tmpl *template.Template
wg sync.WaitGroup
stopc chan struct{}
notificationManager nfmanager.NotificationManager
}
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore) (*Server, error) {
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore, nfManager nfmanager.NotificationManager) (*Server, error) {
server := &Server{
logger: logger.With("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver"),
registry: registry,
@ -73,6 +75,7 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
orgID: orgID,
stateStore: stateStore,
stopc: make(chan struct{}),
notificationManager: nfManager,
}
signozRegisterer := prometheus.WrapRegistererWithPrefix("signoz_", registry)
signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, signozRegisterer)
@ -190,7 +193,7 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
}
server.pipelineBuilder = notify.NewPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
server.dispatcherMetrics = dispatch.NewDispatcherMetrics(false, signozRegisterer)
server.dispatcherMetrics = NewDispatcherMetrics(false, signozRegisterer)
return server, nil
}
@ -204,7 +207,6 @@ func (server *Server) GetAlerts(ctx context.Context, params alertmanagertypes.Ge
func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanagertypes.PostableAlerts) error {
alerts, err := alertmanagertypes.NewAlertsFromPostableAlerts(postableAlerts, time.Duration(server.srvConfig.Global.ResolveTimeout), time.Now())
// Notification sending alert takes precedence over validation errors.
if err := server.alerts.Put(alerts...); err != nil {
return err
@ -295,7 +297,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
return d
}
server.dispatcher = dispatch.NewDispatcher(
server.dispatcher = NewDispatcher(
server.alerts,
routes,
pipeline,
@ -304,6 +306,8 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
nil,
server.logger,
server.dispatcherMetrics,
server.notificationManager,
server.orgID,
)
// Do not try to add these to server.wg as there seems to be a race condition if

View File

@ -11,6 +11,7 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/go-openapi/strfmt"
@ -23,7 +24,8 @@ import (
)
func TestServerSetConfigAndStop(t *testing.T) {
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore())
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@ -34,7 +36,8 @@ func TestServerSetConfigAndStop(t *testing.T) {
}
func TestServerTestReceiverTypeWebhook(t *testing.T) {
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore())
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@ -81,7 +84,8 @@ func TestServerPutAlerts(t *testing.T) {
stateStore := alertmanagertypestest.NewStateStore()
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), srvCfg, "1", stateStore)
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")

View File

@ -0,0 +1,43 @@
package alertmanagerserver
import "github.com/prometheus/client_golang/prometheus"
type DispatcherMetrics struct {
aggrGroups prometheus.Gauge
processingDuration prometheus.Summary
aggrGroupLimitReached prometheus.Counter
}
// NewDispatcherMetrics returns a new registered DispatchMetrics.
// todo(aniketio-ctrl): change prom metrics to otel metrics
func NewDispatcherMetrics(registerLimitMetrics bool, r prometheus.Registerer) *DispatcherMetrics {
m := DispatcherMetrics{
aggrGroups: prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "signoz_alertmanager_dispatcher_aggregation_groups",
Help: "Number of active aggregation groups",
},
),
processingDuration: prometheus.NewSummary(
prometheus.SummaryOpts{
Name: "signoz_alertmanager_dispatcher_alert_processing_duration_seconds",
Help: "Summary of latencies for the processing of alerts.",
},
),
aggrGroupLimitReached: prometheus.NewCounter(
prometheus.CounterOpts{
Name: "signoz_alertmanager_dispatcher_aggregation_group_limit_reached_total",
Help: "Number of times when dispatcher failed to create new aggregation group due to limit.",
},
),
}
if r != nil {
r.MustRegister(m.aggrGroups, m.processingDuration)
if registerLimitMetrics {
r.MustRegister(m.aggrGroupLimitReached)
}
}
return &m
}

View File

@ -0,0 +1,18 @@
package nfmanager
import "github.com/SigNoz/signoz/pkg/factory"
type Config struct {
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("nfmanager"), newConfig)
}
func newConfig() factory.Config {
return Config{}
}
func (c Config) Validate() error {
return nil
}

View File

@ -0,0 +1,75 @@
package nfmanagertest
import (
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
// MockNotificationManager is a simple mock implementation of NotificationManager
type MockNotificationManager struct {
configs map[string]*alertmanagertypes.NotificationConfig
errors map[string]error
}
// NewMock creates a new mock notification manager
func NewMock() *MockNotificationManager {
return &MockNotificationManager{
configs: make(map[string]*alertmanagertypes.NotificationConfig),
errors: make(map[string]error),
}
}
func getKey(orgId string, ruleId string) string {
return orgId + ":" + ruleId
}
func (m *MockNotificationManager) GetNotificationConfig(orgID string, ruleID string) (*alertmanagertypes.NotificationConfig, error) {
key := getKey(orgID, ruleID)
if err := m.errors[key]; err != nil {
return nil, err
}
if config := m.configs[key]; config != nil {
return config, nil
}
notificationConfig := alertmanagertypes.GetDefaultNotificationConfig()
return &notificationConfig, nil
}
func (m *MockNotificationManager) SetNotificationConfig(orgID string, ruleID string, config *alertmanagertypes.NotificationConfig) error {
key := getKey(orgID, ruleID)
if err := m.errors[key]; err != nil {
return err
}
m.configs[key] = config
return nil
}
func (m *MockNotificationManager) DeleteNotificationConfig(orgID string, ruleID string) error {
key := getKey(orgID, ruleID)
if err := m.errors[key]; err != nil {
return err
}
delete(m.configs, key)
return nil
}
func (m *MockNotificationManager) SetMockConfig(orgID, ruleID string, config *alertmanagertypes.NotificationConfig) {
key := getKey(orgID, ruleID)
m.configs[key] = config
}
func (m *MockNotificationManager) SetMockError(orgID, ruleID string, err error) {
key := getKey(orgID, ruleID)
m.errors[key] = err
}
func (m *MockNotificationManager) ClearMockData() {
m.configs = make(map[string]*alertmanagertypes.NotificationConfig)
m.errors = make(map[string]error)
}
func (m *MockNotificationManager) HasConfig(orgID, ruleID string) bool {
key := getKey(orgID, ruleID)
_, exists := m.configs[key]
return exists
}

View File

@ -0,0 +1,13 @@
// Package nfmanager provides interfaces and implementations for alert notification grouping strategies.
package nfmanager
import (
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
// NotificationManager defines how alerts should be grouped and configured for notification with multi-tenancy support.
type NotificationManager interface {
GetNotificationConfig(orgID string, ruleID string) (*alertmanagertypes.NotificationConfig, error)
SetNotificationConfig(orgID string, ruleID string, config *alertmanagertypes.NotificationConfig) error
DeleteNotificationConfig(orgID string, ruleID string) error
}

View File

@ -0,0 +1,103 @@
package rulebasednotification
import (
"context"
"sync"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/factory"
)
type provider struct {
settings factory.ScopedProviderSettings
orgToFingerprintToNotificationConfig map[string]map[string]alertmanagertypes.NotificationConfig
mutex sync.RWMutex
}
// NewFactory creates a new factory for the rule-based grouping strategy.
func NewFactory() factory.ProviderFactory[nfmanager.NotificationManager, nfmanager.Config] {
return factory.NewProviderFactory(
factory.MustNewName("rulebased"),
func(ctx context.Context, settings factory.ProviderSettings, config nfmanager.Config) (nfmanager.NotificationManager, error) {
return New(ctx, settings, config)
},
)
}
// New creates a new rule-based grouping strategy provider.
func New(ctx context.Context, providerSettings factory.ProviderSettings, config nfmanager.Config) (nfmanager.NotificationManager, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification")
return &provider{
settings: settings,
orgToFingerprintToNotificationConfig: make(map[string]map[string]alertmanagertypes.NotificationConfig),
}, nil
}
// GetNotificationConfig retrieves the notification configuration for the specified alert and organization.
func (r *provider) GetNotificationConfig(orgID string, ruleID string) (*alertmanagertypes.NotificationConfig, error) {
notificationConfig := alertmanagertypes.GetDefaultNotificationConfig()
if orgID == "" || ruleID == "" {
return &notificationConfig, nil
}
r.mutex.RLock()
defer r.mutex.RUnlock()
if orgConfigs, exists := r.orgToFingerprintToNotificationConfig[orgID]; exists {
if config, configExists := orgConfigs[ruleID]; configExists {
if config.Renotify.RenotifyInterval != 0 {
notificationConfig.Renotify.RenotifyInterval = config.Renotify.RenotifyInterval
}
if config.Renotify.NoDataInterval != 0 {
notificationConfig.Renotify.NoDataInterval = config.Renotify.NoDataInterval
}
for k, v := range config.NotificationGroup {
notificationConfig.NotificationGroup[k] = v
}
}
}
return &notificationConfig, nil
}
// SetNotificationConfig updates the notification configuration for the specified alert and organization.
func (r *provider) SetNotificationConfig(orgID string, ruleID string, config *alertmanagertypes.NotificationConfig) error {
if orgID == "" || ruleID == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no org or rule id provided")
}
if config == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "notification config cannot be nil")
}
r.mutex.Lock()
defer r.mutex.Unlock()
// Initialize org map if it doesn't exist
if r.orgToFingerprintToNotificationConfig[orgID] == nil {
r.orgToFingerprintToNotificationConfig[orgID] = make(map[string]alertmanagertypes.NotificationConfig)
}
r.orgToFingerprintToNotificationConfig[orgID][ruleID] = config.DeepCopy()
return nil
}
func (r *provider) DeleteNotificationConfig(orgID string, ruleID string) error {
if orgID == "" || ruleID == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no org or rule id provided")
}
r.mutex.Lock()
defer r.mutex.Unlock()
if _, exists := r.orgToFingerprintToNotificationConfig[orgID]; exists {
delete(r.orgToFingerprintToNotificationConfig[orgID], ruleID)
}
return nil
}

View File

@ -0,0 +1,270 @@
package rulebasednotification
import (
"context"
"github.com/prometheus/common/model"
"sync"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createTestProviderSettings() factory.ProviderSettings {
return instrumentationtest.New().ToProviderSettings()
}
func TestNewFactory(t *testing.T) {
providerFactory := NewFactory()
assert.NotNil(t, providerFactory)
assert.Equal(t, "rulebased", providerFactory.Name().String())
}
func TestNew(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
require.NoError(t, err)
assert.NotNil(t, provider)
// Verify provider implements the interface correctly
assert.Implements(t, (*nfmanager.NotificationManager)(nil), provider)
}
func TestProvider_SetNotificationConfig(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
require.NoError(t, err)
tests := []struct {
name string
orgID string
ruleID string
config *alertmanagertypes.NotificationConfig
wantErr bool
}{
{
name: "valid parameters",
orgID: "org1",
ruleID: "rule1",
config: &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 2 * time.Hour,
NoDataInterval: 2 * time.Hour,
},
},
wantErr: false,
},
{
name: "empty orgID",
orgID: "",
ruleID: "rule1",
config: &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: time.Hour,
NoDataInterval: time.Hour,
},
},
wantErr: true, // Should error due to validation
},
{
name: "empty ruleID",
orgID: "org1",
ruleID: "",
config: &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: time.Hour,
NoDataInterval: time.Hour,
},
},
wantErr: true, // Should error due to validation
},
{
name: "nil config",
orgID: "org1",
ruleID: "rule1",
config: nil,
wantErr: true, // Should error due to nil config
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := provider.SetNotificationConfig(tt.orgID, tt.ruleID, tt.config)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
// If we set a config successfully, we should be able to retrieve it
if tt.orgID != "" && tt.ruleID != "" && tt.config != nil {
retrievedConfig, retrieveErr := provider.GetNotificationConfig(tt.orgID, tt.ruleID)
assert.NoError(t, retrieveErr)
assert.NotNil(t, retrievedConfig)
assert.Equal(t, tt.config.Renotify, retrievedConfig.Renotify)
}
}
})
}
}
func TestProvider_GetNotificationConfig(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
require.NoError(t, err)
orgID := "test-org"
ruleID := "rule1"
customConfig := &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 30 * time.Minute,
NoDataInterval: 30 * time.Minute,
},
}
ruleId1 := "rule-1"
customConfig1 := &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{
model.LabelName("group1"): {},
model.LabelName("group2"): {},
},
}
// Set config for alert1
err = provider.SetNotificationConfig(orgID, ruleID, customConfig)
require.NoError(t, err)
err = provider.SetNotificationConfig(orgID, ruleId1, customConfig1)
require.NoError(t, err)
tests := []struct {
name string
orgID string
ruleID string
alert *types.Alert
expectedConfig *alertmanagertypes.NotificationConfig
shouldFallback bool
}{
{
name: "existing config",
orgID: orgID,
ruleID: ruleID,
expectedConfig: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{
model.LabelName("ruleId"): {},
},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 30 * time.Minute,
NoDataInterval: 30 * time.Minute,
},
},
shouldFallback: false,
},
{
name: "non-existing config - fallback",
orgID: orgID,
ruleID: ruleId1,
expectedConfig: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{
model.LabelName("group1"): {},
model.LabelName("group2"): {},
model.LabelName("ruleId"): {},
},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 4 * time.Hour,
NoDataInterval: 4 * time.Hour,
},
}, // Will get fallback from standardnotification
shouldFallback: false,
},
{
name: "empty orgID - fallback",
orgID: "",
ruleID: ruleID,
expectedConfig: nil, // Will get fallback
shouldFallback: true,
},
{
name: "nil alert - fallback",
orgID: orgID,
ruleID: "rule3", // Different ruleID to get fallback
alert: nil,
expectedConfig: nil, // Will get fallback
shouldFallback: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := provider.GetNotificationConfig(tt.orgID, tt.ruleID)
assert.NoError(t, err)
if tt.shouldFallback {
// Should get fallback config (4 hour default)
assert.NotNil(t, config)
assert.Equal(t, 4*time.Hour, config.Renotify.RenotifyInterval)
} else {
// Should get our custom config
assert.NotNil(t, config)
assert.Equal(t, tt.expectedConfig, config)
}
})
}
}
func TestProvider_ConcurrentAccess(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
require.NoError(t, err)
orgID := "test-org"
var wg sync.WaitGroup
// Writer goroutine
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
config := &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: time.Duration(i+1) * time.Minute,
NoDataInterval: time.Duration(i+1) * time.Minute,
},
}
err := provider.SetNotificationConfig(orgID, "rule1", config)
assert.NoError(t, err)
}
}()
// Reader goroutine
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 50; i++ {
config, err := provider.GetNotificationConfig(orgID, "rule1")
assert.NoError(t, err)
assert.NotNil(t, config)
}
}()
// Wait for both goroutines to complete
wg.Wait()
}

View File

@ -5,6 +5,7 @@ import (
"sync"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
@ -32,6 +33,8 @@ type Service struct {
// Mutex to protect the servers map
serversMtx sync.RWMutex
notificationManager nfmanager.NotificationManager
}
func New(
@ -41,6 +44,7 @@ func New(
stateStore alertmanagertypes.StateStore,
configStore alertmanagertypes.ConfigStore,
orgGetter organization.Getter,
nfManager nfmanager.NotificationManager,
) *Service {
service := &Service{
config: config,
@ -50,6 +54,7 @@ func New(
settings: settings,
servers: make(map[string]*alertmanagerserver.Server),
serversMtx: sync.RWMutex{},
notificationManager: nfManager,
}
return service
@ -167,7 +172,7 @@ func (service *Service) newServer(ctx context.Context, orgID string) (*alertmana
return nil, err
}
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore)
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore, service.notificationManager)
if err != nil {
return nil, err
}

View File

@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
@ -20,16 +21,17 @@ type provider struct {
settings factory.ScopedProviderSettings
configStore alertmanagertypes.ConfigStore
stateStore alertmanagertypes.StateStore
notificationManager nfmanager.NotificationManager
stopC chan struct{}
}
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, settings factory.ProviderSettings, config alertmanager.Config) (alertmanager.Alertmanager, error) {
return New(ctx, settings, config, sqlstore, orgGetter)
return New(ctx, settings, config, sqlstore, orgGetter, notificationManager)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter) (*provider, error) {
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) (*provider, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager")
configStore := sqlalertmanagerstore.NewConfigStore(sqlstore)
stateStore := sqlalertmanagerstore.NewStateStore(sqlstore)
@ -42,11 +44,13 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
stateStore,
configStore,
orgGetter,
notificationManager,
),
settings: settings,
config: config,
configStore: configStore,
stateStore: stateStore,
notificationManager: notificationManager,
stopC: make(chan struct{}),
}
@ -191,3 +195,19 @@ func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[s
return alertmanagertypes.NewStatsFromChannels(channels), nil
}
func (provider *provider) SetNotificationConfig(ctx context.Context, orgID valuer.UUID, ruleId string, config *alertmanagertypes.NotificationConfig) error {
err := provider.notificationManager.SetNotificationConfig(orgID.StringValue(), ruleId, config)
if err != nil {
return err
}
return nil
}
func (provider *provider) DeleteNotificationConfig(ctx context.Context, orgID valuer.UUID, ruleId string) error {
err := provider.notificationManager.DeleteNotificationConfig(orgID.StringValue(), ruleId)
if err != nil {
return err
}
return nil
}

View File

@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@ -35,7 +36,9 @@ func TestRegenerateConnectionUrlWithUpdatedConfig(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlStore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter, notificationManager)
require.NoError(err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()
@ -92,7 +95,9 @@ func TestAgentCheckIns(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlStore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter, notificationManager)
require.NoError(err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()
@ -188,7 +193,9 @@ func TestCantDisconnectNonExistentAccount(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlStore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter, notificationManager)
require.NoError(err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()
@ -216,7 +223,9 @@ func TestConfigureService(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlStore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter, notificationManager)
require.NoError(err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@ -29,7 +30,8 @@ func TestIntegrationLifecycle(t *testing.T) {
providerSettings := instrumentationtest.New().ToProviderSettings()
sharder, _ := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
orgGetter := implorganization.NewGetter(implorganization.NewStore(store), sharder)
alertmanager, _ := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, store, orgGetter)
notificationManager := nfmanagertest.NewMock()
alertmanager, _ := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, store, orgGetter, notificationManager)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()
analytics := analyticstest.New()

View File

@ -226,6 +226,7 @@ func NewManager(o *ManagerOptions) (*Manager, error) {
sqlstore: o.SqlStore,
}
zap.L().Debug("Manager created successfully with NotificationGroup")
return m, nil
}
@ -278,7 +279,14 @@ func (m *Manager) initiate(ctx context.Context) error {
loadErrors = append(loadErrors, err)
continue
}
if parsedRule.NotificationSettings != nil {
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, org.ID, rec.ID.StringValue(), &config)
if err != nil {
loadErrors = append(loadErrors, err)
zap.L().Info("failed to set rule notification config", zap.String("ruleId", rec.ID.StringValue()))
}
}
if !parsedRule.Disabled {
err := m.addTask(ctx, org.ID, &parsedRule, taskName)
if err != nil {
@ -360,17 +368,22 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
} else {
preferredChannels = parsedRule.PreferredChannels
}
err = cfg.UpdateRuleIDMatcher(id.StringValue(), preferredChannels)
if err != nil {
return err
}
if parsedRule.NotificationSettings != nil {
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, orgID, existingRule.ID.StringValue(), &config)
if err != nil {
return err
}
}
err = m.alertmanager.SetConfig(ctx, cfg)
if err != nil {
return err
}
err = m.syncRuleStateWithTask(ctx, orgID, prepareTaskName(existingRule.ID.StringValue()), &parsedRule)
if err != nil {
return err
@ -453,6 +466,11 @@ func (m *Manager) DeleteRule(ctx context.Context, idStr string) error {
return err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return err
}
return m.ruleStore.DeleteRule(ctx, id, func(ctx context.Context) error {
cfg, err := m.alertmanager.GetConfig(ctx, claims.OrgID)
if err != nil {
@ -469,6 +487,8 @@ func (m *Manager) DeleteRule(ctx context.Context, idStr string) error {
return err
}
err = m.alertmanager.DeleteNotificationConfig(ctx, orgID, id.String())
taskName := prepareTaskName(id.StringValue())
m.deleteTask(taskName)
@ -547,6 +567,14 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
preferredChannels = parsedRule.PreferredChannels
}
if parsedRule.NotificationSettings != nil {
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, orgID, storedRule.ID.StringValue(), &config)
if err != nil {
return err
}
}
err = cfg.CreateRuleIDMatcher(id.StringValue(), preferredChannels)
if err != nil {
return err
@ -558,7 +586,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
}
taskName := prepareTaskName(id.StringValue())
if err := m.addTask(ctx, orgID, &parsedRule, taskName); err != nil {
if err = m.addTask(ctx, orgID, &parsedRule, taskName); err != nil {
return err
}
@ -732,13 +760,12 @@ func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
alert := alerts[0]
generatorURL := alert.GeneratorURL
a := &alertmanagertypes.PostableAlert{
Annotations: alert.Annotations.Map(),
StartsAt: strfmt.DateTime(alert.FiredAt),
Alert: alertmanagertypes.AlertModel{
a := &alertmanagertypes.PostableAlert{}
a.Annotations = alert.Annotations.Map()
a.StartsAt = strfmt.DateTime(alert.FiredAt)
a.Alert = alertmanagertypes.AlertModel{
Labels: alert.Labels.Map(),
GeneratorURL: strfmt.URI(generatorURL),
},
}
if !alert.ResolvedAt.IsZero() {
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)

View File

@ -5,6 +5,7 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
@ -265,7 +266,8 @@ func setupTestManager(t *testing.T) (*Manager, *rulestoretest.MockSQLRuleStore,
t.Fatalf("Failed to create noop sharder: %v", err)
}
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), noopSharder)
alertManager, err := signozalertmanager.New(context.TODO(), settings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter)
notificationManager := nfmanagertest.NewMock()
alertManager, err := signozalertmanager.New(context.TODO(), settings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter, notificationManager)
if err != nil {
t.Fatalf("Failed to create alert manager: %v", err)
}

View File

@ -654,6 +654,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (interface{}, er
}
if smpl.IsMissing {
lb.Set(labels.AlertNameLabel, "[No data] "+r.Name())
lb.Set(labels.NoDataLabel, "true")
}
// Links with timestamps should go in annotations since labels

View File

@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@ -312,7 +313,9 @@ func NewFilterSuggestionsTestBed(t *testing.T) *FilterSuggestionsTestBed {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter, notificationManager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@ -14,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@ -492,7 +493,9 @@ func NewTestbedWithoutOpamp(t *testing.T, sqlStore sqlstore.SQLStore) *LogPipeli
sharder, err := noopsharder.New(context.Background(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlStore), sharder)
alertmanager, err := signozalertmanager.New(context.Background(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.Background(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, sqlStore, orgGetter, notificationManager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@ -373,7 +374,9 @@ func NewCloudIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *CloudI
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter)
nfmanager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter, nfmanager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@ -588,7 +589,11 @@ func NewIntegrationsTestBed(t *testing.T, testDB sqlstore.SQLStore) *Integration
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter)
nfManager := nfmanagertest.NewMock()
if err != nil {
t.Fatal(err)
}
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter, nfManager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@ -17,6 +17,7 @@ const (
MetricNameLabel = "__name__"
TemporalityLabel = "__temporality__"
AlertNameLabel = "alertname"
NoDataLabel = "nodata"
// AlertStateLabel is the label name indicating the state of an alert.
AlertStateLabel = "alertstate"

View File

@ -8,6 +8,7 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
@ -29,7 +30,9 @@ func TestNewHandlers(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@ -8,6 +8,7 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
@ -29,7 +30,9 @@ func TestNewModules(t *testing.T) {
sharder, err := noopsharder.New(context.TODO(), providerSettings, sharder.Config{})
require.NoError(t, err)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
require.NoError(t, err)
jwt := authtypes.NewJWT("", 1*time.Hour, 1*time.Hour)
emailing := emailingtest.New()

View File

@ -2,6 +2,8 @@ package signoz
import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/analytics/noopanalytics"
@ -153,9 +155,15 @@ func NewPrometheusProviderFactories(telemetryStore telemetrystore.TelemetryStore
)
}
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
func NewNotificationManagerProviderFactories() factory.NamedMap[factory.ProviderFactory[nfmanager.NotificationManager, nfmanager.Config]] {
return factory.MustNewNamedMap(
signozalertmanager.NewFactory(sqlstore, orgGetter),
rulebasednotification.NewFactory(),
)
}
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
return factory.MustNewNamedMap(
signozalertmanager.NewFactory(sqlstore, orgGetter, notificationManager),
)
}

View File

@ -4,6 +4,7 @@ import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
@ -54,7 +55,8 @@ func TestNewProviderFactories(t *testing.T) {
assert.NotPanics(t, func() {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter)
notificationManager := nfmanagertest.NewMock()
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager)
})
assert.NotPanics(t, func() {

View File

@ -2,8 +2,8 @@ package signoz
import (
"context"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/emailing"
@ -230,12 +230,24 @@ func New(
// Initialize user getter
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
// shared NotificationManager instance for both alertmanager and rules
notificationManager, err := factory.NewProviderFromNamedMap(
ctx,
providerSettings,
nfmanager.Config{},
NewNotificationManagerProviderFactories(),
"rulebased",
)
if err != nil {
return nil, err
}
// Initialize alertmanager from the available alertmanager provider factories
alertmanager, err := factory.NewProviderFromNamedMap(
ctx,
providerSettings,
config.Alertmanager,
NewAlertmanagerProviderFactories(sqlstore, orgGetter),
NewAlertmanagerProviderFactories(sqlstore, orgGetter, notificationManager),
config.Alertmanager.Provider,
)
if err != nil {
@ -315,6 +327,7 @@ func New(
Prometheus: prometheus,
Alertmanager: alertmanager,
Querier: querier,
Rules: ruler,
Zeus: zeus,
Licensing: licensing,
Emailing: emailing,

View File

@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/go-openapi/runtime/middleware"
"github.com/go-openapi/strfmt"
v2 "github.com/prometheus/alertmanager/api/v2"
"github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/api/v2/restapi/operations/alert"
@ -28,11 +27,9 @@ type (
// An alias for the Alert type from the alertmanager package.
Alert = types.Alert
// An alias for the PostableAlert type from the alertmanager package.
PostableAlert = models.PostableAlert
// A slice of PostableAlert.
PostableAlerts = []*PostableAlert
PostableAlerts = models.PostableAlerts
// An alias for the GettableAlert type from the alertmanager package.
GettableAlert = models.GettableAlert
@ -86,26 +83,6 @@ func NewDeprecatedGettableAlertsFromGettableAlerts(gettableAlerts GettableAlerts
return deprecatedGettableAlerts
}
// Converts a slice of Alert to a slice of PostableAlert.
func NewPostableAlertsFromAlerts(alerts []*types.Alert) PostableAlerts {
postableAlerts := make(PostableAlerts, 0, len(alerts))
for _, alert := range alerts {
start := strfmt.DateTime(alert.StartsAt)
end := strfmt.DateTime(alert.EndsAt)
postableAlerts = append(postableAlerts, &models.PostableAlert{
Annotations: v2.ModelLabelSetToAPILabelSet(alert.Annotations),
EndsAt: end,
StartsAt: start,
Alert: models.Alert{
GeneratorURL: strfmt.URI(alert.GeneratorURL),
Labels: v2.ModelLabelSetToAPILabelSet(alert.Labels),
},
})
}
return postableAlerts
}
// Converts a slice of PostableAlert to a slice of Alert.
func NewAlertsFromPostableAlerts(postableAlerts PostableAlerts, resolveTimeout time.Duration, now time.Time) ([]*types.Alert, []error) {
alerts := v2.OpenAPIAlertsToAlerts(postableAlerts)

View File

@ -14,11 +14,13 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/uptrace/bun"
)
const (
DefaultReceiverName string = "default-receiver"
DefaultGroupBy string = "ruleId"
)
var (
@ -398,3 +400,54 @@ func init() {
commoncfg.MarshalSecretValue = true
config.MarshalSecretValue = true
}
// NotificationConfig holds configuration for alert notifications timing.
type NotificationConfig struct {
NotificationGroup map[model.LabelName]struct{}
Renotify ReNotificationConfig
}
func (nc *NotificationConfig) DeepCopy() NotificationConfig {
deepCopy := *nc
deepCopy.NotificationGroup = make(map[model.LabelName]struct{})
deepCopy.Renotify.NoDataInterval = nc.Renotify.NoDataInterval
deepCopy.Renotify.RenotifyInterval = nc.Renotify.RenotifyInterval
for k, v := range nc.NotificationGroup {
deepCopy.NotificationGroup[k] = v
}
return deepCopy
}
type ReNotificationConfig struct {
NoDataInterval time.Duration
RenotifyInterval time.Duration
}
func NewNotificationConfig(groups []string, renotifyInterval time.Duration, noDataRenotifyInterval time.Duration) NotificationConfig {
notificationConfig := GetDefaultNotificationConfig()
if renotifyInterval != 0 {
notificationConfig.Renotify.RenotifyInterval = renotifyInterval
}
if noDataRenotifyInterval != 0 {
notificationConfig.Renotify.NoDataInterval = noDataRenotifyInterval
}
for _, group := range groups {
notificationConfig.NotificationGroup[model.LabelName(group)] = struct{}{}
}
return notificationConfig
}
func GetDefaultNotificationConfig() NotificationConfig {
defaultGroups := make(map[model.LabelName]struct{})
defaultGroups[model.LabelName(DefaultGroupBy)] = struct{}{}
return NotificationConfig{
NotificationGroup: defaultGroups,
Renotify: ReNotificationConfig{
RenotifyInterval: 4 * time.Hour,
NoDataInterval: 4 * time.Hour,
}, //substitute for no - notify
}
}

View File

@ -3,6 +3,8 @@ package ruletypes
import (
"context"
"encoding/json"
"fmt"
"slices"
"time"
"unicode/utf8"
@ -12,6 +14,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/utils/times"
"github.com/SigNoz/signoz/pkg/query-service/utils/timestamp"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
type AlertType string
@ -57,6 +60,47 @@ type PostableRule struct {
Evaluation *EvaluationEnvelope `yaml:"evaluation,omitempty" json:"evaluation,omitempty"`
SchemaVersion string `json:"schemaVersion,omitempty"`
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
}
type NotificationSettings struct {
NotificationGroupBy []string `json:"notificationGroupBy,omitempty"`
ReNotifyInterval Duration `json:"renotify,omitempty"`
AlertStates []model.AlertState `json:"alertStates,omitempty"`
}
func (ns *NotificationSettings) GetAlertManagerNotificationConfig() alertmanagertypes.NotificationConfig {
var renotifyInterval Duration
var noDataRenotifyInterval Duration
if slices.Contains(ns.AlertStates, model.StateNoData) {
noDataRenotifyInterval = ns.ReNotifyInterval
}
if slices.Contains(ns.AlertStates, model.StateFiring) {
renotifyInterval = ns.ReNotifyInterval
}
return alertmanagertypes.NewNotificationConfig(ns.NotificationGroupBy, time.Duration(renotifyInterval), time.Duration(noDataRenotifyInterval))
}
func (ns *NotificationSettings) UnmarshalJSON(data []byte) error {
type Alias NotificationSettings
aux := &struct {
*Alias
}{
Alias: (*Alias)(ns),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
// Validate states after unmarshaling
for _, state := range ns.AlertStates {
if state != model.StateFiring && state != model.StateNoData {
return fmt.Errorf("invalid alert state: %s", state)
}
}
return nil
}
func (r *PostableRule) processRuleDefaults() error {