Merge branch 'main' into indirectDescOperatorImprovement

This commit is contained in:
Ekansh Gupta 2025-09-25 10:05:52 +05:30 committed by GitHub
commit dd89f274c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 4897 additions and 430 deletions

View File

@ -1,6 +1,6 @@
services: services:
signoz-otel-collector: signoz-otel-collector:
image: signoz/signoz-otel-collector:v0.128.2 image: signoz/signoz-otel-collector:v0.129.6
container_name: signoz-otel-collector-dev container_name: signoz-otel-collector-dev
command: command:
- --config=/etc/otel-collector-config.yaml - --config=/etc/otel-collector-config.yaml

View File

@ -21,10 +21,9 @@ jobs:
- postgres - postgres
- sqlite - sqlite
clickhouse-version: clickhouse-version:
- 24.1.2-alpine
- 25.5.6 - 25.5.6
schema-migrator-version: schema-migrator-version:
- v0.128.1 - v0.129.6
postgres-version: postgres-version:
- 15 - 15
if: | if: |

4
.gitignore vendored
View File

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

View File

@ -192,7 +192,7 @@ Tests can be configured using pytest options:
- `--sqlstore-provider` - Choose database provider (default: postgres) - `--sqlstore-provider` - Choose database provider (default: postgres)
- `--postgres-version` - PostgreSQL version (default: 15) - `--postgres-version` - PostgreSQL version (default: 15)
- `--clickhouse-version` - ClickHouse version (default: 24.1.2-alpine) - `--clickhouse-version` - ClickHouse version (default: 25.5.6)
- `--zookeeper-version` - Zookeeper version (default: 3.7.1) - `--zookeeper-version` - Zookeeper version (default: 3.7.1)
Example: Example:

View File

@ -0,0 +1,15 @@
import { Typography } from 'antd';
function AnnouncementsModal(): JSX.Element {
return (
<div className="announcements-modal-container">
<div className="announcements-modal-container-header">
<Typography.Text className="announcements-modal-title">
Announcements
</Typography.Text>
</div>
</div>
);
}
export default AnnouncementsModal;

View File

@ -0,0 +1,160 @@
import { toast } from '@signozhq/sonner';
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
import { useCallback, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
const [activeTab, setActiveTab] = useState('feedback');
const [feedback, setFeedback] = useState('');
const location = useLocation();
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (): Promise<void> => {
setIsLoading(true);
let entityName = 'Feedback';
if (activeTab === 'reportBug') {
entityName = 'Bug report';
} else if (activeTab === 'featureRequest') {
entityName = 'Feature request';
}
logEvent('Feedback: Submitted', {
data: feedback,
type: activeTab,
page: location.pathname,
})
.then(() => {
onClose();
toast.success(`${entityName} submitted successfully`, {
position: 'top-right',
});
})
.catch(() => {
console.error(`Failed to submit ${entityName}`);
toast.error(`Failed to submit ${entityName}`, {
position: 'top-right',
});
})
.finally(() => {
setIsLoading(false);
});
};
useEffect(
() => (): void => {
setFeedback('');
setActiveTab('feedback');
},
[],
);
const items = [
{
label: (
<div className="feedback-modal-tab-label">
<div className="tab-icon dot feedback-tab" />
Feedback
</div>
),
key: 'feedback',
value: 'feedback',
},
{
label: (
<div className="feedback-modal-tab-label">
<div className="tab-icon dot bug-tab" />
Report a bug
</div>
),
key: 'reportBug',
value: 'reportBug',
},
{
label: (
<div className="feedback-modal-tab-label">
<div className="tab-icon dot feature-tab" />
Feature request
</div>
),
key: 'featureRequest',
value: 'featureRequest',
},
];
const handleFeedbackChange = (
e: React.ChangeEvent<HTMLTextAreaElement>,
): void => {
setFeedback(e.target.value);
};
const handleContactSupportClick = useCallback((): void => {
handleContactSupport(isCloudUserVal);
}, [isCloudUserVal]);
return (
<div className="feedback-modal-container">
<div className="feedback-modal-header">
<Radio.Group
value={activeTab}
defaultValue={activeTab}
optionType="button"
className="feedback-modal-tabs"
options={items}
onChange={(e: RadioChangeEvent): void => setActiveTab(e.target.value)}
/>
</div>
<div className="feedback-modal-content">
<div className="feedback-modal-content-header">
<Input.TextArea
placeholder="Write your feedback here..."
rows={6}
required
className="feedback-input"
value={feedback}
onChange={handleFeedbackChange}
/>
</div>
</div>
<div className="feedback-modal-content-footer">
<Button
className="periscope-btn primary"
type="primary"
onClick={handleSubmit}
loading={isLoading}
disabled={feedback.length === 0}
>
Submit
</Button>
<div className="feedback-modal-content-footer-info-text">
<Typography.Text>
Have a specific issue?{' '}
<Typography.Link
className="contact-support-link"
onClick={handleContactSupportClick}
>
Contact Support{' '}
</Typography.Link>
or{' '}
<a
href="https://signoz.io/docs/introduction/"
target="_blank"
rel="noreferrer"
className="read-docs-link"
>
Read our docs
</a>
</Typography.Text>
</div>
</div>
</div>
);
}
export default FeedbackModal;

View File

@ -0,0 +1,253 @@
.header-right-section-container {
display: flex;
align-items: center;
gap: 8px;
}
.share-modal-content,
.feedback-modal-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
width: 460px;
border-radius: 4px;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.absolute-relative-time-toggler-container {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
.absolute-relative-time-toggler-label {
color: var(--bg-vanilla-100);
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.absolute-relative-time-toggler {
display: flex;
gap: 4px;
align-items: center;
}
.absolute-relative-time-error {
font-size: 12px;
color: var(--bg-amber-600);
}
.share-link {
.url-share-container {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
.url-share-container-header {
display: flex;
flex-direction: column;
gap: 4px;
.url-share-title,
.url-share-sub-title {
color: var(--bg-vanilla-100);
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.url-share-sub-title {
font-size: 12px;
color: var(--bg-vanilla-300);
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
}
}
}
}
.feedback-modal-container {
.feedback-modal-tabs {
width: 100%;
display: flex;
.ant-radio-button-wrapper {
flex: 1;
margin: 0px !important;
border: 1px solid var(--bg-slate-400);
&:before {
display: none;
}
.ant-radio-button-checked {
background-color: var(--bg-slate-400);
}
}
.feedback-modal-tab-label {
display: flex;
align-items: center;
gap: 8px;
.tab-icon {
width: 6px;
height: 6px;
}
.feedback-tab {
background-color: var(--bg-sakura-500);
}
.bug-tab {
background-color: var(--bg-amber-500);
}
.feature-tab {
background-color: var(--bg-robin-500);
}
}
.ant-tabs-nav-list {
.ant-tabs-tab {
padding: 6px 16px;
border-radius: 2px;
background: var(--bg-ink-400);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
border: 1px solid var(--bg-slate-400);
margin: 0 !important;
.ant-tabs-tab-btn {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
letter-spacing: -0.06px;
}
&-active {
background: var(--bg-slate-400);
color: var(--bg-vanilla-100);
border-bottom: none !important;
.ant-tabs-tab-btn {
color: var(--bg-vanilla-100);
}
}
}
}
}
.feedback-modal-content {
display: flex;
flex-direction: column;
gap: 16px;
.feedback-input {
resize: none;
text-area {
resize: none;
}
}
.feedback-content-include-console-logs {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
}
.feedback-modal-content-footer {
display: flex;
flex-direction: column;
gap: 16px;
.feedback-modal-content-footer-info-text {
font-size: 12px;
color: var(--bg-vanilla-400, #c0c1c3);
text-align: center;
/* button/ small */
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 200% */
.contact-support-link,
.read-docs-link {
color: var(--bg-robin-400);
font-weight: 500;
font-size: 12px;
}
}
}
}
.lightMode {
.share-modal-content,
.feedback-modal-container {
.absolute-relative-time-toggler-container {
.absolute-relative-time-toggler-label {
color: var(--bg-ink-400);
}
}
.share-link {
.url-share-container {
.url-share-container-header {
.url-share-title,
.url-share-sub-title {
color: var(--bg-ink-400);
}
.url-share-sub-title {
color: var(--bg-ink-300);
}
}
}
}
}
.feedback-modal-container {
.feedback-modal-tabs {
.ant-radio-button-wrapper {
flex: 1;
margin: 0px !important;
border: 1px solid var(--bg-vanilla-300);
&:before {
display: none;
}
.ant-radio-button-checked {
background-color: var(--bg-vanilla-300);
}
}
}
.feedback-modal-content-footer {
.feedback-modal-content-footer-info-text {
color: var(--bg-slate-400);
}
}
}
}

View File

@ -0,0 +1,137 @@
import './HeaderRightSection.styles.scss';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { Globe, Inbox, SquarePen } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import AnnouncementsModal from './AnnouncementsModal';
import FeedbackModal from './FeedbackModal';
import ShareURLModal from './ShareURLModal';
interface HeaderRightSectionProps {
enableAnnouncements: boolean;
enableShare: boolean;
enableFeedback: boolean;
}
function HeaderRightSection({
enableAnnouncements,
enableShare,
enableFeedback,
}: HeaderRightSectionProps): JSX.Element {
const location = useLocation();
const [openFeedbackModal, setOpenFeedbackModal] = useState(false);
const [openShareURLModal, setOpenShareURLModal] = useState(false);
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
const handleOpenFeedbackModal = useCallback((): void => {
logEvent('Feedback: Clicked', {
page: location.pathname,
});
setOpenFeedbackModal(true);
setOpenShareURLModal(false);
setOpenAnnouncementsModal(false);
}, [location.pathname]);
const handleOpenShareURLModal = useCallback((): void => {
logEvent('Share: Clicked', {
page: location.pathname,
});
setOpenShareURLModal(true);
setOpenFeedbackModal(false);
setOpenAnnouncementsModal(false);
}, [location.pathname]);
const handleCloseFeedbackModal = (): void => {
setOpenFeedbackModal(false);
};
const handleOpenFeedbackModalChange = (open: boolean): void => {
setOpenFeedbackModal(open);
};
const handleOpenAnnouncementsModalChange = (open: boolean): void => {
setOpenAnnouncementsModal(open);
};
const handleOpenShareURLModalChange = (open: boolean): void => {
setOpenShareURLModal(open);
};
return (
<div className="header-right-section-container">
{enableFeedback && (
<Popover
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<FeedbackModal onClose={handleCloseFeedbackModal} />}
destroyTooltipOnHide
arrow={false}
trigger="click"
open={openFeedbackModal}
onOpenChange={handleOpenFeedbackModalChange}
>
<Button
className="share-feedback-btn periscope-btn ghost"
icon={<SquarePen size={14} />}
onClick={handleOpenFeedbackModal}
/>
</Popover>
)}
{enableAnnouncements && (
<Popover
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<AnnouncementsModal />}
arrow={false}
destroyTooltipOnHide
trigger="click"
open={openAnnouncementsModal}
onOpenChange={handleOpenAnnouncementsModalChange}
>
<Button
icon={<Inbox size={14} />}
className="periscope-btn ghost announcements-btn"
onClick={(): void => {
logEvent('Announcements: Clicked', {
page: location.pathname,
});
}}
/>
</Popover>
)}
{enableShare && (
<Popover
rootClassName="header-section-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={<ShareURLModal />}
open={openShareURLModal}
destroyTooltipOnHide
arrow={false}
trigger="click"
onOpenChange={handleOpenShareURLModalChange}
>
<Button
className="share-link-btn periscope-btn ghost"
icon={<Globe size={14} />}
onClick={handleOpenShareURLModal}
>
Share
</Button>
</Popover>
)}
</div>
);
}
export default HeaderRightSection;

View File

@ -0,0 +1,171 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Switch, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import { Check, Info, Link2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
const routesToBeSharedWithTime = [
ROUTES.LOGS_EXPLORER,
ROUTES.TRACES_EXPLORER,
ROUTES.METRICS_EXPLORER_EXPLORER,
ROUTES.METER_EXPLORER,
];
function ShareURLModal(): JSX.Element {
const urlQuery = useUrlQuery();
const location = useLocation();
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
selectedTime !== 'custom',
);
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
const relativeTime = urlQuery.get(QueryParams.relativeTime);
const [isURLCopied, setIsURLCopied] = useState(false);
const [, handleCopyToClipboard] = useCopyToClipboard();
const isValidateRelativeTime = useMemo(
() =>
selectedTime !== 'custom' ||
(startTime && endTime && selectedTime === 'custom'),
[startTime, endTime, selectedTime],
);
const shareURLWithTime = useMemo(
() => relativeTime || (startTime && endTime),
[relativeTime, startTime, endTime],
);
const isRouteToBeSharedWithTime = useMemo(
() =>
routesToBeSharedWithTime.some((route) =>
matchPath(location.pathname, { path: route, exact: true }),
),
[location.pathname],
);
// eslint-disable-next-line sonarjs/cognitive-complexity
const processURL = (): string => {
let currentUrl = window.location.href;
const isCustomTime = !!(startTime && endTime && selectedTime === 'custom');
if (shareURLWithTime || isRouteToBeSharedWithTime) {
if (enableAbsoluteTime || isCustomTime) {
if (selectedTime === 'custom') {
if (startTime && endTime) {
urlQuery.set(QueryParams.startTime, startTime.toString());
urlQuery.set(QueryParams.endTime, endTime.toString());
}
} else {
const { minTime, maxTime } = GetMinMax(selectedTime);
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
}
urlQuery.delete(QueryParams.relativeTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
}
}
return currentUrl;
};
const handleCopyURL = (): void => {
const URL = processURL();
handleCopyToClipboard(URL);
setIsURLCopied(true);
logEvent('Share: Copy link clicked', {
page: location.pathname,
URL,
});
setTimeout(() => {
setIsURLCopied(false);
}, 1000);
};
return (
<div className="share-modal-content">
{(shareURLWithTime || isRouteToBeSharedWithTime) && (
<>
<div className="absolute-relative-time-toggler-container">
<Typography.Text className="absolute-relative-time-toggler-label">
Enable absolute time
</Typography.Text>
<div className="absolute-relative-time-toggler">
{!isValidateRelativeTime && (
<Info size={14} color={Color.BG_AMBER_600} />
)}
<Switch
checked={enableAbsoluteTime}
disabled={!isValidateRelativeTime}
size="small"
onChange={(): void => {
setEnableAbsoluteTime((prev) => !prev);
}}
/>
</div>
</div>
{!isValidateRelativeTime && (
<div className="absolute-relative-time-error">
Please select / enter valid relative time to toggle.
</div>
)}
</>
)}
<div className="share-link">
<div className="url-share-container">
<div className="url-share-container-header">
<Typography.Text className="url-share-title">
Share page link
</Typography.Text>
<Typography.Text className="url-share-sub-title">
Share the current page link with your team member
</Typography.Text>
</div>
<Button
className="periscope-btn secondary"
onClick={handleCopyURL}
icon={isURLCopied ? <Check size={14} /> : <Link2 size={14} />}
>
Copy page link
</Button>
</div>
</div>
</div>
);
}
export default ShareURLModal;

View File

@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import AnnouncementsModal from '../AnnouncementsModal';
describe('AnnouncementsModal', () => {
it('should render announcements modal with title', () => {
render(<AnnouncementsModal />);
expect(screen.getByText('Announcements')).toBeInTheDocument();
});
it('should have proper structure and classes', () => {
render(<AnnouncementsModal />);
const container = screen
.getByText('Announcements')
.closest('.announcements-modal-container');
expect(container).toBeInTheDocument();
const headerContainer = screen
.getByText('Announcements')
.closest('.announcements-modal-container-header');
expect(headerContainer).toBeInTheDocument();
});
it('should render without any errors', () => {
expect(() => render(<AnnouncementsModal />)).not.toThrow();
});
});

View File

@ -0,0 +1,274 @@
/* eslint-disable sonarjs/no-duplicate-string */
// Mock dependencies before imports
import { toast } from '@signozhq/sonner';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
import { useLocation } from 'react-router-dom';
import FeedbackModal from '../FeedbackModal';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve()),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
jest.mock('pages/Integrations/utils', () => ({
handleContactSupport: jest.fn(),
}));
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
const mockHandleContactSupport = handleContactSupport as jest.Mock;
const mockToast = toast as jest.Mocked<typeof toast>;
const mockOnClose = jest.fn();
const mockLocation = {
pathname: '/test-path',
};
describe('FeedbackModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue(mockLocation);
mockUseGetTenantLicense.mockReturnValue({
isCloudUser: false,
});
mockToast.success.mockClear();
mockToast.error.mockClear();
});
it('should render feedback modal with all tabs', () => {
render(<FeedbackModal onClose={mockOnClose} />);
expect(screen.getByText('Feedback')).toBeInTheDocument();
expect(screen.getByText('Report a bug')).toBeInTheDocument();
expect(screen.getByText('Feature request')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Write your feedback here...'),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});
it('should switch between tabs when clicked', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
// Initially, feedback radio should be active
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
expect(feedbackRadio).toBeChecked();
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Bug radio should now be active
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
expect(bugRadio).toBeChecked();
const featureTab = screen.getByText('Feature request');
await user.click(featureTab);
// Feature radio should now be active
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
expect(featureRadio).toBeChecked();
});
it('should update feedback text when typing in textarea', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const testFeedback = 'This is my feedback';
await user.type(textarea, testFeedback);
expect(textarea).toHaveValue(testFeedback);
});
it('should submit feedback and log event when submit button is clicked', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
const testFeedback = 'Test feedback content';
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'feedback',
page: mockLocation.pathname,
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockToast.success).toHaveBeenCalledWith(
'Feedback submitted successfully',
{
position: 'top-right',
},
);
});
it('should submit bug report with correct type', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
// Switch to bug report tab
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Verify bug report radio is now active
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
expect(bugRadio).toBeChecked();
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
const testFeedback = 'This is a bug report';
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'reportBug',
page: mockLocation.pathname,
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockToast.success).toHaveBeenCalledWith(
'Bug report submitted successfully',
{
position: 'top-right',
},
);
});
it('should submit feature request with correct type', async () => {
const user = userEvent.setup();
render(<FeedbackModal onClose={mockOnClose} />);
// Switch to feature request tab
const featureTab = screen.getByText('Feature request');
await user.click(featureTab);
// Verify feature request radio is now active
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
expect(featureRadio).toBeChecked();
const textarea = screen.getByPlaceholderText('Write your feedback here...');
const submitButton = screen.getByRole('button', { name: /submit/i });
const testFeedback = 'This is a feature request';
await user.type(textarea, testFeedback);
await user.click(submitButton);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
data: testFeedback,
type: 'featureRequest',
page: mockLocation.pathname,
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockToast.success).toHaveBeenCalledWith(
'Feature request submitted successfully',
{
position: 'top-right',
},
);
});
it('should call handleContactSupport when contact support link is clicked', async () => {
const user = userEvent.setup();
const isCloudUser = true;
mockUseGetTenantLicense.mockReturnValue({
isCloudUser,
});
render(<FeedbackModal onClose={mockOnClose} />);
const contactSupportLink = screen.getByText('Contact Support');
await user.click(contactSupportLink);
expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser);
});
it('should handle non-cloud user for contact support', async () => {
const user = userEvent.setup();
const isCloudUser = false;
mockUseGetTenantLicense.mockReturnValue({
isCloudUser,
});
render(<FeedbackModal onClose={mockOnClose} />);
const contactSupportLink = screen.getByText('Contact Support');
await user.click(contactSupportLink);
expect(mockHandleContactSupport).toHaveBeenCalledWith(isCloudUser);
});
it('should render docs link with correct attributes', () => {
render(<FeedbackModal onClose={mockOnClose} />);
const docsLink = screen.getByText('Read our docs');
expect(docsLink).toHaveAttribute(
'href',
'https://signoz.io/docs/introduction/',
);
expect(docsLink).toHaveAttribute('target', '_blank');
expect(docsLink).toHaveAttribute('rel', 'noreferrer');
});
it('should reset form state when component unmounts', async () => {
const user = userEvent.setup();
// Render component
const { unmount } = render(<FeedbackModal onClose={mockOnClose} />);
// Change the form state first
const textArea = screen.getByPlaceholderText('Write your feedback here...');
await user.type(textArea, 'Some feedback text');
// Change the active tab
const bugTab = screen.getByText('Report a bug');
await user.click(bugTab);
// Verify state has changed
expect(textArea).toHaveValue('Some feedback text');
// Unmount the component - this should trigger cleanup
unmount();
// Re-render the component to verify state was reset
render(<FeedbackModal onClose={mockOnClose} />);
// Verify form state is reset
const newTextArea = screen.getByPlaceholderText(
'Write your feedback here...',
);
expect(newTextArea).toHaveValue(''); // Should be empty
// Verify active radio is reset to default (Feedback radio)
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
expect(feedbackRadio).toBeChecked();
});
});

