diff --git a/.gitignore b/.gitignore index 014c7c2800bc..c002fbe276c1 100644 --- a/.gitignore +++ b/.gitignore @@ -230,6 +230,6 @@ poetry.toml # LSP config files pyrightconfig.json -# End of https://www.toptal.com/developers/gitignore/api/python -frontend/.cursor/rules/ \ No newline at end of file +# cursor files +frontend/.cursor/ diff --git a/ee/query-service/app/server.go b/ee/query-service/app/server.go index a963cf4e33f2..dab2b7f51630 100644 --- a/ee/query-service/app/server.go +++ b/ee/query-service/app/server.go @@ -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 diff --git a/ee/query-service/rules/anomaly.go b/ee/query-service/rules/anomaly.go index 2ac3b56cb949..53f205e8d004 100644 --- a/ee/query-service/rules/anomaly.go +++ b/ee/query-service/rules/anomaly.go @@ -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() diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 1d9255a329e8..a5e8d86c3fce 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -3,6 +3,7 @@ import type { Config } from '@jest/types'; const USE_SAFE_NAVIGATE_MOCK_PATH = '/__mocks__/useSafeNavigate.ts'; const config: Config.InitialOptions = { + silent: true, clearMocks: true, coverageDirectory: 'coverage', coverageReporters: ['text', 'cobertura', 'html', 'json-summary'], diff --git a/frontend/public/Images/cloud.svg b/frontend/public/Images/cloud.svg new file mode 100644 index 000000000000..c7138d589b2f --- /dev/null +++ b/frontend/public/Images/cloud.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/api/routingPolicies/createRoutingPolicy.ts b/frontend/src/api/routingPolicies/createRoutingPolicy.ts new file mode 100644 index 000000000000..5ec9847b69b7 --- /dev/null +++ b/frontend/src/api/routingPolicies/createRoutingPolicy.ts @@ -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 | ErrorResponseV2 +> => { + try { + const response = await axios.post(`/notification-policy`, props); + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + return ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default createRoutingPolicy; diff --git a/frontend/src/api/routingPolicies/deleteRoutingPolicy.ts b/frontend/src/api/routingPolicies/deleteRoutingPolicy.ts new file mode 100644 index 000000000000..5b0d3df14d97 --- /dev/null +++ b/frontend/src/api/routingPolicies/deleteRoutingPolicy.ts @@ -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 | ErrorResponseV2 +> => { + try { + const response = await axios.delete( + `/notification-policy/${routingPolicyId}`, + ); + + return { + httpStatusCode: response.status, + data: response.data, + }; + } catch (error) { + return ErrorResponseHandlerV2(error as AxiosError); + } +}; + +export default deleteRoutingPolicy; diff --git a/frontend/src/api/routingPolicies/getRoutingPolicies.ts b/frontend/src/api/routingPolicies/getRoutingPolicies.ts new file mode 100644 index 000000000000..43191aebd77f --- /dev/null +++ b/frontend/src/api/routingPolicies/getRoutingPolicies.ts @@ -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, +): Promise | 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); + } +}; diff --git a/frontend/src/api/routingPolicies/updateRoutingPolicy.ts b/frontend/src/api/routingPolicies/updateRoutingPolicy.ts new file mode 100644 index 000000000000..08448562cdd0 --- /dev/null +++ b/frontend/src/api/routingPolicies/updateRoutingPolicy.ts @@ -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 | 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); + } +}; + +export default updateRoutingPolicy; diff --git a/frontend/src/components/ErrorBoundaryHOC/README.md b/frontend/src/components/ErrorBoundaryHOC/README.md new file mode 100644 index 000000000000..4022cc96681d --- /dev/null +++ b/frontend/src/components/ErrorBoundaryHOC/README.md @@ -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 + +``` + +## Advanced Usage + +### Custom Fallback Component + +```tsx +const CustomFallback = () => ( +
+

Oops! Something went wrong

+ +
+); + +const SafeComponent = withErrorBoundary(MyComponent, { + fallback: +}); +``` + +### 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

(component, options?)` + +#### Parameters + +- `component: ComponentType

