mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-05 20:50:45 +01:00
Compare commits
7 Commits
issue_5601
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb326a9146 | ||
|
|
ed287be741 | ||
|
|
52d1731d48 | ||
|
|
feeee00791 | ||
|
|
7633845f27 | ||
|
|
06dd0ed3d3 | ||
|
|
a6ac14344e |
@@ -24,6 +24,8 @@
|
||||
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
|
||||
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
|
||||
|
||||
"field_slack_recipient": "Recipient",
|
||||
"field_slack_title": "Title",
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
"tooltip_opsgenie_api_key": "Learn how to obtain the API key from your OpsGenie account [here](https://support.atlassian.com/opsgenie/docs/integrate-opsgenie-with-prometheus/).",
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to. It must be an https URL on chat.googleapis.com.",
|
||||
"google_chat_webhook_url_invalid": "Webhook URL must be an https URL on chat.googleapis.com",
|
||||
"field_slack_recipient": "Recipient",
|
||||
"field_slack_title": "Title",
|
||||
"field_slack_description": "Description",
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import { GoogleChatInitialConfig } from 'container/CreateAlertChannels/defaults';
|
||||
import {
|
||||
googleChatDescriptionDefaultValue,
|
||||
googleChatTitleDefaultValue,
|
||||
opsGenieDescriptionDefaultValue,
|
||||
opsGenieMessageDefaultValue,
|
||||
opsGeniePriorityDefaultValue,
|
||||
pagerDutyAdditionalDetailsDefaultValue,
|
||||
pagerDutyDescriptionDefaultVaule,
|
||||
pagerDutyDescriptionDefaultValue,
|
||||
pagerDutySeverityTextDefaultValue,
|
||||
slackDescriptionDefaultValue,
|
||||
slackTitleDefaultValue,
|
||||
} from 'mocks-server/__mockdata__/alerts';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import { testLabelInputAndHelpValue } from './testUtils';
|
||||
|
||||
@@ -225,7 +235,7 @@ describe('Create Alert Channel', () => {
|
||||
);
|
||||
|
||||
expect(descriptionTextArea).toHaveTextContent(
|
||||
pagerDutyDescriptionDefaultVaule,
|
||||
pagerDutyDescriptionDefaultValue,
|
||||
);
|
||||
});
|
||||
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
|
||||
@@ -419,5 +429,150 @@ describe('Create Alert Channel', () => {
|
||||
expect(descriptionTextArea).toHaveTextContent(slackDescriptionDefaultValue);
|
||||
});
|
||||
});
|
||||
describe('Google Chat', () => {
|
||||
const validWebhookUrl =
|
||||
'https://chat.googleapis.com/v1/spaces/AAAA/messages?key=dummy_key&token=dummy_token';
|
||||
|
||||
beforeEach(() => {
|
||||
render(<CreateAlertChannels preType={ChannelType.GoogleChat} />);
|
||||
});
|
||||
|
||||
it('Should check if the selected item in the type dropdown has text "Google Chat"', () => {
|
||||
expect(screen.getByText('Google Chat')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should check if Webhook URL label and input are displayed properly', () => {
|
||||
testLabelInputAndHelpValue({
|
||||
labelText: 'field_webhook_url',
|
||||
testId: 'webhook-url-textbox',
|
||||
});
|
||||
});
|
||||
|
||||
it('Should check if Title contains the google chat template', () => {
|
||||
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
|
||||
googleChatTitleDefaultValue,
|
||||
);
|
||||
});
|
||||
|
||||
it('Should check if Description contains the google chat template', () => {
|
||||
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
|
||||
googleChatDescriptionDefaultValue,
|
||||
);
|
||||
});
|
||||
|
||||
it('Should check if saving with a webhook url outside chat.googleapis.com displays error notification', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('channel-name-textbox'),
|
||||
'gchat-channel',
|
||||
);
|
||||
await user.type(
|
||||
screen.getByTestId('webhook-url-textbox'),
|
||||
'https://example.com/webhook',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(errorNotification).toHaveBeenCalledWith({
|
||||
message: 'Error',
|
||||
description: 'google_chat_webhook_url_invalid',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should check if saving sends a googlechat_configs payload', async () => {
|
||||
let requestBody: unknown;
|
||||
server.use(
|
||||
rest.post('http://localhost/api/v1/channels', async (req, res, ctx) => {
|
||||
requestBody = await req.json();
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: 'channel created' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('channel-name-textbox'),
|
||||
'gchat-channel',
|
||||
);
|
||||
await user.type(screen.getByTestId('webhook-url-textbox'), validWebhookUrl);
|
||||
|
||||
await user.click(screen.getByTestId('save-channel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(successNotification).toHaveBeenCalledWith({
|
||||
message: 'Success',
|
||||
description: 'channel_creation_done',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(requestBody).toStrictEqual({
|
||||
name: 'gchat-channel',
|
||||
googlechat_configs: [
|
||||
{
|
||||
webhook_url: validWebhookUrl,
|
||||
title: GoogleChatInitialConfig.title,
|
||||
text: GoogleChatInitialConfig.text,
|
||||
send_resolved: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Changing the channel type', () => {
|
||||
async function selectType(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
optionText: string,
|
||||
): Promise<void> {
|
||||
// the type dropdown opens on the inner search input of the antd select
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
await user.click(await screen.findByTitle(optionText));
|
||||
}
|
||||
|
||||
it('Should check if switching to Google Chat and back swaps the prefilled templates', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateAlertChannels preType={ChannelType.Slack} />);
|
||||
|
||||
await selectType(user, 'Google Chat');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
|
||||
googleChatTitleDefaultValue,
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
|
||||
googleChatDescriptionDefaultValue,
|
||||
);
|
||||
|
||||
await selectType(user, 'Slack');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('title-textarea')).toHaveTextContent(
|
||||
slackTitleDefaultValue,
|
||||
),
|
||||
);
|
||||
expect(screen.getByTestId('description-textarea')).toHaveTextContent(
|
||||
slackDescriptionDefaultValue,
|
||||
);
|
||||
});
|
||||
|
||||
it('Should check if switching to Pagerduty prefills the pagerduty description and not the opsgenie one', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateAlertChannels preType={ChannelType.Opsgenie} />);
|
||||
|
||||
await selectType(user, 'Pagerduty');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('pager-description-textarea')).toHaveTextContent(
|
||||
pagerDutyDescriptionDefaultValue,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
opsGenieMessageDefaultValue,
|
||||
opsGeniePriorityDefaultValue,
|
||||
pagerDutyAdditionalDetailsDefaultValue,
|
||||
pagerDutyDescriptionDefaultVaule,
|
||||
pagerDutyDescriptionDefaultValue,
|
||||
pagerDutySeverityTextDefaultValue,
|
||||
slackDescriptionDefaultValue,
|
||||
slackTitleDefaultValue,
|
||||
@@ -150,7 +150,7 @@ describe('Create Alert Channel (Normal User)', () => {
|
||||
);
|
||||
|
||||
expect(descriptionTextArea).toHaveTextContent(
|
||||
pagerDutyDescriptionDefaultVaule,
|
||||
pagerDutyDescriptionDefaultValue,
|
||||
);
|
||||
});
|
||||
it('Should check if Severity label, info (help_pager_severity), and textbox are displayed properly', () => {
|
||||
|
||||
@@ -104,6 +104,7 @@ export enum ChannelType {
|
||||
Pagerduty = 'pagerduty',
|
||||
Opsgenie = 'opsgenie',
|
||||
MsTeams = 'msteams',
|
||||
GoogleChat = 'googlechat',
|
||||
}
|
||||
|
||||
// LabelFilterStatement will be used for preparing filter conditions / matchers
|
||||
@@ -125,3 +126,11 @@ export interface MsTeamsChannel extends Channel {
|
||||
title?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface GoogleChatChannel extends Channel {
|
||||
// incoming webhook url of the google chat space, must be an
|
||||
// https url on chat.googleapis.com
|
||||
webhook_url?: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
import { EmailChannel, OpsgenieChannel, PagerChannel } from './config';
|
||||
import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
WebhookChannel,
|
||||
} from './config';
|
||||
|
||||
// shared by slack and ms teams, both render the same title / description boxes
|
||||
export const SlackInitialConfig: Partial<SlackChannel> = {
|
||||
text: `{{ range .Alerts -}}
|
||||
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
|
||||
|
||||
*Summary:* {{ .Annotations.summary }}
|
||||
*Description:* {{ .Annotations.description }}
|
||||
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
|
||||
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
|
||||
|
||||
*Details:*
|
||||
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
|
||||
{{ end }}
|
||||
{{ end }}`,
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
|
||||
{{" "}}(
|
||||
{{- with .CommonLabels.Remove .GroupLabels.Names }}
|
||||
{{- range $index, $label := .SortedPairs -}}
|
||||
{{ if $index }}, {{ end }}
|
||||
{{- $label.Name }}="{{ $label.Value -}}"
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
)
|
||||
{{- end }}`,
|
||||
};
|
||||
|
||||
// mirrors DefaultGoogleChatReceiverConfig in pkg/types/alertmanagertypes/googlechat.go,
|
||||
// which the backend applies when title / text are left empty
|
||||
export const GoogleChatInitialConfig: Partial<GoogleChatChannel> = {
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
text: `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
|
||||
**Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
|
||||
**Description:** {{ .Annotations.description }}{{ end }}
|
||||
{{ end }}`,
|
||||
};
|
||||
|
||||
export const PagerInitialConfig: Partial<PagerChannel> = {
|
||||
description: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||
@@ -446,3 +493,26 @@ export const EmailInitialConfig: Partial<EmailChannel> = {
|
||||
</body>
|
||||
</html>`,
|
||||
};
|
||||
|
||||
// prefilled values of every channel type, keyed by type so the form can apply
|
||||
// exactly one set of defaults and swap it when the type changes
|
||||
export const ChannelInitialConfig: Record<
|
||||
ChannelType,
|
||||
Partial<
|
||||
SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel &
|
||||
GoogleChatChannel
|
||||
>
|
||||
> = {
|
||||
[ChannelType.Slack]: SlackInitialConfig,
|
||||
[ChannelType.MsTeams]: SlackInitialConfig,
|
||||
[ChannelType.GoogleChat]: GoogleChatInitialConfig,
|
||||
[ChannelType.Pagerduty]: PagerInitialConfig,
|
||||
[ChannelType.Opsgenie]: OpsgenieInitialConfig,
|
||||
[ChannelType.Email]: EmailInitialConfig,
|
||||
[ChannelType.Webhook]: {},
|
||||
};
|
||||
|
||||
@@ -14,16 +14,24 @@ import testPagerApi from 'api/channels/testPager';
|
||||
import testSlackApi from 'api/channels/testSlack';
|
||||
import testWebhookApi from 'api/channels/testWebhook';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
useCreateChannel,
|
||||
useTestChannel,
|
||||
} from 'api/generated/services/channels';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import FormAlertChannels from 'container/FormAlertChannels';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
@@ -31,12 +39,12 @@ import {
|
||||
ValidatePagerChannel,
|
||||
WebhookChannel,
|
||||
} from './config';
|
||||
import { ChannelInitialConfig } from './defaults';
|
||||
import {
|
||||
EmailInitialConfig,
|
||||
OpsgenieInitialConfig,
|
||||
PagerInitialConfig,
|
||||
} from './defaults';
|
||||
import { isChannelType } from './utils';
|
||||
isChannelType,
|
||||
isValidGoogleChatWebhookURL,
|
||||
prepareGoogleChatRequest,
|
||||
} from './utils';
|
||||
|
||||
import './CreateAlertChannels.styles.scss';
|
||||
|
||||
@@ -60,69 +68,38 @@ function CreateAlertChannels({
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel
|
||||
EmailChannel &
|
||||
GoogleChatChannel
|
||||
>
|
||||
>({
|
||||
>(() => ({
|
||||
send_resolved: true,
|
||||
text: `{{ range .Alerts -}}
|
||||
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}
|
||||
|
||||
*Summary:* {{ .Annotations.summary }}
|
||||
*Description:* {{ .Annotations.description }}
|
||||
*RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}
|
||||
*RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}
|
||||
|
||||
*Details:*
|
||||
{{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }}
|
||||
{{ end }}
|
||||
{{ end }}`,
|
||||
title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}
|
||||
{{- if gt (len .CommonLabels) (len .GroupLabels) -}}
|
||||
{{" "}}(
|
||||
{{- with .CommonLabels.Remove .GroupLabels.Names }}
|
||||
{{- range $index, $label := .SortedPairs -}}
|
||||
{{ if $index }}, {{ end }}
|
||||
{{- $label.Name }}="{{ $label.Value -}}"
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
)
|
||||
{{- end }}`,
|
||||
});
|
||||
...ChannelInitialConfig[preType],
|
||||
}));
|
||||
const [savingState, setSavingState] = useState<boolean>(false);
|
||||
const [testingState, setTestingState] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { mutateAsync: createChannel } = useCreateChannel();
|
||||
const { mutateAsync: testChannel } = useTestChannel();
|
||||
|
||||
const [type, setType] = useState<ChannelType>(preType);
|
||||
const onTypeChangeHandler = useCallback(
|
||||
(value: string) => {
|
||||
const currentType = type;
|
||||
setType(value as ChannelType);
|
||||
|
||||
if (value === ChannelType.Pagerduty && currentType !== value) {
|
||||
// reset config to pager defaults
|
||||
setSelectedConfig({
|
||||
name: selectedConfig?.name,
|
||||
send_resolved: selectedConfig.send_resolved,
|
||||
...PagerInitialConfig,
|
||||
});
|
||||
const nextType = value as ChannelType;
|
||||
if (nextType === type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === ChannelType.Opsgenie && currentType !== value) {
|
||||
setSelectedConfig((selectedConfig) => ({
|
||||
...selectedConfig,
|
||||
...OpsgenieInitialConfig,
|
||||
}));
|
||||
}
|
||||
setType(nextType);
|
||||
|
||||
// reset config to email defaults
|
||||
if (value === ChannelType.Email && currentType !== value) {
|
||||
setSelectedConfig((selectedConfig) => ({
|
||||
...selectedConfig,
|
||||
...EmailInitialConfig,
|
||||
}));
|
||||
}
|
||||
// the fields the types share (title, text, description) keep the value of
|
||||
// the type that was selected before, so the new type's defaults have to be
|
||||
// written to both the config and the form
|
||||
const defaults = ChannelInitialConfig[nextType];
|
||||
setSelectedConfig((selectedConfig) => ({ ...selectedConfig, ...defaults }));
|
||||
formInstance.setFieldsValue(defaults);
|
||||
},
|
||||
[type, selectedConfig],
|
||||
[type, formInstance],
|
||||
);
|
||||
|
||||
const prepareSlackRequest = useCallback(
|
||||
@@ -407,6 +384,56 @@ function CreateAlertChannels({
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const validateGoogleChatConfig = useCallback((): boolean => {
|
||||
if (!selectedConfig.webhook_url) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('webhook_url_required'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: t('google_chat_webhook_url_invalid'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [selectedConfig.webhook_url, notifications, t]);
|
||||
|
||||
const onGoogleChatHandler = useCallback(async () => {
|
||||
if (!validateGoogleChatConfig()) {
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await createChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_creation_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_creation_done') };
|
||||
} catch (error) {
|
||||
showErrorModal(toAPIError(error as ErrorType<RenderErrorResponseDTO>));
|
||||
return { status: 'failed', statusMessage: t('channel_creation_failed') };
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateGoogleChatConfig,
|
||||
createChannel,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
t,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
if (!selectedConfig.name) {
|
||||
@@ -424,6 +451,7 @@ function CreateAlertChannels({
|
||||
[ChannelType.Opsgenie]: onOpsgenieHandler,
|
||||
[ChannelType.MsTeams]: onMsTeamsHandler,
|
||||
[ChannelType.Email]: onEmailHandler,
|
||||
[ChannelType.GoogleChat]: onGoogleChatHandler,
|
||||
};
|
||||
|
||||
if (isChannelType(value)) {
|
||||
@@ -455,6 +483,7 @@ function CreateAlertChannels({
|
||||
onOpsgenieHandler,
|
||||
onMsTeamsHandler,
|
||||
onEmailHandler,
|
||||
onGoogleChatHandler,
|
||||
notifications,
|
||||
t,
|
||||
],
|
||||
@@ -492,6 +521,13 @@ function CreateAlertChannels({
|
||||
request = prepareEmailRequest();
|
||||
await testEmail(request);
|
||||
break;
|
||||
case ChannelType.GoogleChat:
|
||||
if (!validateGoogleChatConfig()) {
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
break;
|
||||
default:
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
@@ -513,7 +549,11 @@ function CreateAlertChannels({
|
||||
status: 'Test success',
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
showErrorModal(
|
||||
error instanceof APIError
|
||||
? error
|
||||
: toAPIError(error as ErrorType<RenderErrorResponseDTO>),
|
||||
);
|
||||
|
||||
logEvent('Alert Channel: Test notification', {
|
||||
type: channelType,
|
||||
@@ -535,6 +575,8 @@ function CreateAlertChannels({
|
||||
prepareSlackRequest,
|
||||
prepareMsTeamsRequest,
|
||||
prepareEmailRequest,
|
||||
validateGoogleChatConfig,
|
||||
testChannel,
|
||||
notifications,
|
||||
],
|
||||
);
|
||||
@@ -562,9 +604,6 @@ function CreateAlertChannels({
|
||||
initialValue: {
|
||||
type,
|
||||
...selectedConfig,
|
||||
...PagerInitialConfig,
|
||||
...OpsgenieInitialConfig,
|
||||
...EmailInitialConfig,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,39 @@
|
||||
import { ChannelType } from './config';
|
||||
import {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
ConfigSecretURLDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelType, GoogleChatChannel } from './config';
|
||||
|
||||
export const isChannelType = (type: string): type is ChannelType =>
|
||||
Object.values(ChannelType).includes(type as ChannelType);
|
||||
|
||||
const GOOGLE_CHAT_WEBHOOK_HOST = 'chat.googleapis.com';
|
||||
|
||||
// the backend enforces the same two rules, this is only for a nicer error experience
|
||||
export const isValidGoogleChatWebhookURL = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol, hostname } = new URL(url);
|
||||
return (
|
||||
protocol === 'https:' && hostname.toLowerCase() === GOOGLE_CHAT_WEBHOOK_HOST
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// create, update and test all send the same body shape
|
||||
export const prepareGoogleChatRequest = (
|
||||
config: Partial<GoogleChatChannel>,
|
||||
): AlertmanagertypesPostableChannelDTO => ({
|
||||
name: config.name || '',
|
||||
googlechat_configs: [
|
||||
{
|
||||
// the generated type models go's config.SecretURL as an object, the api takes a string
|
||||
webhook_url: (config.webhook_url || '') as unknown as ConfigSecretURLDTO,
|
||||
title: config.title || '',
|
||||
text: config.text || '',
|
||||
send_resolved: config.send_resolved || false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -14,10 +14,17 @@ import testPagerApi from 'api/channels/testPager';
|
||||
import testSlackApi from 'api/channels/testSlack';
|
||||
import testWebhookApi from 'api/channels/testWebhook';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
useTestChannel,
|
||||
useUpdateChannelByID,
|
||||
} from 'api/generated/services/channels';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
MsTeamsChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
@@ -25,10 +32,15 @@ import {
|
||||
ValidatePagerChannel,
|
||||
WebhookChannel,
|
||||
} from 'container/CreateAlertChannels/config';
|
||||
import {
|
||||
isValidGoogleChatWebhookURL,
|
||||
prepareGoogleChatRequest,
|
||||
} from 'container/CreateAlertChannels/utils';
|
||||
import FormAlertChannels from 'container/FormAlertChannels';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
function EditAlertChannels({
|
||||
initialValue,
|
||||
@@ -45,7 +57,8 @@ function EditAlertChannels({
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel
|
||||
EmailChannel &
|
||||
GoogleChatChannel
|
||||
>
|
||||
>({
|
||||
...initialValue,
|
||||
@@ -54,6 +67,26 @@ function EditAlertChannels({
|
||||
const [testingState, setTestingState] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { mutateAsync: updateChannel } = useUpdateChannelByID();
|
||||
const { mutateAsync: testChannel } = useTestChannel();
|
||||
|
||||
const notifyError = useCallback(
|
||||
(error: unknown): APIError => {
|
||||
const apiError =
|
||||
error instanceof APIError
|
||||
? error
|
||||
: toAPIError(error as ErrorType<RenderErrorResponseDTO>);
|
||||
|
||||
notifications.error({
|
||||
message: apiError.getErrorCode(),
|
||||
description: apiError.getErrorMessage(),
|
||||
});
|
||||
|
||||
return apiError;
|
||||
},
|
||||
[notifications],
|
||||
);
|
||||
|
||||
const [type, setType] = useState<ChannelType>(
|
||||
initialValue?.type ? (initialValue.type as ChannelType) : ChannelType.Slack,
|
||||
);
|
||||
@@ -364,6 +397,61 @@ function EditAlertChannels({
|
||||
}
|
||||
}, [prepareMsTeamsRequest, t, notifications, selectedConfig]);
|
||||
|
||||
const validateGoogleChatConfig = useCallback((): string => {
|
||||
if (!selectedConfig?.webhook_url) {
|
||||
return t('webhook_url_required');
|
||||
}
|
||||
|
||||
if (!isValidGoogleChatWebhookURL(selectedConfig.webhook_url)) {
|
||||
return t('google_chat_webhook_url_invalid');
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [selectedConfig, t]);
|
||||
|
||||
const onGoogleChatEditHandler = useCallback(async () => {
|
||||
const validationError = validateGoogleChatConfig();
|
||||
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
return { status: 'failed', statusMessage: validationError };
|
||||
}
|
||||
|
||||
setSavingState(true);
|
||||
|
||||
try {
|
||||
await updateChannel({
|
||||
pathParams: { id },
|
||||
data: prepareGoogleChatRequest(selectedConfig),
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Success',
|
||||
description: t('channel_edit_done'),
|
||||
});
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
return { status: 'success', statusMessage: t('channel_edit_done') };
|
||||
} catch (error) {
|
||||
const apiError = notifyError(error);
|
||||
return {
|
||||
status: 'failed',
|
||||
statusMessage: apiError.getErrorMessage() || t('channel_edit_failed'),
|
||||
};
|
||||
} finally {
|
||||
setSavingState(false);
|
||||
}
|
||||
}, [
|
||||
validateGoogleChatConfig,
|
||||
updateChannel,
|
||||
id,
|
||||
selectedConfig,
|
||||
notifications,
|
||||
notifyError,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(
|
||||
async (value: ChannelType) => {
|
||||
let result;
|
||||
@@ -379,6 +467,8 @@ function EditAlertChannels({
|
||||
result = await onOpsgenieEditHandler();
|
||||
} else if (value === ChannelType.Email) {
|
||||
result = await onEmailEditHandler();
|
||||
} else if (value === ChannelType.GoogleChat) {
|
||||
result = await onGoogleChatEditHandler();
|
||||
}
|
||||
logEvent('Alert Channel: Save channel', {
|
||||
type: value,
|
||||
@@ -397,6 +487,7 @@ function EditAlertChannels({
|
||||
onMsTeamsEditHandler,
|
||||
onOpsgenieEditHandler,
|
||||
onEmailEditHandler,
|
||||
onGoogleChatEditHandler,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -438,6 +529,19 @@ function EditAlertChannels({
|
||||
await testEmail(request);
|
||||
}
|
||||
break;
|
||||
case ChannelType.GoogleChat: {
|
||||
const validationError = validateGoogleChatConfig();
|
||||
if (validationError !== '') {
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
description: validationError,
|
||||
});
|
||||
setTestingState(false);
|
||||
return;
|
||||
}
|
||||
await testChannel({ data: prepareGoogleChatRequest(selectedConfig) });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
notifications.error({
|
||||
message: 'Error',
|
||||
@@ -459,10 +563,7 @@ function EditAlertChannels({
|
||||
status: 'Test success',
|
||||
});
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
notifyError(error);
|
||||
logEvent('Alert Channel: Test notification', {
|
||||
type: channelType,
|
||||
sendResolvedAlert: selectedConfig?.send_resolved,
|
||||
@@ -476,6 +577,9 @@ function EditAlertChannels({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
t,
|
||||
notifyError,
|
||||
validateGoogleChatConfig,
|
||||
testChannel,
|
||||
prepareWebhookRequest,
|
||||
preparePagerRequest,
|
||||
prepareSlackRequest,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input } from 'antd';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { GoogleChatChannel } from '../../CreateAlertChannels/config';
|
||||
import { isValidGoogleChatWebhookURL } from '../../CreateAlertChannels/utils';
|
||||
|
||||
function GoogleChat({ setSelectedConfig }: GoogleChatProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="webhook_url"
|
||||
label={t('field_webhook_url')}
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value: string): Promise<void> =>
|
||||
!value || isValidGoogleChatWebhookURL(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error(t('google_chat_webhook_url_invalid'))),
|
||||
},
|
||||
]}
|
||||
tooltip={{
|
||||
title: (
|
||||
<MarkdownRenderer
|
||||
markdownContent={t('tooltip_google_chat_url')}
|
||||
variables={{}}
|
||||
/>
|
||||
),
|
||||
overlayInnerStyle: { maxWidth: 400 },
|
||||
placement: 'right',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
onChange={(event): void => {
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
webhook_url: event.target.value,
|
||||
}));
|
||||
}}
|
||||
data-testid="webhook-url-textbox"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="title" label={t('field_slack_title')}>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="title-textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="text" label={t('field_slack_description')}>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
onChange={(event): void =>
|
||||
setSelectedConfig((value) => ({
|
||||
...value,
|
||||
text: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="description-textarea"
|
||||
placeholder={t('placeholder_slack_description')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface GoogleChatProps {
|
||||
setSelectedConfig: Dispatch<SetStateAction<Partial<GoogleChatChannel>>>;
|
||||
}
|
||||
|
||||
export default GoogleChat;
|
||||
@@ -9,6 +9,7 @@ import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ChannelType,
|
||||
EmailChannel,
|
||||
GoogleChatChannel,
|
||||
OpsgenieChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import history from 'lib/history';
|
||||
|
||||
import EmailSettings from './Settings/Email';
|
||||
import GoogleChatSettings from './Settings/GoogleChat';
|
||||
import MsTeamsSettings from './Settings/MsTeams';
|
||||
import OpsgenieSettings from './Settings/Opsgenie';
|
||||
import PagerSettings from './Settings/Pager';
|
||||
@@ -49,6 +51,8 @@ function FormAlertChannels({
|
||||
return <PagerSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.MsTeams:
|
||||
return <MsTeamsSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.GoogleChat:
|
||||
return <GoogleChatSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Opsgenie:
|
||||
return <OpsgenieSettings setSelectedConfig={setSelectedConfig} />;
|
||||
case ChannelType.Email:
|
||||
@@ -129,6 +133,14 @@ function FormAlertChannels({
|
||||
<Select.Option value="msteams" key="msteams" data-testid="select-option">
|
||||
Microsoft Teams
|
||||
</Select.Option>
|
||||
|
||||
<Select.Option
|
||||
value="googlechat"
|
||||
key="googlechat"
|
||||
data-testid="select-option"
|
||||
>
|
||||
Google Chat
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
@@ -176,7 +188,8 @@ interface FormAlertChannelsProps {
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
OpsgenieChannel &
|
||||
EmailChannel
|
||||
EmailChannel &
|
||||
GoogleChatChannel
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -27,9 +27,13 @@ export const slackTitleDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status
|
||||
|
||||
export const slackDescriptionDefaultValue = `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} *Summary:* {{ .Annotations.summary }} *Description:* {{ .Annotations.description }} *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}} *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}} *Details:* {{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }} {{ end }} {{ end }}`;
|
||||
|
||||
export const googleChatTitleDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`;
|
||||
|
||||
export const googleChatDescriptionDefaultValue = `{{ range .Alerts -}} **Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }} **Summary:** {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }} **Description:** {{ .Annotations.description }}{{ end }} {{ end }}`;
|
||||
|
||||
export const editSlackDescriptionDefaultValue = `{{ range .Alerts -}} *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }} dummy_summary *Summary:* {{ .Annotations.summary }} *Description:* {{ .Annotations.description }} *Details:* {{ range .Labels.SortedPairs }} • *{{ .Name }}:* {{ .Value }} {{ end }} {{ end }}`;
|
||||
|
||||
export const pagerDutyDescriptionDefaultVaule = `{{ if gt (len .Alerts.Firing) 0 -}} Alerts Firing: {{ range .Alerts.Firing }} - Message: {{ .Annotations.description }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Annotations: {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Source: {{ .GeneratorURL }} {{ end }} {{- end }} {{ if gt (len .Alerts.Resolved) 0 -}} Alerts Resolved: {{ range .Alerts.Resolved }} - Message: {{ .Annotations.description }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Annotations: {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Source: {{ .GeneratorURL }} {{ end }} {{- end }}`;
|
||||
export const pagerDutyDescriptionDefaultValue = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }} {{- if gt (len .CommonLabels) (len .GroupLabels) -}} {{" "}}( {{- with .CommonLabels.Remove .GroupLabels.Names }} {{- range $index, $label := .SortedPairs -}} {{ if $index }}, {{ end }} {{- $label.Name }}="{{ $label.Value -}}" {{- end }} {{- end -}} ) {{- end }}`;
|
||||
|
||||
export const pagerDutyAdditionalDetailsDefaultValue = JSON.stringify({
|
||||
firing: `{{ .Alerts.Firing | toJson }}`,
|
||||
|
||||
@@ -10,6 +10,7 @@ import Spinner from 'components/Spinner';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ChannelType,
|
||||
GoogleChatChannel,
|
||||
MsTeamsChannel,
|
||||
PagerChannel,
|
||||
SlackChannel,
|
||||
@@ -59,11 +60,20 @@ function ChannelsEdit(): JSX.Element {
|
||||
|
||||
const prepChannelConfig = (): {
|
||||
type: string;
|
||||
channel: SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel;
|
||||
channel: SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
GoogleChatChannel;
|
||||
} => {
|
||||
let channel: SlackChannel & WebhookChannel & PagerChannel & MsTeamsChannel = {
|
||||
let channel: SlackChannel &
|
||||
WebhookChannel &
|
||||
PagerChannel &
|
||||
MsTeamsChannel &
|
||||
GoogleChatChannel = {
|
||||
name: '',
|
||||
};
|
||||
|
||||
if (value && 'slack_configs' in value) {
|
||||
const slackConfig = value.slack_configs[0];
|
||||
channel = slackConfig;
|
||||
@@ -81,6 +91,16 @@ function ChannelsEdit(): JSX.Element {
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'googlechat_configs' in value) {
|
||||
const [googleChatConfig] = value.googlechat_configs;
|
||||
channel = googleChatConfig;
|
||||
return {
|
||||
type: ChannelType.GoogleChat,
|
||||
channel,
|
||||
};
|
||||
}
|
||||
|
||||
if (value && 'pagerduty_configs' in value) {
|
||||
const pagerConfig = value.pagerduty_configs[0];
|
||||
channel = pagerConfig;
|
||||
|
||||
@@ -182,4 +182,56 @@ describe('ValueSelector', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('opening and closing without touching the list', () => {
|
||||
function renderWith(
|
||||
selection: VariableSelection,
|
||||
options: string[],
|
||||
): jest.Mock {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ValueSelector
|
||||
options={options}
|
||||
variableType="dynamic"
|
||||
multiSelect
|
||||
showAllOption
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
emptyFallback={{ value: [], allSelected: false }}
|
||||
testId="variable-select-env"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
async function openThenClose(): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const control = screen.getByTestId('variable-select-env');
|
||||
await user.click(control.querySelector('input') as HTMLInputElement);
|
||||
await user.keyboard('{Escape}');
|
||||
}
|
||||
|
||||
it('does not promote a pick that covers every available option to ALL', async () => {
|
||||
// A narrow time range can leave only the selected value in the list. That is
|
||||
// still an explicit pick, not "everything, always".
|
||||
const onChange = renderWith(
|
||||
{ value: ['checkout-service-prod'], allSelected: false },
|
||||
['checkout-service-prod'],
|
||||
);
|
||||
|
||||
await openThenClose();
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not rewrite a dynamic ALL into concrete values', async () => {
|
||||
const onChange = renderWith({ value: null, allSelected: true }, OPTIONS);
|
||||
|
||||
await openThenClose();
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,133 @@ describe('reconcileWithOptions', () => {
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
describe('preserveSelection (options moved on their own — time range, reload)', () => {
|
||||
const multi = model({
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
dynamicAttribute: 'service.name',
|
||||
});
|
||||
|
||||
it('keeps a multi-select pick the new option list no longer offers', () => {
|
||||
expect(
|
||||
reconcileWithOptions(multi, { value: ['frontend'], allSelected: false }, [
|
||||
'backend',
|
||||
'cart',
|
||||
]),
|
||||
).toStrictEqual({ value: null, allSelected: true });
|
||||
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{ value: ['frontend'], allSelected: false },
|
||||
['backend', 'cart'],
|
||||
{ preserveSelection: true },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('still materializes ALL, which must track the option list', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
{ value: ['a'], allSelected: true },
|
||||
['a', 'b'],
|
||||
{ preserveSelection: true },
|
||||
),
|
||||
).toStrictEqual({ value: ['a', 'b'], allSelected: true });
|
||||
});
|
||||
|
||||
it('still fills the default when nothing is selected yet', () => {
|
||||
expect(
|
||||
reconcileWithOptions(multi, { value: [], allSelected: false }, ['a', 'b'], {
|
||||
preserveSelection: true,
|
||||
}),
|
||||
).toStrictEqual({ value: null, allSelected: true });
|
||||
});
|
||||
});
|
||||
|
||||
// A typed value is in no option list, so no refetch can invalidate it.
|
||||
describe('customValues (typed in, never offered by the data)', () => {
|
||||
const multi = model({
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
dynamicAttribute: 'service.name',
|
||||
});
|
||||
|
||||
it('keeps them through a re-scope that drops a fetched value', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{
|
||||
value: ['frontend', 'typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
},
|
||||
['backend', 'cart'],
|
||||
),
|
||||
).toStrictEqual({
|
||||
value: ['typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
});
|
||||
});
|
||||
|
||||
it('never re-defaults a selection made only of them', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{ value: ['typed-in'], allSelected: false, customValues: ['typed-in'] },
|
||||
['backend', 'cart'],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// An inert marker is not worth a store write + dependent refetch to prune.
|
||||
it('leaves a stale marker alone when it drops nothing', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{
|
||||
value: ['frontend', 'typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in', 'removed-earlier'],
|
||||
},
|
||||
['frontend'],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('prunes markers for values it does drop', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{
|
||||
value: ['stale', 'typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
},
|
||||
['frontend'],
|
||||
),
|
||||
).toStrictEqual({
|
||||
value: ['typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
});
|
||||
});
|
||||
|
||||
it('still drops an unmarked value the list no longer offers', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{ value: ['frontend', 'stale'], allSelected: false },
|
||||
['frontend'],
|
||||
),
|
||||
).toStrictEqual({ value: ['frontend'], allSelected: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuredDefaultValue', () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import { selectionFromCommittedValues } from '../utils/selectionUtils';
|
||||
|
||||
const OPTIONS = ['checkout', 'payments', 'cart'];
|
||||
const FALLBACK: VariableSelection = { value: null, allSelected: true };
|
||||
|
||||
function commit(
|
||||
values: string[],
|
||||
overrides: Partial<Parameters<typeof selectionFromCommittedValues>[0]> = {},
|
||||
): VariableSelection {
|
||||
return selectionFromCommittedValues({
|
||||
values,
|
||||
options: OPTIONS,
|
||||
showAllOption: true,
|
||||
emptyFallback: FALLBACK,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// What a multi-select commit resolves to. The option list is known only here, so this
|
||||
// is the one place a typed value can be recognised.
|
||||
describe('selectionFromCommittedValues', () => {
|
||||
it('marks values the option list did not offer as typed in', () => {
|
||||
expect(commit(['checkout', 'typed-in'])).toStrictEqual({
|
||||
value: ['checkout', 'typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a selection made only of typed-in values', () => {
|
||||
expect(commit(['a', 'b'])).toStrictEqual({
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
customValues: ['a', 'b'],
|
||||
});
|
||||
});
|
||||
|
||||
it('records no marker when every pick came from the list', () => {
|
||||
expect(commit(['checkout', 'cart'])).toStrictEqual({
|
||||
value: ['checkout', 'cart'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a set covering every option as ALL', () => {
|
||||
expect(commit(OPTIONS)).toStrictEqual({
|
||||
value: OPTIONS,
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ALL re-materializes to the option set, so recording this as ALL would drop the
|
||||
// typed value on the next refetch.
|
||||
it('does not read every option PLUS a typed value as ALL', () => {
|
||||
expect(commit([...OPTIONS, 'typed-in'])).toStrictEqual({
|
||||
value: [...OPTIONS, 'typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
});
|
||||
});
|
||||
|
||||
// Derived from the values + options at commit time, never from the old selection.
|
||||
it('recomputes the marker: a typed value the data now offers is a normal pick', () => {
|
||||
expect(
|
||||
commit(['checkout', 'was-typed'], {
|
||||
options: [...OPTIONS, 'was-typed'],
|
||||
}),
|
||||
).toStrictEqual({ value: ['checkout', 'was-typed'], allSelected: false });
|
||||
});
|
||||
|
||||
it('does not read it as ALL when the variable offers no ALL', () => {
|
||||
expect(commit(OPTIONS, { showAllOption: false })).toStrictEqual({
|
||||
value: OPTIONS,
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an empty commit to the variable fallback', () => {
|
||||
expect(commit([])).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('marks everything while the options have not arrived', () => {
|
||||
// Nothing to judge against yet; erring this way keeps a value rather than dropping it.
|
||||
expect(commit(['typed-in'], { options: [] })).toStrictEqual({
|
||||
value: ['typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { VariableCycleReason } from '../../store/slices/variableFetchSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import { useAutoSelect } from '../hooks/useAutoSelect';
|
||||
|
||||
@@ -15,7 +17,11 @@ function run(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
selection: VariableSelection,
|
||||
cycleReason?: VariableCycleReason,
|
||||
): VariableSelection | undefined {
|
||||
useDashboardStore.setState({
|
||||
variableCycleReasons: cycleReason ? { [variable.name]: cycleReason } : {},
|
||||
});
|
||||
const onAutoSelect = jest.fn();
|
||||
renderHook(() => useAutoSelect(variable, options, selection, onAutoSelect));
|
||||
return onAutoSelect.mock.calls[0]?.[0];
|
||||
@@ -70,11 +76,13 @@ describe('useAutoSelect', () => {
|
||||
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
|
||||
});
|
||||
|
||||
it('falls back to ALL, not the first option, when every selected value is gone', () => {
|
||||
// Re-scoped options only — a time-range refetch must NOT re-default; see below.
|
||||
it('re-scoped: falls back to ALL, not the first option, when every selected value is gone', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
['x', 'y'],
|
||||
{ value: ['a', 'b'], allSelected: false },
|
||||
VariableCycleReason.ValueCascade,
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
|
||||
});
|
||||
@@ -102,20 +110,23 @@ describe('useAutoSelect', () => {
|
||||
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
|
||||
});
|
||||
|
||||
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
|
||||
it('re-scoped: keeps the still-valid subset of a multi-select', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true }),
|
||||
['a', 'b', 'd'],
|
||||
{ value: ['a', 'b', 'c'], allSelected: false },
|
||||
VariableCycleReason.ValueCascade,
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: false });
|
||||
});
|
||||
|
||||
it('re-defaults a multi-select when none of the selected values remain', () => {
|
||||
const next = run(model({ type: 'QUERY', multiSelect: true }), ['x', 'y'], {
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
it('re-scoped: re-defaults a multi-select when none of the selected values remain', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true }),
|
||||
['x', 'y'],
|
||||
{ value: ['a', 'b'], allSelected: false },
|
||||
VariableCycleReason.ValueCascade,
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['x'], allSelected: false });
|
||||
});
|
||||
|
||||
@@ -151,4 +162,45 @@ describe('useAutoSelect', () => {
|
||||
});
|
||||
expect(next).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('by cycle reason', () => {
|
||||
const service = model({
|
||||
name: 'service',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
dynamicAttribute: 'service.name',
|
||||
});
|
||||
const gone: VariableSelection = { value: ['frontend'], allSelected: false };
|
||||
|
||||
it('keeps the selection when a full cycle refetched the options', () => {
|
||||
// The new window has no data for the selected service — no reason to widen to ALL.
|
||||
const next = run(
|
||||
service,
|
||||
['backend', 'cart'],
|
||||
gone,
|
||||
VariableCycleReason.FullCycle,
|
||||
);
|
||||
expect(next).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-scopes the selection when a value cascade refetched the options', () => {
|
||||
const next = run(
|
||||
service,
|
||||
['backend', 'cart'],
|
||||
gone,
|
||||
VariableCycleReason.ValueCascade,
|
||||
);
|
||||
expect(next).toStrictEqual({ value: null, allSelected: true });
|
||||
});
|
||||
|
||||
it('reconciles a variable with no cycle of its own (custom definition change)', () => {
|
||||
const next = run(
|
||||
model({ name: 'env', type: 'CUSTOM', multiSelect: true }),
|
||||
['staging', 'prod'],
|
||||
{ value: ['dev'], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['staging'], allSelected: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,11 +13,11 @@ jest.mock('nuqs', () => ({
|
||||
useQueryState: (): unknown => [null, jest.fn()],
|
||||
}));
|
||||
|
||||
const mockGlobalTime = { minTime: 1, maxTime: 2, selectedTime: '5m' };
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({
|
||||
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
|
||||
}),
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
|
||||
@@ -150,3 +150,57 @@ describe('useVariableSelection — setSelection', () => {
|
||||
expect(svcCycleId()).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVariableSelection — what a time-range change enqueues', () => {
|
||||
// Longer than FETCH_CYCLE_DEBOUNCE_MS, which the hook keeps private.
|
||||
const PAST_DEBOUNCE = 400;
|
||||
|
||||
function reasons(): Record<string, string> {
|
||||
return useDashboardStore.getState().variableCycleReasons;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockGlobalTime.selectedTime = '5m';
|
||||
useDashboardStore.setState({
|
||||
variableValues: {},
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableCycleReasons: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// The tag is what stops the reconcile re-defaulting a user's selection.
|
||||
it('tags every variable as a full cycle, overriding an earlier cascade tag', () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useVariableSelection(dashboard),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(PAST_DEBOUNCE);
|
||||
});
|
||||
expect(reasons()).toStrictEqual({ env: 'full-cycle', svc: 'full-cycle' });
|
||||
|
||||
// A value change re-scopes the dependent's options: it may drop what no longer applies.
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['prod'], allSelected: false });
|
||||
});
|
||||
expect(reasons().svc).toBe('value-cascade');
|
||||
|
||||
mockGlobalTime.selectedTime = '30m';
|
||||
rerender();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(PAST_DEBOUNCE);
|
||||
});
|
||||
|
||||
expect(reasons()).toStrictEqual({ env: 'full-cycle', svc: 'full-cycle' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
|
||||
import { selectionFromCommittedValues } from '../../utils/selectionUtils';
|
||||
import OverflowValuesTooltip from './OverflowValuesTooltip';
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
|
||||
@@ -75,13 +76,23 @@ function ValueSelector({
|
||||
options.every((option) => draft.includes(option));
|
||||
|
||||
const commit = (values: string[]): void => {
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
const next: VariableSelection =
|
||||
values.length === 0 ? emptyFallback : { value: values, allSelected: isAll };
|
||||
// A close that left the list as it opened commits nothing — else a pick covering
|
||||
// every option this window offers would be promoted to a standing ALL.
|
||||
if (
|
||||
areSelectionsEqual(
|
||||
{ value: values, allSelected: false },
|
||||
{ value: committedValues, allSelected: false },
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = selectionFromCommittedValues({
|
||||
values,
|
||||
options,
|
||||
showAllOption,
|
||||
emptyFallback,
|
||||
});
|
||||
|
||||
// Closing without actually changing the selection must not re-fire onChange —
|
||||
// that would needlessly re-cascade to dependent variables/panels.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import {
|
||||
selectVariableCycleReason,
|
||||
VariableCycleReason,
|
||||
} from '../../store/slices/variableFetchSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { reconcileWithOptions } from '../utils/resolveVariableSelection';
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
|
||||
@@ -9,6 +14,9 @@ import type { VariableSelection } from '../selectionTypes';
|
||||
* `onAutoSelect` only when the value must change. The reconcile rule lives in
|
||||
* {@link reconcileWithOptions} (shared with seed + payload defaulting) so the bar
|
||||
* and the panel query can never disagree about a variable's default.
|
||||
*
|
||||
* Only a value cascade may re-default the selection; a full cycle (time range,
|
||||
* reload) leaves the user's pick alone. Types with no cycle of their own reconcile.
|
||||
*/
|
||||
export function useAutoSelect(
|
||||
variable: VariableFormModel,
|
||||
@@ -16,8 +24,14 @@ export function useAutoSelect(
|
||||
selection: VariableSelection,
|
||||
onAutoSelect: (selection: VariableSelection) => void,
|
||||
): void {
|
||||
const cycleReason = useDashboardStore(
|
||||
selectVariableCycleReason(variable.name),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const next = reconcileWithOptions(variable, selection, options);
|
||||
const next = reconcileWithOptions(variable, selection, options, {
|
||||
preserveSelection: cycleReason === VariableCycleReason.FullCycle,
|
||||
});
|
||||
if (next) {
|
||||
onAutoSelect(next);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ export interface VariableSelection {
|
||||
value: SelectedVariableValue;
|
||||
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
|
||||
allSelected: boolean;
|
||||
/**
|
||||
* Entries of `value` the user typed rather than picked. Never in any option list,
|
||||
* so the reconcile keeps them instead of reading them as invalid.
|
||||
*/
|
||||
customValues?: string[];
|
||||
}
|
||||
|
||||
/** Selected values for a dashboard's variables, keyed by variable name. */
|
||||
|
||||
@@ -134,12 +134,23 @@ export function resolveDefaultSelection(
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
interface ReconcileOptions {
|
||||
/**
|
||||
* Set when no other variable caused this refetch (time-range change, reload): the
|
||||
* selection then outranks the options and is kept as-is. Leave false for a
|
||||
* dependency cascade, where a selection that no longer applies must give way.
|
||||
*/
|
||||
preserveSelection?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles a variable's current selection against its freshly-fetched options.
|
||||
* Returns the next selection, or null when nothing should change (a valid pick is
|
||||
* left untouched — local-first). Behaviour, in order:
|
||||
* - materialize ALL to the full option set (query/custom);
|
||||
* - keep a still-valid multi-select subset, dropping only invalid entries;
|
||||
* - keep a multi-select selection outright when `preserveSelection` is set;
|
||||
* - keep a still-valid multi-select subset, dropping only entries the list no longer
|
||||
* offers and the user did not type in (`customValues`);
|
||||
* - otherwise auto-pick the default (or first option) so dependent variables and
|
||||
* panels always resolve against a usable value.
|
||||
*/
|
||||
@@ -147,6 +158,7 @@ export function reconcileWithOptions(
|
||||
model: VariableFormModel,
|
||||
current: VariableSelection,
|
||||
options: string[],
|
||||
{ preserveSelection = false }: ReconcileOptions = {},
|
||||
): VariableSelection | null {
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
@@ -161,13 +173,31 @@ export function reconcileWithOptions(
|
||||
Array.isArray(current.value) &&
|
||||
current.value.length > 0
|
||||
) {
|
||||
const valid = current.value.map(String).filter((c) => options.includes(c));
|
||||
// A pick this window has no data for is still the user's filter; re-defaulting it
|
||||
// here is what widened a single pick to ALL on every time-range change.
|
||||
if (preserveSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A typed value is in no option list, so it is never "no longer offered".
|
||||
const custom = new Set(current.customValues ?? []);
|
||||
const valid = current.value
|
||||
.map(String)
|
||||
.filter((c) => options.includes(c) || custom.has(c));
|
||||
|
||||
if (valid.length === current.value.length) {
|
||||
return null;
|
||||
}
|
||||
return valid.length > 0
|
||||
? { value: valid, allSelected: false }
|
||||
: fillDefault(model, options);
|
||||
if (valid.length === 0) {
|
||||
return fillDefault(model, options);
|
||||
}
|
||||
|
||||
const customValues = valid.filter((v) => custom.has(v));
|
||||
return {
|
||||
value: valid,
|
||||
allSelected: false,
|
||||
...(customValues.length > 0 && { customValues }),
|
||||
};
|
||||
}
|
||||
|
||||
if (!model.multiSelect) {
|
||||
|
||||
@@ -47,6 +47,43 @@ export function hasUsableValue(
|
||||
return value !== '' && value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
interface CommittedValues {
|
||||
values: string[];
|
||||
options: string[];
|
||||
showAllOption: boolean;
|
||||
emptyFallback: VariableSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection a multi-select commit resolves to. Options are known only here, so
|
||||
* this is where a value the list never offered is recorded as typed in.
|
||||
*/
|
||||
export function selectionFromCommittedValues({
|
||||
values,
|
||||
options,
|
||||
showAllOption,
|
||||
emptyFallback,
|
||||
}: CommittedValues): VariableSelection {
|
||||
if (values.length === 0) {
|
||||
return emptyFallback;
|
||||
}
|
||||
|
||||
const customValues = values.filter((value) => !options.includes(value));
|
||||
// ALL re-materializes to the option set, so a set carrying a typed value is not ALL
|
||||
// — the next refetch would expand it back and drop what the user typed.
|
||||
const allSelected =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
customValues.length === 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
|
||||
return {
|
||||
value: values,
|
||||
allSelected,
|
||||
...(customValues.length > 0 && { customValues }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
|
||||
export function selectionToPayload(
|
||||
selection: VariableSelectionMap,
|
||||
|
||||
@@ -34,6 +34,7 @@ function reset(names: string[], context: VariableFetchContext): void {
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableCycleReasons: {},
|
||||
variableFetchContext: null,
|
||||
});
|
||||
store().initVariableFetch(names, context);
|
||||
@@ -133,6 +134,33 @@ describe('variableFetchSlice', () => {
|
||||
expect(states().q1).toBe('error');
|
||||
expect(states().q2).toBe('idle');
|
||||
});
|
||||
|
||||
// The reason is what tells the post-fetch reconcile whether it may re-default a
|
||||
// selection: a full cycle must not, a value cascade must.
|
||||
it('tags a full cycle, then re-tags only the cascaded variables', () => {
|
||||
store().enqueueFetchAll();
|
||||
expect(store().variableCycleReasons).toStrictEqual({
|
||||
q1: 'full-cycle',
|
||||
q2: 'full-cycle',
|
||||
d1: 'full-cycle',
|
||||
d2: 'full-cycle',
|
||||
});
|
||||
|
||||
resolve('q1');
|
||||
store().enqueueDescendants('q1');
|
||||
expect(store().variableCycleReasons).toStrictEqual({
|
||||
q1: 'full-cycle',
|
||||
q2: 'value-cascade',
|
||||
d1: 'full-cycle',
|
||||
d2: 'full-cycle',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the reason for a variable that no longer exists', () => {
|
||||
store().enqueueFetchAll();
|
||||
store().initVariableFetch(['q1'], context);
|
||||
expect(store().variableCycleReasons).toStrictEqual({ q1: 'full-cycle' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('variableFetchSlice — query depends on a dynamic', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type FetchMaps,
|
||||
isVariableInActiveFetchState,
|
||||
resolveFetchState,
|
||||
VariableCycleReason,
|
||||
VariableFetchState,
|
||||
} from './variableFetchSlice.utils';
|
||||
|
||||
@@ -30,7 +31,10 @@ function queryParentsHaveValues(
|
||||
);
|
||||
}
|
||||
|
||||
export { VariableFetchState } from './variableFetchSlice.utils';
|
||||
export {
|
||||
VariableCycleReason,
|
||||
VariableFetchState,
|
||||
} from './variableFetchSlice.utils';
|
||||
|
||||
/**
|
||||
* Runtime fetch orchestration for dashboard variables — native port of V1's
|
||||
@@ -45,6 +49,8 @@ export interface VariableFetchSlice {
|
||||
variableFetchStates: Record<string, VariableFetchState>;
|
||||
variableLastUpdated: Record<string, number>;
|
||||
variableCycleIds: Record<string, number>;
|
||||
/** Why each variable's current cycle was enqueued, read by the post-fetch reconcile. */
|
||||
variableCycleReasons: Record<string, VariableCycleReason>;
|
||||
/**
|
||||
* Whether a QUERY/DYNAMIC variable settled its fetch with zero options (so it
|
||||
* will never get a value). Lets a dependent panel fall through to "no data"
|
||||
@@ -106,6 +112,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableCycleReasons: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
@@ -115,6 +122,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableCycleReasons: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
@@ -132,6 +140,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
initVariableFetch: (names, context): void => {
|
||||
const maps = cloneMaps(get());
|
||||
const resolvedEmpty = { ...get().variableResolvedEmpty };
|
||||
const reasons = { ...get().variableCycleReasons };
|
||||
names.forEach((name) => {
|
||||
if (!maps.states[name]) {
|
||||
maps.states[name] = VariableFetchState.Idle;
|
||||
@@ -144,12 +153,14 @@ export const createVariableFetchSlice: StateCreator<
|
||||
delete maps.lastUpdated[name];
|
||||
delete maps.cycleIds[name];
|
||||
delete resolvedEmpty[name];
|
||||
delete reasons[name];
|
||||
}
|
||||
});
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
variableCycleReasons: reasons,
|
||||
variableResolvedEmpty: resolvedEmpty,
|
||||
variableFetchContext: context,
|
||||
});
|
||||
@@ -171,6 +182,11 @@ export const createVariableFetchSlice: StateCreator<
|
||||
dynamicVariableOrder,
|
||||
} = variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
const reasons = { ...get().variableCycleReasons };
|
||||
const bump = (name: string): void => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
reasons[name] = VariableCycleReason.FullCycle;
|
||||
};
|
||||
|
||||
// Query variables wait only for their QUERY parents. A DYNAMIC parent does not
|
||||
// gate: its option fetch feeds only its own dropdown, while its selected value
|
||||
@@ -178,7 +194,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
// dependent query substitutes it immediately and refetches via the cascade if
|
||||
// it later changes. Text/custom parents resolve synchronously, so nothing waits.
|
||||
queryVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
bump(name);
|
||||
const parents = dependencyData.parentGraph[name] || [];
|
||||
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
|
||||
maps.states[name] = hasQueryParents
|
||||
@@ -192,7 +208,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
const orderedQuery = new Set(queryVariableOrder);
|
||||
Object.keys(variableTypes).forEach((name) => {
|
||||
if (variableTypes[name] === 'QUERY' && !orderedQuery.has(name)) {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
bump(name);
|
||||
maps.states[name] = resolveFetchState(maps, name);
|
||||
}
|
||||
});
|
||||
@@ -203,7 +219,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
// populate fast even when query variables are slow; a sibling selection change
|
||||
// later refetches them via `enqueueDescendantsBatch`.
|
||||
dynamicVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
bump(name);
|
||||
maps.states[name] = resolveFetchState(maps, name);
|
||||
});
|
||||
|
||||
@@ -211,6 +227,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
variableCycleReasons: reasons,
|
||||
lastFetchAllKey: key ?? get().lastFetchAllKey,
|
||||
});
|
||||
},
|
||||
@@ -290,6 +307,11 @@ export const createVariableFetchSlice: StateCreator<
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
const reasons = { ...get().variableCycleReasons };
|
||||
const bump = (name: string): void => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
reasons[name] = VariableCycleReason.ValueCascade;
|
||||
};
|
||||
const changed = new Set(names);
|
||||
// Callers commit values before this runs, so the gate sees the new parent values.
|
||||
const selection = selectVariableValues(get().dashboardId)(get());
|
||||
@@ -305,7 +327,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
});
|
||||
});
|
||||
queryDescendants.forEach((desc) => {
|
||||
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
|
||||
bump(desc);
|
||||
maps.states[desc] = queryParentsHaveValues(
|
||||
desc,
|
||||
variableFetchContext,
|
||||
@@ -322,7 +344,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
dynamicVariableOrder
|
||||
.filter((dynName) => !changed.has(dynName))
|
||||
.forEach((dynName) => {
|
||||
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
|
||||
bump(dynName);
|
||||
maps.states[dynName] = resolveFetchState(maps, dynName);
|
||||
});
|
||||
}
|
||||
@@ -331,6 +353,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
variableCycleReasons: reasons,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -347,6 +370,12 @@ export const selectVariableCycleId =
|
||||
(state: DashboardStore): number =>
|
||||
state.variableCycleIds[name] ?? 0;
|
||||
|
||||
/** Selector: why a variable's cycle was enqueued. Undefined for types that never fetch. */
|
||||
export const selectVariableCycleReason =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): VariableCycleReason | undefined =>
|
||||
state.variableCycleReasons[name];
|
||||
|
||||
/** Selector: whether a variable has completed at least one fetch. */
|
||||
export const selectVariableFetchedOnce =
|
||||
(name: string) =>
|
||||
|
||||
@@ -7,6 +7,14 @@ export enum VariableFetchState {
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
/** Why a cycle was started — only a cascade may re-default a user's selection. */
|
||||
export enum VariableCycleReason {
|
||||
/** `enqueueFetchAll`: load, time-range or variable-order change. */
|
||||
FullCycle = 'full-cycle',
|
||||
/** `enqueueDescendantsBatch`: a parent or sibling variable's value changed. */
|
||||
ValueCascade = 'value-cascade',
|
||||
}
|
||||
|
||||
/** Mutable clones a fetch action works over before committing back in one `set`. */
|
||||
export interface FetchMaps {
|
||||
states: Record<string, VariableFetchState>;
|
||||
|
||||
@@ -20,7 +20,6 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
|
||||
}
|
||||
|
||||
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
|
||||
var storableDashboard *dashboardtypes.StorableDashboard
|
||||
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
|
||||
@@ -33,14 +32,13 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storableDashboard = storable
|
||||
return m.store.Create(ctx, storable)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromStorableDashboards([]*dashboardtypes.StorableDashboard{storableDashboard}))
|
||||
m.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromPostableDashboardV2(postable))
|
||||
return dashboard, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -239,16 +239,9 @@ func processJSONParser(parent *pipelinetypes.PipelineOperator) ([]pipelinetypes.
|
||||
return nil, errors.NewInternalf(CodeInvalidOperatorType, "operator type received %s", parent.Type)
|
||||
}
|
||||
|
||||
parseFromNotNilCheck, err := fieldNotNilCheck(parent.ParseFrom)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, CodeFieldNilCheckType,
|
||||
"couldn't generate nil check for parseFrom of json parser op %s: %s", parent.Name, err,
|
||||
)
|
||||
}
|
||||
parent.If = fmt.Sprintf(
|
||||
`%s && ((type(%s) == "string" && isJSON(%s) && type(fromJSON(unquote(%s))) == "map" ) || type(%s) == "map")`,
|
||||
parseFromNotNilCheck, parent.ParseFrom, parent.ParseFrom, parent.ParseFrom, parent.ParseFrom,
|
||||
)
|
||||
// on_error: send_quiet replaces the expensive isJSON `if` check;
|
||||
// parse failures pass the record through unchanged without noisy logs.
|
||||
parent.OnError = signozstanzahelper.SendOnErrorQuiet
|
||||
if parent.EnableFlattening {
|
||||
parent.MaxFlatteningDepth = constants.MaxJSONFlatteningDepth
|
||||
}
|
||||
@@ -298,7 +291,7 @@ func processJSONParser(parent *pipelinetypes.PipelineOperator) ([]pipelinetypes.
|
||||
}
|
||||
|
||||
// JSONMapping: host
|
||||
err = generateMoveOperators(mapping[pipelinetypes.Host], `resource["host.name"]`)
|
||||
err := generateMoveOperators(mapping[pipelinetypes.Host], `resource["host.name"]`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -324,6 +324,17 @@ func TestNoCollectorErrorsFromProcessorsForMismatchedLogs(t *testing.T) {
|
||||
makeTestLog("mismatching log", map[string]string{
|
||||
"test_json": "bad json",
|
||||
}),
|
||||
}, {
|
||||
"json parser should quietly ignore log with non JSON body",
|
||||
pipelinetypes.PipelineOperator{
|
||||
ID: "json",
|
||||
Type: "json_parser",
|
||||
Enabled: true,
|
||||
Name: "json parser",
|
||||
ParseFrom: "body",
|
||||
ParseTo: "attributes",
|
||||
},
|
||||
makeTestLog("plain text log", map[string]string{}),
|
||||
}, {
|
||||
"move parser should ignore non matching logs",
|
||||
pipelinetypes.PipelineOperator{
|
||||
@@ -894,8 +905,8 @@ func TestProcessJSONParser_WithFlatteningAndMapping(t *testing.T) {
|
||||
require.Equal(t, 1, parentOp.MaxFlatteningDepth)
|
||||
require.Nil(t, parentOp.Mapping) // Mapping should be removed
|
||||
require.Nil(t, parent.Mapping) // Mapping should be removed
|
||||
require.Contains(t, parentOp.If, `isJSON(body)`)
|
||||
require.Contains(t, parentOp.If, `type(body)`)
|
||||
require.Empty(t, parentOp.If)
|
||||
require.Equal(t, signozstanzahelper.SendOnErrorQuiet, parentOp.OnError)
|
||||
|
||||
require.Equal(t, 1+totalOps, len(ops))
|
||||
|
||||
@@ -951,7 +962,8 @@ func TestProcessJSONParser_WithoutMapping(t *testing.T) {
|
||||
require.True(t, op.EnableFlattening)
|
||||
require.True(t, op.EnablePaths)
|
||||
require.Equal(t, "parsed", op.PathPrefix)
|
||||
require.Contains(t, op.If, `isJSON(body)`)
|
||||
require.Empty(t, op.If)
|
||||
require.Equal(t, signozstanzahelper.SendOnErrorQuiet, op.OnError)
|
||||
}
|
||||
|
||||
func TestProcessJSONParser_Simple(t *testing.T) {
|
||||
@@ -975,7 +987,8 @@ func TestProcessJSONParser_Simple(t *testing.T) {
|
||||
require.False(t, op.EnableFlattening)
|
||||
require.False(t, op.EnablePaths)
|
||||
require.Equal(t, "", op.PathPrefix)
|
||||
require.Contains(t, op.If, `isJSON(body)`)
|
||||
require.Empty(t, op.If)
|
||||
require.Equal(t, signozstanzahelper.SendOnErrorQuiet, op.OnError)
|
||||
}
|
||||
|
||||
func TestProcessJSONParser_InvalidType(t *testing.T) {
|
||||
|
||||
@@ -176,69 +176,6 @@ func NewGettableDashboardFromDashboard(dashboard *Dashboard) (*GettableDashboard
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]any {
|
||||
stats := make(map[string]any)
|
||||
stats["dashboard.panels.count"] = int64(0)
|
||||
stats["dashboard.panels.traces.count"] = int64(0)
|
||||
stats["dashboard.panels.metrics.count"] = int64(0)
|
||||
stats["dashboard.panels.logs.count"] = int64(0)
|
||||
for _, dashboard := range dashboards {
|
||||
addStatsFromStorableDashboard(dashboard, stats)
|
||||
}
|
||||
|
||||
stats["dashboard.count"] = int64(len(dashboards))
|
||||
return stats
|
||||
}
|
||||
|
||||
func addStatsFromStorableDashboard(dashboard *StorableDashboard, stats map[string]any) {
|
||||
if dashboard.Data == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if dashboard.Data["widgets"] == nil {
|
||||
return
|
||||
}
|
||||
|
||||
widgets, ok := dashboard.Data["widgets"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, ok := widgets.([]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
for _, widget := range data {
|
||||
sData, ok := widget.(map[string]interface{})
|
||||
if ok && sData["query"] != nil {
|
||||
stats["dashboard.panels.count"] = stats["dashboard.panels.count"].(int64) + 1
|
||||
query, ok := sData["query"].(map[string]interface{})
|
||||
if ok && query["queryType"] == "builder" && query["builder"] != nil {
|
||||
builderData, ok := query["builder"].(map[string]interface{})
|
||||
if ok && builderData["queryData"] != nil {
|
||||
builderQueryData, ok := builderData["queryData"].([]interface{})
|
||||
if ok {
|
||||
for _, queryData := range builderQueryData {
|
||||
data, ok := queryData.(map[string]interface{})
|
||||
if ok {
|
||||
switch data["dataSource"] {
|
||||
case "traces":
|
||||
stats["dashboard.panels.traces.count"] = stats["dashboard.panels.traces.count"].(int64) + 1
|
||||
case "metrics":
|
||||
stats["dashboard.panels.metrics.count"] = stats["dashboard.panels.metrics.count"].(int64) + 1
|
||||
case "logs":
|
||||
stats["dashboard.panels.logs.count"] = stats["dashboard.panels.logs.count"].(int64) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (storableDashboardData *StorableDashboardData) GetWidgetIds() []string {
|
||||
data := *storableDashboardData
|
||||
widgetIds := []string{}
|
||||
|
||||
90
pkg/types/dashboardtypes/perses_dashboard_stats.go
Normal file
90
pkg/types/dashboardtypes/perses_dashboard_stats.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
statKeyDashboardCount = "dashboard.count"
|
||||
statKeyPanelCount = "dashboard.panels.count"
|
||||
statKeyPanelTracesCount = "dashboard.panels.traces.count"
|
||||
statKeyPanelMetricsCount = "dashboard.panels.metrics.count"
|
||||
statKeyPanelLogsCount = "dashboard.panels.logs.count"
|
||||
)
|
||||
|
||||
// panelSignalStatKeys maps a builder query's signal to the stat it contributes
|
||||
// to. Signal-less queries (promql, clickhouse sql, formulas) count towards the
|
||||
// panel total only.
|
||||
var panelSignalStatKeys = map[telemetrytypes.Signal]string{
|
||||
telemetrytypes.SignalTraces: statKeyPanelTracesCount,
|
||||
telemetrytypes.SignalMetrics: statKeyPanelMetricsCount,
|
||||
telemetrytypes.SignalLogs: statKeyPanelLogsCount,
|
||||
}
|
||||
|
||||
// NewStatsFromStorableDashboards reports the stats of stored dashboards. Rows that
|
||||
// do not decode as v2 contribute to dashboard.count only.
|
||||
func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]any {
|
||||
stats := newPanelStats()
|
||||
|
||||
for _, dashboard := range dashboards {
|
||||
if dashboard == nil {
|
||||
continue
|
||||
}
|
||||
dashboardV2, err := dashboard.ToDashboardV2(nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addPanelStats(&dashboardV2.Spec, stats)
|
||||
}
|
||||
|
||||
stats[statKeyDashboardCount] = int64(len(dashboards))
|
||||
return stats
|
||||
}
|
||||
|
||||
// NewStatsFromPostableDashboardV2 reports the stats of a dashboard as it is
|
||||
// created, straight off the postable spec — the create path has no reason to make
|
||||
// a storable round-trip just to be counted.
|
||||
func NewStatsFromPostableDashboardV2(postable PostableDashboardV2) map[string]any {
|
||||
stats := newPanelStats()
|
||||
addPanelStats(&postable.Spec, stats)
|
||||
|
||||
stats[statKeyDashboardCount] = int64(1)
|
||||
return stats
|
||||
}
|
||||
|
||||
func newPanelStats() map[string]any {
|
||||
return map[string]any{
|
||||
statKeyPanelCount: int64(0),
|
||||
statKeyPanelTracesCount: int64(0),
|
||||
statKeyPanelMetricsCount: int64(0),
|
||||
statKeyPanelLogsCount: int64(0),
|
||||
}
|
||||
}
|
||||
|
||||
// addPanelStats counts the panels of a v2 spec, and each panel's queries against
|
||||
// the signal they read.
|
||||
func addPanelStats(spec *DashboardSpec, stats map[string]any) {
|
||||
for _, panel := range spec.Panels {
|
||||
if panel == nil {
|
||||
continue
|
||||
}
|
||||
incrementStat(stats, statKeyPanelCount)
|
||||
|
||||
for _, query := range panel.Spec.Queries {
|
||||
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, envelope := range composite.Queries {
|
||||
if key, ok := panelSignalStatKeys[envelope.GetSignal()]; ok {
|
||||
incrementStat(stats, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func incrementStat(stats map[string]any, key string) {
|
||||
count, _ := stats[key].(int64)
|
||||
stats[key] = count + 1
|
||||
}
|
||||
220
pkg/types/dashboardtypes/perses_dashboard_stats_test.go
Normal file
220
pkg/types/dashboardtypes/perses_dashboard_stats_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func statsSpecJSON(panelsJSON string) string {
|
||||
return `{
|
||||
"display": {"name": "Stats Dashboard"},
|
||||
"variables": [],
|
||||
"panels": {` + panelsJSON + `},
|
||||
"layouts": [],
|
||||
"links": []
|
||||
}`
|
||||
}
|
||||
|
||||
// newStatsStorableV2 builds a stored v2 row from a panels JSON fragment, going
|
||||
// through the untyped data blob the way a row read off the DB does.
|
||||
func newStatsStorableV2(t *testing.T, panelsJSON string) *StorableDashboard {
|
||||
t.Helper()
|
||||
|
||||
raw := `{
|
||||
"metadata": {"schemaVersion": "` + SchemaVersion + `"},
|
||||
"spec": ` + statsSpecJSON(panelsJSON) + `
|
||||
}`
|
||||
|
||||
var data StorableDashboardData
|
||||
require.NoError(t, json.Unmarshal([]byte(raw), &data))
|
||||
|
||||
return &StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
OrgID: valuer.GenerateUUID(),
|
||||
Source: SourceUser,
|
||||
Name: "stats-dashboard",
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func newStatsPostableV2(t *testing.T, panelsJSON string) PostableDashboardV2 {
|
||||
t.Helper()
|
||||
|
||||
var spec DashboardSpec
|
||||
require.NoError(t, json.Unmarshal([]byte(statsSpecJSON(panelsJSON)), &spec))
|
||||
|
||||
return PostableDashboardV2{
|
||||
DashboardV2MetadataBase: DashboardV2MetadataBase{SchemaVersion: SchemaVersion},
|
||||
Name: "stats-dashboard",
|
||||
Spec: spec,
|
||||
}
|
||||
}
|
||||
|
||||
func statsPanel(queriesJSON string) string {
|
||||
return `{
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [` + queriesJSON + `]
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
// A panel holds a single query, so its name never matters to the assertions.
|
||||
func statsBuilderQuery(signal string) string {
|
||||
return `{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": ` + statsBuilderQuerySpec("A", signal) + `}}
|
||||
}`
|
||||
}
|
||||
|
||||
func statsBuilderQuerySpec(name, signal string) string {
|
||||
aggregations := `[{"expression": "count()"}]`
|
||||
if signal == "metrics" {
|
||||
aggregations = `[{"metricName": "m", "timeAggregation": "rate", "spaceAggregation": "sum"}]`
|
||||
}
|
||||
return `{"name": "` + name + `", "signal": "` + signal + `", "aggregations": ` + aggregations + `}`
|
||||
}
|
||||
|
||||
func TestNewStatsFromStorableDashboardsCountsV2Panels(t *testing.T) {
|
||||
dashboard := newStatsStorableV2(t, `
|
||||
"p1": `+statsPanel(statsBuilderQuery("logs"))+`,
|
||||
"p2": `+statsPanel(statsBuilderQuery("metrics"))+`,
|
||||
"p3": `+statsPanel(statsBuilderQuery("traces"))+`
|
||||
`)
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(3), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
}
|
||||
|
||||
// A panel carries exactly one query envelope, so multi-signal panels arrive as a
|
||||
// composite: the panel counts once and every builder sub-query counts its signal.
|
||||
func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
|
||||
composite := `{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": ` + statsBuilderQuerySpec("A", "traces") + `},
|
||||
{"type": "builder_query", "spec": ` + statsBuilderQuerySpec("B", "logs") + `}
|
||||
]}}}
|
||||
}`
|
||||
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(composite))
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
// promql and clickhouse queries carry no signal, so they land in the panel total
|
||||
// and nowhere else.
|
||||
func TestNewStatsFromStorableDashboardsIgnoresSignallessQueries(t *testing.T) {
|
||||
promql := `{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}
|
||||
}`
|
||||
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(promql))
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromStorableDashboardsAggregatesAcrossDashboards(t *testing.T) {
|
||||
first := newStatsStorableV2(t, `"p1": `+statsPanel(statsBuilderQuery("logs")))
|
||||
second := newStatsStorableV2(t, `
|
||||
"p1": `+statsPanel(statsBuilderQuery("logs"))+`,
|
||||
"p2": `+statsPanel(statsBuilderQuery("traces"))+`
|
||||
`)
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{first, second})
|
||||
|
||||
assert.Equal(t, int64(2), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(3), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(2), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
}
|
||||
|
||||
// v1 rows are counted as dashboards but contribute no panel stats — the counters
|
||||
// read the v2 spec only.
|
||||
func TestNewStatsFromStorableDashboardsSkipsNonV2Rows(t *testing.T) {
|
||||
v1 := &StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
OrgID: valuer.GenerateUUID(),
|
||||
Source: SourceUser,
|
||||
Name: "legacy-dashboard",
|
||||
Data: StorableDashboardData{
|
||||
"title": "Legacy Title",
|
||||
"version": "v5",
|
||||
"widgets": []any{
|
||||
map[string]any{"query": map[string]any{
|
||||
"queryType": "builder",
|
||||
"builder": map[string]any{
|
||||
"queryData": []any{map[string]any{"dataSource": "logs"}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
empty := &StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
OrgID: valuer.GenerateUUID(),
|
||||
Source: SourceUser,
|
||||
Name: "bare",
|
||||
}
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{v1, empty})
|
||||
|
||||
assert.Equal(t, int64(2), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
// The create path counts off the postable spec, so it never round-trips through a
|
||||
// storable to be counted.
|
||||
func TestNewStatsFromPostableDashboardV2(t *testing.T) {
|
||||
postable := newStatsPostableV2(t, `
|
||||
"p1": `+statsPanel(statsBuilderQuery("logs"))+`,
|
||||
"p2": `+statsPanel(statsBuilderQuery("traces"))+`
|
||||
`)
|
||||
|
||||
stats := NewStatsFromPostableDashboardV2(postable)
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(2), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromPostableDashboardV2WithNoPanels(t *testing.T) {
|
||||
stats := NewStatsFromPostableDashboardV2(newStatsPostableV2(t, ``))
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromStorableDashboardsWithNoDashboards(t *testing.T) {
|
||||
stats := NewStatsFromStorableDashboards(nil)
|
||||
|
||||
assert.Equal(t, int64(0), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
@@ -359,15 +359,28 @@ def test_preview_logs_pipelines_success(
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Create a preview request with a pipeline and sample logs.
|
||||
Preview a json_parser pipeline with one JSON log and one plain-text log.
|
||||
|
||||
Tests:
|
||||
1. Send preview request with valid pipeline configuration
|
||||
2. Verify the preview processes logs correctly
|
||||
3. Verify the response contains processed logs
|
||||
1. JSON body gets parsed into attributes
|
||||
2. Non-JSON body passes through unchanged instead of being dropped
|
||||
"""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
empty_log_fields = {
|
||||
"id": "",
|
||||
"trace_id": "",
|
||||
"span_id": "",
|
||||
"trace_flags": 0,
|
||||
"severity_text": "",
|
||||
"severity_number": 0,
|
||||
"attributes_string": {},
|
||||
"attributes_int": {},
|
||||
"attributes_float": {},
|
||||
"attributes_bool": {},
|
||||
"resources_string": {},
|
||||
}
|
||||
|
||||
preview_payload = {
|
||||
"pipelines": [
|
||||
{
|
||||
@@ -396,29 +409,25 @@ def test_preview_logs_pipelines_success(
|
||||
{
|
||||
"type": "json_parser",
|
||||
"id": "json-parser-preview",
|
||||
"orderId": 1,
|
||||
"enabled": True,
|
||||
"parse_from": "body",
|
||||
"parse_to": "attributes",
|
||||
"on_error": "send",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"logs": [
|
||||
{
|
||||
"body": '{"level": "info", "message": "Test log message", "timestamp": "2024-01-01T00:00:00Z"}',
|
||||
"body": '{"level": "info", "message": "json log"}',
|
||||
"timestamp": 1704067200000000000, # nanoseconds, not milliseconds
|
||||
"id": "",
|
||||
"trace_id": "",
|
||||
"span_id": "",
|
||||
"trace_flags": 0,
|
||||
"severity_text": "",
|
||||
"severity_number": 0,
|
||||
"attributes_string": {},
|
||||
"attributes_int": {},
|
||||
"attributes_float": {},
|
||||
"attributes_bool": {},
|
||||
"resources_string": {"service.name": "test-service"},
|
||||
}
|
||||
**empty_log_fields,
|
||||
},
|
||||
{
|
||||
"body": "plain text log that is not json",
|
||||
"timestamp": 1704067201000000000,
|
||||
**empty_log_fields,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -435,13 +444,16 @@ def test_preview_logs_pipelines_success(
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
response_data = response.json()
|
||||
assert response_data["status"] == "success"
|
||||
assert "data" in response_data
|
||||
assert "logs" in response_data["data"]
|
||||
assert len(response_data["data"]["logs"]) == 1
|
||||
logs = response_data["data"]["logs"]
|
||||
assert len(logs) == 2
|
||||
|
||||
# Verify the log was processed
|
||||
processed_log = response_data["data"]["logs"][0]
|
||||
assert "attributes_string" in processed_log or "attributes" in processed_log
|
||||
json_log = next(log for log in logs if log["body"].startswith("{"))
|
||||
assert json_log["attributes_string"]["level"] == "info"
|
||||
assert json_log["attributes_string"]["message"] == "json log"
|
||||
|
||||
plain_log = next(log for log in logs if not log["body"].startswith("{"))
|
||||
assert plain_log["body"] == "plain text log that is not json"
|
||||
assert plain_log["attributes_string"] == {}
|
||||
|
||||
|
||||
def test_create_multiple_pipelines_success(
|
||||
|
||||
Reference in New Issue
Block a user