View File

@ -0,0 +1,192 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
// Mock dependencies before imports
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { useLocation } from 'react-router-dom';
import HeaderRightSection from '../HeaderRightSection';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
}));
jest.mock('../FeedbackModal', () => ({
__esModule: true,
default: ({ onClose }: { onClose: () => void }): JSX.Element => (
<div data-testid="feedback-modal">
<button onClick={onClose} type="button">
Close Feedback
</button>
</div>
),
}));
jest.mock('../ShareURLModal', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="share-modal">Share URL Modal</div>
),
}));
jest.mock('../AnnouncementsModal', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="announcements-modal">Announcements Modal</div>
),
}));
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const defaultProps = {
enableAnnouncements: true,
enableShare: true,
enableFeedback: true,
};
const mockLocation = {
pathname: '/test-path',
};
describe('HeaderRightSection', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue(mockLocation);
});
it('should render all buttons when all features are enabled', () => {
render(<HeaderRightSection {...defaultProps} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(3);
expect(screen.getByRole('button', { name: /share/i })).toBeInTheDocument();
// Check for feedback button by class
const feedbackButton = document.querySelector(
'.share-feedback-btn[class*="share-feedback-btn"]',
);
expect(feedbackButton).toBeInTheDocument();
// Check for announcements button by finding the inbox icon
const inboxIcon = document.querySelector('.lucide-inbox');
expect(inboxIcon).toBeInTheDocument();
});
it('should render only enabled features', () => {
render(
<HeaderRightSection
enableAnnouncements={false}
enableShare={false}
enableFeedback
/>,
);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(1);
expect(
screen.queryByRole('button', { name: /share/i }),
).not.toBeInTheDocument();
// Check that inbox icon is not present
const inboxIcon = document.querySelector('.lucide-inbox');
expect(inboxIcon).not.toBeInTheDocument();
// Check that feedback button is present
const squarePenIcon = document.querySelector('.lucide-square-pen');
expect(squarePenIcon).toBeInTheDocument();
});
it('should open feedback modal and log event when feedback button is clicked', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
const feedbackButton = document
.querySelector('.lucide-square-pen')
?.closest('button');
expect(feedbackButton).toBeInTheDocument();
await user.click(feedbackButton!);
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
});
it('should open share modal and log event when share button is clicked', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
const shareButton = screen.getByRole('button', { name: /share/i });
await user.click(shareButton);
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
page: mockLocation.pathname,
});
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
});
it('should log event when announcements button is clicked', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
const announcementsButton = document
.querySelector('.lucide-inbox')
?.closest('button');
expect(announcementsButton).toBeInTheDocument();
await user.click(announcementsButton!);
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
page: mockLocation.pathname,
});
});
it('should close feedback modal when onClose is called', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
// Open feedback modal
const feedbackButton = document
.querySelector('.lucide-square-pen')
?.closest('button');
expect(feedbackButton).toBeInTheDocument();
await user.click(feedbackButton!);
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
// Close feedback modal
const closeFeedbackButton = screen.getByText('Close Feedback');
await user.click(closeFeedbackButton);
expect(screen.queryByTestId('feedback-modal')).not.toBeInTheDocument();
});
it('should close other modals when opening feedback modal', async () => {
const user = userEvent.setup();
render(<HeaderRightSection {...defaultProps} />);
// Open share modal first
const shareButton = screen.getByRole('button', { name: /share/i });
await user.click(shareButton);
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
// Open feedback modal - should close share modal
const feedbackButton = document
.querySelector('.lucide-square-pen')
?.closest('button');
expect(feedbackButton).toBeInTheDocument();
await user.click(feedbackButton!);
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
expect(screen.queryByTestId('share-modal')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,289 @@
// Mock dependencies before imports
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import { useSelector } from 'react-redux';
import { matchPath, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import ShareURLModal from '../ShareURLModal';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(),
matchPath: jest.fn(),
}));
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: jest.fn(),
}));
// Mock window.location
const mockLocation = {
href: 'https://example.com/test-path?param=value',
origin: 'https://example.com',
};
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
});
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseUrlQuery = useUrlQuery as jest.Mock;
const mockUseSelector = useSelector as jest.Mock;
const mockGetMinMax = GetMinMax as jest.Mock;
const mockUseCopyToClipboard = useCopyToClipboard as jest.Mock;
const mockMatchPath = matchPath as jest.Mock;
const mockUrlQuery = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
toString: jest.fn(() => 'param=value'),
};
const mockHandleCopyToClipboard = jest.fn();
const TEST_PATH = '/test-path';
const ENABLE_ABSOLUTE_TIME_TEXT = 'Enable absolute time';
describe('ShareURLModal', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue({
pathname: TEST_PATH,
});
mockUseUrlQuery.mockReturnValue(mockUrlQuery);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
mockGetMinMax.mockReturnValue({
minTime: 1000000,
maxTime: 2000000,
});
mockUseCopyToClipboard.mockReturnValue([null, mockHandleCopyToClipboard]);
mockMatchPath.mockReturnValue(false);
// Reset URL query mocks - all return null by default
mockUrlQuery.get.mockReturnValue(null);
// Reset mock functions
mockUrlQuery.set.mockClear();
mockUrlQuery.delete.mockClear();
mockUrlQuery.toString.mockReturnValue('param=value');
});
it('should render share modal with copy button', () => {
render(<ShareURLModal />);
expect(screen.getByText('Share page link')).toBeInTheDocument();
expect(
screen.getByText('Share the current page link with your team member'),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /copy page link/i }),
).toBeInTheDocument();
});
it('should copy URL and log event when copy button is clicked', async () => {
const user = userEvent.setup();
render(<ShareURLModal />);
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
page: TEST_PATH,
URL: expect.any(String),
});
});
it('should show absolute time toggle when on time-enabled route', () => {
mockMatchPath.mockReturnValue(true); // Simulate being on a route that supports time
render(<ShareURLModal />);
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeInTheDocument();
});
it('should show absolute time toggle when URL has time parameters', () => {
mockUrlQuery.get.mockImplementation((key: string) =>
key === 'relativeTime' ? '5min' : null,
);
render(<ShareURLModal />);
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
});
it('should toggle absolute time switch', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min', // Non-custom time should enable absolute time by default
});
render(<ShareURLModal />);
const toggleSwitch = screen.getByRole('switch');
// Should be checked by default for non-custom time
expect(toggleSwitch).toBeChecked();
await user.click(toggleSwitch);
expect(toggleSwitch).not.toBeChecked();
});
it('should disable toggle when relative time is invalid', () => {
mockUseSelector.mockReturnValue({
selectedTime: 'custom',
});
// Invalid - missing start and end time for custom
mockUrlQuery.get.mockReturnValue(null);
mockMatchPath.mockReturnValue(true);
render(<ShareURLModal />);
expect(
screen.getByText('Please select / enter valid relative time to toggle.'),
).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeDisabled();
});
it('should process URL with absolute time for non-custom time', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
render(<ShareURLModal />);
// Absolute time should be enabled by default for non-custom time
// Click copy button directly
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000');
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime');
});
it('should process URL with custom time parameters', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: 'custom',
});
mockUrlQuery.get.mockImplementation((key: string) => {
switch (key) {
case 'startTime':
return '1500000';
case 'endTime':
return '1600000';
default:
return null;
}
});
render(<ShareURLModal />);
// Should be enabled by default for custom time
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1500000');
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '1600000');
});
it('should process URL with relative time when absolute time is disabled', async () => {
const user = userEvent.setup();
mockMatchPath.mockReturnValue(true);
mockUseSelector.mockReturnValue({
selectedTime: '5min',
});
render(<ShareURLModal />);
// Disable absolute time first (it's enabled by default for non-custom time)
const toggleSwitch = screen.getByRole('switch');
await user.click(toggleSwitch);
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime');
expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min');
});
it('should handle routes that should be shared with time', async () => {
const user = userEvent.setup();
mockUseLocation.mockReturnValue({
pathname: ROUTES.LOGS_EXPLORER,
});
mockMatchPath.mockImplementation(
(pathname: string, options: any) => options.path === ROUTES.LOGS_EXPLORER,
);
render(<ShareURLModal />);
expect(screen.getByText(ENABLE_ABSOLUTE_TIME_TEXT)).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeChecked();
// on clicking copy page link, the copied url should have startTime and endTime
const copyButton = screen.getByRole('button', { name: /copy page link/i });
await user.click(copyButton);
expect(mockUrlQuery.set).toHaveBeenCalledWith('startTime', '1000000');
expect(mockUrlQuery.set).toHaveBeenCalledWith('endTime', '2000000');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('relativeTime');
// toggle the switch to share url with relative time
const toggleSwitch = screen.getByRole('switch');
await user.click(toggleSwitch);
await user.click(copyButton);
expect(mockUrlQuery.delete).toHaveBeenCalledWith('startTime');
expect(mockUrlQuery.delete).toHaveBeenCalledWith('endTime');
expect(mockUrlQuery.set).toHaveBeenCalledWith('relativeTime', '5min');
});
});

View File

@ -1,4 +1,5 @@
import { Tabs, TabsProps } from 'antd'; import { Tabs, TabsProps } from 'antd';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { import {
generatePath, generatePath,
matchPath, matchPath,
@ -17,6 +18,7 @@ function RouteTab({
activeKey, activeKey,
onChangeHandler, onChangeHandler,
history, history,
showRightSection,
...rest ...rest
}: RouteTabProps & TabsProps): JSX.Element { }: RouteTabProps & TabsProps): JSX.Element {
const params = useParams<Params>(); const params = useParams<Params>();
@ -61,12 +63,22 @@ function RouteTab({
items={items} items={items}
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...rest} {...rest}
tabBarExtraContent={
showRightSection && (
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
)
}
/> />
); );
} }
RouteTab.defaultProps = { RouteTab.defaultProps = {
onChangeHandler: undefined, onChangeHandler: undefined,
showRightSection: true,
}; };
export default RouteTab; export default RouteTab;

View File

@ -13,4 +13,5 @@ export interface RouteTabProps {
activeKey: TabsProps['activeKey']; activeKey: TabsProps['activeKey'];
onChangeHandler?: (key: string) => void; onChangeHandler?: (key: string) => void;
history: History<unknown>; history: History<unknown>;
showRightSection: boolean;
} }

View File

@ -6,13 +6,16 @@ import { Activity, ChartLine } from 'lucide-react';
import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context'; import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
import Stepper from '../Stepper'; import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import AlertThreshold from './AlertThreshold'; import AlertThreshold from './AlertThreshold';
import AnomalyThreshold from './AnomalyThreshold'; import AnomalyThreshold from './AnomalyThreshold';
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants'; import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
function AlertCondition(): JSX.Element { function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState(); const { alertType, setAlertType } = useCreateAlertState();
const showCondensedLayoutFlag = showCondensedLayout();
const showMultipleTabs = const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT || alertType === AlertTypes.ANOMALY_BASED_ALERT ||
@ -75,6 +78,11 @@ function AlertCondition(): JSX.Element {
</div> </div>
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />} {alertType !== AlertTypes.ANOMALY_BASED_ALERT && <AlertThreshold />}
{alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />} {alertType === AlertTypes.ANOMALY_BASED_ALERT && <AnomalyThreshold />}
{showCondensedLayoutFlag ? (
<div className="condensed-advanced-options-container">
<AdvancedOptions />
</div>
) : null}
</div> </div>
); );
} }

View File

@ -2,6 +2,7 @@ import './styles.scss';
import { Button, Select, Typography } from 'antd'; import { Button, Select, Typography } from 'antd';
import getAllChannels from 'api/channels/getAll'; import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
@ -17,6 +18,8 @@ import {
THRESHOLD_MATCH_TYPE_OPTIONS, THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS, THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants'; } from '../context/constants';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { showCondensedLayout } from '../utils';
import ThresholdItem from './ThresholdItem'; import ThresholdItem from './ThresholdItem';
import { UpdateThreshold } from './types'; import { UpdateThreshold } from './types';
import { import {
@ -37,6 +40,7 @@ function AlertThreshold(): JSX.Element {
>(['getChannels'], { >(['getChannels'], {
queryFn: () => getAllChannels(), queryFn: () => getAllChannels(),
}); });
const showCondensedLayoutFlag = showCondensedLayout();
const channels = data?.data || []; const channels = data?.data || [];
const { currentQuery } = useQueryBuilder(); const { currentQuery } = useQueryBuilder();
@ -81,8 +85,18 @@ function AlertThreshold(): JSX.Element {
}); });
}; };
const evaluationWindowContext = showCondensedLayoutFlag ? (
<EvaluationSettings />
) : (
<strong>Evaluation Window.</strong>
);
return ( return (
<div className="alert-threshold-container"> <div
className={classNames('alert-threshold-container', {
'condensed-alert-threshold-container': showCondensedLayoutFlag,
})}
>
{/* Main condition sentence */} {/* Main condition sentence */}
<div className="alert-condition-sentences"> <div className="alert-condition-sentences">
<div className="alert-condition-sentence"> <div className="alert-condition-sentence">
@ -128,7 +142,7 @@ function AlertThreshold(): JSX.Element {
options={THRESHOLD_MATCH_TYPE_OPTIONS} options={THRESHOLD_MATCH_TYPE_OPTIONS}
/> />
<Typography.Text className="sentence-text"> <Typography.Text className="sentence-text">
during the <strong>Evaluation Window.</strong> during the {evaluationWindowContext}
</Typography.Text> </Typography.Text>
</div> </div>
</div> </div>

View File

@ -84,6 +84,9 @@
color: var(--text-vanilla-400); color: var(--text-vanilla-400);
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
display: flex;
align-items: center;
gap: 8px;
} }
.ant-select { .ant-select {
@ -275,3 +278,43 @@
} }
} }
} }
.condensed-alert-threshold-container,
.condensed-anomaly-threshold-container {
width: 100%;
}
.condensed-advanced-options-container {
margin-top: 16px;
width: fit-parent;
}
.condensed-evaluation-settings-container {
.ant-btn {
display: flex;
align-items: center;
width: 240px;
justify-content: space-between;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
.evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400);
font-size: 12px;
}
.evaluate-alert-conditions-button-right {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
gap: 8px;
.evaluate-alert-conditions-button-right-text {
font-size: 12px;
font-weight: 500;
background-color: var(--bg-slate-400);
padding: 1px 4px;
}
}
}
}

View File