` - 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; + 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. diff --git a/frontend/src/components/ErrorBoundaryHOC/__tests__/withErrorBoundary.test.tsx b/frontend/src/components/ErrorBoundaryHOC/__tests__/withErrorBoundary.test.tsx new file mode 100644 index 000000000000..3cec7083326a --- /dev/null +++ b/frontend/src/components/ErrorBoundaryHOC/__tests__/withErrorBoundary.test.tsx @@ -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

{fallback}
; + } + return
{children}
; + } + } + + return { + ErrorBoundary: MockErrorBoundary, + SeverityLevel: { + error: 'error', + warning: 'warning', + info: 'info', + }, + }; +}); + +jest.mock( + '../../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback', + () => + function MockErrorBoundaryFallback(): JSX.Element { + return ( +
Default Error Fallback
+ ); + }, +); + +// 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
{message}
; +} + +TestComponent.defaultProps = { + shouldThrow: false, + message: 'Test Component', +}; + +// Test component with display name +function NamedComponent(): JSX.Element { + return
Named Component
; +} +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(); + + // 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(); + + // 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 = ( +
Custom Error UI
+ ); + const options: WithErrorBoundaryOptions = { + fallback: customFallback, + }; + const SafeComponent = withErrorBoundary(TestComponent, options); + + // Act + render(); + + // 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(); + + // 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
Anonymous
; + } + + // Act + const SafeAnonymousComponent = withErrorBoundary(AnonymousComponent); + + // Assert + expect(SafeAnonymousComponent.displayName).toBe( + 'withErrorBoundary(AnonymousComponent)', + ); + }); +}); diff --git a/frontend/src/components/ErrorBoundaryHOC/index.ts b/frontend/src/components/ErrorBoundaryHOC/index.ts new file mode 100644 index 000000000000..1e7e5a6ae10c --- /dev/null +++ b/frontend/src/components/ErrorBoundaryHOC/index.ts @@ -0,0 +1,2 @@ +export type { WithErrorBoundaryOptions } from './withErrorBoundary'; +export { default as withErrorBoundary } from './withErrorBoundary'; diff --git a/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.example.tsx b/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.example.tsx new file mode 100644 index 000000000000..ce0c83fa537b --- /dev/null +++ b/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.example.tsx @@ -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 ( +
+

Problematic Component

+

This component can throw errors when the button is clicked.

+ +
+ ); +} + +/** + * Basic usage - wraps component with default error boundary + */ +export const SafeProblematicComponent = withErrorBoundary(ProblematicComponent); + +/** + * Usage with custom fallback component + */ +function CustomErrorFallback(): JSX.Element { + return ( +
+

Custom Error Fallback

+

Something went wrong in this specific component!

+ +
+ ); +} + +export const SafeProblematicComponentWithCustomFallback = withErrorBoundary( + ProblematicComponent, + { + fallback: , + }, +); + +/** + * 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 ( +
+

{title}

+
    + {data.map((item, index) => ( + // eslint-disable-next-line react/no-array-index-key +
  • {item.name}
  • + ))} +
+
+ ); +} + +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 ( +
+

Error Boundary HOC Examples

+ +
+

1. Basic Usage

+ +
+ +
+

2. With Custom Fallback

+ +
+ +
+

3. With Custom Error Handler

+ +
+ +
+

4. Wrapped Existing Component

+ +
+
+ ); +} diff --git a/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.tsx b/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.tsx new file mode 100644 index 000000000000..62c552641506 --- /dev/null +++ b/frontend/src/components/ErrorBoundaryHOC/withErrorBoundary.tsx @@ -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; + 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:
Something went wrong!
+ * }); + * + * @example + * // With custom error handler + * const SafeComponent = withErrorBoundary(MyComponent, { + * onError: (error, errorInfo) => { + * console.error('Component error:', error, errorInfo); + * } + * }); + */ +function withErrorBoundary

>( + WrappedComponent: ComponentType

, + options: WithErrorBoundaryOptions = {}, +): ComponentType

{ + const { + fallback = , + onError, + sentryOptions = {}, + } = options; + + function WithErrorBoundaryComponent(props: P): JSX.Element { + return ( + { + // 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 */} + + + ); + } + + // Set display name for debugging purposes + WithErrorBoundaryComponent.displayName = `withErrorBoundary(${ + WrappedComponent.displayName || WrappedComponent.name || 'Component' + })`; + + return WithErrorBoundaryComponent; +} + +export default withErrorBoundary; +export type { WithErrorBoundaryOptions }; diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index 6d34aa4b29c0..f59f2c21124b 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -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; diff --git a/frontend/src/container/AllError/index.tsx b/frontend/src/container/AllError/index.tsx index 58596e5bf64e..3cbda162c04a 100644 --- a/frontend/src/container/AllError/index.tsx +++ b/frontend/src/container/AllError/index.tsx @@ -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, }} diff --git a/frontend/src/container/AllError/tests/AllError.test.tsx b/frontend/src/container/AllError/tests/AllError.test.tsx index 25124edac053..f9ed1efc3763 100644 --- a/frontend/src/container/AllError/tests/AllError.test.tsx +++ b/frontend/src/container/AllError/tests/AllError.test.tsx @@ -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( + , + ); + + // 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( + , + ); + 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( + , + ); + 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'); + }); + }); + }); }); diff --git a/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/TopErrors.tsx b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/TopErrors.tsx index 909a46558106..bc9c839e642b 100644 --- a/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/TopErrors.tsx +++ b/frontend/src/container/ApiMonitoring/Explorer/Domains/DomainDetails/TopErrors.tsx @@ -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); diff --git a/frontend/src/container/MetricsExplorer/Summary/__tests__/Summary.test.tsx b/frontend/src/container/MetricsExplorer/Summary/__tests__/Summary.test.tsx index 48522c686875..0923e405cf47 100644 --- a/frontend/src/container/MetricsExplorer/Summary/__tests__/Summary.test.tsx +++ b/frontend/src/container/MetricsExplorer/Summary/__tests__/Summary.test.tsx @@ -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'; diff --git a/frontend/src/container/RoutingPolicies/DeleteRoutingPolicy.tsx b/frontend/src/container/RoutingPolicies/DeleteRoutingPolicy.tsx new file mode 100644 index 000000000000..da2d78935231 --- /dev/null +++ b/frontend/src/container/RoutingPolicies/DeleteRoutingPolicy.tsx @@ -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 ( + Delete Routing Policy} + open + closable={false} + onCancel={handleClose} + footer={[ + , + , + ]} + > + + {`Are you sure you want to delete ${routingPolicy?.name} routing policy? Deleting a routing policy is irreversible and cannot be undone.`} + + + ); +} + +export default DeleteRoutingPolicy; diff --git a/frontend/src/container/RoutingPolicies/RoutingPolicies.tsx b/frontend/src/container/RoutingPolicies/RoutingPolicies.tsx new file mode 100644 index 000000000000..b5cb3f08d4f5 --- /dev/null +++ b/frontend/src/container/RoutingPolicies/RoutingPolicies.tsx @@ -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): void => { + setSearchTerm(e.target.value || ''); + }; + + return ( +

+
+ Routing Policies + + Create and manage routing policies. + + + } + value={searchTerm} + onChange={handleSearch} + /> + + + + +
+ + {policyDetailsModalState.isOpen && ( + + )} + {isDeleteModalOpen && ( + + )} +
+
+ ); +} + +export default RoutingPolicies; diff --git a/frontend/src/container/RoutingPolicies/RoutingPolicyDetails.tsx b/frontend/src/container/RoutingPolicies/RoutingPolicyDetails.tsx new file mode 100644 index 000000000000..0a0726fe0714 --- /dev/null +++ b/frontend/src/container/RoutingPolicies/RoutingPolicyDetails.tsx @@ -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 = ( + + + No channels yet. + {user?.role === USER_ROLES.ADMIN ? ( + + Create one + + + ) : ( + Please ask your admin to create one. + )} + + + + ); + + return ( + {modalTitle}} + centered + open + className="create-policy-modal" + width={600} + onCancel={closeModal} + footer={null} + maskClosable={false} + > + + + form={form} + initialValues={initialFormState} + onFinish={handleSave} + > +
+
+ Routing Policy Name + + + +
+
+ Description + + + +
+
+ Expression + + + +
+
+ Notification Channels + +