mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +00:00
Merge branch 'main' into indirectDescOperatorImprovement
This commit is contained in:
commit
dd89f274c1
@ -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
|
||||||
|
|||||||
3
.github/workflows/integrationci.yaml
vendored
3
.github/workflows/integrationci.yaml
vendored
@ -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
4
.gitignore
vendored
@ -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/
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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;
|
||||||
160
frontend/src/components/HeaderRightSection/FeedbackModal.tsx
Normal file
160
frontend/src/components/HeaderRightSection/FeedbackModal.tsx
Normal 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;
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
171
frontend/src/components/HeaderRightSection/ShareURLModal.tsx
Normal file
171
frontend/src/components/HeaderRightSection/ShareURLModal.tsx
Normal 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;
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
import EvaluationWindowPopover from './EvaluationWindowPopover';
|
||||||
|
|
||||||
|
export default EvaluationWindowPopover;
|
||||||
@ -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>,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.';
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
import EvaluationSettings from './EvaluationSettings';
|
||||||
|
|
||||||
|
export default EvaluationSettings;
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
import NotificationSettings from './NotificationSettings';
|
||||||
|
|
||||||
|
export default NotificationSettings;
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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' };
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
@ -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],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 },
|
||||||
|
|||||||
@ -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 ?? '',
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
.alerts-container {
|
.alerts-container {
|
||||||
.ant-tabs-nav-wrap:first-of-type {
|
.ant-tabs-nav {
|
||||||
padding-left: 16px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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%;
|
||||||
|
|||||||
@ -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>,
|
||||||
|
|||||||
@ -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
14
go.mod
@ -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
32
go.sum
@ -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=
|
||||||
|
|||||||
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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],
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user