@ -7,7 +7,10 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import AlertCondition from './AlertCondition'; import AlertCondition from './AlertCondition';
import { CreateAlertProvider } from './context'; import { CreateAlertProvider } from './context';
import CreateAlertHeader from './CreateAlertHeader'; import CreateAlertHeader from './CreateAlertHeader';
import EvaluationSettings from './EvaluationSettings';
import NotificationSettings from './NotificationSettings';
import QuerySection from './QuerySection'; import QuerySection from './QuerySection';
import { showCondensedLayout } from './utils';
function CreateAlertV2({ function CreateAlertV2({
initialQuery = initialQueriesMap.metrics, initialQuery = initialQueriesMap.metrics,
@ -16,14 +19,18 @@ function CreateAlertV2({
}): JSX.Element { }): JSX.Element {
useShareBuilderUrl({ defaultValue: initialQuery }); useShareBuilderUrl({ defaultValue: initialQuery });
const showCondensedLayoutFlag = showCondensedLayout();
return ( return (
<div className="create-alert-v2-container"> <CreateAlertProvider>
<CreateAlertProvider> <div className="create-alert-v2-container">
<CreateAlertHeader /> <CreateAlertHeader />
<QuerySection /> <QuerySection />
<AlertCondition /> <AlertCondition />
</CreateAlertProvider> {!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
</div> <NotificationSettings />
</div>
</CreateAlertProvider>
); );
} }

View File

@ -0,0 +1,129 @@
import { Collapse, Input, Select, Typography } from 'antd';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import { useCreateAlertState } from '../context';
import AdvancedOptionItem from './AdvancedOptionItem';
import EvaluationCadence from './EvaluationCadence';
function AdvancedOptions(): JSX.Element {
const { advancedOptions, setAdvancedOptions } = useCreateAlertState();
const timeOptions = Y_AXIS_CATEGORIES.find(
(category) => category.name === 'Time',
)?.units.map((unit) => ({ label: unit.name, value: unit.id }));
return (
<div className="advanced-options-container">
<Collapse bordered={false}>
<Collapse.Panel header="ADVANCED OPTIONS" key="1">
<EvaluationCadence />
<AdvancedOptionItem
title="Alert when data stops coming"
description="Send notification if no data is received for a specified time period."
tooltipText="Useful for monitoring data pipelines or services that should continuously send data. For example, alert if no logs are received for 10 minutes"
input={
<div className="advanced-option-item-input-group">
<Input
placeholder="Enter tolerance limit..."
type="number"
style={{ width: 100 }}
onChange={(e): void =>
setAdvancedOptions({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit: Number(e.target.value),
timeUnit: advancedOptions.sendNotificationIfDataIsMissing.timeUnit,
},
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
/>
<Select
style={{ width: 120 }}
options={timeOptions}
placeholder="Select time unit"
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit:
advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit,
timeUnit: value as string,
},
})
}
value={advancedOptions.sendNotificationIfDataIsMissing.timeUnit}
/>
</div>
}
/>
<AdvancedOptionItem
title="Minimum data required"
description="Only trigger alert when there are enough data points to make a reliable decision."
tooltipText="Prevents false alarms when there's insufficient data. For example, require at least 5 data points before checking if CPU usage is above 80%."
input={
<div className="advanced-option-item-input-group">
<Input
placeholder="Enter minimum datapoints..."
style={{ width: 100 }}
type="number"
onChange={(e): void =>
setAdvancedOptions({
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
payload: {
minimumDatapoints: Number(e.target.value),
},
})
}
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
/>
<Typography.Text>Datapoints</Typography.Text>
</div>
}
/>
<AdvancedOptionItem
title="Account for data delay"
description="Shift the evaluation window backwards to account for data processing delays."
tooltipText="Use when your data takes time to arrive on the platform. For example, if logs typically arrive 5 minutes late, set a 5-minute delay so the alert checks the correct time window."
input={
<div className="advanced-option-item-input-group">
<Input
placeholder="Enter delay..."
style={{ width: 100 }}
type="number"
onChange={(e): void =>
setAdvancedOptions({
type: 'SET_DELAY_EVALUATION',
payload: {
delay: Number(e.target.value),
timeUnit: advancedOptions.delayEvaluation.timeUnit,
},
})
}
value={advancedOptions.delayEvaluation.delay}
/>
<Select
style={{ width: 120 }}
options={timeOptions}
placeholder="Select time unit"
onChange={(value): void =>
setAdvancedOptions({
type: 'SET_DELAY_EVALUATION',
payload: {
delay: advancedOptions.delayEvaluation.delay,
timeUnit: value as string,
},
})
}
value={advancedOptions.delayEvaluation.timeUnit}
/>
</div>
}
/>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default AdvancedOptions;

View File

@ -0,0 +1,91 @@
import './styles.scss';
import { Button, Popover, Typography } from 'antd';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useState } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import AdvancedOptions from './AdvancedOptions';
import EvaluationWindowPopover from './EvaluationWindowPopover';
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
function EvaluationSettings(): JSX.Element {
const {
alertType,
evaluationWindow,
setEvaluationWindow,
} = useCreateAlertState();
const [
isEvaluationWindowPopoverOpen,
setIsEvaluationWindowPopoverOpen,
] = useState(false);
const showCondensedLayoutFlag = showCondensedLayout();
const popoverContent = (
<Popover
open={isEvaluationWindowPopoverOpen}
onOpenChange={(visibility: boolean): void => {
setIsEvaluationWindowPopoverOpen(visibility);
}}
content={
<EvaluationWindowPopover
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
}
trigger="click"
showArrow={false}
>
<Button>
<div className="evaluate-alert-conditions-button-left">
{getTimeframeText(evaluationWindow)}
</div>
<div className="evaluate-alert-conditions-button-right">
<div className="evaluate-alert-conditions-button-right-text">
{getEvaluationWindowTypeText(evaluationWindow.windowType)}
</div>
{isEvaluationWindowPopoverOpen ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</div>
</Button>
</Popover>
);
// Layout consists of only the evaluation window popover
if (showCondensedLayoutFlag) {
return (
<div
className="condensed-evaluation-settings-container"
data-testid="condensed-evaluation-settings-container"
>
{popoverContent}
</div>
);
}
// Layout consists of
// - Stepper header
// - Evaluation window popover
// - Advanced options
return (
<div className="evaluation-settings-container">
<Stepper stepNumber={3} label="Evaluation settings" />
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
<div className="evaluate-alert-conditions-container">
<Typography.Text>Check conditions using data from</Typography.Text>
<div className="evaluate-alert-conditions-separator" />
{popoverContent}
</div>
)}
<AdvancedOptions />
</div>
);
}
export default EvaluationSettings;

View File

@ -0,0 +1,221 @@
import { Input, Select, Typography } from 'antd';
import { useMemo } from 'react';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../../context/constants';
import {
CUMULATIVE_WINDOW_DESCRIPTION,
ROLLING_WINDOW_DESCRIPTION,
TIMEZONE_DATA,
} from '../constants';
import TimeInput from '../TimeInput';
import { IEvaluationWindowDetailsProps } from '../types';
import { getCumulativeWindowTimeframeText } from '../utils';
function EvaluationWindowDetails({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowDetailsProps): JSX.Element {
const currentHourOptions = useMemo(() => {
const options = [];
for (let i = 0; i < 60; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const currentMonthOptions = useMemo(() => {
const options = [];
for (let i = 1; i <= 31; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const displayText = useMemo(() => {
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe === 'custom'
) {
return `Last ${evaluationWindow.startingAt.number} ${
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS.find(
(option) => option.value === evaluationWindow.startingAt.unit,
)?.label
}`;
}
if (evaluationWindow.windowType === 'cumulative') {
return getCumulativeWindowTimeframeText(evaluationWindow);
}
return '';
}, [evaluationWindow]);
if (
evaluationWindow.windowType === 'rolling' &&
evaluationWindow.timeframe !== 'custom'
) {
return <div />;
}
const isCurrentHour =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentHour';
const isCurrentDay =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentDay';
const isCurrentMonth =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentMonth';
const handleNumberChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: value,
time: evaluationWindow.startingAt.time,
timezone: evaluationWindow.startingAt.timezone,
unit: evaluationWindow.startingAt.unit,
},
});
};
const handleTimeChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: value,
timezone: evaluationWindow.startingAt.timezone,
unit: evaluationWindow.startingAt.unit,
},
});
};
const handleUnitChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: evaluationWindow.startingAt.timezone,
unit: value,
},
});
};
const handleTimezoneChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: value,
unit: evaluationWindow.startingAt.unit,
},
});
};
if (isCurrentHour) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING AT MINUTE</Typography.Text>
<Select
options={currentHourOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
</div>
);
}
if (isCurrentDay) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
if (isCurrentMonth) {
return (
<div className="evaluation-window-details">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>STARTING ON DAY</Typography.Text>
<Select
options={currentMonthOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
return (
<div className="evaluation-window-details">
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
<Typography.Text>Specify custom duration</Typography.Text>
<Typography.Text>{displayText}</Typography.Text>
<div className="select-group">
<Typography.Text>VALUE</Typography.Text>
<Input
name="value"
type="number"
value={evaluationWindow.startingAt.number}
onChange={(e): void => handleNumberChange(e.target.value)}
placeholder="Enter value"
/>
</div>
<div className="select-group time-select-group">
<Typography.Text>UNIT</Typography.Text>
<Select
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
value={evaluationWindow.startingAt.unit || null}
onChange={handleUnitChange}
placeholder="Select unit"
/>
</div>
</div>
);
}
export default EvaluationWindowDetails;

View File

@ -0,0 +1,161 @@
import { Button, Typography } from 'antd';
import classNames from 'classnames';
import { Check } from 'lucide-react';
import {
CUMULATIVE_WINDOW_DESCRIPTION,
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
ROLLING_WINDOW_DESCRIPTION,
} from '../constants';
import {
CumulativeWindowTimeframes,
IEvaluationWindowPopoverProps,
RollingWindowTimeframes,
} from '../types';
import EvaluationWindowDetails from './EvaluationWindowDetails';
import { useKeyboardNavigationForEvaluationWindowPopover } from './useKeyboardNavigation';
function EvaluationWindowPopover({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowPopoverProps): JSX.Element {
const {
containerRef,
firstItemRef,
} = useKeyboardNavigationForEvaluationWindowPopover({
onSelect: (value: string, sectionId: string): void => {
if (sectionId === 'window-type') {
setEvaluationWindow({
type: 'SET_WINDOW_TYPE',
payload: value as 'rolling' | 'cumulative',
});
} else if (sectionId === 'timeframe') {
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
});
}
},
onEscape: (): void => {
const triggerElement = document.querySelector(
'[aria-haspopup="true"]',
) as HTMLElement;
triggerElement?.focus();
},
});
const renderEvaluationWindowContent = (
label: string,
contentOptions: Array<{ label: string; value: string }>,
currentValue: string,
onChange: (value: string) => void,
sectionId: string,
): JSX.Element => (
<div className="evaluation-window-content-item" data-section-id={sectionId}>
<Typography.Text className="evaluation-window-content-item-label">
{label}
</Typography.Text>
<div className="evaluation-window-content-list">
{contentOptions.map((option, index) => (
<div
className={classNames('evaluation-window-content-list-item', {
active: currentValue === option.value,
})}
key={option.value}
role="button"
tabIndex={0}
data-value={option.value}
data-section-id={sectionId}
onClick={(): void => onChange(option.value)}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange(option.value);
}
}}
ref={index === 0 ? firstItemRef : undefined}
>
<Typography.Text>{option.label}</Typography.Text>
{currentValue === option.value && <Check size={12} />}
</div>
))}
</div>
</div>
);
const renderSelectionContent = (): JSX.Element => {
if (evaluationWindow.windowType === 'rolling') {
if (evaluationWindow.timeframe === 'custom') {
return (
<EvaluationWindowDetails
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
);
}
return (
<div className="selection-content">
<Typography.Text>{ROLLING_WINDOW_DESCRIPTION}</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
if (
evaluationWindow.windowType === 'cumulative' &&
!evaluationWindow.timeframe
) {
return (
<div className="selection-content">
<Typography.Text>{CUMULATIVE_WINDOW_DESCRIPTION}</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
return (
<EvaluationWindowDetails
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
);
};
return (
<div
className="evaluation-window-popover"
ref={containerRef}
role="menu"
aria-label="Evaluation window options"
>
<div className="evaluation-window-content">
{renderEvaluationWindowContent(
'EVALUATION WINDOW',
EVALUATION_WINDOW_TYPE,
evaluationWindow.windowType,
(value: string): void =>
setEvaluationWindow({
type: 'SET_WINDOW_TYPE',
payload: value as 'rolling' | 'cumulative',
}),
'window-type',
)}
{renderEvaluationWindowContent(
'TIMEFRAME',
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
evaluationWindow.timeframe,
(value: string): void =>
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
}),
'timeframe',
)}
{renderSelectionContent()}
</div>
</div>
);
}
export default EvaluationWindowPopover;

View File

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

View File

@ -0,0 +1,180 @@
import React, { useCallback, useEffect, useRef } from 'react';
interface UseKeyboardNavigationOptions {
onSelect?: (value: string, sectionId: string) => void;
onEscape?: () => void;
}
export const useKeyboardNavigationForEvaluationWindowPopover = ({
onSelect,
onEscape,
}: UseKeyboardNavigationOptions = {}): {
containerRef: React.RefObject<HTMLDivElement>;
firstItemRef: React.RefObject<HTMLDivElement>;
} => {
const containerRef = useRef<HTMLDivElement>(null);
const firstItemRef = useRef<HTMLDivElement>(null);
const getFocusableItems = useCallback((): HTMLElement[] => {
if (!containerRef.current) return [];
return Array.from(
containerRef.current.querySelectorAll(
'.evaluation-window-content-list-item[tabindex="0"]',
),
) as HTMLElement[];
}, []);
const getInteractiveElements = useCallback((): HTMLElement[] => {
if (!containerRef.current) return [];
const detailsSection = containerRef.current.querySelector(
'.evaluation-window-details',
);
if (!detailsSection) return [];
return Array.from(
detailsSection.querySelectorAll(
'input, select, button, [tabindex="0"], [tabindex="-1"]',
),
) as HTMLElement[];
}, []);
const getCurrentIndex = useCallback((items: HTMLElement[]): number => {
const activeElement = document.activeElement as HTMLElement;
return items.findIndex((item) => item === activeElement);
}, []);
const navigateWithinSection = useCallback(
(direction: 'up' | 'down'): void => {
const items = getFocusableItems();
if (items.length === 0) return;
const currentIndex = getCurrentIndex(items);
let nextIndex: number;
if (direction === 'down') {
nextIndex = (currentIndex + 1) % items.length;
} else {
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
}
items[nextIndex]?.focus();
},
[getFocusableItems, getCurrentIndex],
);
const navigateToDetails = useCallback((): void => {
const interactiveElements = getInteractiveElements();
interactiveElements[0]?.focus();
}, [getInteractiveElements]);
const navigateBackToSection = useCallback((): void => {
const items = getFocusableItems();
items[0]?.focus();
}, [getFocusableItems]);
const navigateBetweenSections = useCallback(
(direction: 'left' | 'right'): void => {
const activeElement = document.activeElement as HTMLElement;
const isInDetails = activeElement?.closest('.evaluation-window-details');
if (isInDetails && direction === 'left') {
navigateBackToSection();
return;
}
const items = getFocusableItems();
if (items.length === 0) return;
const currentIndex = getCurrentIndex(items);
const DATA_ATTR = 'data-section-id';
const currentSectionId = items[currentIndex]?.getAttribute(DATA_ATTR);
if (currentSectionId === 'window-type' && direction === 'right') {
const timeframeItem = items.find(
(item) => item.getAttribute(DATA_ATTR) === 'timeframe',
);
timeframeItem?.focus();
} else if (currentSectionId === 'timeframe' && direction === 'left') {
const windowTypeItem = items.find(
(item) => item.getAttribute(DATA_ATTR) === 'window-type',
);
windowTypeItem?.focus();
} else if (currentSectionId === 'timeframe' && direction === 'right') {
navigateToDetails();
}
},
[
navigateBackToSection,
navigateToDetails,
getFocusableItems,
getCurrentIndex,
],
);
const handleSelection = useCallback((): void => {
const activeElement = document.activeElement as HTMLElement;
if (!activeElement || !onSelect) return;
const value = activeElement.getAttribute('data-value');
const sectionId = activeElement.getAttribute('data-section-id');
if (value && sectionId) {
onSelect(value, sectionId);
}
}, [onSelect]);
const handleKeyDown = useCallback(
(event: KeyboardEvent): void => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
navigateWithinSection('down');
break;
case 'ArrowUp':
event.preventDefault();
navigateWithinSection('up');
break;
case 'ArrowLeft':
event.preventDefault();
navigateBetweenSections('left');
break;
case 'ArrowRight':
event.preventDefault();
navigateBetweenSections('right');
break;
case 'Enter':
case ' ':
event.preventDefault();
handleSelection();
break;
case 'Escape':
event.preventDefault();
onEscape?.();
break;
default:
break;
}
},
[navigateWithinSection, navigateBetweenSections, handleSelection, onEscape],
);
useEffect((): (() => void) | undefined => {
const container = containerRef.current;
if (!container) return undefined;
container.addEventListener('keydown', handleKeyDown);
return (): void => container.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
useEffect((): void => {
if (firstItemRef.current) {
firstItemRef.current.focus();
}
}, []);
return {
containerRef: containerRef as React.RefObject<HTMLDivElement>,
firstItemRef: firstItemRef as React.RefObject<HTMLDivElement>,
};
};

View File

@ -49,3 +49,40 @@
user-select: none; user-select: none;
} }
} }
.lightMode {
.time-input-container {
.time-input-field {
background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400);
&::placeholder {
color: var(--bg-ink-300);
}
&:hover {
border-color: var(--bg-ink-300);
}
&:focus {
border-color: var(--bg-ink-300);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
&:disabled {
background-color: var(--bg-vanilla-300);
color: var(--bg-ink-300);
cursor: not-allowed;
&:hover {
border-color: var(--bg-vanilla-300);
}
}
}
.time-input-separator {
color: var(--bg-ink-300);
}
}
}

View File

@ -0,0 +1,141 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import AdvancedOptions from '../AdvancedOptions';
import { createMockAlertContextState } from './testUtils';
const mockSetAdvancedOptions = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setAdvancedOptions: mockSetAdvancedOptions,
}),
);
const ALERT_WHEN_DATA_STOPS_COMING_TEXT = 'Alert when data stops coming';
const MINIMUM_DATA_REQUIRED_TEXT = 'Minimum data required';
const ACCOUNT_FOR_DATA_DELAY_TEXT = 'Account for data delay';
const ADVANCED_OPTION_ITEM_CLASS = '.advanced-option-item';
const SWITCH_ROLE_SELECTOR = '[role="switch"]';
describe('AdvancedOptions', () => {
it('should render evaluation cadence and the advanced options minimized by default', () => {
render(<AdvancedOptions />);
expect(screen.getByText('ADVANCED OPTIONS')).toBeInTheDocument();
expect(screen.queryByText('How often to check')).not.toBeInTheDocument();
expect(
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
).not.toBeInTheDocument();
});
it('should be able to expand the advanced options', () => {
render(<AdvancedOptions />);
expect(
screen.queryByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(MINIMUM_DATA_REQUIRED_TEXT),
).not.toBeInTheDocument();
expect(
screen.queryByText(ACCOUNT_FOR_DATA_DELAY_TEXT),
).not.toBeInTheDocument();
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
expect(screen.getByText('How often to check')).toBeInTheDocument();
expect(screen.getByText('Alert when data stops coming')).toBeInTheDocument();
expect(screen.getByText('Minimum data required')).toBeInTheDocument();
expect(screen.getByText('Account for data delay')).toBeInTheDocument();
});
it('"Alert when data stops coming" works as expected', () => {
render(<AdvancedOptions />);
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
const alertWhenDataStopsComingContainer = screen
.getByText(ALERT_WHEN_DATA_STOPS_COMING_TEXT)
.closest(ADVANCED_OPTION_ITEM_CLASS);
const alertWhenDataStopsComingSwitch = alertWhenDataStopsComingContainer?.querySelector(
SWITCH_ROLE_SELECTOR,
) as HTMLElement;
fireEvent.click(alertWhenDataStopsComingSwitch);
const toleranceInput = screen.getByPlaceholderText(
'Enter tolerance limit...',
);
fireEvent.change(toleranceInput, { target: { value: '10' } });
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING',
payload: {
toleranceLimit: 10,
timeUnit: 'min',
},
});
});
it('"Minimum data required" works as expected', () => {
render(<AdvancedOptions />);
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
const minimumDataRequiredContainer = screen
.getByText(MINIMUM_DATA_REQUIRED_TEXT)
.closest(ADVANCED_OPTION_ITEM_CLASS);
const minimumDataRequiredSwitch = minimumDataRequiredContainer?.querySelector(
SWITCH_ROLE_SELECTOR,
) as HTMLElement;
fireEvent.click(minimumDataRequiredSwitch);
const minimumDataRequiredInput = screen.getByPlaceholderText(
'Enter minimum datapoints...',
);
fireEvent.change(minimumDataRequiredInput, { target: { value: '10' } });
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS',
payload: {
minimumDatapoints: 10,
},
});
});
it('"Account for data delay" works as expected', () => {
render(<AdvancedOptions />);
const collapse = screen.getByRole('button', { name: /ADVANCED OPTIONS/i });
fireEvent.click(collapse);
const accountForDataDelayContainer = screen
.getByText(ACCOUNT_FOR_DATA_DELAY_TEXT)
.closest(ADVANCED_OPTION_ITEM_CLASS);
const accountForDataDelaySwitch = accountForDataDelayContainer?.querySelector(
SWITCH_ROLE_SELECTOR,
) as HTMLElement;
fireEvent.click(accountForDataDelaySwitch);
const delayInput = screen.getByPlaceholderText('Enter delay...');
fireEvent.change(delayInput, { target: { value: '10' } });
expect(mockSetAdvancedOptions).toHaveBeenCalledWith({
type: 'SET_DELAY_EVALUATION',
payload: {
delay: 10,
timeUnit: 'min',
},
});
});
});

View File

@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import * as utils from 'container/CreateAlertV2/utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import EvaluationSettings from '../EvaluationSettings';
import { createMockAlertContextState } from './testUtils';
const mockSetEvaluationWindow = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setEvaluationWindow: mockSetEvaluationWindow,
}),
);
jest.mock('../AdvancedOptions', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="advanced-options">AdvancedOptions</div>
),
}));
const EVALUATION_SETTINGS_TEXT = 'Evaluation settings';
const CHECK_CONDITIONS_USING_DATA_FROM_TEXT =
'Check conditions using data from';
describe('EvaluationSettings', () => {
it('should render the default evaluation settings layout', () => {
render(<EvaluationSettings />);
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
expect(
screen.getByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).toBeInTheDocument();
expect(screen.getByTestId('advanced-options')).toBeInTheDocument();
});
it('should not render evaluation window for anomaly based alert', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
alertType: AlertTypes.ANOMALY_BASED_ALERT,
}),
);
render(<EvaluationSettings />);
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
expect(
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).not.toBeInTheDocument();
});
it('should render the condensed evaluation settings layout', () => {
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
render(<EvaluationSettings />);
// Header, check conditions using data from and advanced options should be hidden
expect(screen.queryByText(EVALUATION_SETTINGS_TEXT)).not.toBeInTheDocument();
expect(
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).not.toBeInTheDocument();
expect(screen.queryByTestId('advanced-options')).not.toBeInTheDocument();
// Only evaluation window popover should be visible
expect(
screen.getByTestId('condensed-evaluation-settings-container'),
).toBeInTheDocument();
});
});

View File

