mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-29 23:00:41 +01:00
Compare commits
13 Commits
ns/read-at
...
bottom-str
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfe0b66462 | ||
|
|
99200734d9 | ||
|
|
62f8b33c1e | ||
|
|
78059357e1 | ||
|
|
c2126bead7 | ||
|
|
7af94bf33a | ||
|
|
a27d5b6a02 | ||
|
|
57dadb5745 | ||
|
|
5dada860f0 | ||
|
|
7699337bd7 | ||
|
|
663b98690b | ||
|
|
5378e2d0e7 | ||
|
|
56365625dd |
@@ -12,13 +12,14 @@ import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import NotFound from 'components/NotFound';
|
||||
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AppLayout from 'container/AppLayout';
|
||||
import Hex from 'crypto-js/enc-hex';
|
||||
import HmacSHA256 from 'crypto-js/hmac-sha256';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
@@ -58,7 +59,6 @@ function App(): JSX.Element {
|
||||
isFetchingActiveLicense,
|
||||
activeLicenseFetchError,
|
||||
userFetchError,
|
||||
featureFlagsFetchError,
|
||||
isLoggedIn: isLoggedInState,
|
||||
featureFlags,
|
||||
org,
|
||||
@@ -66,6 +66,8 @@ function App(): JSX.Element {
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const chatSupport = useChatSupport();
|
||||
|
||||
const { hostname } = window.location;
|
||||
const [pathname, setPathname] = useState(history.location.pathname);
|
||||
@@ -253,7 +255,9 @@ function App(): JSX.Element {
|
||||
}, [isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// The bottom strip carries Support, so the floating bubble goes entirely.
|
||||
if (
|
||||
isSavedViewEnabled ||
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname === '/ai-assistant' ||
|
||||
@@ -263,71 +267,32 @@ function App(): JSX.Element {
|
||||
} else {
|
||||
window.Pylon?.('showChatBubble');
|
||||
}
|
||||
}, [pathname]);
|
||||
}, [pathname, isSavedViewEnabled]);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
// Identity for the Pylon widget. Whether this user gets Pylon at all is
|
||||
// `useChatSupport`'s call — this only fills in who they are.
|
||||
useEffect(() => {
|
||||
// feature flag shouldn't be loading and featureFlags or fetchError any one of this should be true indicating that req is complete
|
||||
// licenses should also be present. there is no check for licenses for loading and error as that is mandatory if not present then routing
|
||||
// to something went wrong which would ideally need a reload.
|
||||
if (
|
||||
!isFetchingFeatureFlags &&
|
||||
(featureFlags || featureFlagsFetchError) &&
|
||||
activeLicense &&
|
||||
trialInfo
|
||||
) {
|
||||
let isChatSupportEnabled = false;
|
||||
let isPremiumSupportEnabled = false;
|
||||
if (featureFlags && featureFlags.length > 0) {
|
||||
isChatSupportEnabled =
|
||||
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
|
||||
?.active || false;
|
||||
|
||||
isPremiumSupportEnabled =
|
||||
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
|
||||
?.active || false;
|
||||
}
|
||||
const showAddCreditCardModal =
|
||||
!isPremiumSupportEnabled && !trialInfo?.trialConvertedToSubscription;
|
||||
|
||||
if (
|
||||
isLoggedInState &&
|
||||
isChatSupportEnabled &&
|
||||
!showAddCreditCardModal &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) &&
|
||||
window.signozBootData?.settings?.pylon?.enabled
|
||||
) {
|
||||
const email = user.email || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
|
||||
}
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (chatSupport !== ChatSupportState.Pylon) {
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
isLoggedInState,
|
||||
user,
|
||||
pathname,
|
||||
trialInfo?.trialConvertedToSubscription,
|
||||
featureFlags,
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
activeLicense,
|
||||
trialInfo,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
]);
|
||||
|
||||
const email = user.email || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
|
||||
}
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
},
|
||||
};
|
||||
}, [chatSupport, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingUser && isCloudUser && user && user.email) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { CreditCard, X } from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
interface AddCreditCardModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAddCreditCard?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown to trial users who have not added a card, in place of chat support.
|
||||
* Submitting creates the subscription and opens the returned billing URL.
|
||||
*/
|
||||
function AddCreditCardModal({
|
||||
open,
|
||||
onClose,
|
||||
onAddCreditCard,
|
||||
}: AddCreditCardModalProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{ onSuccess: handleBillingOnSuccess, onError: handleBillingOnError },
|
||||
);
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
onAddCreditCard?.();
|
||||
updateCreditCard({ url: getBaseUrl() });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
open={open}
|
||||
closable
|
||||
onCancel={onClose}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={onClose}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>.
|
||||
Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddCreditCardModal.defaultProps = { onAddCreditCard: undefined };
|
||||
|
||||
export default AddCreditCardModal;
|
||||
9
frontend/src/components/AddCreditCardModal/stories/AddCreditCardModal.stories.mocks.tsx
generated
Normal file
9
frontend/src/components/AddCreditCardModal/stories/AddCreditCardModal.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
const SUBSCRIPTIONS = 'http://localhost/api/v1/subscriptions';
|
||||
|
||||
export const createSubscriptionHandlers = [
|
||||
rest.post(SUBSCRIPTIONS, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: { redirectURL: '' } })),
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
|
||||
import { createSubscriptionHandlers } from './AddCreditCardModal.stories.mocks';
|
||||
|
||||
import AddCreditCardModal from '../AddCreditCardModal';
|
||||
|
||||
/** Stands in for the surfaces that open it: the strip's Support button, and
|
||||
* `LaunchChatSupport` in the custom domain and onboarding flows. */
|
||||
function ModalFixture(): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
data-testid="open-add-credit-card"
|
||||
onClick={(): void => setOpen(true)}
|
||||
>
|
||||
Contact support
|
||||
</Button>
|
||||
<AddCreditCardModal open={open} onClose={(): void => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Add Credit Card Modal',
|
||||
component: ModalFixture,
|
||||
tags: ['play'],
|
||||
decorators: [withCanvas({ maxWidth: 400 })],
|
||||
parameters: {
|
||||
msw: { handlers: createSubscriptionHandlers },
|
||||
},
|
||||
} satisfies Meta<typeof ModalFixture>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** What a trial user without a card sees in place of chat support. */
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByTestId('open-add-credit-card'),
|
||||
);
|
||||
await screen.findByRole('dialog', {
|
||||
name: /Add Credit Card for Chat Support/i,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,63 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
|
||||
export default function ChatSupportGateway(): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
onError: handleBillingOnError,
|
||||
},
|
||||
);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `chat support icon`,
|
||||
page: pathname,
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
url: getBaseUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-support-gateway">
|
||||
@@ -76,47 +28,16 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Credit Card Modal */}
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
<AddCreditCardModal
|
||||
open={isAddCreditCardModalOpen}
|
||||
closable
|
||||
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>
|
||||
. Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
onAddCreditCard={(): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `chat support icon`,
|
||||
page: pathname,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Modal, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import './LaunchChatSupport.styles.scss';
|
||||
|
||||
@@ -41,7 +33,6 @@ function LaunchChatSupport({
|
||||
chatMessageDisabled = false,
|
||||
}: LaunchChatSupportProps): JSX.Element | null {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
trialInfo,
|
||||
featureFlags,
|
||||
@@ -119,43 +110,12 @@ function LaunchChatSupport({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
onError: handleBillingOnError,
|
||||
},
|
||||
);
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `facing issues button`,
|
||||
page: pathname,
|
||||
...attributes,
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
url: getBaseUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
return isCloudUserVal && isChatSupportEnabled ? ( // Note: we would need to move this condition to license based in future
|
||||
@@ -175,47 +135,11 @@ function LaunchChatSupport({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Add Credit Card Modal */}
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
<AddCreditCardModal
|
||||
open={isAddCreditCardModalOpen}
|
||||
closable
|
||||
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>
|
||||
. Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
onAddCreditCard={handleAddCreditCard}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -885,7 +885,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{showAddCreditCardModal && <ChatSupportGateway />}
|
||||
{showAddCreditCardModal && !isSavedViewEnabled && <ChatSupportGateway />}
|
||||
{showChangelogModal && changelog && (
|
||||
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
|
||||
)}
|
||||
|
||||
36
frontend/src/container/BottomStrip/AskNoz/AskNoz.module.scss
Normal file
36
frontend/src/container/BottomStrip/AskNoz/AskNoz.module.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
.askNoz {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
line-height: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.pulseDot {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
animation: askNozDotPulse 1.5s ease-in-out infinite;
|
||||
transform: scale(0.8);
|
||||
margin-right: -12px;
|
||||
}
|
||||
|
||||
@keyframes askNozDotPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
76
frontend/src/container/BottomStrip/AskNoz/AskNoz.tsx
Normal file
76
frontend/src/container/BottomStrip/AskNoz/AskNoz.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
|
||||
import styles from './AskNoz.module.scss';
|
||||
|
||||
/**
|
||||
* Opens the Noz drawer, matching the header entry it replaces. Cmd+K opens the
|
||||
* modal instead; this is deliberately the drawer.
|
||||
*
|
||||
* Carries the header's pending badge: when Noz is blocked on the user
|
||||
* (`awaiting_approval` / `awaiting_clarification`) a dot pulses. Without it,
|
||||
* hiding the header button would remove a notification rather than move it.
|
||||
*/
|
||||
function AskNoz(): JSX.Element | null {
|
||||
const { pathname } = useLocation();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isDrawerOpen = useAIAssistantStore((state) => state.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((state) => state.isModalOpen);
|
||||
const pendingUserInputCount = useAIAssistantStore(
|
||||
selectPendingUserInputStreamCount,
|
||||
);
|
||||
|
||||
// Noz is already on screen in the modal, so the "needs you" dot would be noise.
|
||||
const showPendingBadge = pendingUserInputCount > 0 && !isModalOpen;
|
||||
|
||||
// The drawer does not render on the Noz full page, so the button would be inert.
|
||||
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
|
||||
|
||||
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.askNoz} data-testid="bottom-strip-ask-noz">
|
||||
{showPendingBadge && (
|
||||
<span className={styles.badge} aria-hidden>
|
||||
<span className={styles.pulseDot}>
|
||||
<Dot size={36} />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="noz-wave"
|
||||
prefix={<Noz size={16} />}
|
||||
onClick={(): void => openAIAssistant()}
|
||||
aria-label={
|
||||
showPendingBadge
|
||||
? `Ask Noz, ${pendingUserInputCount} ${
|
||||
pendingUserInputCount === 1 ? 'action needs' : 'actions need'
|
||||
} your response`
|
||||
: 'Ask Noz'
|
||||
}
|
||||
>
|
||||
Ask Noz
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AskNoz;
|
||||
@@ -0,0 +1,133 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import AskNoz from '../AskNoz';
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled');
|
||||
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
|
||||
openAIAssistant: jest.fn(),
|
||||
useAIAssistantStore: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockEnabled = useIsAIAssistantEnabled as jest.MockedFunction<
|
||||
typeof useIsAIAssistantEnabled
|
||||
>;
|
||||
const mockOpen = openAIAssistant as jest.MockedFunction<typeof openAIAssistant>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { useAIAssistantStore } = jest.requireMock(
|
||||
'container/AIAssistant/store/useAIAssistantStore',
|
||||
) as { useAIAssistantStore: jest.Mock };
|
||||
|
||||
/** The component reads the store through three separate selector calls. */
|
||||
function mockStore({
|
||||
isDrawerOpen = false,
|
||||
isModalOpen = false,
|
||||
pendingCount = 0,
|
||||
}: {
|
||||
isDrawerOpen?: boolean;
|
||||
isModalOpen?: boolean;
|
||||
pendingCount?: number;
|
||||
} = {}): void {
|
||||
const state = { isDrawerOpen, isModalOpen, streams: {} };
|
||||
useAIAssistantStore.mockImplementation((selector: (s: unknown) => unknown) => {
|
||||
const picked = selector(state);
|
||||
// `selectPendingUserInputStreamCount` walks `streams`, which is empty here,
|
||||
// so stand in the count we want to assert against.
|
||||
return typeof picked === 'number' ? pendingCount : picked;
|
||||
});
|
||||
}
|
||||
|
||||
// `TooltipSimple` replaces its trigger's props, so the testId lives on the
|
||||
// wrapper and the button itself is reached by role — there is only ever one.
|
||||
const SLOT = 'bottom-strip-ask-noz';
|
||||
|
||||
describe('AskNoz', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockEnabled.mockReturnValue(true);
|
||||
mockStore();
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders when the assistant is enabled', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when the assistant is disabled', () => {
|
||||
mockEnabled.mockReturnValue(false);
|
||||
|
||||
const { queryByTestId } = render(<AskNoz />);
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing while the drawer is open', () => {
|
||||
mockStore({ isDrawerOpen: true });
|
||||
|
||||
const { queryByTestId } = render(<AskNoz />);
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing on the Noz full page, where the drawer does not mount', () => {
|
||||
const { queryByTestId } = render(<AskNoz />, undefined, {
|
||||
initialRoute: '/ai-assistant/some-conversation-id',
|
||||
});
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what it does', () => {
|
||||
it('opens the drawer on click', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
fireEvent.click(getByRole('button'));
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending badge', () => {
|
||||
it('announces the count when Noz is waiting on the user', () => {
|
||||
mockStore({ pendingCount: 2 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Ask Noz, 2 actions need your response',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the singular for one pending action', () => {
|
||||
mockStore({ pendingCount: 1 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Ask Noz, 1 action needs your response',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays quiet when nothing is pending', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
|
||||
});
|
||||
|
||||
it('stays quiet while the modal is open, Noz is already on screen', () => {
|
||||
mockStore({ isModalOpen: true, pendingCount: 3 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
--button-font-size: 12px;
|
||||
|
||||
font-family: var(--font-family-sf-mono, monospace);
|
||||
|
||||
// Above page content, below the body-portalled overlays that are meant to
|
||||
@@ -23,14 +25,21 @@
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Temporary placeholder for the left slot. Replaced later.
|
||||
.version {
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.left {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// separator b/w strip entities
|
||||
.left > *:not(:last-child)::after,
|
||||
.right > *:not(:last-child)::after {
|
||||
content: '';
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--l2-border);
|
||||
margin-left: var(--spacing-4);
|
||||
}
|
||||
|
||||
53
frontend/src/container/BottomStrip/LeftSlot/LeftSlot.tsx
Normal file
53
frontend/src/container/BottomStrip/LeftSlot/LeftSlot.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import StripKeyValue from '../components/StripKeyValue/StripKeyValue';
|
||||
import StripTypography from '../components/StripTypography/StripTypography';
|
||||
import { useBottomStripStore } from '../store/useBottomStripStore';
|
||||
import { type StripItem, StripItemKind } from '../types';
|
||||
|
||||
function renderItem(item: StripItem, index: number): JSX.Element {
|
||||
if (item.kind === StripItemKind.KeyValue) {
|
||||
return (
|
||||
<StripKeyValue
|
||||
key={index}
|
||||
label={item.label}
|
||||
value={item.value}
|
||||
prefix={item.prefix}
|
||||
tone={item.tone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<StripTypography key={index} prefix={item.prefix}>
|
||||
{item.text}
|
||||
</StripTypography>
|
||||
);
|
||||
}
|
||||
|
||||
function LeftSlot(): JSX.Element | null {
|
||||
const { versionData } = useAppContext();
|
||||
const left = useBottomStripStore((state) => state.left);
|
||||
const ownerId = useBottomStripStore((state) => state.ownerId);
|
||||
|
||||
const version = versionData?.version?.trim();
|
||||
const versionNode = version ? (
|
||||
<StripTypography>{version}</StripTypography>
|
||||
) : null;
|
||||
|
||||
if (!left?.length) {
|
||||
return versionNode;
|
||||
}
|
||||
|
||||
return (
|
||||
// Keyed so a page whose items throw does not leave the boundary latched on
|
||||
// the fallback for every page after it.
|
||||
<Sentry.ErrorBoundary key={ownerId} fallback={<>{versionNode}</>}>
|
||||
{left.map(renderItem)}
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default LeftSlot;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
|
||||
function SupportButton(): JSX.Element | null {
|
||||
const chatSupport = useChatSupport();
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
if (chatSupport === ChatSupportState.Unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (chatSupport === ChatSupportState.NeedsCard) {
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
return;
|
||||
}
|
||||
window.Pylon?.('show');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<MessageSquareText size={16} />}
|
||||
onClick={handleClick}
|
||||
testId="bottom-strip-support"
|
||||
>
|
||||
Support
|
||||
</Button>
|
||||
|
||||
<AddCreditCardModal
|
||||
open={isAddCreditCardModalOpen}
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SupportButton;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import SupportButton from '../SupportButton';
|
||||
|
||||
jest.mock('hooks/useChatSupport', () => ({
|
||||
...jest.requireActual('hooks/useChatSupport'),
|
||||
useChatSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockChatSupport = useChatSupport as jest.MockedFunction<
|
||||
typeof useChatSupport
|
||||
>;
|
||||
|
||||
const BUTTON = 'bottom-strip-support';
|
||||
const MODAL_TITLE = 'Add Credit Card for Chat Support';
|
||||
|
||||
describe('SupportButton', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.Pylon = jest.fn() as never;
|
||||
});
|
||||
|
||||
describe('when Pylon is available', () => {
|
||||
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.Pylon));
|
||||
|
||||
it('shows the button', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(getByTestId(BUTTON)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the Pylon widget on click', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(window.Pylon).toHaveBeenCalledWith('show');
|
||||
});
|
||||
|
||||
it('does not open the credit card modal', () => {
|
||||
const { getByTestId, queryByText } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(queryByText(MODAL_TITLE)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user needs a card', () => {
|
||||
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.NeedsCard));
|
||||
|
||||
it('shows the same button', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(getByTestId(BUTTON)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the credit card modal on click, not Pylon', () => {
|
||||
const { getByTestId, getByText } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
|
||||
expect(window.Pylon).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when support is unavailable', () => {
|
||||
beforeEach(() =>
|
||||
mockChatSupport.mockReturnValue(ChatSupportState.Unavailable),
|
||||
);
|
||||
|
||||
it('renders nothing at all', () => {
|
||||
const { queryByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(queryByTestId(BUTTON)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,65 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Info } from 'types/api/v1/version/get';
|
||||
|
||||
import BottomStrip, {
|
||||
BOTTOM_STRIP_HEIGHT,
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
BOTTOM_STRIP_ON_CLASS,
|
||||
} from '..';
|
||||
import { useBottomStripStore } from '../store/useBottomStripStore';
|
||||
import { useBottomStrip } from '../useBottomStrip';
|
||||
import { StripItemKind } from '../types';
|
||||
|
||||
/** Stands in for a page that puts something on the left of the strip. */
|
||||
function Page({ text }: { text: string }): null {
|
||||
useBottomStrip(useMemo(() => [{ kind: StripItemKind.Text, text }], [text]));
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page whose icon throws when the strip renders it. `prefix` is the one place
|
||||
* the config still takes a node, so it is the only way a page can break it.
|
||||
*/
|
||||
function BoomPage(): null {
|
||||
useBottomStrip([{ kind: StripItemKind.Text, text: 'boom', prefix: <Boom /> }]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A page whose value changes without needing `rerender`. */
|
||||
function ChangingPage(): JSX.Element {
|
||||
const [count, setCount] = useState(600);
|
||||
|
||||
useBottomStrip(
|
||||
useMemo(
|
||||
() => [{ kind: StripItemKind.Text, text: `${count} traces` }],
|
||||
[count],
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={(): void => setCount(42)}>
|
||||
change
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A page node that blows up while the strip renders it. */
|
||||
function Boom(): JSX.Element {
|
||||
throw new Error('bad left node');
|
||||
}
|
||||
|
||||
const VERSION = 'v0.134.67';
|
||||
const versionData: Info = { version: VERSION, ee: 'Y', setupCompleted: true };
|
||||
const withVersion = { appContextOverrides: { versionData } };
|
||||
|
||||
describe('BottomStrip', () => {
|
||||
// The store is module level, so it outlives each test.
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('publishes the body class and height property while mounted', () => {
|
||||
const { unmount } = render(<BottomStrip />);
|
||||
|
||||
@@ -28,22 +81,96 @@ describe('BottomStrip', () => {
|
||||
it.each([['v0.134.67'], ['main-64f1c2a']])(
|
||||
'renders the build version %p exactly as given',
|
||||
(version) => {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
const { getByText } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: {
|
||||
versionData: { version, ee: 'Y', setupCompleted: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
|
||||
expect(getByText(version)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the strip without a version when none is available', () => {
|
||||
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: { versionData: null },
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
|
||||
const strip = getByTestId('bottom-strip');
|
||||
|
||||
expect(strip).toBeInTheDocument();
|
||||
expect(strip).toHaveTextContent('');
|
||||
});
|
||||
|
||||
// `tests/test-utils` builds its wrapper around the first `ui`, so `rerender`
|
||||
// re-renders the original tree. These drive change through state and through
|
||||
// separate trees instead, which the module-level store lets them share.
|
||||
describe('left slot', () => {
|
||||
it('shows what the page put there instead of the version', () => {
|
||||
render(<Page text="600 traces" />);
|
||||
const { getByText, queryByText } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByText('600 traces')).toBeInTheDocument();
|
||||
expect(queryByText(VERSION)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the version once the page is gone', () => {
|
||||
const page = render(<Page text="600 traces" />);
|
||||
const { getByText } = render(<BottomStrip />, undefined, withVersion);
|
||||
|
||||
page.unmount();
|
||||
|
||||
expect(getByText(VERSION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates when the page changes what it shows', () => {
|
||||
render(<ChangingPage />);
|
||||
const { getByText, getByRole } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByText('600 traces')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'change' }));
|
||||
|
||||
expect(getByText('42 traces')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the version when the page node throws', () => {
|
||||
// React logs the caught error, which is noise here.
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
render(<BoomPage />);
|
||||
const { getByTestId, getByText } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(getByText(VERSION)).toBeInTheDocument();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps the new page value when the old page unmounts after it', () => {
|
||||
// Navigation order: the next page mounts before the last one unmounts,
|
||||
// so without the owner guard the outgoing page wipes the incoming value.
|
||||
const pageA = render(<Page text="page A" />);
|
||||
render(<Page text="page B" />);
|
||||
const { getByText } = render(<BottomStrip />, undefined, withVersion);
|
||||
|
||||
pageA.unmount();
|
||||
|
||||
expect(getByText('page B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// `:` is spaced like a word, per the Figma, so the gap here is a space width
|
||||
// rather than the wider gap between whole items.
|
||||
.keyValue {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: var(--spacing-1);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { StripTone } from '../../types';
|
||||
|
||||
import styles from './StripKeyValue.module.scss';
|
||||
|
||||
export interface StripKeyValueProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
/** Leading icon. Same shape as `Button`'s. */
|
||||
prefix?: ReactNode;
|
||||
/** Tints the icon; the text stays neutral at every tone. */
|
||||
tone?: StripTone;
|
||||
}
|
||||
|
||||
/** A labelled number, rendered `Spans : 31`. */
|
||||
function StripKeyValue({
|
||||
label,
|
||||
value,
|
||||
prefix,
|
||||
tone,
|
||||
}: StripKeyValueProps): JSX.Element {
|
||||
return (
|
||||
<span className={styles.keyValue}>
|
||||
{prefix && (
|
||||
<span className={cx(styles.prefix, tone && styles[tone])} data-tone={tone}>
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text as="span" className={styles.label}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" className={styles.colon}>
|
||||
:
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" className={styles.value}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
StripKeyValue.defaultProps = { prefix: undefined, tone: StripTone.Default };
|
||||
|
||||
export default StripKeyValue;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { StripTone } from '../../../types';
|
||||
|
||||
import StripKeyValue from '../StripKeyValue';
|
||||
|
||||
describe('StripKeyValue', () => {
|
||||
it('renders the label and the value around a colon it owns', () => {
|
||||
const { getByText, container } = render(
|
||||
<StripKeyValue label="Spans" value={31} />,
|
||||
);
|
||||
|
||||
expect(getByText('Spans')).toBeInTheDocument();
|
||||
expect(getByText('31')).toBeInTheDocument();
|
||||
// The consumer never builds the string, so the spacing cannot drift.
|
||||
expect(container.textContent).toBe('Spans:31');
|
||||
});
|
||||
|
||||
it('tints only the icon when the tone is set', () => {
|
||||
const { container } = render(
|
||||
<StripKeyValue
|
||||
label="Errors"
|
||||
value={4}
|
||||
tone={StripTone.Error}
|
||||
prefix={<svg data-testid="icon" />}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The tone lands on the icon, never on the text.
|
||||
expect(container.querySelector('[data-tone]')).toHaveAttribute(
|
||||
'data-tone',
|
||||
'error',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the icon untinted by default', () => {
|
||||
const { container } = render(
|
||||
<StripKeyValue label="Errors" value={0} prefix={<svg />} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-tone]')).toHaveAttribute(
|
||||
'data-tone',
|
||||
'default',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
.stripTypography {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './StripTypography.module.scss';
|
||||
|
||||
interface StripTypographyProps {
|
||||
children: ReactNode;
|
||||
/** Leading icon. Same shape as `Button`'s. */
|
||||
prefix?: ReactNode;
|
||||
}
|
||||
|
||||
/** A single phrase. A labelled number belongs in `StripKeyValue`. */
|
||||
function StripTypography({
|
||||
children,
|
||||
prefix,
|
||||
}: StripTypographyProps): JSX.Element {
|
||||
return (
|
||||
<span className={styles.stripTypography}>
|
||||
{prefix}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
StripTypography.defaultProps = { prefix: undefined };
|
||||
|
||||
export type { StripTypographyProps };
|
||||
export default StripTypography;
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import AskNoz from './AskNoz/AskNoz';
|
||||
import LeftSlot from './LeftSlot/LeftSlot';
|
||||
import SupportButton from './SupportButton/SupportButton';
|
||||
|
||||
import styles from './BottomStrip.module.scss';
|
||||
|
||||
@@ -9,9 +12,6 @@ export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
|
||||
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
|
||||
|
||||
function BottomStrip(): JSX.Element {
|
||||
const { versionData } = useAppContext();
|
||||
const version = versionData?.version?.trim();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.setProperty(
|
||||
@@ -28,13 +28,12 @@ function BottomStrip(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.strip} data-testid="bottom-strip">
|
||||
<div className={styles.left}>
|
||||
{version && (
|
||||
<span className={styles.version} data-testid="bottom-strip-version">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
<LeftSlot />
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<AskNoz />
|
||||
<SupportButton />
|
||||
</div>
|
||||
<div className={styles.right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { StripItem } from '../types';
|
||||
|
||||
interface BottomStripState {
|
||||
/** What the strip shows on the left, or null to fall back to the version. */
|
||||
left: StripItem[] | null;
|
||||
/** Which page owns the current value — see `clearLeft`. */
|
||||
ownerId: string | null;
|
||||
setLeft: (ownerId: string, left: StripItem[] | null) => void;
|
||||
clearLeft: (ownerId: string) => void;
|
||||
}
|
||||
|
||||
export const useBottomStripStore = create<BottomStripState>()((set, get) => ({
|
||||
left: null,
|
||||
ownerId: null,
|
||||
setLeft: (ownerId, left): void => set({ left, ownerId }),
|
||||
// Only the current owner may clear. On a plain route swap React runs the old
|
||||
// page's cleanup before the new page's effect, so this is moot, but two
|
||||
// consumers can be mounted at once (a page under a drawer): the one that set
|
||||
// last owns the slot, and the other unmounting must not wipe it.
|
||||
clearLeft: (ownerId): void => {
|
||||
if (get().ownerId === ownerId) {
|
||||
set({ left: null, ownerId: null });
|
||||
}
|
||||
},
|
||||
}));
|
||||
31
frontend/src/container/BottomStrip/types.ts
Normal file
31
frontend/src/container/BottomStrip/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export enum StripTone {
|
||||
Default = 'default',
|
||||
Warning = 'warning',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export enum StripItemKind {
|
||||
/** A phrase in the page's own words, e.g. "18 services". */
|
||||
Text = 'text',
|
||||
/** A labelled number, rendered `label : value`. */
|
||||
KeyValue = 'keyValue',
|
||||
}
|
||||
|
||||
/** What a page may put on the left of the strip. */
|
||||
export type StripItem =
|
||||
| {
|
||||
kind: StripItemKind.Text;
|
||||
text: string;
|
||||
prefix?: ReactNode;
|
||||
}
|
||||
| {
|
||||
kind: StripItemKind.KeyValue;
|
||||
/** Without a colon; the strip renders the separator. */
|
||||
label: string;
|
||||
value: string | number;
|
||||
prefix?: ReactNode;
|
||||
/** Tints the icon; the text stays neutral at every tone. */
|
||||
tone?: StripTone;
|
||||
};
|
||||
25
frontend/src/container/BottomStrip/useBottomStrip.ts
Normal file
25
frontend/src/container/BottomStrip/useBottomStrip.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useId } from 'react';
|
||||
|
||||
import { useBottomStripStore } from './store/useBottomStripStore';
|
||||
import type { StripItem } from './types';
|
||||
|
||||
/**
|
||||
* Puts `items` in the bottom strip for as long as the calling page is mounted.
|
||||
* Pass null to show nothing and let the version through. The left is the only
|
||||
* side a page contributes to; the right is the strip's own.
|
||||
*
|
||||
* There is no refresh API by design: a page that refetches re-renders, which
|
||||
* produces new items, which re-runs this effect. Memoise the array where it is
|
||||
* built, or the store is written on every render.
|
||||
*/
|
||||
export function useBottomStrip(items: StripItem[] | null): void {
|
||||
const ownerId = useId();
|
||||
const setLeft = useBottomStripStore((state) => state.setLeft);
|
||||
const clearLeft = useBottomStripStore((state) => state.clearLeft);
|
||||
|
||||
useEffect(() => {
|
||||
setLeft(ownerId, items);
|
||||
|
||||
return (): void => clearLeft(ownerId);
|
||||
}, [items, ownerId, setLeft, clearLeft]);
|
||||
}
|
||||
148
frontend/src/hooks/__tests__/useChatSupport.test.tsx
Normal file
148
frontend/src/hooks/__tests__/useChatSupport.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
jest.mock('providers/App/App');
|
||||
jest.mock('hooks/useGetTenantLicense');
|
||||
|
||||
const mockAppContext = useAppContext as jest.MockedFunction<
|
||||
typeof useAppContext
|
||||
>;
|
||||
const mockLicense = useGetTenantLicense as jest.MockedFunction<
|
||||
typeof useGetTenantLicense
|
||||
>;
|
||||
|
||||
const flag = (name: FeatureKeys, active: boolean): Record<string, unknown> => ({
|
||||
name,
|
||||
active,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
});
|
||||
|
||||
function setup({
|
||||
chatSupport = true,
|
||||
premiumSupport = false,
|
||||
trialConverted = false,
|
||||
isCloudUser = true,
|
||||
isEnterpriseSelfHostedUser = false,
|
||||
isLoggedIn = true,
|
||||
pylonEnabled = true,
|
||||
isFetchingFeatureFlags = false,
|
||||
activeLicense = {} as unknown,
|
||||
trialInfo = {} as unknown,
|
||||
} = {}): void {
|
||||
window.signozBootData = {
|
||||
settings: { pylon: { enabled: pylonEnabled } },
|
||||
} as never;
|
||||
|
||||
mockAppContext.mockReturnValue({
|
||||
featureFlags: [
|
||||
flag(FeatureKeys.CHAT_SUPPORT, chatSupport),
|
||||
flag(FeatureKeys.PREMIUM_SUPPORT, premiumSupport),
|
||||
],
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError: null,
|
||||
trialInfo: trialInfo && { trialConvertedToSubscription: trialConverted },
|
||||
isLoggedIn,
|
||||
activeLicense,
|
||||
} as never);
|
||||
|
||||
mockLicense.mockReturnValue({
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
} as never);
|
||||
}
|
||||
|
||||
const state = (): ChatSupportState =>
|
||||
renderHook(() => useChatSupport()).result.current;
|
||||
|
||||
describe('useChatSupport', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('pylon', () => {
|
||||
it('hands off to Pylon for a cloud user past trial', () => {
|
||||
setup({ trialConverted: true });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('hands off to Pylon for enterprise self-hosted', () => {
|
||||
setup({
|
||||
trialConverted: true,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('hands off to Pylon when premium support is on, card or not', () => {
|
||||
setup({ premiumSupport: true, trialConverted: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('offers nothing when Pylon is not configured server side', () => {
|
||||
setup({ trialConverted: true, pylonEnabled: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsCard', () => {
|
||||
it('offers the card flow to a cloud user still on trial', () => {
|
||||
setup({ trialConverted: false, premiumSupport: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.NeedsCard);
|
||||
});
|
||||
|
||||
it('offers nothing to a non-cloud user needing a card', () => {
|
||||
setup({
|
||||
trialConverted: false,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unavailable', () => {
|
||||
it('offers nothing without the chat support flag', () => {
|
||||
setup({ chatSupport: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing when logged out', () => {
|
||||
setup({ isLoggedIn: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing while the flags are still loading', () => {
|
||||
setup({ isFetchingFeatureFlags: true });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing before the licence has loaded', () => {
|
||||
setup({ activeLicense: null });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing on a tenant that is neither cloud nor enterprise', () => {
|
||||
setup({
|
||||
trialConverted: true,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
frontend/src/hooks/useChatSupport.ts
Normal file
70
frontend/src/hooks/useChatSupport.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export enum ChatSupportState {
|
||||
/** Pylon is configured for this user — hand off to the widget. */
|
||||
Pylon = 'pylon',
|
||||
/** Trial without a card — offer the Add Credit Card flow instead. */
|
||||
NeedsCard = 'needsCard',
|
||||
/** No support entry at all. */
|
||||
Unavailable = 'unavailable',
|
||||
}
|
||||
|
||||
export function useChatSupport(): ChatSupportState {
|
||||
const {
|
||||
featureFlags,
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
trialInfo,
|
||||
isLoggedIn,
|
||||
activeLicense,
|
||||
} = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
return useMemo(() => {
|
||||
const isReady =
|
||||
!isFetchingFeatureFlags &&
|
||||
(featureFlags || featureFlagsFetchError) &&
|
||||
activeLicense &&
|
||||
trialInfo;
|
||||
if (!isReady || !isLoggedIn) {
|
||||
return ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const flag = (name: FeatureKeys): boolean =>
|
||||
featureFlags?.find((f) => f.name === name)?.active || false;
|
||||
|
||||
if (!flag(FeatureKeys.CHAT_SUPPORT)) {
|
||||
return ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const needsCard =
|
||||
!flag(FeatureKeys.PREMIUM_SUPPORT) &&
|
||||
!trialInfo?.trialConvertedToSubscription;
|
||||
|
||||
if (needsCard) {
|
||||
// The credit card flow is cloud-only
|
||||
return isCloudUser
|
||||
? ChatSupportState.NeedsCard
|
||||
: ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const pylonConfigured = Boolean(
|
||||
window.signozBootData?.settings?.pylon?.enabled,
|
||||
);
|
||||
return (isCloudUser || isEnterpriseSelfHostedUser) && pylonConfigured
|
||||
? ChatSupportState.Pylon
|
||||
: ChatSupportState.Unavailable;
|
||||
}, [
|
||||
activeLicense,
|
||||
featureFlags,
|
||||
featureFlagsFetchError,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isFetchingFeatureFlags,
|
||||
isLoggedIn,
|
||||
trialInfo,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
|
||||
import { StripItemKind, StripTone } from 'container/BottomStrip/types';
|
||||
|
||||
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||
|
||||
describe('useTraceDetailsStripInfo', () => {
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('puts the span and error counts in the strip', () => {
|
||||
renderHook(() =>
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: 600,
|
||||
totalErrorSpansCount: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(useBottomStripStore.getState().left).toMatchObject([
|
||||
{ kind: StripItemKind.KeyValue, label: 'Spans', value: 600 },
|
||||
{
|
||||
kind: StripItemKind.KeyValue,
|
||||
label: 'Errors',
|
||||
value: 4,
|
||||
tone: StripTone.Error,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves the error item untoned when nothing failed', () => {
|
||||
renderHook(() =>
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: 600,
|
||||
totalErrorSpansCount: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(useBottomStripStore.getState().left?.[1]).toMatchObject({
|
||||
value: 0,
|
||||
tone: StripTone.Default,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the strip when the page unmounts', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: 600,
|
||||
totalErrorSpansCount: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(useBottomStripStore.getState().left).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
@@ -144,6 +145,10 @@ function TraceDetailsV3(): JSX.Element {
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
|
||||
|
||||
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
@@ -441,7 +446,10 @@ function TraceDetailsV3(): JSX.Element {
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
|
||||
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
|
||||
import {
|
||||
type StripItem,
|
||||
StripItemKind,
|
||||
StripTone,
|
||||
} from 'container/BottomStrip/types';
|
||||
|
||||
interface UseTraceDetailsStripInfoArgs {
|
||||
totalSpansCount: number;
|
||||
totalErrorSpansCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the counts rather than fetching them: the trace query key includes the
|
||||
* selected span, so a fetch here would fire on every span click.
|
||||
*/
|
||||
export function useTraceDetailsStripInfo({
|
||||
totalSpansCount,
|
||||
totalErrorSpansCount,
|
||||
}: UseTraceDetailsStripInfoArgs): void {
|
||||
const items = useMemo<StripItem[]>(
|
||||
() => [
|
||||
{
|
||||
kind: StripItemKind.KeyValue,
|
||||
label: 'Spans',
|
||||
value: totalSpansCount,
|
||||
prefix: <ChartNoAxesGantt size={13} />,
|
||||
},
|
||||
{
|
||||
kind: StripItemKind.KeyValue,
|
||||
label: 'Errors',
|
||||
value: totalErrorSpansCount,
|
||||
prefix: <TriangleAlert size={13} />,
|
||||
tone: totalErrorSpansCount > 0 ? StripTone.Error : StripTone.Default,
|
||||
},
|
||||
],
|
||||
[totalSpansCount, totalErrorSpansCount],
|
||||
);
|
||||
|
||||
useBottomStrip(items);
|
||||
}
|
||||
@@ -813,6 +813,12 @@ body.ai-assistant-panel-open {
|
||||
}
|
||||
}
|
||||
|
||||
body.bottom-strip-on {
|
||||
.PylonChat-chatWindowFrameContainer {
|
||||
bottom: var(--bottom-strip-height, 0px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
[role='tab'] {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
@@ -40,15 +40,11 @@ func stripKeyAlias(name string) string {
|
||||
return keyAliasRe.ReplaceAllString(name, "")
|
||||
}
|
||||
|
||||
// unwrapVariant returns the concrete value inside the driver's scan envelopes: chcol.Variant for a
|
||||
// Dynamic column (a JSON path such as body_v2.level), and chcol.JSON for a whole JSON column, decoded
|
||||
// into a nested document.
|
||||
// unwrapVariant returns the concrete value inside the chcol.Variant envelope the driver scans a
|
||||
// Dynamic column — a JSON path such as body_v2.level — into.
|
||||
func unwrapVariant(val any) any {
|
||||
switch v := val.(type) {
|
||||
case chcol.Variant:
|
||||
if v, ok := val.(chcol.Variant); ok {
|
||||
return v.Any()
|
||||
case chcol.JSON:
|
||||
return telemetrystoretypes.NestedJSON(v)
|
||||
}
|
||||
return val
|
||||
}
|
||||
@@ -62,7 +58,7 @@ func labelValue(val any) string {
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := val.(map[string]any); ok {
|
||||
if v, ok := val.(telemetrystoretypes.JSONValue); ok {
|
||||
if raw, err := json.Marshal(v); err == nil {
|
||||
return string(raw)
|
||||
}
|
||||
@@ -208,7 +204,7 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
Value: *val,
|
||||
})
|
||||
|
||||
case *chcol.JSON, *chcol.Variant:
|
||||
case *telemetrystoretypes.JSONValue, *chcol.Variant:
|
||||
val := labelValue(derefValue(ptr))
|
||||
lblVals = append(lblVals, val)
|
||||
lblObjs = append(lblObjs, &qbtypes.Label{
|
||||
@@ -540,14 +536,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
name := stripKeyAlias(colNames[i])
|
||||
|
||||
// de-reference the typed pointer to any
|
||||
raw := reflect.ValueOf(cellPtr).Elem().Interface()
|
||||
// the attributes bag is flattened to dotted keys downstream; decode it flat so a key stored as both a scalar and an object is not collapsed into a mislabeled key.
|
||||
var val any
|
||||
if j, ok := raw.(chcol.JSON); ok && name == "attributes" {
|
||||
val = telemetrystoretypes.FlattenJSON(j)
|
||||
} else {
|
||||
val = unwrapVariant(raw)
|
||||
}
|
||||
val := unwrapVariant(reflect.ValueOf(cellPtr).Elem().Interface())
|
||||
|
||||
// special-case: timestamp column
|
||||
if name == "timestamp" || name == "timestamp_datetime" {
|
||||
@@ -587,6 +576,8 @@ func flattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
|
||||
switch child := v.(type) {
|
||||
case map[string]any:
|
||||
flattenJSONPaths(key, child, out)
|
||||
case telemetrystoretypes.JSONValue:
|
||||
flattenJSONPaths(key, child, out)
|
||||
default:
|
||||
out[key] = v
|
||||
}
|
||||
@@ -602,7 +593,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
|
||||
attrStr, hasStr := data["attributes_string"]
|
||||
attrNum, hasNum := data["attributes_number"]
|
||||
attrBool, hasBool := data["attributes_bool"]
|
||||
attrJSON, _ := data["attributes"].(map[string]any)
|
||||
attrJSON, _ := data["attributes"].(telemetrystoretypes.JSONValue)
|
||||
// todo(nitya): move to resource json
|
||||
resStr, hasRes := data["resources_string"]
|
||||
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
|
||||
|
||||
@@ -3,9 +3,14 @@ package querier
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -78,31 +83,101 @@ func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A JSON path (e.g. `body_v2.level`) comes back as a Dynamic column, which the driver scans into a
|
||||
// chcol.Variant envelope; a whole JSON column comes back as chcol.JSON, decoded into a nested document.
|
||||
// A ClickHouse query can put a JSON column in the result of any request type — e.g.
|
||||
// `select * from signoz_logs.logs_v2` on a body_v2 stack, where `*` covers body_v2.
|
||||
func TestConsume_JSONColumn(t *testing.T) {
|
||||
ts := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
body := `{"level":"error","attrs":{"code":500}}`
|
||||
wantBody := telemetrystoretypes.JSONValue{
|
||||
"level": "error",
|
||||
"attrs": map[string]any{"code": float64(500)},
|
||||
}
|
||||
|
||||
// the scalar reader reuses its scan slots across rows, so each row must still carry its own body
|
||||
t.Run("scalar", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{{body, uint64(3)}, {`{"level":"warn"}`, uint64(1)}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeScalar, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.ScalarData)
|
||||
require.Len(t, data.Data, 2)
|
||||
assert.Equal(t, wantBody, data.Data[0][0])
|
||||
assert.Equal(t, uint64(3), data.Data[0][1])
|
||||
assert.Equal(t, telemetrystoretypes.JSONValue{"level": "warn"}, data.Data[1][0])
|
||||
assert.Equal(t, uint64(1), data.Data[1][1])
|
||||
})
|
||||
|
||||
t.Run("time series", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{{ts, body, uint64(3)}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.TimeSeriesData)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
require.Len(t, data.Aggregations[0].Series, 1)
|
||||
require.Len(t, data.Aggregations[0].Series[0].Values, 1)
|
||||
assert.Equal(t, float64(3), data.Aggregations[0].Series[0].Values[0].Value)
|
||||
})
|
||||
|
||||
// grouping by a JSON column is legal in ClickHouse, so each document has to label its own
|
||||
// series rather than being dropped, which would merge every group into one
|
||||
t.Run("time series grouped by the JSON column", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{
|
||||
{ts, `{"level":"error"}`, uint64(7)},
|
||||
{ts, `{"level":"warn"}`, uint64(2)},
|
||||
}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.TimeSeriesData)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
require.Len(t, data.Aggregations[0].Series, 2)
|
||||
|
||||
got := map[string]float64{}
|
||||
for _, series := range data.Aggregations[0].Series {
|
||||
require.Len(t, series.Labels, 1)
|
||||
require.Len(t, series.Values, 1)
|
||||
got[series.Labels[0].Value.(string)] = series.Values[0].Value
|
||||
}
|
||||
assert.Equal(t, map[string]float64{`{"level":"error"}`: 7, `{"level":"warn"}`: 2}, got)
|
||||
})
|
||||
|
||||
t.Run("raw", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "timestamp", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
}, [][]any{{ts, body}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeRaw, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.RawData)
|
||||
require.Len(t, data.Rows, 1)
|
||||
assert.Equal(t, ts, data.Rows[0].Timestamp.UTC())
|
||||
assert.Equal(t, wantBody, data.Rows[0].Data["body_v2"])
|
||||
})
|
||||
}
|
||||
|
||||
// A JSON path (e.g. `body_v2.level`) comes back as a Dynamic column, which the driver scans
|
||||
// into a chcol.Variant envelope rather than the value itself.
|
||||
func TestUnwrapVariant(t *testing.T) {
|
||||
assert.Equal(t, "error", unwrapVariant(chcol.NewDynamicWithType("error", "String")))
|
||||
assert.Nil(t, unwrapVariant(chcol.Dynamic{}))
|
||||
assert.Equal(t, uint64(3), unwrapVariant(uint64(3)))
|
||||
|
||||
j := chcol.NewJSON()
|
||||
j.SetValueAtPath("level", "error")
|
||||
j.SetValueAtPath("attrs.code", int64(500))
|
||||
assert.Equal(t, map[string]any{
|
||||
"level": "error",
|
||||
"attrs": map[string]any{"code": float64(500)},
|
||||
}, unwrapVariant(*j))
|
||||
}
|
||||
|
||||
// labelValue renders a JSON group-by value as a stable, sorted-key string so structurally equal
|
||||
// documents share a series.
|
||||
func TestLabelValue(t *testing.T) {
|
||||
assert.Equal(t, "", labelValue(nil))
|
||||
assert.Equal(t, "error", labelValue(chcol.NewDynamicWithType("error", "String")))
|
||||
assert.Equal(t, `{"attrs":{"code":500},"level":"error"}`, labelValue(map[string]any{
|
||||
"level": "error",
|
||||
"attrs": map[string]any{"code": 500},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
|
||||
@@ -132,7 +207,7 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
|
||||
{
|
||||
name: "JSONOnly_FlattensNestedPaths_PreservesTypes",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{
|
||||
"attributes": telemetrystoretypes.JSONValue{
|
||||
"http": map[string]any{"route": "/api/pay", "retry": map[string]any{"count": float64(3)}},
|
||||
"cache.hit": true,
|
||||
},
|
||||
@@ -144,7 +219,7 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
|
||||
data: map[string]any{
|
||||
"attributes_string": map[string]string{"http.route": "/old", "only.map": "m"},
|
||||
"attributes_number": map[string]float64{"http.status": 500},
|
||||
"attributes": map[string]any{"http": map[string]any{"route": "/new"}, "only.json": "j"},
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"route": "/new"}, "only.json": "j"},
|
||||
},
|
||||
want: map[string]any{"http.route": "/old", "only.map": "m", "http.status": float64(500), "only.json": "j"},
|
||||
},
|
||||
@@ -154,7 +229,7 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
|
||||
"attributes_string": map[string]string{"http.route": "/map"},
|
||||
"attributes_number": map[string]float64{"http.status": 200},
|
||||
"attributes_bool": map[string]bool{"cache.hit": true},
|
||||
"attributes": map[string]any{},
|
||||
"attributes": telemetrystoretypes.JSONValue{},
|
||||
},
|
||||
want: map[string]any{"http.route": "/map", "http.status": float64(200), "cache.hit": true},
|
||||
},
|
||||
@@ -162,28 +237,28 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
|
||||
name: "MapOnly_NilJSON_BehavesAsAbsent",
|
||||
data: map[string]any{
|
||||
"attributes_string": map[string]string{"http.route": "/map"},
|
||||
"attributes": map[string]any(nil),
|
||||
"attributes": telemetrystoretypes.JSONValue(nil),
|
||||
},
|
||||
want: map[string]any{"http.route": "/map"},
|
||||
},
|
||||
{
|
||||
name: "Arrays_StayLeafValues",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{"http": map[string]any{"tags": []any{"a", "b"}, "codes": []any{float64(1), float64(2)}}},
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"tags": []any{"a", "b"}, "codes": []any{float64(1), float64(2)}}},
|
||||
},
|
||||
want: map[string]any{"http.tags": []any{"a", "b"}, "http.codes": []any{float64(1), float64(2)}},
|
||||
},
|
||||
{
|
||||
name: "TopLevelArrayOfMaps_StaysNativeLeaf",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
|
||||
"attributes": telemetrystoretypes.JSONValue{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
|
||||
},
|
||||
want: map[string]any{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
|
||||
},
|
||||
{
|
||||
name: "NestedArrayOfMaps_StaysNativeLeaf_NoIndexPaths",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
|
||||
},
|
||||
want: map[string]any{"http.items": []any{map[string]any{"a": float64(1)}}},
|
||||
},
|
||||
@@ -191,28 +266,28 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
|
||||
name: "DualWritten_NestedArray_IndexKeysAndJSONArrayCoexist",
|
||||
data: map[string]any{
|
||||
"attributes_number": map[string]float64{"http.items.0.a": 1},
|
||||
"attributes": map[string]any{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
|
||||
},
|
||||
want: map[string]any{"http.items.0.a": float64(1), "http.items": []any{map[string]any{"a": float64(1)}}},
|
||||
},
|
||||
{
|
||||
name: "JSONNull_KeptAsNil",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{"k": nil},
|
||||
"attributes": telemetrystoretypes.JSONValue{"k": nil},
|
||||
},
|
||||
want: map[string]any{"k": nil},
|
||||
},
|
||||
{
|
||||
name: "KeyIsLeafValue_NotFlattened",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{"http": "plaintext"},
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": "plaintext"},
|
||||
},
|
||||
want: map[string]any{"http": "plaintext"},
|
||||
},
|
||||
{
|
||||
name: "KeyIsParent_FlattensToDottedPath",
|
||||
data: map[string]any{
|
||||
"attributes": map[string]any{"http": map[string]any{"route": "/a"}},
|
||||
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"route": "/a"}},
|
||||
},
|
||||
want: map[string]any{"http.route": "/a"},
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -1201,7 +1202,7 @@ func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, res
|
||||
// carried one. Anything that is not a decoded document — the legacy string body, a NULL cell —
|
||||
// is legal under these names and left alone.
|
||||
func stripEmptyBodyMessage(val any) {
|
||||
bodyMap, ok := val.(map[string]any)
|
||||
bodyMap, ok := val.(telemetrystoretypes.JSONValue)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
@@ -49,11 +50,6 @@ type rule struct {
|
||||
OrgID string `bun:"org_id,type:text"`
|
||||
}
|
||||
|
||||
type routePolicyRuleData struct {
|
||||
PreferredChannels []string `json:"preferredChannels"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
}
|
||||
|
||||
type addRoutePolicies struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
@@ -191,20 +187,20 @@ func (migration *addRoutePolicies) migrateRulesToRoutePolicies(ctx context.Conte
|
||||
func (migration *addRoutePolicies) convertRulesToRoutes(rules []*rule, channelsByOrg map[string][]string) ([]*expressionRoute, error) {
|
||||
var routes []*expressionRoute
|
||||
for _, r := range rules {
|
||||
var ruleData routePolicyRuleData
|
||||
if err := json.Unmarshal([]byte(r.Data), &ruleData); err != nil {
|
||||
var gettableRule ruletypes.GettableRule
|
||||
if err := json.Unmarshal([]byte(r.Data), &gettableRule); err != nil {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "failed to unmarshal rule data for rule ID %s: %v", r.ID, err)
|
||||
}
|
||||
|
||||
if len(ruleData.PreferredChannels) == 0 {
|
||||
if len(gettableRule.PreferredChannels) == 0 {
|
||||
channels, exists := channelsByOrg[r.OrgID]
|
||||
if !exists || len(channels) == 0 {
|
||||
continue
|
||||
}
|
||||
ruleData.PreferredChannels = channels
|
||||
gettableRule.PreferredChannels = channels
|
||||
}
|
||||
severity := "critical"
|
||||
if v, ok := ruleData.Labels["severity"]; ok {
|
||||
if v, ok := gettableRule.Labels["severity"]; ok {
|
||||
severity = v
|
||||
}
|
||||
expression := fmt.Sprintf(`%s == "%s" && %s == "%s"`, "threshold.name", severity, "ruleId", r.ID.String())
|
||||
@@ -222,7 +218,7 @@ func (migration *addRoutePolicies) convertRulesToRoutes(rules []*rule, channelsB
|
||||
},
|
||||
Expression: expression,
|
||||
ExpressionKind: "rule",
|
||||
Channels: ruleData.PreferredChannels,
|
||||
Channels: gettableRule.PreferredChannels,
|
||||
Name: r.ID.StringValue(),
|
||||
Enabled: true,
|
||||
OrgID: r.OrgID,
|
||||
|
||||
@@ -97,8 +97,8 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
options.MaxIdleConns = config.Connection.MaxIdleConns
|
||||
options.MaxOpenConns = config.Connection.MaxOpenConns
|
||||
options.DialTimeout = config.Connection.DialTimeout
|
||||
// Decode JSON columns via the flattened native serialization (CH 25.6+); without it clickhouse-go mis-decodes the SharedData layout of JSON(max_dynamic_paths=0) columns and desyncs the native protocol.
|
||||
options.Settings["output_format_native_use_flattened_dynamic_and_json_serialization"] = 1
|
||||
// This is to avoid the driver decoding issues with JSON columns
|
||||
options.Settings["output_format_native_write_json_as_string"] = 1
|
||||
|
||||
chConn, err := clickhouse.Open(options)
|
||||
if err != nil {
|
||||
@@ -184,7 +184,7 @@ func (p *provider) Query(ctx context.Context, query string, args ...interface{})
|
||||
}
|
||||
|
||||
return &rowsWithHooks{
|
||||
Rows: rows,
|
||||
Rows: telemetrystore.WrapRows(rows),
|
||||
ctx: ctx,
|
||||
event: event,
|
||||
onClose: func() { telemetrystore.WrapAfterQuery(p.hooks, ctx, event) },
|
||||
|
||||
39
pkg/telemetrystore/rows.go
Normal file
39
pkg/telemetrystore/rows.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package telemetrystore
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
)
|
||||
|
||||
// WrapRows reports JSONValue as the scan type of every JSON column. Nested JSON — Array(JSON),
|
||||
// Map(String, JSON) — is not covered.
|
||||
func WrapRows(rows driver.Rows) driver.Rows {
|
||||
return &rowsWithJSONScanType{Rows: rows}
|
||||
}
|
||||
|
||||
type rowsWithJSONScanType struct {
|
||||
driver.Rows
|
||||
}
|
||||
|
||||
func (r *rowsWithJSONScanType) ColumnTypes() []driver.ColumnType {
|
||||
colTypes := r.Rows.ColumnTypes()
|
||||
wrapped := make([]driver.ColumnType, len(colTypes))
|
||||
for i, colType := range colTypes {
|
||||
wrapped[i] = colType
|
||||
if strings.HasPrefix(strings.ToUpper(colType.DatabaseTypeName()), "JSON") {
|
||||
wrapped[i] = jsonColumnType{ColumnType: colType}
|
||||
}
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
type jsonColumnType struct {
|
||||
driver.ColumnType
|
||||
}
|
||||
|
||||
func (jsonColumnType) ScanType() reflect.Type {
|
||||
return reflect.TypeFor[telemetrystoretypes.JSONValue]()
|
||||
}
|
||||
23
pkg/telemetrystore/telemetrystoretest/conn.go
Normal file
23
pkg/telemetrystore/telemetrystoretest/conn.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package telemetrystoretest
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
)
|
||||
|
||||
// conn wraps rows the way the clickhouse provider does, so mocked JSON columns report the scan
|
||||
// type they do in production.
|
||||
type conn struct {
|
||||
clickhouse.Conn
|
||||
}
|
||||
|
||||
func (c conn) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
|
||||
rows, err := c.Conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return telemetrystore.WrapRows(rows), nil
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func New(_ telemetrystore.Config, matcher sqlmock.QueryMatcher) *Provider {
|
||||
|
||||
// ClickhouseDB returns the mock Clickhouse connection.
|
||||
func (p *Provider) ClickhouseDB() clickhouse.Conn {
|
||||
return p.clickhouseDB.(clickhouse.Conn)
|
||||
return conn{Conn: p.clickhouseDB.(clickhouse.Conn)}
|
||||
}
|
||||
|
||||
// Cluster returns the cluster name.
|
||||
|
||||
@@ -364,26 +364,12 @@ func (provider *provider) gc(ctx context.Context, org *types.Organization) error
|
||||
}
|
||||
|
||||
func (provider *provider) flushLastObservedAt(ctx context.Context, org *types.Organization) error {
|
||||
tokens, err := provider.tokenStore.ListByOrgID(ctx, org.ID)
|
||||
accessTokenToLastObservedAt, err := provider.listLastObservedAtDesc(ctx, org.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
observedTokens := make([]*authtypes.StorableToken, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
cachedLastObservedAt, ok := provider.lastObservedAtCache.Get(lastObservedAtCacheKey(token.AccessToken, token.UserID))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := token.UpdateLastObservedAt(cachedLastObservedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
observedTokens = append(observedTokens, token)
|
||||
}
|
||||
|
||||
if err := provider.tokenStore.UpdateLastObservedAt(ctx, observedTokens); err != nil {
|
||||
if err := provider.tokenStore.UpdateLastObservedAtByAccessToken(ctx, accessTokenToLastObservedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -232,16 +232,15 @@ func (store *store) ListByUserID(ctx context.Context, userID valuer.UUID) ([]*au
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateLastObservedAt(ctx context.Context, tokens []*authtypes.StorableToken) error {
|
||||
if len(tokens) == 0 {
|
||||
func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, accessTokenToLastObservedAt []map[string]any) error {
|
||||
if len(accessTokenToLastObservedAt) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
values := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewValues(&tokens).
|
||||
Column("id", "last_observed_at", "updated_at")
|
||||
NewValues(&accessTokenToLastObservedAt)
|
||||
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
@@ -251,8 +250,8 @@ func (store *store) UpdateLastObservedAt(ctx context.Context, tokens []*authtype
|
||||
Model((*authtypes.StorableToken)(nil)).
|
||||
TableExpr("update_cte").
|
||||
Set("last_observed_at = update_cte.last_observed_at").
|
||||
Set("updated_at = update_cte.updated_at").
|
||||
Where("auth_token.id = update_cte.id").
|
||||
Where("auth_token.access_token = update_cte.access_token").
|
||||
Where("auth_token.user_id = update_cte.user_id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/textproto"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
// ChannelEmailConfig carries no SMTP transport fields: the smarthost,
|
||||
// credentials and TLS settings come from the deployment's global config, so a
|
||||
// channel can only choose recipients and body.
|
||||
type ChannelEmailConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
To string `json:"to" required:"true"`
|
||||
HTML valuer.UnsetOrNonEmptyString `json:"html"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelEmailConfig) Validate() error {
|
||||
if c.To == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.to is required for an email channel")
|
||||
}
|
||||
|
||||
// A read reports header names as textproto canonicalizes them, turning
|
||||
// "subject" into "Subject", so a name that is not already in that form is
|
||||
// rejected rather than answered with one the caller never sent.
|
||||
for _, header := range slices.Sorted(maps.Keys(c.Headers)) {
|
||||
if canonical := textproto.CanonicalMIMEHeaderKey(header); canonical != header {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.headers name %q must be written as %q", header, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelEmailConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
EmailConfigs: []*config.EmailConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultEmailConfig.VSendResolved)},
|
||||
To: c.To,
|
||||
HTML: c.HTML.StringValue(),
|
||||
Headers: c.Headers,
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelEmailConfigFromReceiver(_ string, receiver *Receiver) (ChannelSpec, error) {
|
||||
email := receiver.EmailConfigs[0]
|
||||
sendResolved := email.VSendResolved
|
||||
|
||||
return &ChannelEmailConfig{
|
||||
SendResolved: &sendResolved,
|
||||
To: email.To,
|
||||
HTML: valuer.UnsetIfEmpty(email.HTML),
|
||||
Headers: email.Headers,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
type ChannelMSTeamsConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
}
|
||||
|
||||
func (c ChannelMSTeamsConfig) Validate() error {
|
||||
if c.WebhookURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.webhookUrl is required for an msteams channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelMSTeamsConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
webhookURL, err := parseSecretURL(c.WebhookURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
MSTeamsV2Configs: []*config.MSTeamsV2Config{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultMSTeamsV2Config.VSendResolved)},
|
||||
WebhookURL: webhookURL,
|
||||
Title: c.Title.StringValue(),
|
||||
Text: c.Text.StringValue(),
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelMSTeamsConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
msteams := receiver.MSTeamsV2Configs[0]
|
||||
sendResolved := msteams.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, msteams.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelMSTeamsConfig{
|
||||
SendResolved: &sendResolved,
|
||||
WebhookURL: formatSecretURL(msteams.WebhookURL),
|
||||
Title: valuer.UnsetIfEmpty(msteams.Title),
|
||||
Text: valuer.UnsetIfEmpty(msteams.Text),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
type ChannelOpsgenieConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIKey string `json:"apiKey" required:"true" format:"password"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
func (c ChannelOpsgenieConfig) Validate() error {
|
||||
if c.APIKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for an opsgenie channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelOpsgenieConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
var apiURL *config.URL
|
||||
if c.APIURL != "" {
|
||||
parsed, err := parseUpstreamURL(c.APIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiURL = parsed
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
OpsGenieConfigs: []*config.OpsGenieConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultOpsGenieConfig.VSendResolved)},
|
||||
APIKey: config.Secret(c.APIKey),
|
||||
APIURL: apiURL,
|
||||
Message: c.Message.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Source: c.Source.StringValue(),
|
||||
Priority: c.Priority,
|
||||
Details: c.Details,
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelOpsgenieConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
opsgenie := receiver.OpsGenieConfigs[0]
|
||||
sendResolved := opsgenie.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, opsgenie.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelOpsgenieConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIKey: string(opsgenie.APIKey),
|
||||
APIURL: formatUpstreamURL(opsgenie.APIURL),
|
||||
Message: valuer.UnsetIfEmpty(opsgenie.Message),
|
||||
Description: valuer.UnsetIfEmpty(opsgenie.Description),
|
||||
Source: valuer.UnsetIfEmpty(opsgenie.Source),
|
||||
Priority: opsgenie.Priority,
|
||||
Details: opsgenie.Details,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
type ChannelPagerdutyConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
RoutingKey string `json:"routingKey" required:"true" format:"password"`
|
||||
URL string `json:"url"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source"`
|
||||
Client valuer.UnsetOrNonEmptyString `json:"client"`
|
||||
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Component string `json:"component"`
|
||||
Group string `json:"group"`
|
||||
Class string `json:"class"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelPagerdutyConfig) Validate() error {
|
||||
if c.RoutingKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.routingKey is required for a pagerduty channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelPagerdutyConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
var eventsURL *config.URL
|
||||
if c.URL != "" {
|
||||
parsed, err := parseUpstreamURL(c.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eventsURL = parsed
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
PagerdutyConfigs: []*config.PagerdutyConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultPagerdutyConfig.VSendResolved)},
|
||||
RoutingKey: config.Secret(c.RoutingKey),
|
||||
URL: eventsURL,
|
||||
Source: c.Source.StringValue(),
|
||||
Client: c.Client.StringValue(),
|
||||
ClientURL: c.ClientURL.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Severity: c.Severity,
|
||||
Component: c.Component,
|
||||
Group: c.Group,
|
||||
Class: c.Class,
|
||||
Details: newUpstreamDetails(c.Details),
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelPagerdutyConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
pagerduty := receiver.PagerdutyConfigs[0]
|
||||
sendResolved := pagerduty.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, pagerduty.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var details map[string]string
|
||||
if len(pagerduty.Details) > 0 {
|
||||
extracted, err := extractStringDetails(name, pagerduty.Details)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details = extracted
|
||||
}
|
||||
|
||||
return &ChannelPagerdutyConfig{
|
||||
SendResolved: &sendResolved,
|
||||
RoutingKey: string(pagerduty.RoutingKey),
|
||||
URL: formatUpstreamURL(pagerduty.URL),
|
||||
Source: valuer.UnsetIfEmpty(pagerduty.Source),
|
||||
Client: valuer.UnsetIfEmpty(pagerduty.Client),
|
||||
ClientURL: valuer.UnsetIfEmpty(pagerduty.ClientURL),
|
||||
Description: valuer.UnsetIfEmpty(pagerduty.Description),
|
||||
Severity: pagerduty.Severity,
|
||||
Component: pagerduty.Component,
|
||||
Group: pagerduty.Group,
|
||||
Class: pagerduty.Class,
|
||||
Details: details,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
type ChannelSlackConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIURL string `json:"apiUrl" required:"true" format:"password"`
|
||||
Channel string `json:"channel"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
Color valuer.UnsetOrNonEmptyString `json:"color"`
|
||||
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink"`
|
||||
Pretext valuer.UnsetOrNonEmptyString `json:"pretext"`
|
||||
Fallback valuer.UnsetOrNonEmptyString `json:"fallback"`
|
||||
Footer valuer.UnsetOrNonEmptyString `json:"footer"`
|
||||
Fields []ChannelSlackField `json:"fields,omitempty"`
|
||||
Actions []ChannelSlackAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelSlackField struct {
|
||||
Title string `json:"title" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
Short *bool `json:"short,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelSlackAction is a link button when URL is set, otherwise a message
|
||||
// button that needs Name. Upstream clears whichever side is not in use.
|
||||
type ChannelSlackAction struct {
|
||||
Type string `json:"type" required:"true"`
|
||||
Text string `json:"text" required:"true"`
|
||||
URL string `json:"url"`
|
||||
Style string `json:"style"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelSlackConfirmation struct {
|
||||
Text string `json:"text" required:"true"`
|
||||
Title string `json:"title"`
|
||||
OkText string `json:"okText"`
|
||||
DismissText string `json:"dismissText"`
|
||||
}
|
||||
|
||||
func (c ChannelSlackConfig) Validate() error {
|
||||
if c.APIURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiUrl is required for a slack channel")
|
||||
}
|
||||
|
||||
for i, field := range c.Fields {
|
||||
if field.Title == "" || field.Value == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.fields[%d] requires title and value", i)
|
||||
}
|
||||
}
|
||||
|
||||
for i, action := range c.Actions {
|
||||
if action.Type == "" || action.Text == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires type and text", i)
|
||||
}
|
||||
if action.URL == "" && action.Name == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires url or name", i)
|
||||
}
|
||||
if action.Confirm != nil && action.Confirm.Text == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d].confirm requires text", i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelSlackConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
apiURL, err := parseSecretURL(c.APIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
SlackConfigs: []*config.SlackConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultSlackConfig.VSendResolved)},
|
||||
APIURL: apiURL,
|
||||
Channel: c.Channel,
|
||||
Title: c.Title.StringValue(),
|
||||
Text: c.Text.StringValue(),
|
||||
Color: c.Color.StringValue(),
|
||||
TitleLink: c.TitleLink.StringValue(),
|
||||
Pretext: c.Pretext.StringValue(),
|
||||
Fallback: c.Fallback.StringValue(),
|
||||
Footer: c.Footer.StringValue(),
|
||||
Fields: newUpstreamSlackFields(c.Fields),
|
||||
Actions: newUpstreamSlackActions(c.Actions),
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelSlackConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
slack := receiver.SlackConfigs[0]
|
||||
sendResolved := slack.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, slack.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelSlackConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIURL: formatSecretURL(slack.APIURL),
|
||||
Channel: slack.Channel,
|
||||
Title: valuer.UnsetIfEmpty(slack.Title),
|
||||
Text: valuer.UnsetIfEmpty(slack.Text),
|
||||
Color: valuer.UnsetIfEmpty(slack.Color),
|
||||
TitleLink: valuer.UnsetIfEmpty(slack.TitleLink),
|
||||
Pretext: valuer.UnsetIfEmpty(slack.Pretext),
|
||||
Fallback: valuer.UnsetIfEmpty(slack.Fallback),
|
||||
Footer: valuer.UnsetIfEmpty(slack.Footer),
|
||||
Fields: newChannelSlackFields(slack.Fields),
|
||||
Actions: newChannelSlackActions(slack.Actions),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newUpstreamSlackFields(fields []ChannelSlackField) []*config.SlackField {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make([]*config.SlackField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
upstream = append(upstream, &config.SlackField{Title: field.Title, Value: field.Value, Short: field.Short})
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func newChannelSlackFields(upstream []*config.SlackField) []ChannelSlackField {
|
||||
if len(upstream) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]ChannelSlackField, 0, len(upstream))
|
||||
for _, field := range upstream {
|
||||
fields = append(fields, ChannelSlackField{Title: field.Title, Value: field.Value, Short: field.Short})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func newUpstreamSlackActions(actions []ChannelSlackAction) []*config.SlackAction {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make([]*config.SlackAction, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
upstreamAction := &config.SlackAction{Type: action.Type, Text: action.Text, URL: action.URL, Style: action.Style, Name: action.Name, Value: action.Value}
|
||||
if action.Confirm != nil {
|
||||
upstreamAction.ConfirmField = &config.SlackConfirmationField{Text: action.Confirm.Text, Title: action.Confirm.Title, OkText: action.Confirm.OkText, DismissText: action.Confirm.DismissText}
|
||||
}
|
||||
upstream = append(upstream, upstreamAction)
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func newChannelSlackActions(upstream []*config.SlackAction) []ChannelSlackAction {
|
||||
if len(upstream) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
actions := make([]ChannelSlackAction, 0, len(upstream))
|
||||
for _, upstreamAction := range upstream {
|
||||
action := ChannelSlackAction{Type: upstreamAction.Type, Text: upstreamAction.Text, URL: upstreamAction.URL, Style: upstreamAction.Style, Name: upstreamAction.Name, Value: upstreamAction.Value}
|
||||
if upstreamAction.ConfirmField != nil {
|
||||
action.Confirm = &ChannelSlackConfirmation{Text: upstreamAction.ConfirmField.Text, Title: upstreamAction.ConfirmField.Title, OkText: upstreamAction.ConfirmField.OkText, DismissText: upstreamAction.ConfirmField.DismissText}
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
// ChannelWebhookConfig splits apart the two authentication modes the legacy API
|
||||
// overloaded onto one password field, where an empty username meant the password
|
||||
// was really a bearer token. Username or Password may be set without the other,
|
||||
// as upstream allows, but not together with BearerToken.
|
||||
type ChannelWebhookConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
URL string `json:"url" required:"true" format:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password" format:"password"`
|
||||
BearerToken string `json:"bearerToken" format:"password"`
|
||||
}
|
||||
|
||||
func (c ChannelWebhookConfig) Validate() error {
|
||||
if c.URL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for a webhook channel")
|
||||
}
|
||||
|
||||
usesBasicAuth := c.Username != "" || c.Password != ""
|
||||
|
||||
if usesBasicAuth && c.BearerToken != "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.bearerToken cannot be combined with config.spec.username or config.spec.password")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelWebhookConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
webhook := &config.WebhookConfig{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultWebhookConfig.VSendResolved)},
|
||||
URL: config.SecretTemplateURL(c.URL),
|
||||
}
|
||||
|
||||
// Seeded from upstream's default rather than a zero value: FollowRedirects
|
||||
// and EnableHTTP2 marshal unconditionally, so a zero value would persist
|
||||
// them as false and read back as a config ChannelWebhookConfig cannot represent.
|
||||
switch {
|
||||
case c.Username != "" || c.Password != "":
|
||||
httpConfig := commoncfg.DefaultHTTPClientConfig
|
||||
httpConfig.BasicAuth = &commoncfg.BasicAuth{
|
||||
Username: c.Username,
|
||||
Password: commoncfg.Secret(c.Password),
|
||||
}
|
||||
webhook.HTTPConfig = &httpConfig
|
||||
case c.BearerToken != "":
|
||||
httpConfig := commoncfg.DefaultHTTPClientConfig
|
||||
httpConfig.Authorization = &commoncfg.Authorization{
|
||||
Type: bearerAuthorizationType,
|
||||
Credentials: commoncfg.Secret(c.BearerToken),
|
||||
}
|
||||
webhook.HTTPConfig = &httpConfig
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
WebhookConfigs: []*config.WebhookConfig{webhook},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelWebhookConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
upstream := receiver.WebhookConfigs[0]
|
||||
sendResolved := upstream.VSendResolved
|
||||
if err := rejectUnsupportedHTTPConfig(name, upstream.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := rejectHTTPBasicAuthBeyondPassword(name, upstream.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := rejectHTTPAuthorizationBeyondBearer(name, upstream.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
webhook := &ChannelWebhookConfig{
|
||||
SendResolved: &sendResolved,
|
||||
URL: string(upstream.URL),
|
||||
}
|
||||
|
||||
if upstream.HTTPConfig != nil {
|
||||
if basicAuth := upstream.HTTPConfig.BasicAuth; basicAuth != nil {
|
||||
webhook.Username = basicAuth.Username
|
||||
webhook.Password = string(basicAuth.Password)
|
||||
}
|
||||
if authorization := upstream.HTTPConfig.Authorization; authorization != nil {
|
||||
webhook.BearerToken = string(authorization.Credentials)
|
||||
}
|
||||
}
|
||||
|
||||
return webhook, nil
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package alertmanagertypes
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
)
|
||||
|
||||
@@ -202,6 +205,808 @@ type ChannelSpec interface {
|
||||
toUndefaultedReceiver(displayName string) (*Receiver, error)
|
||||
}
|
||||
|
||||
type ChannelSlackConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIURL string `json:"apiUrl" required:"true" format:"password"`
|
||||
Channel string `json:"channel"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
Color valuer.UnsetOrNonEmptyString `json:"color"`
|
||||
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink"`
|
||||
Pretext valuer.UnsetOrNonEmptyString `json:"pretext"`
|
||||
Fallback valuer.UnsetOrNonEmptyString `json:"fallback"`
|
||||
Footer valuer.UnsetOrNonEmptyString `json:"footer"`
|
||||
Fields []ChannelSlackField `json:"fields,omitempty"`
|
||||
Actions []ChannelSlackAction `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelSlackField struct {
|
||||
Title string `json:"title" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
Short *bool `json:"short,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelSlackAction is a link button when URL is set, otherwise a message
|
||||
// button that needs Name. Upstream clears whichever side is not in use.
|
||||
type ChannelSlackAction struct {
|
||||
Type string `json:"type" required:"true"`
|
||||
Text string `json:"text" required:"true"`
|
||||
URL string `json:"url"`
|
||||
Style string `json:"style"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelSlackConfirmation struct {
|
||||
Text string `json:"text" required:"true"`
|
||||
Title string `json:"title"`
|
||||
OkText string `json:"okText"`
|
||||
DismissText string `json:"dismissText"`
|
||||
}
|
||||
|
||||
func (c ChannelSlackConfig) Validate() error {
|
||||
if c.APIURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiUrl is required for a slack channel")
|
||||
}
|
||||
|
||||
for i, field := range c.Fields {
|
||||
if field.Title == "" || field.Value == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.fields[%d] requires title and value", i)
|
||||
}
|
||||
}
|
||||
|
||||
for i, action := range c.Actions {
|
||||
if action.Type == "" || action.Text == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires type and text", i)
|
||||
}
|
||||
if action.URL == "" && action.Name == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d] requires url or name", i)
|
||||
}
|
||||
if action.Confirm != nil && action.Confirm.Text == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.actions[%d].confirm requires text", i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelSlackConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
apiURL, err := parseSecretURL(c.APIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
SlackConfigs: []*config.SlackConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultSlackConfig.VSendResolved)},
|
||||
APIURL: apiURL,
|
||||
Channel: c.Channel,
|
||||
Title: c.Title.StringValue(),
|
||||
Text: c.Text.StringValue(),
|
||||
Color: c.Color.StringValue(),
|
||||
TitleLink: c.TitleLink.StringValue(),
|
||||
Pretext: c.Pretext.StringValue(),
|
||||
Fallback: c.Fallback.StringValue(),
|
||||
Footer: c.Footer.StringValue(),
|
||||
Fields: newUpstreamSlackFields(c.Fields),
|
||||
Actions: newUpstreamSlackActions(c.Actions),
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelSlackConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
slack := receiver.SlackConfigs[0]
|
||||
sendResolved := slack.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, slack.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelSlackConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIURL: formatSecretURL(slack.APIURL),
|
||||
Channel: slack.Channel,
|
||||
Title: valuer.UnsetIfEmpty(slack.Title),
|
||||
Text: valuer.UnsetIfEmpty(slack.Text),
|
||||
Color: valuer.UnsetIfEmpty(slack.Color),
|
||||
TitleLink: valuer.UnsetIfEmpty(slack.TitleLink),
|
||||
Pretext: valuer.UnsetIfEmpty(slack.Pretext),
|
||||
Fallback: valuer.UnsetIfEmpty(slack.Fallback),
|
||||
Footer: valuer.UnsetIfEmpty(slack.Footer),
|
||||
Fields: newChannelSlackFields(slack.Fields),
|
||||
Actions: newChannelSlackActions(slack.Actions),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newUpstreamSlackFields(fields []ChannelSlackField) []*config.SlackField {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make([]*config.SlackField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
upstream = append(upstream, &config.SlackField{Title: field.Title, Value: field.Value, Short: field.Short})
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func newChannelSlackFields(upstream []*config.SlackField) []ChannelSlackField {
|
||||
if len(upstream) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := make([]ChannelSlackField, 0, len(upstream))
|
||||
for _, field := range upstream {
|
||||
fields = append(fields, ChannelSlackField{Title: field.Title, Value: field.Value, Short: field.Short})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func newUpstreamSlackActions(actions []ChannelSlackAction) []*config.SlackAction {
|
||||
if len(actions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
upstream := make([]*config.SlackAction, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
upstreamAction := &config.SlackAction{Type: action.Type, Text: action.Text, URL: action.URL, Style: action.Style, Name: action.Name, Value: action.Value}
|
||||
if action.Confirm != nil {
|
||||
upstreamAction.ConfirmField = &config.SlackConfirmationField{Text: action.Confirm.Text, Title: action.Confirm.Title, OkText: action.Confirm.OkText, DismissText: action.Confirm.DismissText}
|
||||
}
|
||||
upstream = append(upstream, upstreamAction)
|
||||
}
|
||||
|
||||
return upstream
|
||||
}
|
||||
|
||||
func newChannelSlackActions(upstream []*config.SlackAction) []ChannelSlackAction {
|
||||
if len(upstream) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
actions := make([]ChannelSlackAction, 0, len(upstream))
|
||||
for _, upstreamAction := range upstream {
|
||||
action := ChannelSlackAction{Type: upstreamAction.Type, Text: upstreamAction.Text, URL: upstreamAction.URL, Style: upstreamAction.Style, Name: upstreamAction.Name, Value: upstreamAction.Value}
|
||||
if upstreamAction.ConfirmField != nil {
|
||||
action.Confirm = &ChannelSlackConfirmation{Text: upstreamAction.ConfirmField.Text, Title: upstreamAction.ConfirmField.Title, OkText: upstreamAction.ConfirmField.OkText, DismissText: upstreamAction.ConfirmField.DismissText}
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
// ChannelEmailConfig carries no SMTP transport fields: the smarthost,
|
||||
// credentials and TLS settings come from the deployment's global config, so a
|
||||
// channel can only choose recipients and body.
|
||||
type ChannelEmailConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
To string `json:"to" required:"true"`
|
||||
HTML valuer.UnsetOrNonEmptyString `json:"html"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelEmailConfig) Validate() error {
|
||||
if c.To == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.to is required for an email channel")
|
||||
}
|
||||
|
||||
// A read reports header names as textproto canonicalizes them, turning
|
||||
// "subject" into "Subject", so a name that is not already in that form is
|
||||
// rejected rather than answered with one the caller never sent.
|
||||
for _, header := range slices.Sorted(maps.Keys(c.Headers)) {
|
||||
if canonical := textproto.CanonicalMIMEHeaderKey(header); canonical != header {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.headers name %q must be written as %q", header, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelEmailConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
EmailConfigs: []*config.EmailConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultEmailConfig.VSendResolved)},
|
||||
To: c.To,
|
||||
HTML: c.HTML.StringValue(),
|
||||
Headers: c.Headers,
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelEmailConfigFromReceiver(_ string, receiver *Receiver) (ChannelSpec, error) {
|
||||
email := receiver.EmailConfigs[0]
|
||||
sendResolved := email.VSendResolved
|
||||
|
||||
return &ChannelEmailConfig{
|
||||
SendResolved: &sendResolved,
|
||||
To: email.To,
|
||||
HTML: valuer.UnsetIfEmpty(email.HTML),
|
||||
Headers: email.Headers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ChannelWebhookConfig splits apart the two authentication modes the legacy API
|
||||
// overloaded onto one password field, where an empty username meant the password
|
||||
// was really a bearer token. Username or Password may be set without the other,
|
||||
// as upstream allows, but not together with BearerToken.
|
||||
type ChannelWebhookConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
URL string `json:"url" required:"true" format:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password" format:"password"`
|
||||
BearerToken string `json:"bearerToken" format:"password"`
|
||||
}
|
||||
|
||||
func (c ChannelWebhookConfig) Validate() error {
|
||||
if c.URL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for a webhook channel")
|
||||
}
|
||||
|
||||
usesBasicAuth := c.Username != "" || c.Password != ""
|
||||
|
||||
if usesBasicAuth && c.BearerToken != "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.bearerToken cannot be combined with config.spec.username or config.spec.password")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelWebhookConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
webhook := &config.WebhookConfig{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultWebhookConfig.VSendResolved)},
|
||||
URL: config.SecretTemplateURL(c.URL),
|
||||
}
|
||||
|
||||
// Seeded from upstream's default rather than a zero value: FollowRedirects
|
||||
// and EnableHTTP2 marshal unconditionally, so a zero value would persist
|
||||
// them as false and read back as a config ChannelWebhookConfig cannot represent.
|
||||
switch {
|
||||
case c.Username != "" || c.Password != "":
|
||||
httpConfig := commoncfg.DefaultHTTPClientConfig
|
||||
httpConfig.BasicAuth = &commoncfg.BasicAuth{
|
||||
Username: c.Username,
|
||||
Password: commoncfg.Secret(c.Password),
|
||||
}
|
||||
webhook.HTTPConfig = &httpConfig
|
||||
case c.BearerToken != "":
|
||||
httpConfig := commoncfg.DefaultHTTPClientConfig
|
||||
httpConfig.Authorization = &commoncfg.Authorization{
|
||||
Type: bearerAuthorizationType,
|
||||
Credentials: commoncfg.Secret(c.BearerToken),
|
||||
}
|
||||
webhook.HTTPConfig = &httpConfig
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
WebhookConfigs: []*config.WebhookConfig{webhook},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelWebhookConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
upstream := receiver.WebhookConfigs[0]
|
||||
sendResolved := upstream.VSendResolved
|
||||
if err := rejectUnsupportedHTTPConfig(name, upstream.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := rejectHTTPBasicAuthBeyondPassword(name, upstream.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := rejectHTTPAuthorizationBeyondBearer(name, upstream.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
webhook := &ChannelWebhookConfig{
|
||||
SendResolved: &sendResolved,
|
||||
URL: string(upstream.URL),
|
||||
}
|
||||
|
||||
if upstream.HTTPConfig != nil {
|
||||
if basicAuth := upstream.HTTPConfig.BasicAuth; basicAuth != nil {
|
||||
webhook.Username = basicAuth.Username
|
||||
webhook.Password = string(basicAuth.Password)
|
||||
}
|
||||
if authorization := upstream.HTTPConfig.Authorization; authorization != nil {
|
||||
webhook.BearerToken = string(authorization.Credentials)
|
||||
}
|
||||
}
|
||||
|
||||
return webhook, nil
|
||||
}
|
||||
|
||||
type ChannelPagerdutyConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
RoutingKey string `json:"routingKey" required:"true" format:"password"`
|
||||
URL string `json:"url"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source"`
|
||||
Client valuer.UnsetOrNonEmptyString `json:"client"`
|
||||
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Component string `json:"component"`
|
||||
Group string `json:"group"`
|
||||
Class string `json:"class"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelPagerdutyConfig) Validate() error {
|
||||
if c.RoutingKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.routingKey is required for a pagerduty channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelPagerdutyConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
var eventsURL *config.URL
|
||||
if c.URL != "" {
|
||||
parsed, err := parseUpstreamURL(c.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eventsURL = parsed
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
PagerdutyConfigs: []*config.PagerdutyConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultPagerdutyConfig.VSendResolved)},
|
||||
RoutingKey: config.Secret(c.RoutingKey),
|
||||
URL: eventsURL,
|
||||
Source: c.Source.StringValue(),
|
||||
Client: c.Client.StringValue(),
|
||||
ClientURL: c.ClientURL.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Severity: c.Severity,
|
||||
Component: c.Component,
|
||||
Group: c.Group,
|
||||
Class: c.Class,
|
||||
Details: newUpstreamDetails(c.Details),
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelPagerdutyConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
pagerduty := receiver.PagerdutyConfigs[0]
|
||||
sendResolved := pagerduty.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, pagerduty.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var details map[string]string
|
||||
if len(pagerduty.Details) > 0 {
|
||||
extracted, err := extractStringDetails(name, pagerduty.Details)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details = extracted
|
||||
}
|
||||
|
||||
return &ChannelPagerdutyConfig{
|
||||
SendResolved: &sendResolved,
|
||||
RoutingKey: string(pagerduty.RoutingKey),
|
||||
URL: formatUpstreamURL(pagerduty.URL),
|
||||
Source: valuer.UnsetIfEmpty(pagerduty.Source),
|
||||
Client: valuer.UnsetIfEmpty(pagerduty.Client),
|
||||
ClientURL: valuer.UnsetIfEmpty(pagerduty.ClientURL),
|
||||
Description: valuer.UnsetIfEmpty(pagerduty.Description),
|
||||
Severity: pagerduty.Severity,
|
||||
Component: pagerduty.Component,
|
||||
Group: pagerduty.Group,
|
||||
Class: pagerduty.Class,
|
||||
Details: details,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ChannelOpsgenieConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIKey string `json:"apiKey" required:"true" format:"password"`
|
||||
APIURL string `json:"apiUrl"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Source valuer.UnsetOrNonEmptyString `json:"source"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
func (c ChannelOpsgenieConfig) Validate() error {
|
||||
if c.APIKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for an opsgenie channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelOpsgenieConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
var apiURL *config.URL
|
||||
if c.APIURL != "" {
|
||||
parsed, err := parseUpstreamURL(c.APIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiURL = parsed
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
OpsGenieConfigs: []*config.OpsGenieConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultOpsGenieConfig.VSendResolved)},
|
||||
APIKey: config.Secret(c.APIKey),
|
||||
APIURL: apiURL,
|
||||
Message: c.Message.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Source: c.Source.StringValue(),
|
||||
Priority: c.Priority,
|
||||
Details: c.Details,
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelOpsgenieConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
opsgenie := receiver.OpsGenieConfigs[0]
|
||||
sendResolved := opsgenie.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, opsgenie.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelOpsgenieConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIKey: string(opsgenie.APIKey),
|
||||
APIURL: formatUpstreamURL(opsgenie.APIURL),
|
||||
Message: valuer.UnsetIfEmpty(opsgenie.Message),
|
||||
Description: valuer.UnsetIfEmpty(opsgenie.Description),
|
||||
Source: valuer.UnsetIfEmpty(opsgenie.Source),
|
||||
Priority: opsgenie.Priority,
|
||||
Details: opsgenie.Details,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ChannelMSTeamsConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
}
|
||||
|
||||
func (c ChannelMSTeamsConfig) Validate() error {
|
||||
if c.WebhookURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.webhookUrl is required for an msteams channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelMSTeamsConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
webhookURL, err := parseSecretURL(c.WebhookURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
MSTeamsV2Configs: []*config.MSTeamsV2Config{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultMSTeamsV2Config.VSendResolved)},
|
||||
WebhookURL: webhookURL,
|
||||
Title: c.Title.StringValue(),
|
||||
Text: c.Text.StringValue(),
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelMSTeamsConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
msteams := receiver.MSTeamsV2Configs[0]
|
||||
sendResolved := msteams.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, msteams.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelMSTeamsConfig{
|
||||
SendResolved: &sendResolved,
|
||||
WebhookURL: formatSecretURL(msteams.WebhookURL),
|
||||
Title: valuer.UnsetIfEmpty(msteams.Title),
|
||||
Text: valuer.UnsetIfEmpty(msteams.Text),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ChannelGoogleChatConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
}
|
||||
|
||||
func (c ChannelGoogleChatConfig) Validate() error {
|
||||
if c.WebhookURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.webhookUrl is required for a googlechat channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelGoogleChatConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
webhookURL, err := parseSecretURL(c.WebhookURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
GoogleChatConfigs: []*GoogleChatReceiverConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultGoogleChatReceiverConfig.VSendResolved)},
|
||||
WebhookURL: webhookURL,
|
||||
Title: c.Title.StringValue(),
|
||||
Text: c.Text.StringValue(),
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelGoogleChatConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
googlechat := receiver.GoogleChatConfigs[0]
|
||||
sendResolved := googlechat.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, googlechat.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelGoogleChatConfig{
|
||||
SendResolved: &sendResolved,
|
||||
WebhookURL: formatSecretURL(googlechat.WebhookURL),
|
||||
Title: valuer.UnsetIfEmpty(googlechat.Title),
|
||||
Text: valuer.UnsetIfEmpty(googlechat.Text),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ChannelJiraConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
// Site is the Jira Cloud base URL, https://<site>.atlassian.net. Only Jira
|
||||
// Cloud is supported; the REST base is derived from it.
|
||||
Site string `json:"site" required:"true"`
|
||||
Project string `json:"project" required:"true"`
|
||||
IssueType string `json:"issueType" required:"true"`
|
||||
Summary valuer.UnsetOrNonEmptyString `json:"summary"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
ResolveTransition string `json:"resolveTransition"`
|
||||
ReopenTransition string `json:"reopenTransition"`
|
||||
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration"`
|
||||
WontFixResolution string `json:"wontFixResolution"`
|
||||
CustomFields map[string]any `json:"customFields,omitempty"`
|
||||
|
||||
Email string `json:"email" required:"true"`
|
||||
APIToken string `json:"apiToken" required:"true" format:"password"`
|
||||
}
|
||||
|
||||
func (c ChannelJiraConfig) Validate() error {
|
||||
for _, required := range []struct {
|
||||
value string
|
||||
field string
|
||||
}{
|
||||
{c.Site, "site"},
|
||||
{c.Project, "project"},
|
||||
{c.IssueType, "issueType"},
|
||||
{c.Email, "email"},
|
||||
{c.APIToken, "apiToken"},
|
||||
} {
|
||||
if required.value == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.%s is required for a jira channel", required.field)
|
||||
}
|
||||
}
|
||||
|
||||
if !c.ReopenDuration.IsZero() {
|
||||
reopenDuration, err := model.ParseDuration(c.ReopenDuration.StringValue())
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "config.spec.reopenDuration %q is not a valid duration", c.ReopenDuration)
|
||||
}
|
||||
|
||||
// A read reports the duration as model.Duration formats it, collapsing
|
||||
// "72h" into "3d", so a value that is not already in that form is rejected
|
||||
// rather than answered with one the caller never sent.
|
||||
if canonical := reopenDuration.String(); canonical != c.ReopenDuration.StringValue() {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.reopenDuration %q must be written as %q", c.ReopenDuration, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelJiraConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
// Seeded from upstream's default rather than a zero value: FollowRedirects
|
||||
// and EnableHTTP2 marshal unconditionally, so a zero value would persist them
|
||||
// as false and read back as a config ChannelJiraConfig cannot represent.
|
||||
httpConfig := commoncfg.DefaultHTTPClientConfig
|
||||
httpConfig.BasicAuth = &commoncfg.BasicAuth{
|
||||
Username: c.Email,
|
||||
Password: commoncfg.Secret(c.APIToken),
|
||||
}
|
||||
|
||||
jira := &JiraReceiverConfig{
|
||||
// JiraReceiverConfig seeds no send_resolved of its own, so unset means off.
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, false)},
|
||||
Site: c.Site,
|
||||
Project: c.Project,
|
||||
IssueType: c.IssueType,
|
||||
Summary: c.Summary.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Priority: c.Priority,
|
||||
Labels: c.Labels,
|
||||
ResolveTransition: c.ResolveTransition,
|
||||
ReopenTransition: c.ReopenTransition,
|
||||
WontFixResolution: c.WontFixResolution,
|
||||
CustomFields: c.CustomFields,
|
||||
HTTPConfig: &httpConfig,
|
||||
}
|
||||
|
||||
if !c.ReopenDuration.IsZero() {
|
||||
reopenDuration, err := model.ParseDuration(c.ReopenDuration.StringValue())
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "parse reopenDuration %q", c.ReopenDuration)
|
||||
}
|
||||
jira.ReopenDuration = reopenDuration
|
||||
}
|
||||
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
JiraConfigs: []*JiraReceiverConfig{jira},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelJiraConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
jira := receiver.JiraConfigs[0]
|
||||
sendResolved := jira.VSendResolved
|
||||
|
||||
if err := rejectUnsupportedHTTPConfig(name, jira.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if jira.HTTPConfig != nil && jira.HTTPConfig.Authorization != nil {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", name)
|
||||
}
|
||||
|
||||
if err := rejectHTTPBasicAuthBeyondPassword(name, jira.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := &ChannelJiraConfig{
|
||||
SendResolved: &sendResolved,
|
||||
Site: jira.Site,
|
||||
Project: jira.Project,
|
||||
IssueType: jira.IssueType,
|
||||
Summary: valuer.UnsetIfEmpty(jira.Summary),
|
||||
Description: valuer.UnsetIfEmpty(jira.Description),
|
||||
Priority: jira.Priority,
|
||||
Labels: jira.Labels,
|
||||
ResolveTransition: jira.ResolveTransition,
|
||||
ReopenTransition: jira.ReopenTransition,
|
||||
ReopenDuration: valuer.UnsetIfEmpty(jira.ReopenDuration.String()),
|
||||
WontFixResolution: jira.WontFixResolution,
|
||||
CustomFields: jira.CustomFields,
|
||||
}
|
||||
|
||||
if jira.HTTPConfig != nil && jira.HTTPConfig.BasicAuth != nil {
|
||||
spec.Email = jira.HTTPConfig.BasicAuth.Username
|
||||
spec.APIToken = string(jira.HTTPConfig.BasicAuth.Password)
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// ChannelJSMOpsConfig carries no API URL: JSM Ops is a single global gateway
|
||||
// keyed by the integration API key, which the notifier pins itself.
|
||||
type ChannelJSMOpsConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIKey string `json:"apiKey" required:"true" format:"password"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
// Tags is the comma-separated list JSM Ops attaches to the alert.
|
||||
Tags valuer.UnsetOrNonEmptyString `json:"tags"`
|
||||
}
|
||||
|
||||
func (c ChannelJSMOpsConfig) Validate() error {
|
||||
if c.APIKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for a jsmops channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelJSMOpsConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
JSMOpsConfigs: []*JSMOpsReceiverConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultJSMOpsReceiverConfig.VSendResolved)},
|
||||
APIKey: config.Secret(c.APIKey),
|
||||
Message: c.Message.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Priority: c.Priority,
|
||||
Tags: c.Tags.StringValue(),
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelJSMOpsConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
jsmops := receiver.JSMOpsConfigs[0]
|
||||
sendResolved := jsmops.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, jsmops.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelJSMOpsConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIKey: string(jsmops.APIKey),
|
||||
Message: valuer.UnsetIfEmpty(jsmops.Message),
|
||||
Description: valuer.UnsetIfEmpty(jsmops.Description),
|
||||
Priority: jsmops.Priority,
|
||||
Tags: valuer.UnsetIfEmpty(jsmops.Tags),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ChannelIncidentIOConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
URL string `json:"url" required:"true"`
|
||||
Token string `json:"token" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelIncidentIOConfig) Validate() error {
|
||||
if c.URL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for an incidentio channel")
|
||||
}
|
||||
|
||||
if c.Token == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.token is required for an incidentio channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelIncidentIOConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
IncidentIOConfigs: []*IncidentIOReceiverConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultIncidentIOReceiverConfig.VSendResolved)},
|
||||
URL: c.URL,
|
||||
Token: config.Secret(c.Token),
|
||||
Title: c.Title.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Metadata: c.Metadata,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelIncidentIOConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
incidentio := receiver.IncidentIOConfigs[0]
|
||||
sendResolved := incidentio.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, incidentio.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelIncidentIOConfig{
|
||||
SendResolved: &sendResolved,
|
||||
URL: incidentio.URL,
|
||||
Token: string(incidentio.Token),
|
||||
Title: valuer.UnsetIfEmpty(incidentio.Title),
|
||||
Description: valuer.UnsetIfEmpty(incidentio.Description),
|
||||
Metadata: incidentio.Metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -5,59 +5,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
type ChannelGoogleChatConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Text valuer.UnsetOrNonEmptyString `json:"text"`
|
||||
}
|
||||
|
||||
func (c ChannelGoogleChatConfig) Validate() error {
|
||||
if c.WebhookURL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.webhookUrl is required for a googlechat channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelGoogleChatConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
webhookURL, err := parseSecretURL(c.WebhookURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
GoogleChatConfigs: []*GoogleChatReceiverConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultGoogleChatReceiverConfig.VSendResolved)},
|
||||
WebhookURL: webhookURL,
|
||||
Title: c.Title.StringValue(),
|
||||
Text: c.Text.StringValue(),
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelGoogleChatConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
googlechat := receiver.GoogleChatConfigs[0]
|
||||
sendResolved := googlechat.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, googlechat.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelGoogleChatConfig{
|
||||
SendResolved: &sendResolved,
|
||||
WebhookURL: formatSecretURL(googlechat.WebhookURL),
|
||||
Title: valuer.UnsetIfEmpty(googlechat.Title),
|
||||
Text: valuer.UnsetIfEmpty(googlechat.Text),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type GoogleChatReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline" json:",inline"`
|
||||
|
||||
@@ -6,64 +6,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
type ChannelIncidentIOConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
URL string `json:"url" required:"true"`
|
||||
Token string `json:"token" required:"true" format:"password"`
|
||||
Title valuer.UnsetOrNonEmptyString `json:"title"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (c ChannelIncidentIOConfig) Validate() error {
|
||||
if c.URL == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.url is required for an incidentio channel")
|
||||
}
|
||||
|
||||
if c.Token == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.token is required for an incidentio channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelIncidentIOConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
IncidentIOConfigs: []*IncidentIOReceiverConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultIncidentIOReceiverConfig.VSendResolved)},
|
||||
URL: c.URL,
|
||||
Token: config.Secret(c.Token),
|
||||
Title: c.Title.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Metadata: c.Metadata,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelIncidentIOConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
incidentio := receiver.IncidentIOConfigs[0]
|
||||
sendResolved := incidentio.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, incidentio.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelIncidentIOConfig{
|
||||
SendResolved: &sendResolved,
|
||||
URL: incidentio.URL,
|
||||
Token: string(incidentio.Token),
|
||||
Title: valuer.UnsetIfEmpty(incidentio.Title),
|
||||
Description: valuer.UnsetIfEmpty(incidentio.Description),
|
||||
Metadata: incidentio.Metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// incidentIOEventsPathPrefix is the path of incident.io's HTTP alert source
|
||||
// endpoint (Alert Events V2 API). The full URL is per-source:
|
||||
// https://api.incident.io/v2/alert_events/http/<source_config_id>.
|
||||
@@ -7,147 +7,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
type ChannelJiraConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
// Site is the Jira Cloud base URL, https://<site>.atlassian.net. Only Jira
|
||||
// Cloud is supported; the REST base is derived from it.
|
||||
Site string `json:"site" required:"true"`
|
||||
Project string `json:"project" required:"true"`
|
||||
IssueType string `json:"issueType" required:"true"`
|
||||
Summary valuer.UnsetOrNonEmptyString `json:"summary"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
ResolveTransition string `json:"resolveTransition"`
|
||||
ReopenTransition string `json:"reopenTransition"`
|
||||
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration"`
|
||||
WontFixResolution string `json:"wontFixResolution"`
|
||||
CustomFields map[string]any `json:"customFields,omitempty"`
|
||||
|
||||
Email string `json:"email" required:"true"`
|
||||
APIToken string `json:"apiToken" required:"true" format:"password"`
|
||||
}
|
||||
|
||||
func (c ChannelJiraConfig) Validate() error {
|
||||
for _, required := range []struct {
|
||||
value string
|
||||
field string
|
||||
}{
|
||||
{c.Site, "site"},
|
||||
{c.Project, "project"},
|
||||
{c.IssueType, "issueType"},
|
||||
{c.Email, "email"},
|
||||
{c.APIToken, "apiToken"},
|
||||
} {
|
||||
if required.value == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.%s is required for a jira channel", required.field)
|
||||
}
|
||||
}
|
||||
|
||||
if !c.ReopenDuration.IsZero() {
|
||||
reopenDuration, err := model.ParseDuration(c.ReopenDuration.StringValue())
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "config.spec.reopenDuration %q is not a valid duration", c.ReopenDuration)
|
||||
}
|
||||
|
||||
// A read reports the duration as model.Duration formats it, collapsing
|
||||
// "72h" into "3d", so a value that is not already in that form is rejected
|
||||
// rather than answered with one the caller never sent.
|
||||
if canonical := reopenDuration.String(); canonical != c.ReopenDuration.StringValue() {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.reopenDuration %q must be written as %q", c.ReopenDuration, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelJiraConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
// Seeded from upstream's default rather than a zero value: FollowRedirects
|
||||
// and EnableHTTP2 marshal unconditionally, so a zero value would persist them
|
||||
// as false and read back as a config ChannelJiraConfig cannot represent.
|
||||
httpConfig := commoncfg.DefaultHTTPClientConfig
|
||||
httpConfig.BasicAuth = &commoncfg.BasicAuth{
|
||||
Username: c.Email,
|
||||
Password: commoncfg.Secret(c.APIToken),
|
||||
}
|
||||
|
||||
jira := &JiraReceiverConfig{
|
||||
// JiraReceiverConfig seeds no send_resolved of its own, so unset means off.
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, false)},
|
||||
Site: c.Site,
|
||||
Project: c.Project,
|
||||
IssueType: c.IssueType,
|
||||
Summary: c.Summary.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Priority: c.Priority,
|
||||
Labels: c.Labels,
|
||||
ResolveTransition: c.ResolveTransition,
|
||||
ReopenTransition: c.ReopenTransition,
|
||||
WontFixResolution: c.WontFixResolution,
|
||||
CustomFields: c.CustomFields,
|
||||
HTTPConfig: &httpConfig,
|
||||
}
|
||||
|
||||
if !c.ReopenDuration.IsZero() {
|
||||
reopenDuration, err := model.ParseDuration(c.ReopenDuration.StringValue())
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "parse reopenDuration %q", c.ReopenDuration)
|
||||
}
|
||||
jira.ReopenDuration = reopenDuration
|
||||
}
|
||||
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
JiraConfigs: []*JiraReceiverConfig{jira},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelJiraConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
jira := receiver.JiraConfigs[0]
|
||||
sendResolved := jira.VSendResolved
|
||||
|
||||
if err := rejectUnsupportedHTTPConfig(name, jira.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if jira.HTTPConfig != nil && jira.HTTPConfig.Authorization != nil {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", name)
|
||||
}
|
||||
|
||||
if err := rejectHTTPBasicAuthBeyondPassword(name, jira.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := &ChannelJiraConfig{
|
||||
SendResolved: &sendResolved,
|
||||
Site: jira.Site,
|
||||
Project: jira.Project,
|
||||
IssueType: jira.IssueType,
|
||||
Summary: valuer.UnsetIfEmpty(jira.Summary),
|
||||
Description: valuer.UnsetIfEmpty(jira.Description),
|
||||
Priority: jira.Priority,
|
||||
Labels: jira.Labels,
|
||||
ResolveTransition: jira.ResolveTransition,
|
||||
ReopenTransition: jira.ReopenTransition,
|
||||
ReopenDuration: valuer.UnsetIfEmpty(jira.ReopenDuration.String()),
|
||||
WontFixResolution: jira.WontFixResolution,
|
||||
CustomFields: jira.CustomFields,
|
||||
}
|
||||
|
||||
if jira.HTTPConfig != nil && jira.HTTPConfig.BasicAuth != nil {
|
||||
spec.Email = jira.HTTPConfig.BasicAuth.Username
|
||||
spec.APIToken = string(jira.HTTPConfig.BasicAuth.Password)
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
const defaultJiraReopenDuration = model.Duration(3 * 24 * time.Hour)
|
||||
|
||||
// Service accounts authenticate against the api.atlassian.com gateway (keyed by
|
||||
@@ -2,63 +2,10 @@ package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
// ChannelJSMOpsConfig carries no API URL: JSM Ops is a single global gateway
|
||||
// keyed by the integration API key, which the notifier pins itself.
|
||||
type ChannelJSMOpsConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
APIKey string `json:"apiKey" required:"true" format:"password"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message"`
|
||||
Description valuer.UnsetOrNonEmptyString `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
// Tags is the comma-separated list JSM Ops attaches to the alert.
|
||||
Tags valuer.UnsetOrNonEmptyString `json:"tags"`
|
||||
}
|
||||
|
||||
func (c ChannelJSMOpsConfig) Validate() error {
|
||||
if c.APIKey == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.apiKey is required for a jsmops channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelJSMOpsConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
return &Receiver{
|
||||
Receiver: &config.Receiver{Name: displayName},
|
||||
JSMOpsConfigs: []*JSMOpsReceiverConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, DefaultJSMOpsReceiverConfig.VSendResolved)},
|
||||
APIKey: config.Secret(c.APIKey),
|
||||
Message: c.Message.StringValue(),
|
||||
Description: c.Description.StringValue(),
|
||||
Priority: c.Priority,
|
||||
Tags: c.Tags.StringValue(),
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newChannelJSMOpsConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
jsmops := receiver.JSMOpsConfigs[0]
|
||||
sendResolved := jsmops.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, jsmops.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ChannelJSMOpsConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIKey: string(jsmops.APIKey),
|
||||
Message: valuer.UnsetIfEmpty(jsmops.Message),
|
||||
Description: valuer.UnsetIfEmpty(jsmops.Description),
|
||||
Priority: jsmops.Priority,
|
||||
Tags: valuer.UnsetIfEmpty(jsmops.Tags),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// JSMOpsAPIBaseURL is the native JSM Ops integration-events gateway. It is a
|
||||
// single global host keyed by the integration API key (no region/cloud id in
|
||||
// the path). The trailing slash is required: the Opsgenie notifier appends
|
||||
@@ -258,6 +258,6 @@ type TokenStore interface {
|
||||
// Delete a token by userID.
|
||||
DeleteByUserID(context.Context, valuer.UUID) error
|
||||
|
||||
// Update last observed at of the given tokens.
|
||||
UpdateLastObservedAt(context.Context, []*StorableToken) error
|
||||
// Update last observed at by access token.
|
||||
UpdateLastObservedAtByAccessToken(context.Context, []map[string]any) error
|
||||
}
|
||||
|
||||
@@ -208,35 +208,6 @@ func NewGettableUnmappedModels(items []*UnmappedModel) *GettableUnmappedModels {
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UpdatableLLMPricingRule) UnmarshalJSON(data []byte) error {
|
||||
type Alias UpdatableLLMPricingRule
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*u = UpdatableLLMPricingRule(temp)
|
||||
return u.Validate()
|
||||
}
|
||||
|
||||
// Validate mirrors the collector's pattern check: at least one pattern, none
|
||||
// empty, all valid path.Match globs.
|
||||
func (u *UpdatableLLMPricingRule) Validate() error {
|
||||
if len(u.ModelPattern) == 0 {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodePricingRuleInvalidInput, "model %q: modelPattern must contain at least one pattern", u.Model)
|
||||
}
|
||||
for _, p := range u.ModelPattern {
|
||||
if p == "" {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodePricingRuleInvalidInput, "model %q: modelPattern must not contain an empty pattern", u.Model)
|
||||
}
|
||||
if _, err := path.Match(p, ""); err != nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodePricingRuleInvalidInput, "model %q: modelPattern %q is not a valid glob", u.Model, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewLLMPricingRuleFromUpdatable(u *UpdatableLLMPricingRule, orgID valuer.UUID, userEmail string, now time.Time) *LLMPricingRule {
|
||||
id := valuer.GenerateUUID()
|
||||
if u.ID != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package llmpricingruletypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -127,34 +126,3 @@ func TestGenerateCollectorConfig_EmptyInputPassthrough(t *testing.T) {
|
||||
assert.Equal(t, in, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatableLLMPricingRuleUnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", pattern: `["gpt-4o*", "gpt-4o"]`},
|
||||
{name: "missing", pattern: ``, wantErr: true},
|
||||
{name: "null", pattern: `null`, wantErr: true},
|
||||
{name: "empty_list", pattern: `[]`, wantErr: true},
|
||||
{name: "empty_entry", pattern: `["gpt-4o*", ""]`, wantErr: true},
|
||||
{name: "bad_glob", pattern: `["gpt-["]`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := `{"modelName": "gpt-4o"}`
|
||||
if tc.pattern != "" {
|
||||
body = `{"modelName": "gpt-4o", "modelPattern": ` + tc.pattern + `}`
|
||||
}
|
||||
var req UpdatableLLMPricingRules
|
||||
err := json.Unmarshal([]byte(`{"rules": [`+body+`]}`), &req)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,37 @@
|
||||
package telemetrystoretypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
// NestedJSON decodes a native JSON column into a nested document via the driver's own marshaler, so
|
||||
// arrays of objects and typed sub-paths survive and Dynamic values arrive unwrapped. A key stored as
|
||||
// both a scalar and an object collapses, as the nested form cannot hold both.
|
||||
func NestedJSON(j chcol.JSON) map[string]any {
|
||||
raw, err := j.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out map[string]any
|
||||
if err := sonic.Unmarshal(raw, &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
var ErrCodeUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
|
||||
|
||||
// FlattenJSON decodes a native JSON column into its leaf paths as dotted keys, so a key stored as
|
||||
// both a scalar and an object survives as two distinct keys — unlike the nested form, which cannot
|
||||
// hold both. Dynamic values arrive unwrapped, arrays of objects intact.
|
||||
func FlattenJSON(j chcol.JSON) map[string]any {
|
||||
paths := j.ValuesByPath()
|
||||
out := make(map[string]any, len(paths))
|
||||
for path, value := range paths {
|
||||
out[path] = decodePathValue(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
// JSONValue is the scan target for a ClickHouse JSON column: the connection sets
|
||||
// output_format_native_write_json_as_string, so the column arrives as a raw document rather than
|
||||
// the chcol.JSON the driver reports as its scan type.
|
||||
type JSONValue map[string]any
|
||||
|
||||
func decodePathValue(value any) any {
|
||||
variant, ok := value.(chcol.Variant)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
raw, err := json.Marshal(variant)
|
||||
if err != nil {
|
||||
// Scan decodes into a fresh map every time: a scan target is reused across rows, and unmarshalling
|
||||
// into the map already there would both keep its keys and hand every row the same map.
|
||||
func (v *JSONValue) Scan(src any) error {
|
||||
var raw []byte
|
||||
switch value := src.(type) {
|
||||
case nil:
|
||||
*v = nil
|
||||
return nil
|
||||
case string:
|
||||
raw = []byte(value)
|
||||
case []byte:
|
||||
raw = value
|
||||
default:
|
||||
return errors.NewInternalf(ErrCodeUnmarshalJSONColumn, "cannot decode %T as a JSON column", src)
|
||||
}
|
||||
var out any
|
||||
if err := sonic.Unmarshal(raw, &out); err != nil {
|
||||
return nil
|
||||
|
||||
decoded := JSONValue{}
|
||||
if err := sonic.Unmarshal(raw, &decoded); err != nil {
|
||||
return errors.WrapInternalf(err, ErrCodeUnmarshalJSONColumn, "failed to unmarshal JSON column")
|
||||
}
|
||||
return out
|
||||
*v = decoded
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package telemetrystoretypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNestedJSON(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
paths map[string]any
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "Empty",
|
||||
paths: nil,
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "FlatScalars",
|
||||
paths: map[string]any{"level": "error", "status": int64(500)},
|
||||
want: map[string]any{"level": "error", "status": float64(500)},
|
||||
},
|
||||
{
|
||||
name: "DottedPathsBecomeNested",
|
||||
paths: map[string]any{"attrs.code": int64(500), "attrs.path": "/checkout"},
|
||||
want: map[string]any{"attrs": map[string]any{"code": float64(500), "path": "/checkout"}},
|
||||
},
|
||||
{
|
||||
name: "ArrayOfObjectsPreserved",
|
||||
paths: map[string]any{"education": []any{map[string]any{"name": "IIT"}}},
|
||||
want: map[string]any{"education": []any{map[string]any{"name": "IIT"}}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
j := chcol.NewJSON()
|
||||
for path, value := range testCase.paths {
|
||||
j.SetValueAtPath(path, value)
|
||||
}
|
||||
assert.Equal(t, testCase.want, NestedJSON(*j))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenJSON(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
paths map[string]any
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "Empty",
|
||||
paths: nil,
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "DottedPathsStayFlat",
|
||||
paths: map[string]any{"http.method": "GET", "level": "error"},
|
||||
want: map[string]any{"http.method": "GET", "level": "error"},
|
||||
},
|
||||
{
|
||||
// A scalar and an object under the same prefix survive as two distinct dotted keys.
|
||||
name: "ScalarAndObjectKey_BothSurvive",
|
||||
paths: map[string]any{"scope": "x", "scope.attributes.name": "y"},
|
||||
want: map[string]any{"scope": "x", "scope.attributes.name": "y"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
j := chcol.NewJSON()
|
||||
for path, value := range testCase.paths {
|
||||
j.SetValueAtPath(path, value)
|
||||
}
|
||||
assert.Equal(t, testCase.want, FlattenJSON(*j))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -130,17 +130,3 @@ def test_bulk_sync(
|
||||
assert all(r["pricing"]["input"] == 5 for r in stored)
|
||||
|
||||
delete_all_llm_pricing_rules(signoz, token)
|
||||
|
||||
|
||||
def test_rejects_rule_without_pattern(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
delete_all_llm_pricing_rules(signoz, token)
|
||||
|
||||
rules = zeus_rules(10)
|
||||
rules[1]["modelPattern"] = []
|
||||
assert upsert_llm_pricing_rules(signoz, token, rules).status_code == HTTPStatus.BAD_REQUEST
|
||||
assert list_llm_pricing_rules(signoz, token) == []
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
|
||||
|
||||
def test_last_observed_at_is_flushed(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
|
||||
"""Verify the tokenizer GC persists the cached last observed at of a used token to the sql store."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
row = conn.execute(
|
||||
sql.text("SELECT last_observed_at FROM auth_token WHERE access_token = :access_token"),
|
||||
{"access_token": token},
|
||||
).fetchone()
|
||||
|
||||
if row is not None and row[0] is not None:
|
||||
return
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
raise AssertionError("last_observed_at was not flushed to the sql store within 30s")
|
||||
@@ -1,33 +0,0 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz_passwordauthn(
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
"""
|
||||
Package-scoped fixture for SigNoz with a short tokenizer GC interval so the last observed at flush runs within a test.
|
||||
"""
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz-passwordauthn",
|
||||
env_overrides={
|
||||
"SIGNOZ_TOKENIZER_OPAQUE_GC_INTERVAL": "5s",
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user