mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +00:00
Merge branch 'main' into thirdpartylistapifix
This commit is contained in:
commit
2f1bd624e6
4
.gitignore
vendored
4
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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'],
|
||||
|
||||
81
frontend/public/Images/cloud.svg
Normal file
81
frontend/public/Images/cloud.svg
Normal 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 |
36
frontend/src/api/routingPolicies/createRoutingPolicy.ts
Normal file
36
frontend/src/api/routingPolicies/createRoutingPolicy.ts
Normal 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;
|
||||
30
frontend/src/api/routingPolicies/deleteRoutingPolicy.ts
Normal file
30
frontend/src/api/routingPolicies/deleteRoutingPolicy.ts
Normal 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;
|
||||
40
frontend/src/api/routingPolicies/getRoutingPolicies.ts
Normal file
40
frontend/src/api/routingPolicies/getRoutingPolicies.ts
Normal 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>);
|
||||
}
|
||||
};
|
||||
40
frontend/src/api/routingPolicies/updateRoutingPolicy.ts
Normal file
40
frontend/src/api/routingPolicies/updateRoutingPolicy.ts
Normal 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;
|
||||
117
frontend/src/components/ErrorBoundaryHOC/README.md
Normal file
117
frontend/src/components/ErrorBoundaryHOC/README.md
Normal 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.
|
||||
@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
2
frontend/src/components/ErrorBoundaryHOC/index.ts
Normal file
2
frontend/src/components/ErrorBoundaryHOC/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type { WithErrorBoundaryOptions } from './withErrorBoundary';
|
||||
export { default as withErrorBoundary } from './withErrorBoundary';
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 };
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
118
frontend/src/container/RoutingPolicies/RoutingPolicies.tsx
Normal file
118
frontend/src/container/RoutingPolicies/RoutingPolicies.tsx
Normal 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;
|
||||
208
frontend/src/container/RoutingPolicies/RoutingPolicyDetails.tsx
Normal file
208
frontend/src/container/RoutingPolicies/RoutingPolicyDetails.tsx
Normal 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;
|
||||
73
frontend/src/container/RoutingPolicies/RoutingPolicyList.tsx
Normal file
73
frontend/src/container/RoutingPolicies/RoutingPolicyList.tsx
Normal 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;
|
||||
137
frontend/src/container/RoutingPolicies/RoutingPolicyListItem.tsx
Normal file
137
frontend/src/container/RoutingPolicies/RoutingPolicyListItem.tsx
Normal 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;
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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('-');
|
||||
});
|
||||
});
|
||||
121
frontend/src/container/RoutingPolicies/__tests__/testUtils.ts
Normal file
121
frontend/src/container/RoutingPolicies/__tests__/testUtils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
8
frontend/src/container/RoutingPolicies/constants.ts
Normal file
8
frontend/src/container/RoutingPolicies/constants.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { RoutingPolicyDetailsFormState } from './types';
|
||||
|
||||
export const INITIAL_ROUTING_POLICY_DETAILS_FORM_STATE: RoutingPolicyDetailsFormState = {
|
||||
name: '',
|
||||
expression: '',
|
||||
channels: [],
|
||||
description: '',
|
||||
};
|
||||
3
frontend/src/container/RoutingPolicies/index.ts
Normal file
3
frontend/src/container/RoutingPolicies/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import RoutingPolicies from './RoutingPolicies';
|
||||
|
||||
export default RoutingPolicies;
|
||||
452
frontend/src/container/RoutingPolicies/styles.scss
Normal file
452
frontend/src/container/RoutingPolicies/styles.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
115
frontend/src/container/RoutingPolicies/types.ts
Normal file
115
frontend/src/container/RoutingPolicies/types.ts
Normal 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;
|
||||
}
|
||||
240
frontend/src/container/RoutingPolicies/useRoutingPolicies.ts
Normal file
240
frontend/src/container/RoutingPolicies/useRoutingPolicies.ts
Normal 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;
|
||||
61
frontend/src/container/RoutingPolicies/utils.tsx
Normal file
61
frontend/src/container/RoutingPolicies/utils.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
24
frontend/src/hooks/routingPolicies/useCreateRoutingPolicy.ts
Normal file
24
frontend/src/hooks/routingPolicies/useCreateRoutingPolicy.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
19
frontend/src/hooks/routingPolicies/useDeleteRoutingPolicy.ts
Normal file
19
frontend/src/hooks/routingPolicies/useDeleteRoutingPolicy.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
39
frontend/src/hooks/routingPolicies/useGetRoutingPolicies.ts
Normal file
39
frontend/src/hooks/routingPolicies/useGetRoutingPolicies.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
25
frontend/src/hooks/routingPolicies/useUpdateRoutingPolicy.ts
Normal file
25
frontend/src/hooks/routingPolicies/useUpdateRoutingPolicy.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
563
pkg/alertmanager/alertmanagerserver/dispatcher.go
Normal file
563
pkg/alertmanager/alertmanagerserver/dispatcher.go
Normal 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
|
||||
}
|
||||
}
|
||||
1013
pkg/alertmanager/alertmanagerserver/distpatcher_test.go
Normal file
1013
pkg/alertmanager/alertmanagerserver/distpatcher_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
43
pkg/alertmanager/alertmanagerserver/telemetry.go
Normal file
43
pkg/alertmanager/alertmanagerserver/telemetry.go
Normal 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
|
||||
}
|
||||
18
pkg/alertmanager/nfmanager/config.go
Normal file
18
pkg/alertmanager/nfmanager/config.go
Normal 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
|
||||
}
|
||||
75
pkg/alertmanager/nfmanager/nfmanagertest/provider.go
Normal file
75
pkg/alertmanager/nfmanager/nfmanagertest/provider.go
Normal 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 ¬ificationConfig, 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
|
||||
}
|
||||
13
pkg/alertmanager/nfmanager/notificationmanager.go
Normal file
13
pkg/alertmanager/nfmanager/notificationmanager.go
Normal 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
|
||||
}
|
||||
103
pkg/alertmanager/nfmanager/rulebasednotification/provider.go
Normal file
103
pkg/alertmanager/nfmanager/rulebasednotification/provider.go
Normal 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 ¬ificationConfig, 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 ¬ificationConfig, 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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user