@ -0,0 +1,200 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import EvaluationWindowDetails from '../EvaluationWindowPopover/EvaluationWindowDetails';
import { createMockEvaluationWindowState } from './testUtils';
const mockEvaluationWindowState = createMockEvaluationWindowState();
const mockSetEvaluationWindow = jest.fn();
describe('EvaluationWindowDetails', () => {
it('should render the evaluation window details for rolling mode with custom timeframe', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '5',
unit: UniversalYAxisUnit.MINUTES,
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.getByText(
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
),
).toBeInTheDocument();
expect(screen.getByText('Specify custom duration')).toBeInTheDocument();
expect(screen.getByText('Last 5 Minutes')).toBeInTheDocument();
});
it('renders the evaluation window details for cumulative mode with current hour', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '1',
timezone: 'UTC',
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.getByText('Current hour, starting at minute 1 (UTC)'),
).toBeInTheDocument();
});
it('renders the evaluation window details for cumulative mode with current day', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
...mockEvaluationWindowState.startingAt,
time: '00:00:00',
timezone: 'UTC',
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.getByText('Current day, starting from 00:00:00 (UTC)'),
).toBeInTheDocument();
});
it('renders the evaluation window details for cumulative mode with current month', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentMonth',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '1',
time: '00:00:00',
timezone: 'UTC',
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.getByText('Current month, starting from day 1 at 00:00:00 (UTC)'),
).toBeInTheDocument();
});
it('should be able to change the value in rolling mode with custom timeframe', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '5',
unit: UniversalYAxisUnit.MINUTES,
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const valueInput = screen.getByPlaceholderText('Enter value');
fireEvent.change(valueInput, { target: { value: '10' } });
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_STARTING_AT',
payload: { ...mockEvaluationWindowState.startingAt, number: '10' },
});
});
it('should be able to change the value in cumulative mode with current hour', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '1',
timezone: 'UTC',
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const selectComponent = screen.getByRole('combobox');
fireEvent.mouseDown(selectComponent);
const option = screen.getByText('10');
fireEvent.click(option);
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_STARTING_AT',
payload: {
...mockEvaluationWindowState.startingAt,
number: 10,
timezone: 'UTC',
},
});
});
it('should be able to change the value in cumulative mode with current day', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
...mockEvaluationWindowState.startingAt,
time: '00:00:00',
timezone: 'UTC',
},
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const timeInputs = screen.getAllByDisplayValue('00');
const hoursInput = timeInputs[0];
fireEvent.change(hoursInput, { target: { value: '10' } });
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_STARTING_AT',
payload: {
...mockEvaluationWindowState.startingAt,
time: '10:00:00',
timezone: 'UTC',
},
});
});
it('should be able to change the value in cumulative mode with current month', () => {
render(
<EvaluationWindowDetails
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentMonth',
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const comboboxes = screen.getAllByRole('combobox');
const daySelectComponent = comboboxes[0];
fireEvent.mouseDown(daySelectComponent);
const option = screen.getByText('10');
fireEvent.click(option);
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_STARTING_AT',
payload: { ...mockEvaluationWindowState.startingAt, number: 10 },
});
});
});

View File

@ -0,0 +1,298 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { EvaluationWindowState } from 'container/CreateAlertV2/context/types';
import {
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
} from '../constants';
import EvaluationWindowPopover from '../EvaluationWindowPopover';
import { createMockEvaluationWindowState } from './testUtils';
const mockEvaluationWindow: EvaluationWindowState = createMockEvaluationWindowState();
const mockSetEvaluationWindow = jest.fn();
const EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS =
'.evaluation-window-content-list-item';
const EVALUATION_WINDOW_DETAILS_TEST_ID = 'evaluation-window-details';
const ENTER_VALUE_PLACEHOLDER = 'Enter value';
const EVALUATION_WINDOW_TEXT = 'EVALUATION WINDOW';
const LAST_5_MINUTES_TEXT = 'Last 5 minutes';
jest.mock('../EvaluationWindowPopover/EvaluationWindowDetails', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid={EVALUATION_WINDOW_DETAILS_TEST_ID}>
<input placeholder={ENTER_VALUE_PLACEHOLDER} />
</div>
),
}));
describe('EvaluationWindowPopover', () => {
it('should render the evaluation window popover with 3 sections', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(screen.getByText(EVALUATION_WINDOW_TEXT)).toBeInTheDocument();
});
it('should render all window type options with rolling selected', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
EVALUATION_WINDOW_TYPE.forEach((option) => {
expect(screen.getByText(option.label)).toBeInTheDocument();
});
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(rollingItem).toHaveClass('active');
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(cumulativeItem).not.toHaveClass('active');
});
it('should render all window type options with cumulative selected', () => {
render(
<EvaluationWindowPopover
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
EVALUATION_WINDOW_TYPE.forEach((option) => {
expect(screen.getByText(option.label)).toBeInTheDocument();
});
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(cumulativeItem).toHaveClass('active');
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(rollingItem).not.toHaveClass('active');
});
it('should render all timeframe options in rolling mode with last 5 minutes selected by default', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
EVALUATION_WINDOW_TIMEFRAME.rolling.forEach((option) => {
expect(screen.getByText(option.label)).toBeInTheDocument();
});
const last5MinutesItem = screen
.getByText(LAST_5_MINUTES_TEXT)
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(last5MinutesItem).toHaveClass('active');
});
it('should render all timeframe options in cumulative mode with current hour selected by default', () => {
render(
<EvaluationWindowPopover
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentHour',
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
EVALUATION_WINDOW_TIMEFRAME.cumulative.forEach((option) => {
expect(screen.getByText(option.label)).toBeInTheDocument();
});
const currentHourItem = screen
.getByText('Current hour')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
expect(currentHourItem).toHaveClass('active');
});
it('renders help text in details section for rolling mode with non-custom timeframe', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.getByText(
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
),
).toBeInTheDocument();
expect(
screen.queryByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
).not.toBeInTheDocument();
});
it('renders EvaluationWindowDetails component in details section for rolling mode with custom timeframe', () => {
render(
<EvaluationWindowPopover
evaluationWindow={createMockEvaluationWindowState({
timeframe: 'custom',
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.queryByText(
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.',
),
).not.toBeInTheDocument();
expect(
screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
).toBeInTheDocument();
});
it('renders EvaluationWindowDetails component in details section for cumulative mode', () => {
render(
<EvaluationWindowPopover
evaluationWindow={createMockEvaluationWindowState({
windowType: 'cumulative',
timeframe: 'currentHour',
})}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
expect(
screen.queryByText(
'A Cumulative Window has a fixed starting point and expands over time.',
),
).not.toBeInTheDocument();
expect(
screen.getByTestId(EVALUATION_WINDOW_DETAILS_TEST_ID),
).toBeInTheDocument();
});
describe('keyboard navigation', () => {
it('should navigate down through window type options', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
rollingItem?.focus();
fireEvent.keyDown(rollingItem, { key: 'ArrowDown' });
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
expect(cumulativeItem).toHaveFocus();
});
it('should navigate up through window type options', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
cumulativeItem?.focus();
fireEvent.keyDown(cumulativeItem, { key: 'ArrowUp' });
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
expect(rollingItem).toHaveFocus();
});
it('should navigate right from window type to timeframe', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
rollingItem?.focus();
fireEvent.keyDown(rollingItem, { key: 'ArrowRight' });
const timeframeItem = screen
.getByText(LAST_5_MINUTES_TEXT)
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
expect(timeframeItem).toHaveFocus();
});
it('should navigate left from timeframe to window type', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const timeframeItem = screen
.getByText(LAST_5_MINUTES_TEXT)
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
timeframeItem?.focus();
fireEvent.keyDown(timeframeItem, { key: 'ArrowLeft' });
const rollingItem = screen
.getByText('Rolling')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS);
expect(rollingItem).toHaveFocus();
});
it('should select option with Enter key', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
cumulativeItem?.focus();
fireEvent.keyDown(cumulativeItem, { key: 'Enter' });
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_WINDOW_TYPE',
payload: 'cumulative',
});
});
it('should select option with Space key', () => {
render(
<EvaluationWindowPopover
evaluationWindow={mockEvaluationWindow}
setEvaluationWindow={mockSetEvaluationWindow}
/>,
);
const cumulativeItem = screen
.getByText('Cumulative')
.closest(EVALUATION_WINDOW_CONTENT_LIST_ITEM_CLASS) as HTMLElement;
cumulativeItem?.focus();
fireEvent.keyDown(cumulativeItem, { key: ' ' });
expect(mockSetEvaluationWindow).toHaveBeenCalledWith({
type: 'SET_WINDOW_TYPE',
payload: 'cumulative',
});
});
});
});

View File

@ -3,8 +3,12 @@ import {
INITIAL_ALERT_STATE, INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE, INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE, INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from 'container/CreateAlertV2/context/constants'; } from 'container/CreateAlertV2/context/constants';
import { ICreateAlertContextProps } from 'container/CreateAlertV2/context/types'; import {
EvaluationWindowState,
ICreateAlertContextProps,
} from 'container/CreateAlertV2/context/types';
import { AlertTypes } from 'types/api/alerts/alertTypes'; import { AlertTypes } from 'types/api/alerts/alertTypes';
export const createMockAlertContextState = ( export const createMockAlertContextState = (
@ -20,5 +24,14 @@ export const createMockAlertContextState = (
setAdvancedOptions: jest.fn(), setAdvancedOptions: jest.fn(),
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE, evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
setEvaluationWindow: jest.fn(), setEvaluationWindow: jest.fn(),
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
setNotificationSettings: jest.fn(),
...overrides,
});
export const createMockEvaluationWindowState = (
overrides?: Partial<EvaluationWindowState>,
): EvaluationWindowState => ({
...INITIAL_EVALUATION_WINDOW_STATE,
...overrides, ...overrides,
}); });

View File

@ -14,6 +14,7 @@ export const EVALUATION_WINDOW_TIMEFRAME = {
{ label: 'Last 1 hour', value: '1h0m0s' }, { label: 'Last 1 hour', value: '1h0m0s' },
{ label: 'Last 2 hours', value: '2h0m0s' }, { label: 'Last 2 hours', value: '2h0m0s' },
{ label: 'Last 4 hours', value: '4h0m0s' }, { label: 'Last 4 hours', value: '4h0m0s' },
{ label: 'Custom', value: 'custom' },
], ],
cumulative: [ cumulative: [
{ label: 'Current hour', value: 'currentHour' }, { label: 'Current hour', value: 'currentHour' },
@ -60,3 +61,9 @@ export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
label: `${timezone.name} (${timezone.offset})`, label: `${timezone.name} (${timezone.offset})`,
value: timezone.value, value: timezone.value,
})); }));
export const CUMULATIVE_WINDOW_DESCRIPTION =
'A Cumulative Window has a fixed starting point and expands over time.';
export const ROLLING_WINDOW_DESCRIPTION =
'A Rolling Window has a fixed size and shifts its starting point over time based on when the rules are evaluated.';

View File

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

View File

@ -238,7 +238,7 @@
} }
} }
.ant-input { .select-group .ant-input:not(.time-input-field) {
background-color: var(--bg-ink-300); background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400); border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100); color: var(--bg-vanilla-100);

View File

@ -32,8 +32,6 @@ export enum CumulativeWindowTimeframes {
export interface IEvaluationWindowPopoverProps { export interface IEvaluationWindowPopoverProps {
evaluationWindow: EvaluationWindowState; evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>; setEvaluationWindow: Dispatch<EvaluationWindowAction>;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
} }
export interface IEvaluationWindowDetailsProps { export interface IEvaluationWindowDetailsProps {

View File

@ -0,0 +1,97 @@
import { Select, Tooltip, Typography } from 'antd';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Info } from 'lucide-react';
import { useMemo } from 'react';
import { useCreateAlertState } from '../context';
function MultipleNotifications(): JSX.Element {
const {
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const spaceAggregationOptions = useMemo(() => {
const allGroupBys = currentQuery.builder.queryData?.reduce<string[]>(
(acc, query) => {
const groupByKeys = query.groupBy?.map((groupBy) => groupBy.key) || [];
return [...acc, ...groupByKeys];
},
[],
);
const uniqueGroupBys = [...new Set(allGroupBys)];
return uniqueGroupBys.map((key) => ({
label: key,
value: key,
}));
}, [currentQuery.builder.queryData]);
const isMultipleNotificationsEnabled = spaceAggregationOptions.length > 0;
const multipleNotificationsInput = useMemo(() => {
const placeholder = isMultipleNotificationsEnabled
? 'Select fields to group by (optional)'
: 'No grouping fields available';
let input = (
<div>
<Select
options={spaceAggregationOptions}
onChange={(value): void => {
setNotificationSettings({
type: 'SET_MULTIPLE_NOTIFICATIONS',
payload: value,
});
}}
value={notificationSettings.multipleNotifications}
mode="multiple"
placeholder={placeholder}
disabled={!isMultipleNotificationsEnabled}
aria-disabled={!isMultipleNotificationsEnabled}
maxTagCount={3}
/>
{isMultipleNotificationsEnabled && (
<Typography.Paragraph className="multiple-notifications-select-description">
{notificationSettings.multipleNotifications?.length
? `Alerts with same ${notificationSettings.multipleNotifications?.join(
', ',
)} will be grouped`
: 'Empty = all matching alerts combined into one notification'}
</Typography.Paragraph>
)}
</div>
);
if (!isMultipleNotificationsEnabled) {
input = (
<Tooltip title="Add 'Group by' fields to your query to enable alert grouping">
{input}
</Tooltip>
);
}
return input;
}, [
isMultipleNotificationsEnabled,
notificationSettings.multipleNotifications,
setNotificationSettings,
spaceAggregationOptions,
]);
return (
<div className="multiple-notifications-container">
<div className="multiple-notifications-header">
<Typography.Text className="multiple-notifications-header-title">
Group alerts by{' '}
<Tooltip title="Group similar alerts together to reduce notification volume. Leave empty to combine all matching alerts into one notification without grouping.">
<Info size={16} />
</Tooltip>
</Typography.Text>
<Typography.Text className="multiple-notifications-header-description">
Combine alerts with the same field values into a single notification.
</Typography.Text>
</div>
{multipleNotificationsInput}
</div>
);
}
export default MultipleNotifications;

View File

@ -0,0 +1,92 @@
import { Button, Popover, Tooltip, Typography } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import { Info } from 'lucide-react';
import { useCreateAlertState } from '../context';
function NotificationMessage(): JSX.Element {
const {
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
const templateVariables = [
{ variable: '{{alertname}}', description: 'Name of the alert rule' },
{
variable: '{{value}}',
description: 'Current value that triggered the alert',
},
{
variable: '{{threshold}}',
description: 'Threshold value from alert condition',
},
{ variable: '{{unit}}', description: 'Unit of measurement for the metric' },
{
variable: '{{severity}}',
description: 'Alert severity level (Critical, Warning, Info)',
},
{
variable: '{{queryname}}',
description: 'Name of the query that triggered the alert',
},
{
variable: '{{labels}}',
description: 'All labels associated with the alert',
},
{
variable: '{{timestamp}}',
description: 'Timestamp when alert was triggered',
},
];
const templateVariableContent = (
<div className="template-variable-content">
<Typography.Text strong>Available Template Variables:</Typography.Text>
{templateVariables.map((item) => (
<div className="template-variable-content-item" key={item.variable}>
<code>{item.variable}</code>
<Typography.Text>{item.description}</Typography.Text>
</div>
))}
</div>
);
return (
<div className="notification-message-container">
<div className="notification-message-header">
<div className="notification-message-header-content">
<Typography.Text className="notification-message-header-title">
Notification Message
<Tooltip title="Customize the message content sent in alert notifications. Template variables like {{alertname}}, {{value}}, and {{threshold}} will be replaced with actual values when the alert fires.">
<Info size={16} />
</Tooltip>
</Typography.Text>
<Typography.Text className="notification-message-header-description">
Custom message content for alert notifications. Use template variables to
include dynamic information.
</Typography.Text>
</div>
<div className="notification-message-header-actions">
<Popover content={templateVariableContent}>
<Button type="text">
<Info size={12} />
Variables
</Button>
</Popover>
</div>
</div>
<TextArea
value={notificationSettings.description}
onChange={(e): void =>
setNotificationSettings({
type: 'SET_DESCRIPTION',
payload: e.target.value,
})
}
placeholder="Enter notification message..."
/>
</div>
);
}
export default NotificationMessage;

View File

@ -0,0 +1,112 @@
import './styles.scss';
import { Input, Select, Typography } from 'antd';
import { useCreateAlertState } from '../context';
import {
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS as RE_NOTIFICATION_UNIT_OPTIONS,
RE_NOTIFICATION_CONDITION_OPTIONS,
} from '../context/constants';
import AdvancedOptionItem from '../EvaluationSettings/AdvancedOptionItem';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import MultipleNotifications from './MultipleNotifications';
import NotificationMessage from './NotificationMessage';
function NotificationSettings(): JSX.Element {
const showCondensedLayoutFlag = showCondensedLayout();
const {
notificationSettings,
setNotificationSettings,
} = useCreateAlertState();
const repeatNotificationsInput = (
<div className="repeat-notifications-input">
<Typography.Text>Every</Typography.Text>
<Input
value={notificationSettings.reNotification.value}
placeholder="Enter time interval..."
disabled={!notificationSettings.reNotification.enabled}
type="number"
onChange={(e): void => {
setNotificationSettings({
type: 'SET_RE_NOTIFICATION',
payload: {
enabled: notificationSettings.reNotification.enabled,
value: parseInt(e.target.value, 10),
unit: notificationSettings.reNotification.unit,
conditions: notificationSettings.reNotification.conditions,
},
});
}}
/>
<Select
value={notificationSettings.reNotification.unit || null}
placeholder="Select unit"
disabled={!notificationSettings.reNotification.enabled}
options={RE_NOTIFICATION_UNIT_OPTIONS}
onChange={(value): void => {
setNotificationSettings({
type: 'SET_RE_NOTIFICATION',
payload: {
enabled: notificationSettings.reNotification.enabled,
value: notificationSettings.reNotification.value,
unit: value,
conditions: notificationSettings.reNotification.conditions,
},
});
}}
/>
<Typography.Text>while</Typography.Text>
<Select
mode="multiple"
value={notificationSettings.reNotification.conditions || null}
placeholder="Select conditions"
disabled={!notificationSettings.reNotification.enabled}
options={RE_NOTIFICATION_CONDITION_OPTIONS}
onChange={(value): void => {
setNotificationSettings({
type: 'SET_RE_NOTIFICATION',
payload: {
enabled: notificationSettings.reNotification.enabled,
value: notificationSettings.reNotification.value,
unit: notificationSettings.reNotification.unit,
conditions: value,
},
});
}}
/>
</div>
);
return (
<div className="notification-settings-container">
<Stepper
stepNumber={showCondensedLayoutFlag ? 3 : 4}
label="Notification settings"
/>
<NotificationMessage />
<div className="notification-settings-content">
<MultipleNotifications />
<AdvancedOptionItem
title="Repeat notifications"
description="Send periodic notifications while the alert condition remains active."
tooltipText="Continue sending periodic notifications while the alert condition persists. Useful for ensuring critical alerts aren't missed during long-running incidents. Configure how often to repeat and under what conditions."
input={repeatNotificationsInput}
onToggle={(): void => {
setNotificationSettings({
type: 'SET_RE_NOTIFICATION',
payload: {
...notificationSettings.reNotification,
enabled: !notificationSettings.reNotification.enabled,
},
});
}}
/>
</div>
</div>
);
}
export default NotificationSettings;

View File

@ -0,0 +1,172 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as createAlertContext from 'container/CreateAlertV2/context';
import {
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from 'container/CreateAlertV2/context/constants';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import MultipleNotifications from '../MultipleNotifications';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
const TEST_QUERY = 'test-query';
const TEST_GROUP_BY_FIELDS = [{ key: 'service' }, { key: 'environment' }];
const TRUE = 'true';
const FALSE = 'false';
const COMBOBOX_ROLE = 'combobox';
const ARIA_DISABLED_ATTR = 'aria-disabled';
const mockSetNotificationSettings = jest.fn();
const mockUseQueryBuilder = {
currentQuery: {
builder: {
queryData: [
{
queryName: TEST_QUERY,
groupBy: [],
},
],
},
},
};
const initialAlertThresholdState = createMockAlertContextState().thresholdState;
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
thresholdState: {
...initialAlertThresholdState,
selectedQuery: TEST_QUERY,
},
setNotificationSettings: mockSetNotificationSettings,
}),
);
describe('MultipleNotifications', () => {
const { useQueryBuilder } = jest.requireMock(
'hooks/queryBuilder/useQueryBuilder',
);
beforeEach(() => {
jest.clearAllMocks();
useQueryBuilder.mockReturnValue(mockUseQueryBuilder);
});
it('should render the multiple notifications component with no grouping fields and disabled input by default', () => {
render(<MultipleNotifications />);
expect(screen.getByText('Group alerts by')).toBeInTheDocument();
expect(
screen.getByText(
'Combine alerts with the same field values into a single notification.',
),
).toBeInTheDocument();
expect(screen.getByText('No grouping fields available')).toBeInTheDocument();
const select = screen.getByRole(COMBOBOX_ROLE);
expect(select).toHaveAttribute(ARIA_DISABLED_ATTR, TRUE);
});
it('should render the multiple notifications component with grouping fields and enabled input when space aggregation options are set', () => {
useQueryBuilder.mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: TEST_QUERY,
groupBy: TEST_GROUP_BY_FIELDS,
},
],
},
},
});
render(<MultipleNotifications />);
expect(
screen.getByText(
'Empty = all matching alerts combined into one notification',
),
).toBeInTheDocument();
const select = screen.getByRole(COMBOBOX_ROLE);
expect(select).toHaveAttribute(ARIA_DISABLED_ATTR, FALSE);
});
it('should render the multiple notifications component with grouping fields and enabled input when space aggregation options are set and multiple notifications are enabled', () => {
useQueryBuilder.mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: TEST_QUERY,
groupBy: TEST_GROUP_BY_FIELDS,
},
],
},
},
});
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
thresholdState: {
...INITIAL_ALERT_THRESHOLD_STATE,
selectedQuery: TEST_QUERY,
},
notificationSettings: {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
multipleNotifications: ['service', 'environment'],
},
setNotificationSettings: mockSetNotificationSettings,
}),
);
render(<MultipleNotifications />);
expect(
screen.getByText('Alerts with same service, environment will be grouped'),
).toBeInTheDocument();
const select = screen.getByRole(COMBOBOX_ROLE);
expect(select).toHaveAttribute(ARIA_DISABLED_ATTR, FALSE);
});
it('should render unique group by options from all queries', async () => {
useQueryBuilder.mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: 'test-query-1',
groupBy: [{ key: 'http.status_code' }],
},
{
queryName: 'test-query-2',
groupBy: [{ key: 'service' }],
},
],
},
},
});
render(<MultipleNotifications />);
const select = screen.getByRole(COMBOBOX_ROLE);
await userEvent.click(select);
expect(
screen.getByRole('option', { name: 'http.status_code' }),
).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'service' })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,75 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as createAlertContext from 'container/CreateAlertV2/context';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import NotificationMessage from '../NotificationMessage';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const mockSetNotificationSettings = jest.fn();
const initialNotificationSettingsState = createMockAlertContextState()
.notificationSettings;
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
notificationSettings: {
...initialNotificationSettingsState,
description: '',
},
setNotificationSettings: mockSetNotificationSettings,
}),
);
describe('NotificationMessage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders textarea with message and placeholder', () => {
render(<NotificationMessage />);
expect(screen.getByText('Notification Message')).toBeInTheDocument();
const textarea = screen.getByPlaceholderText('Enter notification message...');
expect(textarea).toBeInTheDocument();
});
it('updates notification settings when textarea value changes', async () => {
const user = userEvent.setup();
render(<NotificationMessage />);
const textarea = screen.getByPlaceholderText('Enter notification message...');
await user.type(textarea, 'x');
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
type: 'SET_DESCRIPTION',
payload: 'x',
});
});
it('displays existing description value', () => {
jest.spyOn(createAlertContext, 'useCreateAlertState').mockImplementation(
() =>
({
notificationSettings: {
description: 'Existing message',
},
setNotificationSettings: mockSetNotificationSettings,
} as any),
);
render(<NotificationMessage />);
const textarea = screen.getByDisplayValue('Existing message');
expect(textarea).toBeInTheDocument();
});
});

