mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-05 13:22:00 +00:00
Compare commits
3 Commits
fix/planne
...
SIG_3522
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7dc942d0 | ||
|
|
0e1bb5fd91 | ||
|
|
d7a743cea9 |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.113.0
|
||||
image: signoz/signoz:v0.114.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -241,7 +241,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.113.0
|
||||
image: signoz/signoz:v0.114.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
@@ -139,7 +139,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -167,7 +167,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.113.0}
|
||||
image: signoz/signoz:${VERSION:-v0.114.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -229,7 +229,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.113.0}
|
||||
image: signoz/signoz:${VERSION:-v0.114.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -157,7 +157,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import * as timeUtils from 'utils/timeUtils';
|
||||
|
||||
import CustomTimePicker from './CustomTimePicker';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useLocation: jest.fn().mockReturnValue({
|
||||
pathname: '/test-path',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('providers/Timezone', () => {
|
||||
const actual = jest.requireActual('providers/Timezone');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useTimezone: jest.fn().mockReturnValue({
|
||||
timezone: {
|
||||
value: 'UTC',
|
||||
offset: '+00:00',
|
||||
name: 'UTC',
|
||||
},
|
||||
browserTimezone: {
|
||||
value: 'UTC',
|
||||
offset: '+00:00',
|
||||
name: 'UTC',
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
interface WrapperProps {
|
||||
initialValue?: string;
|
||||
showLiveLogs?: boolean;
|
||||
onValidCustomDateChange?: () => void;
|
||||
onError?: () => void;
|
||||
onSelect?: (value: string) => void;
|
||||
onCustomDateHandler?: () => void;
|
||||
onCustomTimeStatusUpdate?: () => void;
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
initialValue = '2024-01-01 00:00:00 - 2024-01-01 01:00:00',
|
||||
showLiveLogs = false,
|
||||
onValidCustomDateChange = (): void => {},
|
||||
onError = (): void => {},
|
||||
onSelect = (): void => {},
|
||||
onCustomDateHandler = (): void => {},
|
||||
onCustomTimeStatusUpdate = (): void => {},
|
||||
}: WrapperProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedTime, setSelectedTime] = useState('custom');
|
||||
const [selectedValue, setSelectedValue] = useState(initialValue);
|
||||
|
||||
const handleSelect = (value: string): void => {
|
||||
setSelectedTime(value);
|
||||
onSelect(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomTimePicker
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onSelect={handleSelect}
|
||||
onError={onError}
|
||||
selectedTime={selectedTime}
|
||||
selectedValue={selectedValue}
|
||||
onValidCustomDateChange={({ timeStr }): void => {
|
||||
setSelectedValue(timeStr);
|
||||
onValidCustomDateChange();
|
||||
}}
|
||||
onCustomDateHandler={(): void => {
|
||||
onCustomDateHandler();
|
||||
}}
|
||||
onCustomTimeStatusUpdate={(): void => {
|
||||
onCustomTimeStatusUpdate();
|
||||
}}
|
||||
items={[
|
||||
{ label: 'Last 5 minutes', value: '5m' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
]}
|
||||
minTime={dayjs('2024-01-01 00:00:00').valueOf() * 1000_000}
|
||||
maxTime={dayjs('2024-01-01 01:00:00').valueOf() * 1000_000}
|
||||
showLiveLogs={showLiveLogs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CustomTimePicker', () => {
|
||||
it('does not close or reset when clicking input while open', () => {
|
||||
render(<Wrapper />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
// Open popover
|
||||
fireEvent.focus(input);
|
||||
|
||||
// Type some text
|
||||
fireEvent.change(input, { target: { value: '5m' } });
|
||||
|
||||
// Click the input again while open
|
||||
fireEvent.mouseDown(input);
|
||||
fireEvent.click(input);
|
||||
|
||||
// Value should remain as typed
|
||||
expect((input as HTMLInputElement).value).toBe('5m');
|
||||
});
|
||||
|
||||
it('applies valid shorthand on Enter', () => {
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: '5m' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(onValid).toHaveBeenCalledTimes(1);
|
||||
// onError(false) may be called by internal reset logic; we only assert that
|
||||
// it was never called with a truthy error state
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('sets error and updates custom time status for invalid shorthand exceeding max allowed window', () => {
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
const onCustomTimeStatusUpdate = jest.fn();
|
||||
|
||||
render(
|
||||
<Wrapper
|
||||
onValidCustomDateChange={onValid}
|
||||
onError={onError}
|
||||
onCustomTimeStatusUpdate={onCustomTimeStatusUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
// large number of days to ensure it exceeds the 15 months allowed window
|
||||
fireEvent.change(input, { target: { value: '9999d' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(true);
|
||||
expect(onCustomTimeStatusUpdate).toHaveBeenCalledWith();
|
||||
expect(onValid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats close after change like pressing Enter (blur + chevron)', () => {
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
// Open and change value so "changed since open" is true
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: '5m' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// Click the chevron (which triggers handleClose)
|
||||
const chevron = document.querySelector(
|
||||
'.time-input-suffix-icon-badge',
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(chevron);
|
||||
|
||||
// Should have applied the value (same as Enter)
|
||||
expect(onValid).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('applies epoch start/end range on Enter via onCustomDateHandler', () => {
|
||||
const onCustomDateHandler = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
render(
|
||||
<Wrapper onCustomDateHandler={onCustomDateHandler} onError={onError} />,
|
||||
);
|
||||
|
||||
const now = dayjs().valueOf();
|
||||
const later = dayjs().add(1, 'hour').valueOf();
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: `${now} - ${later}` },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(onCustomDateHandler).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('uses validateTimeRange result for generic formatted ranges (valid case)', () => {
|
||||
const validateTimeRangeSpy = jest.spyOn(timeUtils, 'validateTimeRange');
|
||||
const onCustomDateHandler = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
validateTimeRangeSpy.mockReturnValue({
|
||||
isValid: true,
|
||||
errorDetails: undefined,
|
||||
startTimeMs: dayjs('2024-01-01 00:00:00').valueOf(),
|
||||
endTimeMs: dayjs('2024-01-01 01:00:00').valueOf(),
|
||||
});
|
||||
|
||||
render(
|
||||
<Wrapper onCustomDateHandler={onCustomDateHandler} onError={onError} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'foo - bar' },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(validateTimeRangeSpy).toHaveBeenCalled();
|
||||
expect(onCustomDateHandler).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
|
||||
validateTimeRangeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('uses validateTimeRange result for generic formatted ranges (invalid case)', () => {
|
||||
const validateTimeRangeSpy = jest.spyOn(timeUtils, 'validateTimeRange');
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
validateTimeRangeSpy.mockReturnValue({
|
||||
isValid: false,
|
||||
errorDetails: {
|
||||
message: 'Invalid range',
|
||||
code: 'INVALID_RANGE',
|
||||
description: 'Start must be before end',
|
||||
},
|
||||
startTimeMs: 0,
|
||||
endTimeMs: 0,
|
||||
});
|
||||
|
||||
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'foo - bar' },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(validateTimeRangeSpy).toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalledWith(true);
|
||||
expect(onValid).not.toHaveBeenCalled();
|
||||
|
||||
validateTimeRangeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('opens live mode with correct label', () => {
|
||||
render(<Wrapper showLiveLogs />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe('Live');
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,10 @@ function CustomTimePicker({
|
||||
const location = useLocation();
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const initialInputValueOnOpenRef = useRef<string>('');
|
||||
const hasChangedSinceOpenRef = useRef<boolean>(false);
|
||||
// Tracks if the last pointer down was on the input so we don't close the popover when user clicks the input again
|
||||
const isClickFromInputRef = useRef(false);
|
||||
|
||||
const [activeView, setActiveView] = useState<ViewType>(DEFAULT_VIEW);
|
||||
|
||||
@@ -238,6 +242,21 @@ function CustomTimePicker({
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean): void => {
|
||||
// Don't close when the user clicked the input (trigger); Ant Design treats trigger as "outside" overlay
|
||||
if (!newOpen && isClickFromInputRef.current) {
|
||||
isClickFromInputRef.current = false;
|
||||
return;
|
||||
}
|
||||
isClickFromInputRef.current = false;
|
||||
|
||||
// If the popover is trying to close and the value changed since opening,
|
||||
// treat it as if the user pressed Enter (attempt to apply the value)
|
||||
if (!newOpen && hasChangedSinceOpenRef.current) {
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
handleInputPressEnter();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(newOpen);
|
||||
|
||||
if (!newOpen) {
|
||||
@@ -406,10 +425,18 @@ function CustomTimePicker({
|
||||
const handleOpen = (e?: React.SyntheticEvent): void => {
|
||||
e?.stopPropagation?.();
|
||||
|
||||
// If the popover is already open, avoid resetting the input value
|
||||
// so that any in-progress edits are preserved.
|
||||
if (open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showLiveLogs) {
|
||||
setOpen(true);
|
||||
setSelectedTimePlaceholderValue('Live');
|
||||
setInputValue('Live');
|
||||
initialInputValueOnOpenRef.current = 'Live';
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -424,11 +451,21 @@ function CustomTimePicker({
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.UK_DATETIME_SECONDS);
|
||||
|
||||
setInputValue(`${startTime} - ${endTime}`);
|
||||
const nextValue = `${startTime} - ${endTime}`;
|
||||
setInputValue(nextValue);
|
||||
initialInputValueOnOpenRef.current = nextValue;
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
// If the value changed since opening, treat this like pressing Enter
|
||||
if (hasChangedSinceOpenRef.current) {
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
handleInputPressEnter();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setCustomDTPickerVisible?.(false);
|
||||
|
||||
@@ -450,6 +487,9 @@ function CustomTimePicker({
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleInputBlur = (): void => {
|
||||
// Track whether the value was changed since the input was opened for editing
|
||||
hasChangedSinceOpenRef.current =
|
||||
inputValue !== initialInputValueOnOpenRef.current;
|
||||
resetErrorStatus();
|
||||
};
|
||||
|
||||
@@ -552,6 +592,12 @@ function CustomTimePicker({
|
||||
readOnly={!open || showLiveLogs}
|
||||
placeholder={selectedTimePlaceholderValue}
|
||||
value={inputValue}
|
||||
onMouseDown={(e): void => {
|
||||
// Only treat as "click from input" when the actual input element is clicked (not suffix/chevron)
|
||||
if (e.target === inputRef.current?.input) {
|
||||
isClickFromInputRef.current = true;
|
||||
}
|
||||
}}
|
||||
onFocus={handleOpen}
|
||||
onClick={handleOpen}
|
||||
onChange={handleInputChange}
|
||||
|
||||
@@ -175,10 +175,7 @@ export function PlannedDowntimeForm(
|
||||
} else {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description:
|
||||
typeof response.error === 'string'
|
||||
? response.error
|
||||
: response.error?.message || 'unexpected_error',
|
||||
description: response.error || 'unexpected_error',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -359,10 +359,7 @@ export function PlannedDowntimeList({
|
||||
|
||||
useEffect(() => {
|
||||
if (downtimeSchedules.isError) {
|
||||
notifications.error({
|
||||
message:
|
||||
downtimeSchedules.error?.message?.toString() || 'Something went wrong',
|
||||
});
|
||||
notifications.error(downtimeSchedules.error);
|
||||
}
|
||||
}, [downtimeSchedules.error, downtimeSchedules.isError, notifications]);
|
||||
|
||||
|
||||
@@ -137,10 +137,7 @@ export const deleteDowntimeHandler = ({
|
||||
|
||||
export const createEditDowntimeSchedule = async (
|
||||
props: DowntimeScheduleUpdatePayload,
|
||||
): Promise<
|
||||
| SuccessResponse<PayloadProps>
|
||||
| ErrorResponse<{ code: string; message: string } | string>
|
||||
> => {
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
if (props.id) {
|
||||
return updateDowntimeSchedule({ ...props });
|
||||
}
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { NotificationProvider, useNotifications } from '../useNotifications';
|
||||
|
||||
const mockSuccess = jest.fn();
|
||||
const mockError = jest.fn();
|
||||
const mockInfo = jest.fn();
|
||||
const mockWarning = jest.fn();
|
||||
const mockOpen = jest.fn();
|
||||
const mockDestroy = jest.fn();
|
||||
|
||||
jest.mock('antd', () => ({
|
||||
notification: {
|
||||
useNotification: () => [
|
||||
{
|
||||
success: mockSuccess,
|
||||
error: mockError,
|
||||
info: mockInfo,
|
||||
warning: mockWarning,
|
||||
open: mockOpen,
|
||||
destroy: mockDestroy,
|
||||
},
|
||||
createElement('div', { 'data-testid': 'notification-holder' }),
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSetExtra = jest.fn();
|
||||
const mockCaptureMessage = jest.fn();
|
||||
|
||||
jest.mock('@sentry/react', () => ({
|
||||
setExtra: (key: string, value: unknown) => mockSetExtra(key, value),
|
||||
captureMessage: (msg: string) => mockCaptureMessage(msg),
|
||||
}));
|
||||
|
||||
describe('useNotifications', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('without provider', () => {
|
||||
it('returns default notification API and does not throw', () => {
|
||||
const { result } = renderHook(() => useNotifications());
|
||||
|
||||
expect(result.current.notifications).toBeDefined();
|
||||
expect(typeof result.current.notifications.success).toBe('function');
|
||||
expect(typeof result.current.notifications.error).toBe('function');
|
||||
expect(typeof result.current.notifications.info).toBe('function');
|
||||
expect(typeof result.current.notifications.warning).toBe('function');
|
||||
expect(typeof result.current.notifications.open).toBe('function');
|
||||
expect(typeof result.current.notifications.destroy).toBe('function');
|
||||
});
|
||||
|
||||
it('no-op methods do not throw when called', () => {
|
||||
const { result } = renderHook(() => useNotifications());
|
||||
|
||||
expect(() => {
|
||||
result.current.notifications.success({ message: 'Ok' });
|
||||
result.current.notifications.error({ message: 'Fail' });
|
||||
result.current.notifications.info({ message: 'Info' });
|
||||
result.current.notifications.warning({ message: 'Warn' });
|
||||
result.current.notifications.open({ message: 'Open' });
|
||||
result.current.notifications.destroy();
|
||||
result.current.notifications.destroy('key');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with NotificationProvider', () => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(NotificationProvider, null, children as ReactElement);
|
||||
|
||||
it('returns notification API that forwards valid payloads', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.success({
|
||||
message: 'Saved',
|
||||
description: 'Your changes were saved.',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockSuccess).toHaveBeenCalledWith({
|
||||
message: 'Saved',
|
||||
description: 'Your changes were saved.',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards valid payloads for error, info, warning, open', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.error({ message: 'Error msg' });
|
||||
});
|
||||
expect(mockError).toHaveBeenCalledWith({ message: 'Error msg' });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.info({ message: 'Info msg' });
|
||||
});
|
||||
expect(mockInfo).toHaveBeenCalledWith({ message: 'Info msg' });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.warning({ message: 'Warn msg' });
|
||||
});
|
||||
expect(mockWarning).toHaveBeenCalledWith({ message: 'Warn msg' });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.open({ message: 'Open msg' });
|
||||
});
|
||||
expect(mockOpen).toHaveBeenCalledWith({ message: 'Open msg' });
|
||||
});
|
||||
|
||||
it('forwards destroy to the underlying API', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.destroy('some-key');
|
||||
});
|
||||
expect(mockDestroy).toHaveBeenCalledWith('some-key');
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.destroy();
|
||||
});
|
||||
expect(mockDestroy).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('accepts React element as message and forwards it', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
const element = createElement('span', null, 'Custom message');
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.success({ message: element });
|
||||
});
|
||||
|
||||
expect(mockSuccess).toHaveBeenCalledWith({ message: element });
|
||||
expect(mockSetExtra).not.toHaveBeenCalled();
|
||||
expect(mockCaptureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts empty object and does not break', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
const emptyPayload = {} as Parameters<
|
||||
ReturnType<typeof useNotifications>['notifications']['success']
|
||||
>[0];
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.success(emptyPayload);
|
||||
});
|
||||
|
||||
expect(mockSuccess).toHaveBeenCalledWith(emptyPayload);
|
||||
expect(mockSetExtra).not.toHaveBeenCalled();
|
||||
expect(mockCaptureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid payloads do not break the app', () => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
createElement(NotificationProvider, null, children as ReactElement);
|
||||
|
||||
it('rejects non-object args and shows fallback', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.success(null as any);
|
||||
});
|
||||
expect(mockSetExtra).toHaveBeenCalledWith('notificationArgs', null);
|
||||
expect(mockCaptureMessage).toHaveBeenCalledWith(
|
||||
'invalid_args_for_notification',
|
||||
);
|
||||
expect(mockSuccess).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects undefined and shows fallback', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.error(undefined as any);
|
||||
});
|
||||
expect(mockSetExtra).toHaveBeenCalledWith('notificationArgs', undefined);
|
||||
expect(mockCaptureMessage).toHaveBeenCalledWith(
|
||||
'invalid_args_for_notification',
|
||||
);
|
||||
expect(mockError).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects primitive args (string, number) and shows fallback', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.info('just a string' as any);
|
||||
});
|
||||
expect(mockSetExtra).toHaveBeenCalledWith(
|
||||
'notificationArgs',
|
||||
'just a string',
|
||||
);
|
||||
expect(mockCaptureMessage).toHaveBeenCalledWith(
|
||||
'invalid_args_for_notification',
|
||||
);
|
||||
expect(mockInfo).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'Something went wrong',
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.warning(42 as any);
|
||||
});
|
||||
expect(mockSetExtra).toHaveBeenCalledWith('notificationArgs', 42);
|
||||
expect(mockCaptureMessage).toHaveBeenCalledWith(
|
||||
'invalid_args_for_notification',
|
||||
);
|
||||
expect(mockWarning).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects message as number and shows fallback', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.success({
|
||||
message: 123 as any,
|
||||
description: 'Valid description',
|
||||
});
|
||||
});
|
||||
expect(mockSetExtra).toHaveBeenCalledWith('notificationArgs', {
|
||||
message: 123,
|
||||
description: 'Valid description',
|
||||
});
|
||||
expect(mockCaptureMessage).toHaveBeenCalledWith(
|
||||
'invalid_args_for_notification',
|
||||
);
|
||||
expect(mockSuccess).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects description as number and shows fallback', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.error({
|
||||
message: 'Valid message',
|
||||
description: 999 as any,
|
||||
});
|
||||
});
|
||||
expect(mockSetExtra).toHaveBeenCalledWith('notificationArgs', {
|
||||
message: 'Valid message',
|
||||
description: 999,
|
||||
});
|
||||
expect(mockCaptureMessage).toHaveBeenCalledWith(
|
||||
'invalid_args_for_notification',
|
||||
);
|
||||
expect(mockError).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects icon as number and shows fallback', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.open({
|
||||
message: 'Valid',
|
||||
icon: 0 as any,
|
||||
});
|
||||
});
|
||||
expect(mockSetExtra).toHaveBeenCalledWith('notificationArgs', {
|
||||
message: 'Valid',
|
||||
icon: 0,
|
||||
});
|
||||
expect(mockCaptureMessage).toHaveBeenCalledWith(
|
||||
'invalid_args_for_notification',
|
||||
);
|
||||
expect(mockOpen).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects message as object (non-React element) and shows fallback', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.notifications.success({
|
||||
message: { foo: 'bar' } as any,
|
||||
});
|
||||
});
|
||||
expect(mockSetExtra).toHaveBeenCalledWith('notificationArgs', {
|
||||
message: { foo: 'bar' },
|
||||
});
|
||||
expect(mockCaptureMessage).toHaveBeenCalledWith(
|
||||
'invalid_args_for_notification',
|
||||
);
|
||||
expect(mockSuccess).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('invalid payloads never throw', () => {
|
||||
const { result } = renderHook(() => useNotifications(), { wrapper });
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.notifications.success(null as any);
|
||||
result.current.notifications.error(undefined as any);
|
||||
result.current.notifications.info(123 as any);
|
||||
result.current.notifications.warning({ message: 0 as any });
|
||||
result.current.notifications.open({
|
||||
message: 'x',
|
||||
description: [],
|
||||
} as any);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +1,30 @@
|
||||
import { isValidElement, useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import {
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
createContext,
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { notification } from 'antd';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import { create } from 'zustand';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
type Notification = {
|
||||
notifications: NotificationInstance;
|
||||
};
|
||||
|
||||
const defaultNotificationInstance: NotificationInstance = {
|
||||
success: (): void => {},
|
||||
error: (): void => {},
|
||||
info: (): void => {},
|
||||
warning: (): void => {},
|
||||
open: (): void => {},
|
||||
destroy: (): void => {},
|
||||
const defaultNotification: Notification = {
|
||||
notifications: {
|
||||
success: (): void => {},
|
||||
error: (): void => {},
|
||||
info: (): void => {},
|
||||
warning: (): void => {},
|
||||
open: (): void => {},
|
||||
destroy: (): void => {},
|
||||
},
|
||||
};
|
||||
|
||||
type NotificationFn = NotificationInstance['success'];
|
||||
|
||||
const guardNotificationPayload = (
|
||||
notificationApi: NotificationInstance,
|
||||
method: 'success' | 'error' | 'info' | 'warning' | 'open',
|
||||
): NotificationFn => {
|
||||
return (notificationArgs): void => {
|
||||
if (
|
||||
!notificationArgs ||
|
||||
typeof notificationArgs !== 'object' ||
|
||||
(notificationArgs?.message !== undefined &&
|
||||
typeof notificationArgs.message !== 'string' &&
|
||||
!isValidElement(notificationArgs.message)) ||
|
||||
(notificationArgs?.icon !== undefined &&
|
||||
typeof notificationArgs.icon !== 'string' &&
|
||||
!isValidElement(notificationArgs.icon)) ||
|
||||
(notificationArgs?.description !== undefined &&
|
||||
typeof notificationArgs.description !== 'string' &&
|
||||
!isValidElement(notificationArgs.description))
|
||||
) {
|
||||
Sentry.setExtra('notificationArgs', notificationArgs);
|
||||
Sentry.captureMessage('invalid_args_for_notification');
|
||||
|
||||
return notificationApi[method]({
|
||||
message: 'Error',
|
||||
description: 'Something went wrong',
|
||||
});
|
||||
}
|
||||
|
||||
return notificationApi[method](notificationArgs);
|
||||
};
|
||||
};
|
||||
|
||||
type NotificationStore = Notification & {
|
||||
setNotifications: (notifications: NotificationInstance) => void;
|
||||
};
|
||||
|
||||
export const useNotificationStore = create<NotificationStore>(
|
||||
(set): NotificationStore => ({
|
||||
notifications: defaultNotificationInstance,
|
||||
setNotifications: (notifications: NotificationInstance): void =>
|
||||
set({
|
||||
notifications: {
|
||||
success: guardNotificationPayload(notifications, 'success'),
|
||||
error: guardNotificationPayload(notifications, 'error'),
|
||||
info: guardNotificationPayload(notifications, 'info'),
|
||||
warning: guardNotificationPayload(notifications, 'warning'),
|
||||
open: guardNotificationPayload(notifications, 'open'),
|
||||
destroy: (key?: string): void => notifications.destroy(key),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
export const NotificationContext = createContext<Notification>(
|
||||
defaultNotification,
|
||||
);
|
||||
|
||||
export function NotificationProvider({
|
||||
@@ -78,21 +33,17 @@ export function NotificationProvider({
|
||||
children: JSX.Element;
|
||||
}): JSX.Element {
|
||||
const [notificationApi, NotificationElement] = notification.useNotification();
|
||||
const setNotifications = useNotificationStore((s) => s.setNotifications);
|
||||
|
||||
useEffect(() => {
|
||||
setNotifications(notificationApi);
|
||||
}, [notificationApi, setNotifications]);
|
||||
const notifications = useMemo(() => ({ notifications: notificationApi }), [
|
||||
notificationApi,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NotificationContext.Provider value={notifications}>
|
||||
{NotificationElement}
|
||||
{children}
|
||||
</>
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useNotifications = (): Notification =>
|
||||
useNotificationStore(
|
||||
useShallow((state) => ({ notifications: state.notifications })),
|
||||
);
|
||||
useContext(NotificationContext);
|
||||
|
||||
@@ -3,10 +3,10 @@ import { ErrorStatusCode, SuccessStatusCode } from 'types/common';
|
||||
|
||||
export type ApiResponse<T> = { data: T };
|
||||
|
||||
export interface ErrorResponse<ErrorObject = string> {
|
||||
export interface ErrorResponse {
|
||||
statusCode: ErrorStatusCode;
|
||||
payload: null;
|
||||
error: ErrorObject;
|
||||
error: string;
|
||||
message: string | null;
|
||||
body?: string | null;
|
||||
}
|
||||
|
||||
@@ -237,11 +237,21 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
return nil, errors.WrapInternalf(promErr, errors.CodeInternal, "error getting matrix from promql query %q", query)
|
||||
}
|
||||
|
||||
excludeLabel := func(labelName string) bool {
|
||||
if labelName == "__name__" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(labelName, "__") || labelName == "fingerprint"
|
||||
}
|
||||
|
||||
var series []*qbv5.TimeSeries
|
||||
for _, v := range matrix {
|
||||
var s qbv5.TimeSeries
|
||||
lbls := make([]*qbv5.Label, 0, len(v.Metric))
|
||||
for name, value := range v.Metric.Copy().Map() {
|
||||
if excludeLabel(name) {
|
||||
continue
|
||||
}
|
||||
lbls = append(lbls, &qbv5.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
Value: value,
|
||||
|
||||
Reference in New Issue
Block a user