View File

@ -0,0 +1,120 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as createAlertContext from 'container/CreateAlertV2/context';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import * as utils from 'container/CreateAlertV2/utils';
import NotificationSettings from '../NotificationSettings';
jest.mock(
'container/CreateAlertV2/NotificationSettings/MultipleNotifications',
() => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="multiple-notifications">MultipleNotifications</div>
),
}),
);
jest.mock(
'container/CreateAlertV2/NotificationSettings/NotificationMessage',
() => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="notification-message">NotificationMessage</div>
),
}),
);
const initialNotificationSettings = createMockAlertContextState()
.notificationSettings;
const mockSetNotificationSettings = jest.fn();
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setNotificationSettings: mockSetNotificationSettings,
}),
);
const REPEAT_NOTIFICATIONS_TEXT = 'Repeat notifications';
const ENTER_TIME_INTERVAL_TEXT = 'Enter time interval...';
describe('NotificationSettings', () => {
it('renders the notification settings tab with step number 4 and default values', () => {
render(<NotificationSettings />);
expect(screen.getByText('Notification settings')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
expect(
screen.getByText(
'Send periodic notifications while the alert condition remains active.',
),
).toBeInTheDocument();
});
it('renders the notification settings tab with step number 3 in condensed layout', () => {
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
render(<NotificationSettings />);
expect(screen.getByText('Notification settings')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
});
describe('Repeat notifications', () => {
it('renders the repeat notifications with inputs hidden when the repeat notifications switch is off', () => {
render(<NotificationSettings />);
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
expect(screen.getByText('Every')).not.toBeVisible();
expect(
screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT),
).not.toBeVisible();
});
it('toggles the repeat notifications switch and shows the inputs', () => {
render(<NotificationSettings />);
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
expect(screen.getByText('Every')).not.toBeVisible();
expect(
screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT),
).not.toBeVisible();
fireEvent.click(screen.getByRole('switch'));
expect(screen.getByText('Every')).toBeVisible();
expect(screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT)).toBeVisible();
});
it('updates state when the repeat notifications input is changed', () => {
jest.spyOn(createAlertContext, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setNotificationSettings: mockSetNotificationSettings,
notificationSettings: {
...initialNotificationSettings,
reNotification: {
...initialNotificationSettings.reNotification,
enabled: true,
},
},
}),
);
render(<NotificationSettings />);
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(ENTER_TIME_INTERVAL_TEXT), {
target: { value: '13' },
});
expect(mockSetNotificationSettings).toHaveBeenLastCalledWith({
type: 'SET_RE_NOTIFICATION',
payload: {
enabled: true,
value: 13,
unit: 'min',
conditions: [],
},
});
});
});
});

View File

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

View File

@ -0,0 +1,346 @@
.notification-settings-container {
display: flex;
flex-direction: column;
margin: 0 16px;
.notification-message-container {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: -8px;
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
padding: 16px;
.notification-message-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.notification-message-header-content {
display: flex;
flex-direction: column;
gap: 8px;
.notification-message-header-title {
display: flex;
gap: 8px;
align-items: center;
color: var(--bg-vanilla-300);
font-family: Inter;
font-size: 14px;
font-weight: 500;
}
.notification-message-header-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
}
.notification-message-header-actions {
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
color: var(--bg-robin-400);
}
}
}
textarea {
height: 150px;
background: var(--bg-ink-400);
border: 1px solid var(--bg-slate-200);
border-radius: 4px;
color: var(--bg-vanilla-400) !important;
font-family: Inter;
font-size: 14px;
}
}
.notification-settings-content {
display: flex;
flex-direction: column;
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
padding: 16px;
margin-top: 16px;
.repeat-notifications-input {
display: flex;
align-items: center;
gap: 8px;
.ant-input {
width: 120px;
border: 1px solid var(--bg-slate-100);
}
.ant-select {
.ant-select-selector {
width: 120px;
}
}
.ant-select-multiple {
.ant-select-selector {
width: 200px;
}
}
}
.multiple-notifications-container {
display: flex;
padding: 4px 16px 16px 16px;
border-bottom: 1px solid var(--bg-slate-400);
justify-content: space-between;
.multiple-notifications-header {
display: flex;
flex-direction: column;
gap: 8px;
.ant-typography {
display: flex;
gap: 4px;
align-items: center;
}
.multiple-notifications-header-title {
color: var(--bg-vanilla-300);
font-family: Inter;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.multiple-notifications-header-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
}
.ant-select {
width: 300px;
}
.multiple-notifications-select-description {
font-size: 10px;
color: var(--bg-vanilla-400);
margin-top: 4px;
}
}
.re-notification-container {
display: flex;
flex-direction: column;
gap: 16px;
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
padding: 16px;
margin-top: 16px;
.advanced-option-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
.advanced-option-item-left-content {
display: flex;
flex-direction: column;
gap: 6px;
.advanced-option-item-title {
color: var(--bg-vanilla-300);
font-family: Inter;
font-size: 14px;
font-weight: 500;
}
.advanced-option-item-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
}
}
.border-bottom {
border-bottom: 1px solid var(--bg-slate-400);
width: 100%;
margin-left: -16px;
margin-right: -32px;
}
.re-notification-condition {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
.ant-typography {
font-size: 14px;
font-weight: 400;
color: var(--bg-vanilla-400);
white-space: nowrap;
}
.ant-select {
width: 200px;
height: 32px;
flex-shrink: 0;
.ant-select-selector {
border: 1px solid var(--bg-slate-400);
}
}
.ant-input {
width: 200px;
flex-shrink: 0;
border: 1px solid var(--bg-slate-400);
}
}
}
}
}
.template-variable-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 2px;
.template-variable-content-item {
display: flex;
gap: 8px;
align-items: center;
code {
background-color: var(--bg-slate-500);
color: var(--bg-vanilla-400);
padding: 2px 4px;
}
}
}
.lightMode {
.notification-settings-container {
.notification-message-container {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.notification-message-header {
.notification-message-header-content {
.notification-message-header-title {
color: var(--bg-ink-300);
}
.notification-message-header-description {
color: var(--bg-ink-400);
}
}
.notification-message-header-actions {
.ant-btn {
color: var(--bg-robin-500);
}
}
}
textarea {
background: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-400) !important;
}
}
.notification-settings-content {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.repeat-notifications-input {
.ant-input {
border: 1px solid var(--bg-vanilla-300);
}
}
.multiple-notifications-container {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.multiple-notifications-header {
.multiple-notifications-header-title {
color: var(--bg-ink-300);
}
.multiple-notifications-header-description {
color: var(--bg-ink-400);
}
}
.multiple-notifications-select-description {
color: var(--bg-ink-400);
}
.border-bottom {
border-bottom: 1px solid var(--bg-vanilla-300);
}
}
}
.re-notification-container {
background-color: var(--bg-vanilla-200);
border: 1px solid var(--bg-vanilla-300);
.advanced-option-item {
.advanced-option-item-left-content {
.advanced-option-item-title {
color: var(--bg-ink-300);
}
.advanced-option-item-description {
color: var(--bg-ink-400);
}
}
}
.border-bottom {
border-bottom: 1px solid var(--bg-vanilla-300);
}
.re-notification-condition {
.ant-typography {
color: var(--bg-ink-400);
}
.ant-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
}
}
.ant-input {
border: 1px solid var(--bg-vanilla-300);
}
}
}
}
.template-variable-content-item {
code {
background-color: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}

View File

@ -13,6 +13,7 @@ import {
AlertThresholdState, AlertThresholdState,
Algorithm, Algorithm,
EvaluationWindowState, EvaluationWindowState,
NotificationSettingsState,
Seasonality, Seasonality,
Threshold, Threshold,
TimeDuration, TimeDuration,
@ -170,3 +171,22 @@ export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' }, { value: UniversalYAxisUnit.HOURS, label: 'Hours' },
{ value: UniversalYAxisUnit.DAYS, label: 'Days' }, { value: UniversalYAxisUnit.DAYS, label: 'Days' },
]; ];
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
{ value: 'firing', label: 'Firing' },
{ value: 'no-data', label: 'No Data' },
];
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
multipleNotifications: [],
reNotification: {
enabled: false,
value: 1,
unit: UniversalYAxisUnit.MINUTES,
conditions: [],
},
description: NOTIFICATION_MESSAGE_PLACEHOLDER,
};

View File

@ -18,6 +18,7 @@ import {
INITIAL_ALERT_STATE, INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE, INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE, INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from './constants'; } from './constants';
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types'; import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
import { import {
@ -27,6 +28,7 @@ import {
buildInitialAlertDef, buildInitialAlertDef,
evaluationWindowReducer, evaluationWindowReducer,
getInitialAlertTypeFromURL, getInitialAlertTypeFromURL,
notificationSettingsReducer,
} from './utils'; } from './utils';
const CreateAlertContext = createContext<ICreateAlertContextProps | null>(null); const CreateAlertContext = createContext<ICreateAlertContextProps | null>(null);
@ -94,6 +96,11 @@ export function CreateAlertProvider(
INITIAL_ADVANCED_OPTIONS_STATE, INITIAL_ADVANCED_OPTIONS_STATE,
); );
const [notificationSettings, setNotificationSettings] = useReducer(
notificationSettingsReducer,
INITIAL_NOTIFICATION_SETTINGS_STATE,
);
useEffect(() => { useEffect(() => {
setThresholdState({ setThresholdState({
type: 'RESET', type: 'RESET',
@ -112,6 +119,8 @@ export function CreateAlertProvider(
setEvaluationWindow, setEvaluationWindow,
advancedOptions, advancedOptions,
setAdvancedOptions, setAdvancedOptions,
notificationSettings,
setNotificationSettings,
}), }),
[ [
alertState, alertState,
@ -120,6 +129,7 @@ export function CreateAlertProvider(
thresholdState, thresholdState,
evaluationWindow, evaluationWindow,
advancedOptions, advancedOptions,
notificationSettings,
], ],
); );

View File

@ -14,6 +14,8 @@ export interface ICreateAlertContextProps {
setAdvancedOptions: Dispatch<AdvancedOptionsAction>; setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
evaluationWindow: EvaluationWindowState; evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>; setEvaluationWindow: Dispatch<EvaluationWindowAction>;
notificationSettings: NotificationSettingsState;
setNotificationSettings: Dispatch<NotificationSettingsAction>;
} }
export interface ICreateAlertProviderProps { export interface ICreateAlertProviderProps {
@ -38,7 +40,8 @@ export type CreateAlertAction =
| { type: 'SET_ALERT_NAME'; payload: string } | { type: 'SET_ALERT_NAME'; payload: string }
| { type: 'SET_ALERT_DESCRIPTION'; payload: string } | { type: 'SET_ALERT_DESCRIPTION'; payload: string }
| { type: 'SET_ALERT_LABELS'; payload: Labels } | { type: 'SET_ALERT_LABELS'; payload: Labels }
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }; | { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
| { type: 'RESET' };
export interface Threshold { export interface Threshold {
id: string; id: string;
@ -190,3 +193,31 @@ export type EvaluationWindowAction =
| { type: 'RESET' }; | { type: 'RESET' };
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule'; export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
export interface NotificationSettingsState {
multipleNotifications: string[] | null;
reNotification: {
enabled: boolean;
value: number;
unit: string;
conditions: ('firing' | 'no-data')[];
};
description: string;
}
export type NotificationSettingsAction =
| {
type: 'SET_MULTIPLE_NOTIFICATIONS';
payload: string[] | null;
}
| {
type: 'SET_RE_NOTIFICATION';
payload: {
enabled: boolean;
value: number;
unit: string;
conditions: ('firing' | 'no-data')[];
};
}
| { type: 'SET_DESCRIPTION'; payload: string }
| { type: 'RESET' };

View File

@ -13,8 +13,10 @@ import { DataSource } from 'types/common/queryBuilder';
import { import {
INITIAL_ADVANCED_OPTIONS_STATE, INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE, INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE, INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from './constants'; } from './constants';
import { import {
AdvancedOptionsAction, AdvancedOptionsAction,
@ -25,6 +27,8 @@ import {
CreateAlertAction, CreateAlertAction,
EvaluationWindowAction, EvaluationWindowAction,
EvaluationWindowState, EvaluationWindowState,
NotificationSettingsAction,
NotificationSettingsState,
} from './types'; } from './types';
export const alertCreationReducer = ( export const alertCreationReducer = (
@ -52,6 +56,8 @@ export const alertCreationReducer = (
...state, ...state,
yAxisUnit: action.payload, yAxisUnit: action.payload,
}; };
case 'RESET':
return INITIAL_ALERT_STATE;
default: default:
return state; return state;
} }
@ -172,3 +178,21 @@ export const evaluationWindowReducer = (
return state; return state;
} }
}; };
export const notificationSettingsReducer = (
state: NotificationSettingsState,
action: NotificationSettingsAction,
): NotificationSettingsState => {
switch (action.type) {
case 'SET_MULTIPLE_NOTIFICATIONS':
return { ...state, multipleNotifications: action.payload };
case 'SET_RE_NOTIFICATION':
return { ...state, reNotification: action.payload };
case 'SET_DESCRIPTION':
return { ...state, description: action.payload };
case 'RESET':
return INITIAL_NOTIFICATION_SETTINGS_STATE;
default:
return state;
}
};

View File

@ -1,3 +1,9 @@
// UI side feature flag // UI side feature flag
export const showNewCreateAlertsPage = (): boolean => export const showNewCreateAlertsPage = (): boolean =>
localStorage.getItem('showNewCreateAlertsPage') === 'true'; localStorage.getItem('showNewCreateAlertsPage') === 'true';
// UI side FF to switch between the 2 layouts of the create alert page
// Layout 1 - Default layout
// Layout 2 - Condensed layout
export const showCondensedLayout = (): boolean =>
localStorage.getItem('showCondensedLayout') === 'true';

View File

@ -17,6 +17,7 @@ export default function LogsError(): JSX.Element {
window.open('https://signoz.io/slack', '_blank'); window.open('https://signoz.io/slack', '_blank');
} }
}; };
return ( return (
<div className="logs-error-container"> <div className="logs-error-container">
<div className="logs-error-content"> <div className="logs-error-content">

View File

@ -57,14 +57,14 @@
border-bottom: 1px solid var(--bg-slate-400); border-bottom: 1px solid var(--bg-slate-400);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 16px;
align-items: center; align-items: center;
margin-right: 16px; padding: 0 8px;
box-sizing: border-box; box-sizing: border-box;
.dashboard-breadcrumbs { .dashboard-breadcrumbs {
width: 100%; width: 100%;
height: 48px; height: 48px;
padding: 16px;
display: flex; display: flex;
gap: 6px; gap: 6px;
align-items: center; align-items: center;

View File

@ -12,6 +12,7 @@ import {
Typography, Typography,
} from 'antd'; } from 'antd';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
@ -321,6 +322,12 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
{title} {title}
</Button> </Button>
</section> </section>
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div> </div>
<section className="dashboard-details"> <section className="dashboard-details">
<div className="left-section"> <div className="left-section">

View File

@ -300,6 +300,7 @@ function RightContainer({
style={{ width: '100%' }} style={{ width: '100%' }}
className="panel-type-select" className="panel-type-select"
data-testid="panel-change-select" data-testid="panel-change-select"
data-stacking-state={stackedBarChart ? 'true' : 'false'}
> >
{graphTypes.map((item) => ( {graphTypes.map((item) => (
<Option key={item.name} value={item.name}> <Option key={item.name} value={item.name}>

View File

@ -1,3 +1,4 @@
/* eslint-disable sonarjs/no-duplicate-string */
// This test suite covers several important scenarios: // This test suite covers several important scenarios:
// - Empty layout - widget should be placed at origin (0,0) // - Empty layout - widget should be placed at origin (0,0)
// - Empty layout with custom dimensions // - Empty layout with custom dimensions
@ -6,13 +7,20 @@
// - Handling multiple rows correctly // - Handling multiple rows correctly
// - Handling widgets with different heights // - Handling widgets with different heights
import { screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { DashboardProvider } from 'providers/Dashboard/Dashboard'; import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider'; import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { useSearchParams } from 'react-router-dom-v5-compat'; import { useSearchParams } from 'react-router-dom-v5-compat';
import i18n from 'ReactI18'; import i18n from 'ReactI18';
import { render } from 'tests/test-utils'; import {
fireEvent,
getByText as getByTextUtil,
render,
userEvent,
within,
} from 'tests/test-utils';
import NewWidget from '..'; import NewWidget from '..';
import { import {
@ -21,6 +29,28 @@ import {
placeWidgetBetweenRows, placeWidgetBetweenRows,
} from '../utils'; } from '../utils';
// Helper function to check stack series state
const checkStackSeriesState = (
container: HTMLElement,
expectedChecked: boolean,
): HTMLElement => {
expect(getByTextUtil(container, 'Stack series')).toBeInTheDocument();
const stackSeriesSection = container.querySelector(
'section > .stack-chart',
) as HTMLElement;
expect(stackSeriesSection).toBeInTheDocument();
const switchElement = within(stackSeriesSection).getByRole('switch');
if (expectedChecked) {
expect(switchElement).toBeChecked();
} else {
expect(switchElement).not.toBeChecked();
}
return switchElement;
};
const MOCK_SEARCH_PARAMS = const MOCK_SEARCH_PARAMS =
'?graphType=bar&widgetId=b473eef0-8eb5-4dd3-8089-c1817734084f&compositeQuery=%7B"id"%3A"f026c678-9abf-42af-a3dc-f73dc8cbb810"%2C"builder"%3A%7B"queryData"%3A%5B%7B"dataSource"%3A"metrics"%2C"queryName"%3A"A"%2C"aggregateOperator"%3A"count"%2C"aggregateAttribute"%3A%7B"id"%3A"----"%2C"dataType"%3A""%2C"key"%3A""%2C"type"%3A""%7D%2C"timeAggregation"%3A"rate"%2C"spaceAggregation"%3A"sum"%2C"filter"%3A%7B"expression"%3A""%7D%2C"aggregations"%3A%5B%7B"metricName"%3A""%2C"temporality"%3A""%2C"timeAggregation"%3A"count"%2C"spaceAggregation"%3A"sum"%2C"reduceTo"%3A"avg"%7D%5D%2C"functions"%3A%5B%5D%2C"filters"%3A%7B"items"%3A%5B%5D%2C"op"%3A"AND"%7D%2C"expression"%3A"A"%2C"disabled"%3Afalse%2C"stepInterval"%3Anull%2C"having"%3A%5B%5D%2C"limit"%3Anull%2C"orderBy"%3A%5B%5D%2C"groupBy"%3A%5B%5D%2C"legend"%3A""%2C"reduceTo"%3A"avg"%2C"source"%3A""%7D%5D%2C"queryFormulas"%3A%5B%5D%2C"queryTraceOperator"%3A%5B%5D%7D%2C"clickhouse_sql"%3A%5B%7B"name"%3A"A"%2C"legend"%3A""%2C"disabled"%3Afalse%2C"query"%3A""%7D%5D%2C"promql"%3A%5B%7B"name"%3A"A"%2C"query"%3A""%2C"legend"%3A""%2C"disabled"%3Afalse%7D%5D%2C"queryType"%3A"builder"%7D&relativeTime=30m'; '?graphType=bar&widgetId=b473eef0-8eb5-4dd3-8089-c1817734084f&compositeQuery=%7B"id"%3A"f026c678-9abf-42af-a3dc-f73dc8cbb810"%2C"builder"%3A%7B"queryData"%3A%5B%7B"dataSource"%3A"metrics"%2C"queryName"%3A"A"%2C"aggregateOperator"%3A"count"%2C"aggregateAttribute"%3A%7B"id"%3A"----"%2C"dataType"%3A""%2C"key"%3A""%2C"type"%3A""%7D%2C"timeAggregation"%3A"rate"%2C"spaceAggregation"%3A"sum"%2C"filter"%3A%7B"expression"%3A""%7D%2C"aggregations"%3A%5B%7B"metricName"%3A""%2C"temporality"%3A""%2C"timeAggregation"%3A"count"%2C"spaceAggregation"%3A"sum"%2C"reduceTo"%3A"avg"%7D%5D%2C"functions"%3A%5B%5D%2C"filters"%3A%7B"items"%3A%5B%5D%2C"op"%3A"AND"%7D%2C"expression"%3A"A"%2C"disabled"%3Afalse%2C"stepInterval"%3Anull%2C"having"%3A%5B%5D%2C"limit"%3Anull%2C"orderBy"%3A%5B%5D%2C"groupBy"%3A%5B%5D%2C"legend"%3A""%2C"reduceTo"%3A"avg"%2C"source"%3A""%7D%5D%2C"queryFormulas"%3A%5B%5D%2C"queryTraceOperator"%3A%5B%5D%7D%2C"clickhouse_sql"%3A%5B%7B"name"%3A"A"%2C"legend"%3A""%2C"disabled"%3Afalse%2C"query"%3A""%7D%5D%2C"promql"%3A%5B%7B"name"%3A"A"%2C"query"%3A""%2C"legend"%3A""%2C"disabled"%3Afalse%7D%5D%2C"queryType"%3A"builder"%7D&relativeTime=30m';
// Mocks // Mocks
@ -279,7 +309,7 @@ describe('Stacking bar in new panel', () => {
jest.fn(), jest.fn(),
]); ]);
const { container, getByText, getByRole } = render( const { container, getByText } = render(
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<DashboardProvider> <DashboardProvider>
<PreferenceContextProvider> <PreferenceContextProvider>
@ -305,7 +335,83 @@ describe('Stacking bar in new panel', () => {
expect(switchBtn).toBeInTheDocument(); expect(switchBtn).toBeInTheDocument();
expect(switchBtn).toHaveClass('ant-switch-checked'); expect(switchBtn).toHaveClass('ant-switch-checked');
// (Optional) More semantic: verify by role // Check that stack series is present and checked
expect(getByRole('switch')).toBeChecked(); checkStackSeriesState(container, true);
});
});
const STACKING_STATE_ATTR = 'data-stacking-state';
describe('when switching to BAR panel type', () => {
jest.setTimeout(10000);
beforeEach(() => {
jest.clearAllMocks();
// Mock useSearchParams to return the expected values
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams(MOCK_SEARCH_PARAMS),
jest.fn(),
]);
});
it('should preserve saved stacking value of true', async () => {
const { getByTestId, getByText, container } = render(
<DashboardProvider>
<NewWidget
selectedGraph={PANEL_TYPES.BAR}
fillSpans={undefined}
yAxisUnit={undefined}
/>
</DashboardProvider>,
);
expect(getByTestId('panel-change-select')).toHaveAttribute(
STACKING_STATE_ATTR,
'true',
);
await userEvent.click(getByText('Bar')); // Panel Type Selected
// find dropdown with - .ant-select-dropdown
const panelDropdown = document.querySelector(
'.ant-select-dropdown',
) as HTMLElement;
expect(panelDropdown).toBeInTheDocument();
// Select TimeSeries from dropdown
const option = within(panelDropdown).getByText('Time Series');
fireEvent.click(option);
expect(getByTestId('panel-change-select')).toHaveAttribute(
STACKING_STATE_ATTR,
'false',
);
// Since we are on timeseries panel, stack series should be false
expect(screen.queryByText('Stack series')).not.toBeInTheDocument();
// switch back to Bar panel
const panelTypeDropdown2 = getByTestId('panel-change-select') as HTMLElement;
expect(panelTypeDropdown2).toBeInTheDocument();
expect(getByTextUtil(panelTypeDropdown2, 'Time Series')).toBeInTheDocument();
fireEvent.click(getByTextUtil(panelTypeDropdown2, 'Time Series'));
// find dropdown with - .ant-select-dropdown
const panelDropdown2 = document.querySelector(
'.ant-select-dropdown',
) as HTMLElement;
// // Select BAR from dropdown
const BarOption = within(panelDropdown2).getByText('Bar');
fireEvent.click(BarOption);
// Stack series should be true
checkStackSeriesState(container, true);
expect(getByTestId('panel-change-select')).toHaveAttribute(
STACKING_STATE_ATTR,
'true',
);
}); });
}); });

View File

@ -0,0 +1,134 @@
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export const BarNonStackedChartData = {
apiResponse: {
data: {
result: [
{
metric: {
'service.name': 'recommendationservice',
},
values: [
[1758713940, '33.933'],
[1758715020, '31.767'],
],
queryName: 'A',
metaData: {
alias: '__result_0',
index: 0,
queryName: 'A',
},
legend: '',
},
{
metric: {
'service.name': 'frontend',
},
values: [
[1758713940, '20.0'],
[1758715020, '25.0'],
],
queryName: 'B',
metaData: {
alias: '__result_1',
index: 1,
queryName: 'B',
},
legend: '',
},
],
resultType: 'time_series',
newResult: {
data: {
resultType: 'time_series',
result: [
{
queryName: 'A',
legend: '',
series: [
{
labels: {
'service.name': 'recommendationservice',
},
labelsArray: [
{
'service.name': 'recommendationservice',
},
],
values: [
{
timestamp: 1758713940000,
value: '33.933',
},
{
timestamp: 1758715020000,
value: '31.767',
},
],
metaData: {
alias: '__result_0',
index: 0,
queryName: 'A',
},
},
],
predictedSeries: [],
upperBoundSeries: [],
lowerBoundSeries: [],
anomalyScores: [],
list: null,
},
{
queryName: 'B',
legend: '',
series: [
{
labels: {
'service.name': 'frontend',
},
labelsArray: [
{
'service.name': 'frontend',
},
],
values: [
{
timestamp: 1758713940000,
value: '20.0',
},
{
timestamp: 1758715020000,
value: '25.0',
},
],
metaData: {
alias: '__result_1',
index: 1,
queryName: 'B',
},
},
],
predictedSeries: [],
upperBoundSeries: [],
lowerBoundSeries: [],
anomalyScores: [],
list: null,
},
],
},
},
},
} as MetricRangePayloadProps,
fillSpans: false,
stackedBarChart: false,
};
export const BarStackedChartData = {
...BarNonStackedChartData,
stackedBarChart: true,
};
export const TimeSeriesChartData = {
...BarNonStackedChartData,
stackedBarChart: false,
};

View File

@ -0,0 +1,50 @@
import { getUPlotChartData } from '../../../lib/uPlotLib/utils/getUplotChartData';
import {
BarNonStackedChartData,
BarStackedChartData,
TimeSeriesChartData,
} from './__mocks__/uplotChartData';
describe('getUplotChartData', () => {
it('should return the correct chart data for non-stacked bar chart', () => {
const result = getUPlotChartData(
BarNonStackedChartData.apiResponse,
BarNonStackedChartData.fillSpans,
BarNonStackedChartData.stackedBarChart,
);
expect(result).toEqual([
[1758713940, 1758715020],
[33.933, 31.767],
[20.0, 25.0],
]);
});
it('should return the correct chart data for stacked bar chart', () => {
const result = getUPlotChartData(
BarStackedChartData.apiResponse,
BarStackedChartData.fillSpans,
BarStackedChartData.stackedBarChart,
);
// For stacked charts, the values should be cumulative
// First series: [33.933, 31.767] + [20.0, 25.0] = [53.933, 56.767]
// Second series: [20.0, 25.0] (unchanged)
expect(result).toHaveLength(3);
expect(result[0]).toEqual([1758713940, 1758715020]);
expect(result[1][0]).toBeCloseTo(53.933, 3);
expect(result[1][1]).toBeCloseTo(56.767, 3);
expect(result[2]).toEqual([20.0, 25.0]);
});
it('should return the correct chart data for time series chart', () => {
const result = getUPlotChartData(
TimeSeriesChartData.apiResponse,
TimeSeriesChartData.fillSpans,
TimeSeriesChartData.stackedBarChart,
);
expect(result).toEqual([
[1758713940, 1758715020],
[33.933, 31.767],
[20.0, 25.0],
]);
});
});

View File

@ -595,6 +595,13 @@ function NewWidget({
selectedGraph, selectedGraph,
); );
setGraphType(type); setGraphType(type);
// with a single source of truth for stacking, we can use the saved stacking value as a default value
const savedStackingValue = getWidget()?.stackedBarChart;
setStackedBarChart(
type === PANEL_TYPES.BAR ? savedStackingValue || false : false,
);
redirectWithQueryBuilderData( redirectWithQueryBuilderData(
updatedQuery, updatedQuery,
{ [QueryParams.graphType]: type }, { [QueryParams.graphType]: type },

View File

@ -553,7 +553,7 @@ export const getDefaultWidgetData = (
timePreferance: 'GLOBAL_TIME', timePreferance: 'GLOBAL_TIME',
softMax: null, softMax: null,
softMin: null, softMin: null,
stackedBarChart: true, stackedBarChart: name === PANEL_TYPES.BAR,
selectedLogFields: defaultLogsSelectedColumns.map((field) => ({ selectedLogFields: defaultLogsSelectedColumns.map((field) => ({
...field, ...field,
type: field.fieldContext ?? '', type: field.fieldContext ?? '',

View File

@ -2,14 +2,14 @@
import '../OnboardingQuestionaire.styles.scss'; import '../OnboardingQuestionaire.styles.scss';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Button, Input, Typography } from 'antd'; import { Button, Checkbox, Input, Typography } from 'antd';
import TextArea from 'antd/lib/input/TextArea'; import TextArea from 'antd/lib/input/TextArea';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
import { ArrowLeft, ArrowRight, CheckCircle } from 'lucide-react'; import { ArrowLeft, ArrowRight, CheckCircle } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export interface SignozDetails { export interface SignozDetails {
interestInSignoz: string | null; interestInSignoz: string[] | null;
otherInterestInSignoz: string | null; otherInterestInSignoz: string | null;
discoverSignoz: string | null; discoverSignoz: string | null;
} }
@ -22,9 +22,12 @@ interface AboutSigNozQuestionsProps {
} }
const interestedInOptions: Record<string, string> = { const interestedInOptions: Record<string, string> = {
savingCosts: 'Saving costs', loweringCosts: 'Lowering observability costs',
otelNativeStack: 'Interested in Otel-native stack', otelNativeStack: 'Interested in OTel-native stack',
allInOne: 'All in one (Logs, Metrics & Traces)', deploymentFlexibility: 'Deployment flexibility (Cloud/Self-Host) in future',
singleTool:
'Single Tool (logs, metrics & traces) to reduce operational overhead',
correlateSignals: 'Correlate signals for faster troubleshooting',
}; };
export function AboutSigNozQuestions({ export function AboutSigNozQuestions({
@ -33,8 +36,8 @@ export function AboutSigNozQuestions({
onNext, onNext,
onBack, onBack,
}: AboutSigNozQuestionsProps): JSX.Element { }: AboutSigNozQuestionsProps): JSX.Element {
const [interestInSignoz, setInterestInSignoz] = useState<string | null>( const [interestInSignoz, setInterestInSignoz] = useState<string[]>(
signozDetails?.interestInSignoz || null, signozDetails?.interestInSignoz || [],
); );
const [otherInterestInSignoz, setOtherInterestInSignoz] = useState<string>( const [otherInterestInSignoz, setOtherInterestInSignoz] = useState<string>(
signozDetails?.otherInterestInSignoz || '', signozDetails?.otherInterestInSignoz || '',
@ -47,8 +50,8 @@ export function AboutSigNozQuestions({
useEffect((): void => { useEffect((): void => {
if ( if (
discoverSignoz !== '' && discoverSignoz !== '' &&
interestInSignoz !== null && interestInSignoz.length > 0 &&
(interestInSignoz !== 'Others' || otherInterestInSignoz !== '') (!interestInSignoz.includes('Others') || otherInterestInSignoz !== '')
) { ) {
setIsNextDisabled(false); setIsNextDisabled(false);
} else { } else {
@ -56,6 +59,14 @@ export function AboutSigNozQuestions({
} }
}, [interestInSignoz, otherInterestInSignoz, discoverSignoz]); }, [interestInSignoz, otherInterestInSignoz, discoverSignoz]);
const handleInterestChange = (option: string, checked: boolean): void => {
if (checked) {
setInterestInSignoz((prev) => [...prev, option]);
} else {
setInterestInSignoz((prev) => prev.filter((item) => item !== option));
}
};
const handleOnNext = (): void => { const handleOnNext = (): void => {
setSignozDetails({ setSignozDetails({
discoverSignoz, discoverSignoz,
@ -108,50 +119,45 @@ export function AboutSigNozQuestions({
<div className="form-group"> <div className="form-group">
<div className="question">What got you interested in SigNoz?</div> <div className="question">What got you interested in SigNoz?</div>
<div className="two-column-grid"> <div className="checkbox-grid">
{Object.keys(interestedInOptions).map((option: string) => ( {Object.keys(interestedInOptions).map((option: string) => (
<Button <div key={option} className="checkbox-item">
key={option} <Checkbox
type="primary" checked={interestInSignoz.includes(option)}
className={`onboarding-questionaire-button ${ onChange={(e): void => handleInterestChange(option, e.target.checked)}
interestInSignoz === option ? 'active' : '' >
}`} {interestedInOptions[option]}
onClick={(): void => setInterestInSignoz(option)} </Checkbox>
> </div>
{interestedInOptions[option]}
{interestInSignoz === option && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
))} ))}
{interestInSignoz === 'Others' ? ( <div className="checkbox-item">
<Input <Checkbox
type="text" checked={interestInSignoz.includes('Others')}
className="onboarding-questionaire-other-input" onChange={(e): void =>
placeholder="Please specify your interest" handleInterestChange('Others', e.target.checked)
value={otherInterestInSignoz}
autoFocus
addonAfter={
otherInterestInSignoz !== '' ? (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
) : (
''
)
} }
onChange={(e): void => setOtherInterestInSignoz(e.target.value)}
/>
) : (
<Button
type="primary"
className={`onboarding-questionaire-button ${
interestInSignoz === 'Others' ? 'active' : ''
}`}
onClick={(): void => setInterestInSignoz('Others')}
> >
Others Others
</Button> </Checkbox>
)} {interestInSignoz.includes('Others') && (
<Input
type="text"
className="onboarding-questionaire-other-input"
placeholder="Please specify your interest"
value={otherInterestInSignoz}
autoFocus
addonAfter={
otherInterestInSignoz !== '' ? (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
) : (
''
)
}
onChange={(e): void => setOtherInterestInSignoz(e.target.value)}
/>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -94,6 +94,7 @@
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 14px;
padding: 12px; padding: 12px;
font-weight: 400;
&::placeholder { &::placeholder {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
@ -290,6 +291,37 @@
gap: 10px; gap: 10px;
} }
.checkbox-grid {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.checkbox-item {
display: flex;
flex-direction: column;
gap: 8px;
.ant-checkbox-wrapper {
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 400;
.ant-checkbox {
.ant-checkbox-inner {
border-color: var(--bg-slate-100);
background-color: var(--bg-ink-200);
}
&.ant-checkbox-checked .ant-checkbox-inner {
background-color: var(--bg-robin-500);
border-color: var(--bg-robin-500);
}
}
}
}
.onboarding-questionaire-button, .onboarding-questionaire-button,
.add-another-member-button, .add-another-member-button,
.remove-team-member-button { .remove-team-member-button {
@ -466,6 +498,7 @@
border: 1px solid var(--bg-vanilla-300); border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100); background: var(--bg-vanilla-100);
color: var(--text-ink-300); color: var(--text-ink-300);
font-weight: 400;
&::placeholder { &::placeholder {
color: var(--bg-slate-400); color: var(--bg-slate-400);
@ -527,6 +560,24 @@
color: var(--bg-slate-300); color: var(--bg-slate-300);
} }
.checkbox-item {
.ant-checkbox-wrapper {
color: var(--bg-ink-300);
.ant-checkbox {
.ant-checkbox-inner {
border-color: var(--bg-vanilla-300);
background-color: var(--bg-vanilla-100);
}
&.ant-checkbox-checked .ant-checkbox-inner {
background-color: var(--bg-robin-500);
border-color: var(--bg-robin-500);
}
}
}
}
input[type='text'] { input[type='text'] {
border: 1px solid var(--bg-vanilla-300); border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100); background: var(--bg-vanilla-100);

View File

@ -38,6 +38,7 @@ const observabilityTools = {
AzureAppMonitor: 'Azure App Monitor', AzureAppMonitor: 'Azure App Monitor',
GCPNativeO11yTools: 'GCP-native o11y tools', GCPNativeO11yTools: 'GCP-native o11y tools',
Honeycomb: 'Honeycomb', Honeycomb: 'Honeycomb',
None: 'None/Starting fresh',
}; };
function OrgQuestions({ function OrgQuestions({
@ -53,9 +54,6 @@ function OrgQuestions({
const [organisationName, setOrganisationName] = useState<string>( const [organisationName, setOrganisationName] = useState<string>(
orgDetails?.organisationName || '', orgDetails?.organisationName || '',
); );
const [usesObservability, setUsesObservability] = useState<boolean | null>(
orgDetails?.usesObservability || null,
);
const [observabilityTool, setObservabilityTool] = useState<string | null>( const [observabilityTool, setObservabilityTool] = useState<string | null>(
orgDetails?.observabilityTool || null, orgDetails?.observabilityTool || null,
); );
@ -83,7 +81,7 @@ function OrgQuestions({
orgDetails.organisationName === organisationName orgDetails.organisationName === organisationName
) { ) {
logEvent('Org Onboarding: Answered', { logEvent('Org Onboarding: Answered', {
usesObservability, usesObservability: !observabilityTool?.includes('None'),
observabilityTool, observabilityTool,
otherTool, otherTool,
usesOtel, usesOtel,
@ -91,7 +89,7 @@ function OrgQuestions({
onNext({ onNext({
organisationName, organisationName,
usesObservability, usesObservability: !observabilityTool?.includes('None'),
observabilityTool, observabilityTool,
otherTool, otherTool,
usesOtel, usesOtel,
@ -114,7 +112,7 @@ function OrgQuestions({
}); });
logEvent('Org Onboarding: Answered', { logEvent('Org Onboarding: Answered', {
usesObservability, usesObservability: !observabilityTool?.includes('None'),
observabilityTool, observabilityTool,
otherTool, otherTool,
usesOtel, usesOtel,
@ -122,7 +120,7 @@ function OrgQuestions({
onNext({ onNext({
organisationName, organisationName,
usesObservability, usesObservability: !observabilityTool?.includes('None'),
observabilityTool, observabilityTool,
otherTool, otherTool,
usesOtel, usesOtel,
@ -152,16 +150,16 @@ function OrgQuestions({
}; };
const isValidUsesObservability = (): boolean => { const isValidUsesObservability = (): boolean => {
if (usesObservability === null) { if (!observabilityTool || observabilityTool === '') {
return false;
}
if (usesObservability && (!observabilityTool || observabilityTool === '')) {
return false; return false;
} }
// eslint-disable-next-line sonarjs/prefer-single-boolean-return // eslint-disable-next-line sonarjs/prefer-single-boolean-return
if (usesObservability && observabilityTool === 'Others' && otherTool === '') { if (
!observabilityTool?.includes('None') &&
observabilityTool === 'Others' &&
otherTool === ''
) {
return false; return false;
} }
@ -177,13 +175,7 @@ function OrgQuestions({
setIsNextDisabled(true); setIsNextDisabled(true);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [organisationName, usesOtel, observabilityTool, otherTool]);
organisationName,
usesObservability,
usesOtel,
observabilityTool,
otherTool,
]);
const handleOnNext = (): void => { const handleOnNext = (): void => {
handleOrgNameUpdate(); handleOrgNameUpdate();
@ -217,99 +209,57 @@ function OrgQuestions({
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="question" htmlFor="usesObservability"> <label className="question" htmlFor="observabilityTool">
Do you currently use any observability/monitoring tool? Which observability tool do you currently use?
</label> </label>
<div className="two-column-grid"> <div className="two-column-grid">
<Button {Object.keys(observabilityTools).map((tool) => (
type="primary" <Button
name="usesObservability" key={tool}
className={`onboarding-questionaire-button ${ type="primary"
usesObservability === true ? 'active' : '' className={`onboarding-questionaire-button ${
}`} observabilityTool === tool ? 'active' : ''
onClick={(): void => { }`}
setUsesObservability(true); onClick={(): void => setObservabilityTool(tool)}
}} >
> {observabilityTools[tool as keyof typeof observabilityTools]}
Yes{' '}
{usesObservability === true && ( {observabilityTool === tool && (
<CheckCircle size={12} color={Color.BG_FOREST_500} /> <CheckCircle size={12} color={Color.BG_FOREST_500} />
)} )}
</Button> </Button>
<Button ))}
type="primary"
className={`onboarding-questionaire-button ${ {observabilityTool === 'Others' ? (
usesObservability === false ? 'active' : '' <Input
}`} type="text"
onClick={(): void => { className="onboarding-questionaire-other-input"
setUsesObservability(false); placeholder="Please specify the tool"
setObservabilityTool(null); value={otherTool || ''}
setOtherTool(''); autoFocus
}} addonAfter={
> otherTool && otherTool !== '' ? (
No{' '} <CheckCircle size={12} color={Color.BG_FOREST_500} />
{usesObservability === false && ( ) : (
<CheckCircle size={12} color={Color.BG_FOREST_500} /> ''
)} )
</Button> }
onChange={(e): void => setOtherTool(e.target.value)}
/>
) : (
<Button
type="primary"
className={`onboarding-questionaire-button ${
observabilityTool === 'Others' ? 'active' : ''
}`}
onClick={(): void => setObservabilityTool('Others')}
>
Others
</Button>
)}
</div> </div>
</div> </div>
{usesObservability && (
<div className="form-group">
<label className="question" htmlFor="observabilityTool">
Which observability tool do you currently use?
</label>
<div className="two-column-grid">
{Object.keys(observabilityTools).map((tool) => (
<Button
key={tool}
type="primary"
className={`onboarding-questionaire-button ${
observabilityTool === tool ? 'active' : ''
}`}
onClick={(): void => setObservabilityTool(tool)}
>
{observabilityTools[tool as keyof typeof observabilityTools]}
{observabilityTool === tool && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
))}
{observabilityTool === 'Others' ? (
<Input
type="text"
className="onboarding-questionaire-other-input"
placeholder="Please specify the tool"
value={otherTool || ''}
autoFocus
addonAfter={
otherTool && otherTool !== '' ? (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
) : (
''
)
}
onChange={(e): void => setOtherTool(e.target.value)}
/>
) : (
<Button
type="primary"
className={`onboarding-questionaire-button ${
observabilityTool === 'Others' ? 'active' : ''
}`}
onClick={(): void => setObservabilityTool('Others')}
>
Others
</Button>
)}
</div>
</div>
)}
<div className="form-group"> <div className="form-group">
<div className="question">Do you already use OpenTelemetry?</div> <div className="question">Do you already use OpenTelemetry?</div>
<div className="two-column-grid"> <div className="two-column-grid">

View File

@ -46,7 +46,7 @@ const INITIAL_ORG_DETAILS: OrgDetails = {
}; };
const INITIAL_SIGNOZ_DETAILS: SignozDetails = { const INITIAL_SIGNOZ_DETAILS: SignozDetails = {
interestInSignoz: '', interestInSignoz: [],
otherInterestInSignoz: '', otherInterestInSignoz: '',
discoverSignoz: '', discoverSignoz: '',
}; };
@ -145,6 +145,9 @@ function OnboardingQuestionaire(): JSX.Element {
}, },
onError: (error) => { onError: (error) => {
showErrorNotification(notifications, error as AxiosError); showErrorNotification(notifications, error as AxiosError);
// Allow user to proceed even if API fails
setCurrentStep(4);
}, },
}, },
); );
@ -174,10 +177,16 @@ function OnboardingQuestionaire(): JSX.Element {
? (orgDetails?.otherTool as string) ? (orgDetails?.otherTool as string)
: (orgDetails?.observabilityTool as string), : (orgDetails?.observabilityTool as string),
where_did_you_discover_signoz: signozDetails?.discoverSignoz as string, where_did_you_discover_signoz: signozDetails?.discoverSignoz as string,
reasons_for_interest_in_signoz: reasons_for_interest_in_signoz: signozDetails?.interestInSignoz?.includes(
signozDetails?.interestInSignoz === 'Others' 'Others',
? (signozDetails?.otherInterestInSignoz as string) )
: (signozDetails?.interestInSignoz as string), ? ([
...(signozDetails?.interestInSignoz?.filter(
(item) => item !== 'Others',
) || []),
signozDetails?.otherInterestInSignoz,
] as string[])
: (signozDetails?.interestInSignoz as string[]),
logs_scale_per_day_in_gb: optimiseSignozDetails?.logsPerDay as number, logs_scale_per_day_in_gb: optimiseSignozDetails?.logsPerDay as number,
number_of_hosts: optimiseSignozDetails?.hostsPerDay as number, number_of_hosts: optimiseSignozDetails?.hostsPerDay as number,
number_of_services: optimiseSignozDetails?.services as number, number_of_services: optimiseSignozDetails?.services as number,

View File

@ -124,15 +124,23 @@ function UplotPanelWrapper({
queryResponse.data.payload.data.result = sortedSeriesData; queryResponse.data.payload.data.result = sortedSeriesData;
} }
const stackedBarChart = useMemo(
() =>
(selectedGraph
? selectedGraph === PANEL_TYPES.BAR
: widget?.panelTypes === PANEL_TYPES.BAR) && widget?.stackedBarChart,
[selectedGraph, widget?.panelTypes, widget?.stackedBarChart],
);
const chartData = getUPlotChartData( const chartData = getUPlotChartData(
queryResponse?.data?.payload, queryResponse?.data?.payload,
widget.fillSpans, widget.fillSpans,
widget?.stackedBarChart, stackedBarChart,
hiddenGraph, hiddenGraph,
); );
useEffect(() => { useEffect(() => {
if (widget.panelTypes === PANEL_TYPES.BAR && widget?.stackedBarChart) { if (widget.panelTypes === PANEL_TYPES.BAR && stackedBarChart) {
const graphV = cloneDeep(graphVisibility)?.slice(1); const graphV = cloneDeep(graphVisibility)?.slice(1);
const isSomeSelectedLegend = graphV?.some((v) => v === false); const isSomeSelectedLegend = graphV?.some((v) => v === false);
if (isSomeSelectedLegend) { if (isSomeSelectedLegend) {
@ -145,7 +153,7 @@ function UplotPanelWrapper({
} }
} }
} }
}, [graphVisibility, hiddenGraph, widget.panelTypes, widget?.stackedBarChart]); }, [graphVisibility, hiddenGraph, widget.panelTypes, stackedBarChart]);
const { timezone } = useTimezone(); const { timezone } = useTimezone();
@ -221,7 +229,7 @@ function UplotPanelWrapper({
setGraphsVisibilityStates: setGraphVisibility, setGraphsVisibilityStates: setGraphVisibility,
panelType: selectedGraph || widget.panelTypes, panelType: selectedGraph || widget.panelTypes,
currentQuery, currentQuery,
stackBarChart: widget?.stackedBarChart, stackBarChart: stackedBarChart,
hiddenGraph, hiddenGraph,
setHiddenGraph, setHiddenGraph,
customTooltipElement, customTooltipElement,
@ -261,6 +269,7 @@ function UplotPanelWrapper({
enableDrillDown, enableDrillDown,
onClickHandler, onClickHandler,
widget, widget,
stackedBarChart,
], ],
); );
@ -274,14 +283,14 @@ function UplotPanelWrapper({
items={menuItemsConfig.items} items={menuItemsConfig.items}
onClose={onClose} onClose={onClose}
/> />
{widget?.stackedBarChart && isFullViewMode && ( {stackedBarChart && isFullViewMode && (
<Alert <Alert
message="Selecting multiple legends is currently not supported in case of stacked bar charts" message="Selecting multiple legends is currently not supported in case of stacked bar charts"
type="info" type="info"
className="info-text" className="info-text"
/> />
)} )}
{isFullViewMode && setGraphVisibility && !widget?.stackedBarChart && ( {isFullViewMode && setGraphVisibility && !stackedBarChart && (
<GraphManager <GraphManager
data={getUPlotChartData(queryResponse?.data?.payload, widget.fillSpans)} data={getUPlotChartData(queryResponse?.data?.payload, widget.fillSpans)}
name={widget.id} name={widget.id}

View File

@ -76,51 +76,8 @@
} }
} }
.share-modal-content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
width: 420px;
.absolute-relative-time-toggler-container {
display: flex;
gap: 8px;
align-items: center;
}
.absolute-relative-time-toggler {
display: flex;
gap: 4px;
align-items: center;
}
.absolute-relative-time-error {
font-size: 12px;
color: var(--bg-amber-600);
}
.share-link {
display: flex;
align-items: center;
.share-url {
flex: 1;
border: 1px solid var(--bg-slate-400);
border-radius: 2px;
background: var(--bg-ink-300);
height: 32px;
padding: 6px 8px;
}
.copy-url-btn {
width: 32px;
}
}
}
.date-time-root, .date-time-root,
.shareable-link-popover-root { .header-section-popover-root {
.ant-popover-inner { .ant-popover-inner {
border-radius: 4px !important; border-radius: 4px !important;
border: 1px solid var(--bg-slate-400); border: 1px solid var(--bg-slate-400);
@ -359,7 +316,7 @@
} }
.date-time-root, .date-time-root,
.shareable-link-popover-root { .header-section-popover-root {
.ant-popover-inner { .ant-popover-inner {
border: 1px solid var(--bg-vanilla-400); border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-100) !important; background: var(--bg-vanilla-100) !important;
@ -471,14 +428,6 @@
} }
} }
.share-modal-content {
.share-link {
.share-url {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
}
}
.reset-button { .reset-button {
background: var(--bg-vanilla-100); background: var(--bg-vanilla-100);
border-color: var(--bg-vanilla-300); border-color: var(--bg-vanilla-300);

View File

@ -1,8 +1,7 @@
import './DateTimeSelectionV2.styles.scss'; import './DateTimeSelectionV2.styles.scss';
import { SyncOutlined } from '@ant-design/icons'; import { SyncOutlined } from '@ant-design/icons';
import { Color } from '@signozhq/design-tokens'; import { Button } from 'antd';
import { Button, Popover, Switch, Typography } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get'; import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set'; import setLocalStorageKey from 'api/browser/localstorage/set';
import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker'; import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker';
@ -15,16 +14,15 @@ import dayjs, { Dayjs } from 'dayjs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate'; import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery'; import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax, { isValidTimeFormat } from 'lib/getMinMax'; import { isValidTimeFormat } from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString'; import getTimeString from 'lib/getTimeString';
import { cloneDeep, isObject } from 'lodash-es'; import { cloneDeep, isObject } from 'lodash-es';
import { Check, Copy, Info, Send, Undo } from 'lucide-react'; import { Undo } from 'lucide-react';
import { useTimezone } from 'providers/Timezone'; import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { connect, useDispatch, useSelector } from 'react-redux'; import { connect, useDispatch, useSelector } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom'; import { RouteComponentProps, withRouter } from 'react-router-dom';
import { useNavigationType, useSearchParams } from 'react-router-dom-v5-compat'; import { useNavigationType, useSearchParams } from 'react-router-dom-v5-compat';
import { useCopyToClipboard } from 'react-use';
import { bindActionCreators, Dispatch } from 'redux'; import { bindActionCreators, Dispatch } from 'redux';
import { ThunkDispatch } from 'redux-thunk'; import { ThunkDispatch } from 'redux-thunk';
import { GlobalTimeLoading, UpdateTimeInterval } from 'store/actions'; import { GlobalTimeLoading, UpdateTimeInterval } from 'store/actions';
@ -53,7 +51,6 @@ import { Form, FormContainer, FormItem } from './styles';
function DateTimeSelection({ function DateTimeSelection({
showAutoRefresh, showAutoRefresh,
showRefreshText = true, showRefreshText = true,
hideShareModal = false,
location, location,
updateTimeInterval, updateTimeInterval,
globalTimeLoading, globalTimeLoading,
@ -81,10 +78,6 @@ function DateTimeSelection({
const searchStartTime = urlQuery.get('startTime'); const searchStartTime = urlQuery.get('startTime');
const searchEndTime = urlQuery.get('endTime'); const searchEndTime = urlQuery.get('endTime');
const relativeTimeFromUrl = urlQuery.get(QueryParams.relativeTime); const relativeTimeFromUrl = urlQuery.get(QueryParams.relativeTime);
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(false);
const [isValidteRelativeTime, setIsValidteRelativeTime] = useState(false);
const [, handleCopyToClipboard] = useCopyToClipboard();
const [isURLCopied, setIsURLCopied] = useState(false);
// Prioritize props for initial modal time, fallback to URL params // Prioritize props for initial modal time, fallback to URL params
let initialModalStartTime = 0; let initialModalStartTime = 0;
@ -324,7 +317,6 @@ function DateTimeSelection({
if (isModalTimeSelection) { if (isModalTimeSelection) {
if (value === 'custom') { if (value === 'custom') {
setCustomDTPickerVisible(true); setCustomDTPickerVisible(true);
setIsValidteRelativeTime(false);
return; return;
} }
onTimeChange?.(value); onTimeChange?.(value);
@ -334,15 +326,12 @@ function DateTimeSelection({
setIsOpen(false); setIsOpen(false);
updateTimeInterval(value); updateTimeInterval(value);
updateLocalStorageForRoutes(value); updateLocalStorageForRoutes(value);
setIsValidteRelativeTime(true);
if (refreshButtonHidden) { if (refreshButtonHidden) {
setRefreshButtonHidden(false); setRefreshButtonHidden(false);
} }
} else { } else {
setRefreshButtonHidden(true); setRefreshButtonHidden(true);
setCustomDTPickerVisible(true); setCustomDTPickerVisible(true);
setIsValidteRelativeTime(false);
setEnableAbsoluteTime(false);
return; return;
} }
@ -458,11 +447,6 @@ function DateTimeSelection({
urlQuery.delete('startTime'); urlQuery.delete('startTime');
urlQuery.delete('endTime'); urlQuery.delete('endTime');
setIsValidteRelativeTime(true);
urlQuery.delete('startTime');
urlQuery.delete('endTime');
urlQuery.set(QueryParams.relativeTime, dateTimeStr); urlQuery.set(QueryParams.relativeTime, dateTimeStr);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`; const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
@ -542,7 +526,6 @@ function DateTimeSelection({
const handleRelativeTimeSync = useCallback( const handleRelativeTimeSync = useCallback(
(relativeTime: string): void => { (relativeTime: string): void => {
updateTimeInterval(relativeTime as Time); updateTimeInterval(relativeTime as Time);
setIsValidteRelativeTime(true);
setRefreshButtonHidden(false); setRefreshButtonHidden(false);
}, },
[updateTimeInterval], [updateTimeInterval],
@ -625,8 +608,6 @@ function DateTimeSelection({
const updatedTime = getCustomOrIntervalTime(time, currentRoute); const updatedTime = getCustomOrIntervalTime(time, currentRoute);
setIsValidteRelativeTime(updatedTime !== 'custom');
const [preStartTime = 0, preEndTime = 0] = getTime() || []; const [preStartTime = 0, preEndTime = 0] = getTime() || [];
setRefreshButtonHidden(updatedTime === 'custom'); setRefreshButtonHidden(updatedTime === 'custom');
@ -654,95 +635,6 @@ function DateTimeSelection({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, updateTimeInterval, globalTimeLoading]); }, [location.pathname, updateTimeInterval, globalTimeLoading]);
// eslint-disable-next-line sonarjs/cognitive-complexity
const shareModalContent = (): JSX.Element => {
let currentUrl = window.location.href;
const startTime = urlQuery.get(QueryParams.startTime);
const endTime = urlQuery.get(QueryParams.endTime);
const isCustomTime = !!(startTime && endTime && selectedTime === 'custom');
if (enableAbsoluteTime || isCustomTime) {
if (selectedTime === 'custom') {
if (searchStartTime && searchEndTime) {
urlQuery.set(QueryParams.startTime, searchStartTime.toString());
urlQuery.set(QueryParams.endTime, searchEndTime.toString());
}
} else {
const { minTime, maxTime } = GetMinMax(selectedTime);
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
}
urlQuery.delete(QueryParams.relativeTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
} else {
urlQuery.delete(QueryParams.startTime);
urlQuery.delete(QueryParams.endTime);
urlQuery.set(QueryParams.relativeTime, selectedTime);
currentUrl = `${window.location.origin}${
location.pathname
}?${urlQuery.toString()}`;
}
return (
<div className="share-modal-content">
<div className="absolute-relative-time-toggler-container">
<div className="absolute-relative-time-toggler">
{(selectedTime === 'custom' || !isValidteRelativeTime) && (
<Info size={14} color={Color.BG_AMBER_600} />
)}
<Switch
checked={enableAbsoluteTime || isCustomTime}
disabled={selectedTime === 'custom' || !isValidteRelativeTime}
size="small"
onChange={(): void => {
setEnableAbsoluteTime(!enableAbsoluteTime);
}}
/>
</div>
<Typography.Text>Enable Absolute Time</Typography.Text>
</div>
{(selectedTime === 'custom' || !isValidteRelativeTime) && (
<div className="absolute-relative-time-error">
Please select / enter valid relative time to toggle.
</div>
)}
<div className="share-link">
<Typography.Text ellipsis className="share-url">
{currentUrl}
</Typography.Text>
<Button
className="periscope-btn copy-url-btn"
onClick={(): void => {
handleCopyToClipboard(currentUrl);
setIsURLCopied(true);
setTimeout(() => {
setIsURLCopied(false);
}, 1000);
}}
icon={
isURLCopied ? (
<Check size={14} color={Color.BG_FOREST_500} />
) : (
<Copy size={14} color={Color.BG_ROBIN_500} />
)
}
/>
</div>
</div>
);
};
const { timezone } = useTimezone(); const { timezone } = useTimezone();
const getSelectedValue = (): string => { const getSelectedValue = (): string => {
@ -814,9 +706,6 @@ function DateTimeSelection({
onValidCustomDateChange={(dateTime): void => { onValidCustomDateChange={(dateTime): void => {
onValidCustomDateHandler(dateTime.timeStr as CustomTimeType); onValidCustomDateHandler(dateTime.timeStr as CustomTimeType);
}} }}
onCustomTimeStatusUpdate={(isValid: boolean): void => {
setIsValidteRelativeTime(isValid);
}}
selectedValue={getSelectedValue()} selectedValue={getSelectedValue()}
data-testid="dropDown" data-testid="dropDown"
items={options} items={options}
@ -843,24 +732,6 @@ function DateTimeSelection({
</FormItem> </FormItem>
</div> </div>
)} )}
{!hideShareModal && (
<Popover
rootClassName="shareable-link-popover-root"
className="shareable-link-popover"
placement="bottomRight"
content={shareModalContent}
arrow={false}
trigger={['hover']}
>
<Button
className="share-link-btn periscope-btn"
icon={<Send size={14} />}
>
Share
</Button>
</Popover>
)}
</FormContainer> </FormContainer>
</Form> </Form>
</div> </div>

View File

@ -1,4 +1,17 @@
.top-nav-container { .top-nav-container {
padding: 0px 8px; padding: 8px;
margin-bottom: 16px; border-bottom: 1px solid var(--bg-slate-500);
display: flex;
align-items: center;
justify-content: end;
gap: 16px;
margin-bottom: 8px;
}
.lightMode {
.top-nav-container {
border-bottom: 1px solid var(--bg-vanilla-300);
}
} }

View File

@ -1,6 +1,6 @@
import './TopNav.styles.scss'; import './TopNav.styles.scss';
import { Col, Row, Space } from 'antd'; import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { matchPath, useHistory } from 'react-router-dom'; import { matchPath, useHistory } from 'react-router-dom';
@ -46,16 +46,9 @@ function TopNav(): JSX.Element | null {
return !isRouteToSkip ? ( return !isRouteToSkip ? (
<div className="top-nav-container"> <div className="top-nav-container">
<Col span={24} style={{ marginTop: '1rem' }}> <NewExplorerCTA />
<Row justify="end"> <DateTimeSelector showAutoRefresh />
<Space align="center" size={16} direction="horizontal"> <HeaderRightSection enableShare enableFeedback enableAnnouncements={false} />
<NewExplorerCTA />
<div>
<DateTimeSelector showAutoRefresh />
</div>
</Space>
</Row>
</Col>
</div> </div>
) : null; ) : null;
} }

View File

@ -1,5 +1,5 @@
.alerts-container { .alerts-container {
.ant-tabs-nav-wrap:first-of-type { .ant-tabs-nav {
padding-left: 16px; padding: 0 8px;
} }
} }

View File

@ -3,6 +3,7 @@ import './AlertList.styles.scss';
import { Tabs } from 'antd'; import { Tabs } from 'antd';
import { TabsProps } from 'antd/lib'; import { TabsProps } from 'antd/lib';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon'; import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import AllAlertRules from 'container/ListAlertRules'; import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime'; import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
@ -28,7 +29,7 @@ function AllAlertList(): JSX.Element {
{ {
label: ( label: (
<div className="periscope-tab top-level-tab"> <div className="periscope-tab top-level-tab">
<GalleryVerticalEnd size={16} /> <GalleryVerticalEnd size={14} />
Triggered Alerts Triggered Alerts
</div> </div>
), ),
@ -38,7 +39,7 @@ function AllAlertList(): JSX.Element {
{ {
label: ( label: (
<div className="periscope-tab top-level-tab"> <div className="periscope-tab top-level-tab">
<Pyramid size={16} /> <Pyramid size={14} />
Alert Rules Alert Rules
</div> </div>
), ),
@ -52,7 +53,7 @@ function AllAlertList(): JSX.Element {
{ {
label: ( label: (
<div className="periscope-tab top-level-tab"> <div className="periscope-tab top-level-tab">
<ConfigureIcon /> <ConfigureIcon width={14} height={14} />
Configuration Configuration
</div> </div>
), ),
@ -82,6 +83,13 @@ function AllAlertList(): JSX.Element {
className={`alerts-container ${ className={`alerts-container ${
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : '' isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
}`} }`}
tabBarExtraContent={
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
}
/> />
); );
} }

View File

@ -7,7 +7,12 @@
} }
.all-errors-right-section { .all-errors-right-section {
padding: 0 10px; .right-toolbar-actions-container {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
} }
.ant-tabs { .ant-tabs {

View File

@ -5,6 +5,7 @@ import { Button, Tooltip } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get'; import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set'; import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames'; import cx from 'classnames';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import QuickFilters from 'components/QuickFilters/QuickFilters'; import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types'; import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import RouteTab from 'components/RouteTab'; import RouteTab from 'components/RouteTab';
@ -74,10 +75,24 @@ function AllErrors(): JSX.Element {
</Tooltip> </Tooltip>
) : undefined ) : undefined
} }
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />} rightActions={
<div className="right-toolbar-actions-container">
<RightToolbarActions onStageRunQuery={handleRunQuery} />
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
}
/> />
<ResourceAttributesFilterV2 /> <ResourceAttributesFilterV2 />
<RouteTab routes={routes} activeKey={pathname} history={history} /> <RouteTab
routes={routes}
activeKey={pathname}
history={history}
showRightSection={false}
/>
</> </>
</TypicalOverlayScrollbar> </TypicalOverlayScrollbar>
</section> </section>

View File

@ -2,11 +2,18 @@
.dashboard-header { .dashboard-header {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 16px; justify-content: space-between;
padding: 0 8px;
gap: 8px; gap: 8px;
height: 48px; height: 48px;
border-bottom: 1px solid var(--bg-slate-500); border-bottom: 1px solid var(--bg-slate-500);
.dashboard-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.icon { .icon {
color: var(--bg-vanilla-400); color: var(--bg-vanilla-400);
} }

View File

@ -1,6 +1,7 @@
import './DashboardsListPage.styles.scss'; import './DashboardsListPage.styles.scss';
import { Space, Typography } from 'antd'; import { Space, Typography } from 'antd';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ListOfAllDashboard from 'container/ListOfDashboard'; import ListOfAllDashboard from 'container/ListOfDashboard';
import { LayoutGrid } from 'lucide-react'; import { LayoutGrid } from 'lucide-react';
@ -13,8 +14,16 @@ function DashboardsListPage(): JSX.Element {
className="dashboard-list-page" className="dashboard-list-page"
> >
<div className="dashboard-header"> <div className="dashboard-header">
<LayoutGrid size={14} className="icon" /> <div className="dashboard-header-left">
<Typography.Text className="text">Dashboards</Typography.Text> <LayoutGrid size={14} className="icon" />
<Typography.Text className="text">Dashboards</Typography.Text>
</div>
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div> </div>
<ListOfAllDashboard /> <ListOfAllDashboard />
</Space> </Space>

View File

@ -33,7 +33,6 @@
height: calc(100vh - 48px); height: calc(100vh - 48px);
border-right: 1px solid var(--Slate-500, #161922); border-right: 1px solid var(--Slate-500, #161922);
background: var(--Ink-500, #0b0c0e); background: var(--Ink-500, #0b0c0e);
padding: 10px 8px;
} }
.settings-page-content { .settings-page-content {

View File

@ -4,7 +4,7 @@
padding-right: 48px; padding-right: 48px;
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 64px auto;
} }
.support-channels { .support-channels {
@ -19,6 +19,7 @@
flex: 0 0 calc(33.333% - 32px); flex: 0 0 calc(33.333% - 32px);
min-height: 200px; min-height: 200px;
position: relative; position: relative;
border: none !important;
.support-channel-title { .support-channel-title {
width: 100%; width: 100%;

View File

@ -205,6 +205,7 @@ export default function Support(): JSX.Element {
<div className="support-channel-action"> <div className="support-channel-action">
<Button <Button
type="default" type="default"
className="periscope-btn secondary"
onClick={(): void => handleChannelClick(channel)} onClick={(): void => handleChannelClick(channel)}
> >
<Text ellipsis>{channel.btnText} </Text> <Text ellipsis>{channel.btnText} </Text>
@ -240,7 +241,7 @@ export default function Support(): JSX.Element {
loading={isLoadingBilling} loading={isLoadingBilling}
disabled={isLoadingBilling} disabled={isLoadingBilling}
onClick={handleAddCreditCard} onClick={handleAddCreditCard}
className="add-credit-card-btn" className="add-credit-card-btn periscope-btn primary"
> >
Add Credit Card Add Credit Card
</Button>, </Button>,

View File

@ -1,5 +1,5 @@
export interface UpdateProfileProps { export interface UpdateProfileProps {
reasons_for_interest_in_signoz: string; reasons_for_interest_in_signoz: string[];
uses_otel: boolean; uses_otel: boolean;
has_existing_observability_tool: boolean; has_existing_observability_tool: boolean;
existing_observability_tool: string; existing_observability_tool: string;

14
go.mod
View File

@ -5,7 +5,7 @@ go 1.24.0
require ( require (
dario.cat/mergo v1.0.1 dario.cat/mergo v1.0.1
github.com/AfterShip/clickhouse-sql-parser v0.4.11 github.com/AfterShip/clickhouse-sql-parser v0.4.11
github.com/ClickHouse/clickhouse-go/v2 v2.36.0 github.com/ClickHouse/clickhouse-go/v2 v2.40.1
github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.129.4 github.com/SigNoz/signoz-otel-collector v0.129.4
@ -66,16 +66,16 @@ require (
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0
go.opentelemetry.io/otel v1.37.0 go.opentelemetry.io/otel v1.37.0
go.opentelemetry.io/otel/metric v1.37.0 go.opentelemetry.io/otel/metric v1.37.0
go.opentelemetry.io/otel/sdk v1.36.0 go.opentelemetry.io/otel/sdk v1.37.0
go.opentelemetry.io/otel/trace v1.37.0 go.opentelemetry.io/otel/trace v1.37.0
go.uber.org/multierr v1.11.0 go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.39.0 golang.org/x/crypto v0.40.0
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
golang.org/x/net v0.41.0 golang.org/x/net v0.42.0
golang.org/x/oauth2 v0.30.0 golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.16.0 golang.org/x/sync v0.16.0
golang.org/x/text v0.26.0 golang.org/x/text v0.27.0
google.golang.org/protobuf v1.36.6 google.golang.org/protobuf v1.36.6
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
@ -91,11 +91,11 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/ClickHouse/ch-go v0.66.0 // indirect github.com/ClickHouse/ch-go v0.67.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/Yiling-J/theine-go v0.6.1 // indirect github.com/Yiling-J/theine-go v0.6.1 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.55.7 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect

32
go.sum
View File

@ -87,10 +87,10 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/ClickHouse/ch-go v0.66.0 h1:hLslxxAVb2PHpbHr4n0d6aP8CEIpUYGMVT1Yj/Q5Img= github.com/ClickHouse/ch-go v0.67.0 h1:18MQF6vZHj+4/hTRaK7JbS/TIzn4I55wC+QzO24uiqc=
github.com/ClickHouse/ch-go v0.66.0/go.mod h1:noiHWyLMJAZ5wYuq3R/K0TcRhrNA8h7o1AqHX0klEhM= github.com/ClickHouse/ch-go v0.67.0/go.mod h1:2MSAeyVmgt+9a2k2SQPPG1b4qbTPzdGDpf1+bcHh+18=
github.com/ClickHouse/clickhouse-go/v2 v2.36.0 h1:FJ03h8VdmBUhvR9nQEu5jRLdfG0c/HSxUjiNdOxRQww= github.com/ClickHouse/clickhouse-go/v2 v2.40.1 h1:PbwsHBgqXRydU7jKULD1C8CHmifczffvQqmFvltM2W4=
github.com/ClickHouse/clickhouse-go/v2 v2.36.0/go.mod h1:aijX64fKD1hAWu/zqWEmiGk7wRE8ZnpN0M3UvjsZG3I= github.com/ClickHouse/clickhouse-go/v2 v2.40.1/go.mod h1:GDzSBLVhladVm8V01aEB36IoBOVLLICfyeuiIp/8Ezc=
github.com/Code-Hex/go-generics-cache v1.5.1 h1:6vhZGc5M7Y/YD8cIUcY8kcuQLB4cHR7U+0KMqAA0KcU= github.com/Code-Hex/go-generics-cache v1.5.1 h1:6vhZGc5M7Y/YD8cIUcY8kcuQLB4cHR7U+0KMqAA0KcU=
github.com/Code-Hex/go-generics-cache v1.5.1/go.mod h1:qxcC9kRVrct9rHeiYpFWSoW1vxyillCVzX13KZG8dl4= github.com/Code-Hex/go-generics-cache v1.5.1/go.mod h1:qxcC9kRVrct9rHeiYpFWSoW1vxyillCVzX13KZG8dl4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@ -116,8 +116,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
@ -1201,8 +1201,8 @@ go.opentelemetry.io/otel/log/logtest v0.0.0-20250526142609-aa5bd0e64989 h1:4JF7o
go.opentelemetry.io/otel/log/logtest v0.0.0-20250526142609-aa5bd0e64989/go.mod h1:NToOxLDCS1tXDSB2dIj44H9xGPOpKr0csIN+gnuihv4= go.opentelemetry.io/otel/log/logtest v0.0.0-20250526142609-aa5bd0e64989/go.mod h1:NToOxLDCS1tXDSB2dIj44H9xGPOpKr0csIN+gnuihv4=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/log v0.12.2 h1:yNoETvTByVKi7wHvYS6HMcZrN5hFLD7I++1xIZ/k6W0= go.opentelemetry.io/otel/sdk/log v0.12.2 h1:yNoETvTByVKi7wHvYS6HMcZrN5hFLD7I++1xIZ/k6W0=
go.opentelemetry.io/otel/sdk/log v0.12.2/go.mod h1:DcpdmUXHJgSqN/dh+XMWa7Vf89u9ap0/AAk/XGLnEzY= go.opentelemetry.io/otel/sdk/log v0.12.2/go.mod h1:DcpdmUXHJgSqN/dh+XMWa7Vf89u9ap0/AAk/XGLnEzY=
go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc h1:uqxdywfHqqCl6LmZzI3pUnXT1RGFYyUgxj0AkWPFxi0= go.opentelemetry.io/otel/sdk/log/logtest v0.0.0-20250521073539-a85ae98dcedc h1:uqxdywfHqqCl6LmZzI3pUnXT1RGFYyUgxj0AkWPFxi0=
@ -1245,8 +1245,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -1337,8 +1337,8 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -1468,8 +1468,8 @@ golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1480,8 +1480,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=

View File

@ -11,7 +11,7 @@ import (
contribsdkconfig "go.opentelemetry.io/contrib/config" contribsdkconfig "go.opentelemetry.io/contrib/config"
sdkmetric "go.opentelemetry.io/otel/metric" sdkmetric "go.opentelemetry.io/otel/metric"
sdkresource "go.opentelemetry.io/otel/sdk/resource" sdkresource "go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0" semconv "go.opentelemetry.io/otel/semconv/v1.34.0"
sdktrace "go.opentelemetry.io/otel/trace" sdktrace "go.opentelemetry.io/otel/trace"
) )

View File

@ -45,7 +45,7 @@ def pytest_addoption(parser: pytest.Parser):
parser.addoption( parser.addoption(
"--clickhouse-version", "--clickhouse-version",
action="store", action="store",
default="24.1.2-alpine", default="25.5.6",
help="clickhouse version", help="clickhouse version",
) )
parser.addoption( parser.addoption(
@ -57,6 +57,6 @@ def pytest_addoption(parser: pytest.Parser):
parser.addoption( parser.addoption(
"--schema-migrator-version", "--schema-migrator-version",
action="store", action="store",
default="v0.128.2", default="v0.129.6",
help="schema migrator version", help="schema migrator version",
) )

View File

@ -29,7 +29,7 @@ class LogsResource(ABC):
self.seen_at_ts_bucket_start = seen_at_ts_bucket_start self.seen_at_ts_bucket_start = seen_at_ts_bucket_start
def np_arr(self) -> np.array: def np_arr(self) -> np.array:
return np.array([self.labels, self.fingerprint, self.seen_at_ts_bucket_start]) return np.array([self.labels, self.fingerprint, self.seen_at_ts_bucket_start, np.uint64(10),np.uint64(15)])
class LogsResourceOrAttributeKeys(ABC): class LogsResourceOrAttributeKeys(ABC):
@ -317,6 +317,9 @@ class Logs(ABC):
self.scope_name, self.scope_name,
self.scope_version, self.scope_version,
self.scope_string, self.scope_string,
np.uint64(10),
np.uint64(15),
self.resources_string,
] ]
) )
@ -378,7 +381,7 @@ def insert_logs(
table="distributed_logs_resource_keys", table="distributed_logs_resource_keys",
data=[resource_key.np_arr() for resource_key in resource_keys], data=[resource_key.np_arr() for resource_key in resource_keys],
) )
clickhouse.conn.insert( clickhouse.conn.insert(
database="signoz_logs", database="signoz_logs",
table="distributed_logs_v2", table="distributed_logs_v2",

View File

@ -593,6 +593,7 @@ class Traces(ABC):
self.db_operation, self.db_operation,
self.has_error, self.has_error,
self.is_remote, self.is_remote,
self.resources_string,
], ],
dtype=object, dtype=object,
) )
@ -681,6 +682,7 @@ def insert_traces(
"db_operation", "db_operation",
"has_error", "has_error",
"is_remote", "is_remote",
"resource",
], ],
data=[trace.np_arr() for trace in traces], data=[trace.np_arr() for trace in traces],
) )