mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-05 20:50:45 +01:00
Compare commits
3 Commits
ns/saved-v
...
v0.136.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06dd0ed3d3 | ||
|
|
a6ac14344e | ||
|
|
e4e1c4b9e0 |
@@ -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
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';
|
||||
import { getStrokeColorForPercent } from 'container/InfraMonitoringK8sV2/components/EntityProgressBar.utils';
|
||||
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import {
|
||||
getHostQueryPayload,
|
||||
@@ -18,26 +18,6 @@ import {
|
||||
|
||||
import infraHostsStyles from './InfraMonitoringHosts.module.scss';
|
||||
|
||||
export function getProgressColor(percent: number): string {
|
||||
if (percent >= 90) {
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
if (percent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
}
|
||||
|
||||
export function getMemoryProgressColor(percent: number): string {
|
||||
if (percent >= 90) {
|
||||
return Color.BG_CHERRY_500;
|
||||
}
|
||||
if (percent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
}
|
||||
|
||||
export type HostDetailMetadataConfigType =
|
||||
K8sDetailsMetadataConfig<InframonitoringtypesHostRecordDTO>;
|
||||
export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
|
||||
@@ -79,7 +59,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
|
||||
render: (value): React.ReactNode => (
|
||||
<Progress
|
||||
percent={Number(Number(value).toFixed(1))}
|
||||
strokeColor={getProgressColor(Number(value))}
|
||||
strokeColor={getStrokeColorForPercent('cpu', Number(value))}
|
||||
showInfo
|
||||
/>
|
||||
),
|
||||
@@ -90,7 +70,7 @@ export const hostDetailsMetadataConfig: HostDetailMetadataConfigType[] = [
|
||||
render: (value): React.ReactNode => (
|
||||
<Progress
|
||||
percent={Number(Number(value).toFixed(1))}
|
||||
strokeColor={getMemoryProgressColor(Number(value))}
|
||||
strokeColor={getStrokeColorForPercent('memory', Number(value))}
|
||||
showInfo
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { getGroupByEl } from 'container/InfraMonitoringK8sV2/Base/utils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
ExpandButtonWrapper,
|
||||
GroupedStatusCounts,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -98,7 +99,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'hostName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Hostname"
|
||||
@@ -108,7 +109,7 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
|
||||
),
|
||||
accessorFn: (row): string => row.hostName ?? '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -168,7 +169,10 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
|
||||
{
|
||||
id: 'cpu',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#cpu-usage">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/host-monitoring#cpu-usage"
|
||||
tooltip={<EntityProgressThresholds type="cpu" />}
|
||||
>
|
||||
CPU Usage
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -195,7 +199,9 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
|
||||
id: 'memory',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader
|
||||
tooltip="Excluding cache memory."
|
||||
tooltip={
|
||||
<EntityProgressThresholds type="memory" note="Excluding cache memory." />
|
||||
}
|
||||
docPath="/infrastructure-monitoring/host-monitoring#memory-usage"
|
||||
>
|
||||
Memory Usage (WSS)
|
||||
@@ -221,9 +227,12 @@ export const hostColumnsConfig: HostColumnConfigType[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'diskUsage',
|
||||
id: 'disk_usage',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/host-monitoring#disk-usage">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/host-monitoring#disk-usage"
|
||||
tooltip={<EntityProgressThresholds type="disk" />}
|
||||
>
|
||||
Disk Usage
|
||||
</ColumnHeader>
|
||||
),
|
||||
|
||||
@@ -3,13 +3,14 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './ColumnHeader.module.scss';
|
||||
import cx from 'classnames';
|
||||
import { MouseEventHandler } from 'react';
|
||||
|
||||
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
|
||||
|
||||
interface ColumnHeaderProps {
|
||||
children?: React.ReactNode;
|
||||
docPath?: string;
|
||||
tooltip?: string;
|
||||
tooltip?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,9 @@ function ColumnHeader({
|
||||
tooltip,
|
||||
className,
|
||||
}: ColumnHeaderProps): JSX.Element {
|
||||
const stopPropagationHandler: MouseEventHandler = (e): void =>
|
||||
e.stopPropagation();
|
||||
|
||||
const renderContent = (): React.ReactNode => {
|
||||
if (children) {
|
||||
return children;
|
||||
@@ -30,21 +34,25 @@ function ColumnHeader({
|
||||
const renderInfoIcon = (): React.ReactNode => {
|
||||
if (docPath) {
|
||||
const tooltipTitle = tooltip || 'Not sure what this means?';
|
||||
const isJustStringTitle = typeof tooltipTitle === 'string';
|
||||
|
||||
return (
|
||||
<TooltipSimple
|
||||
arrow
|
||||
title={
|
||||
<>
|
||||
<div onClick={stopPropagationHandler}>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
onClick={stopPropagationHandler}
|
||||
>
|
||||
Learn more.
|
||||
{isJustStringTitle
|
||||
? 'Learn more.'
|
||||
: 'Check the documentation to learn more.'}
|
||||
</a>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.infoIcon}>
|
||||
@@ -56,7 +64,9 @@ function ColumnHeader({
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipSimple title={tooltip}>
|
||||
<TooltipSimple
|
||||
title={<div onClick={stopPropagationHandler}>{tooltip}</div>}
|
||||
>
|
||||
<div className={styles.infoIcon}>
|
||||
<Info size="md" />
|
||||
</div>
|
||||
|
||||
@@ -128,6 +128,8 @@ export function K8sBaseList<
|
||||
|
||||
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
|
||||
rowHeight: 42,
|
||||
headerHeight: 58,
|
||||
paginationHeight: 52,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -436,16 +438,17 @@ export function K8sBaseList<
|
||||
isFetching={isFetching}
|
||||
cancelQuery={cancelQuery}
|
||||
/>
|
||||
|
||||
<K8sInstrumentationChecksCallout entity={entity} />
|
||||
|
||||
<K8sTableToolbar
|
||||
entity={entity}
|
||||
eventCategory={eventCategory}
|
||||
leftFilters={leftFilters}
|
||||
onOpenOptionsDrawer={handleOpenOptionsDrawer}
|
||||
/>
|
||||
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
<K8sInstrumentationChecksCallout entity={entity} />
|
||||
|
||||
<K8sTableToolbar
|
||||
entity={entity}
|
||||
eventCategory={eventCategory}
|
||||
leftFilters={leftFilters}
|
||||
onOpenOptionsDrawer={handleOpenOptionsDrawer}
|
||||
/>
|
||||
|
||||
{isError && (
|
||||
<Typography>
|
||||
{data?.error?.toString() || 'Something went wrong'}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
|
||||
--tanstack-table-row-height: 36px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 15px;
|
||||
--tanstack-cell-padding-left-override: 26px;
|
||||
--tanstack-cell-padding-right-override: 15px;
|
||||
|
||||
& [data-hide-expanded='true'] {
|
||||
|
||||
@@ -10,18 +10,19 @@ import TanStackTable, {
|
||||
TableColumnDef,
|
||||
TanStackTableStateProvider,
|
||||
} from 'components/TanStackTableView';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { CornerDownRight } from '@signozhq/icons';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringGroupBy,
|
||||
@@ -36,6 +37,8 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
|
||||
import styles from './K8sExpandedRow.module.scss';
|
||||
import { buildExpressionFromGroupMeta } from './utils';
|
||||
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
import { getUnstableCurrentSearchParams } from 'container/TopNav/DateTimeSelectionV2/utils/getUnstableCurrentSearchParams';
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
const EXPANDED_ROW_LIMIT = 10;
|
||||
|
||||
@@ -92,7 +95,6 @@ export function K8sExpandedRow<
|
||||
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -258,13 +260,26 @@ export function K8sExpandedRow<
|
||||
},
|
||||
};
|
||||
|
||||
const newUrlQuery = new URLSearchParams(urlQuery.toString());
|
||||
newUrlQuery.set(
|
||||
const searchParams = getUnstableCurrentSearchParams();
|
||||
|
||||
searchParams.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(updatedQuery)),
|
||||
);
|
||||
|
||||
safeNavigate(`${location.pathname}?${newUrlQuery.toString()}`);
|
||||
searchParams.delete(INFRA_MONITORING_K8S_PARAMS_KEYS.GROUP_BY);
|
||||
searchParams.delete(INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED);
|
||||
searchParams.delete(orderByParamKey);
|
||||
searchParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE, '1');
|
||||
|
||||
if (orderBy) {
|
||||
searchParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
|
||||
JSON.stringify(orderBy),
|
||||
);
|
||||
}
|
||||
|
||||
safeNavigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
};
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
@@ -276,6 +291,7 @@ export function K8sExpandedRow<
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
className={styles.viewAllButton}
|
||||
data-testid="expanded-row-view-all"
|
||||
onClick={handleViewAllClick}
|
||||
prefix={<CornerDownRight size={14} />}
|
||||
>
|
||||
|
||||
@@ -6,9 +6,13 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import {
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
} from '../hooks';
|
||||
import { useInfraMonitoringGroupByData } from './useInfraMonitoringGroupByData';
|
||||
@@ -16,6 +20,19 @@ import { useInfraMonitoringGroupByData } from './useInfraMonitoringGroupByData';
|
||||
import styles from './K8sTableToolbar.module.scss';
|
||||
import { logInfraGroupByCustomizedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
const NAME_COLUMN_KEYS: Set<string> = new Set([
|
||||
INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
|
||||
]);
|
||||
|
||||
interface K8sTableToolbarProps {
|
||||
entity: InfraMonitoringEntity;
|
||||
eventCategory: InfraMonitoringEvents;
|
||||
@@ -35,11 +52,17 @@ function K8sTableToolbar({
|
||||
useInfraMonitoringGroupByData(entity);
|
||||
|
||||
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
|
||||
const handleGroupByChange = useCallback(
|
||||
(value: string[]) => {
|
||||
void setCurrentPage(1);
|
||||
|
||||
if (orderBy && NAME_COLUMN_KEYS.has(orderBy.columnName)) {
|
||||
void setOrderBy(null);
|
||||
}
|
||||
|
||||
void setGroupBy(value);
|
||||
|
||||
void logEvent(InfraMonitoringEvents.GroupByChanged, {
|
||||
@@ -50,15 +73,16 @@ function K8sTableToolbar({
|
||||
|
||||
logInfraGroupByCustomizedEvent(entity, value);
|
||||
},
|
||||
[entity, eventCategory, setCurrentPage, setGroupBy],
|
||||
[entity, eventCategory, orderBy, setCurrentPage, setOrderBy, setGroupBy],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.groupByContainer}>
|
||||
<div className={styles.groupByContainer} data-testid="k8s-table-group-by">
|
||||
<div className={styles.groupByLabel}>Group by</div>
|
||||
<Select
|
||||
className={styles.groupBySelect}
|
||||
data-testid="k8s-table-group-by-select"
|
||||
loading={isLoadingGroupByFilters}
|
||||
mode="multiple"
|
||||
value={groupBy}
|
||||
|
||||
@@ -1370,4 +1370,127 @@ describe('K8sBaseList', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBy change clears orderBy', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [{ id: 'item-1' }],
|
||||
total: 1,
|
||||
error: null,
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(ctx.json({ status: 'success', data: { ready: true } })),
|
||||
),
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
keys: {
|
||||
resource: [{ name: 'k8s.namespace.name' }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear orderBy for name columns when groupBy is changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
// k8s.pod.name is a name column - should be cleared
|
||||
orderBy: JSON.stringify({ columnName: 'k8s.pod.name', order: 'desc' }),
|
||||
},
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('k8s-table-group-by')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open group by dropdown using testId
|
||||
const groupByContainer = screen.getByTestId('k8s-table-group-by-select');
|
||||
const groupBySelect = groupByContainer.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as Element;
|
||||
await user.click(groupBySelect);
|
||||
|
||||
// Wait for options to load and click on the namespace option
|
||||
const namespaceOption = await screen.findByTitle('k8s.namespace.name');
|
||||
await user.click(namespaceOption);
|
||||
|
||||
// Verify orderBy was cleared (set to null) for name column
|
||||
await waitFor(() => {
|
||||
const orderByCalls = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('orderBy'))
|
||||
.filter((v) => v !== undefined);
|
||||
|
||||
const hasOrderByCleared = orderByCalls.some((v) => v === null);
|
||||
expect(hasOrderByCleared).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep orderBy for non-name columns when groupBy is changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
// cpu is NOT a name column - should be kept
|
||||
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
|
||||
},
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('k8s-table-group-by')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open group by dropdown using testId
|
||||
const groupByContainer = screen.getByTestId('k8s-table-group-by-select');
|
||||
const groupBySelect = groupByContainer.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as Element;
|
||||
await user.click(groupBySelect);
|
||||
|
||||
// Wait for options to load and click on the namespace option
|
||||
const namespaceOption = await screen.findByTitle('k8s.namespace.name');
|
||||
await user.click(namespaceOption);
|
||||
|
||||
// Verify orderBy was NOT cleared for non-name column
|
||||
await waitFor(() => {
|
||||
const orderByCalls = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('orderBy'))
|
||||
.filter((v) => v !== undefined);
|
||||
|
||||
// orderBy should never be set to null
|
||||
const hasOrderByCleared = orderByCalls.some((v) => v === null);
|
||||
expect(hasOrderByCleared).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'clusterName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Cluster Name"
|
||||
@@ -70,7 +70,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
|
||||
),
|
||||
accessorFn: (row): string => row.clusterName || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -69,7 +70,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'daemonsetName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="DaemonSet Name"
|
||||
@@ -80,7 +81,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME] || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -174,7 +175,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -192,7 +196,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={cpuRequest} type="request" />
|
||||
<EntityProgressBar value={cpuRequest} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -200,7 +204,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/daemonsets#cpu-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -217,7 +224,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={cpuLimit} type="limit" />
|
||||
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -251,7 +258,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -269,7 +279,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={memoryRequest} type="request" />
|
||||
<EntityProgressBar value={memoryRequest} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -277,7 +287,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/daemonsets#mem-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -294,7 +307,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={memoryLimit} type="limit" />
|
||||
<EntityProgressBar value={memoryLimit} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -70,7 +71,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'deploymentName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Deployment Name"
|
||||
@@ -81,7 +82,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -162,7 +163,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -180,7 +184,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
entity={InfraMonitoringEntity.DEPLOYMENTS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={cpuRequest} type="request" />
|
||||
<EntityProgressBar value={cpuRequest} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -188,7 +192,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/deployments#cpu-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -205,7 +212,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
entity={InfraMonitoringEntity.DEPLOYMENTS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={cpuLimit} type="limit" />
|
||||
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -238,7 +245,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/deployments#mem-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -256,7 +266,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
entity={InfraMonitoringEntity.DEPLOYMENTS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={memoryRequest} type="request" />
|
||||
<EntityProgressBar value={memoryRequest} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -264,7 +274,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/deployments#mem-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -281,7 +294,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
entity={InfraMonitoringEntity.DEPLOYMENTS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={memoryLimit} type="limit" />
|
||||
<EntityProgressBar value={memoryLimit} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -63,7 +64,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'jobName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Job Name"
|
||||
@@ -74,7 +75,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME] || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -158,7 +159,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -176,7 +180,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
attribute="CPU Request"
|
||||
rowId={rowId}
|
||||
>
|
||||
<EntityProgressBar value={cpuRequest} type="request" />
|
||||
<EntityProgressBar value={cpuRequest} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -184,7 +188,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/jobs#cpu-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -201,7 +208,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
attribute="CPU Limit"
|
||||
rowId={rowId}
|
||||
>
|
||||
<EntityProgressBar value={cpuLimit} type="limit" />
|
||||
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -234,7 +241,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/jobs#mem-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -252,7 +262,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
attribute="Memory Request"
|
||||
rowId={rowId}
|
||||
>
|
||||
<EntityProgressBar value={memoryRequest} type="request" />
|
||||
<EntityProgressBar value={memoryRequest} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -260,7 +270,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/jobs#mem-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -277,7 +290,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
attribute="Memory Limit"
|
||||
rowId={rowId}
|
||||
>
|
||||
<EntityProgressBar value={memoryLimit} type="limit" />
|
||||
<EntityProgressBar value={memoryLimit} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -66,7 +66,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'namespaceName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Namespace Name"
|
||||
@@ -76,7 +76,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
|
||||
),
|
||||
accessorFn: (row): string => row.namespaceName || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
|
||||
@@ -68,7 +68,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'nodeName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Node Name"
|
||||
@@ -78,7 +78,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
|
||||
),
|
||||
accessorFn: (row): string => row.nodeName || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -68,7 +69,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'podName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Pod Name"
|
||||
@@ -79,7 +80,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -96,7 +97,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): string => row.podStatus,
|
||||
width: { min: 160 },
|
||||
width: { min: 250 },
|
||||
enableSort: false,
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ row }): React.ReactNode => {
|
||||
@@ -193,7 +194,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/pods#cpu-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -210,7 +214,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={cpuRequest} type="request" />
|
||||
<EntityProgressBar value={cpuRequest} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -218,7 +222,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/pods#cpu-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -234,7 +241,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={cpuLimit} type="limit" />
|
||||
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -266,7 +273,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/pods#mem-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -283,7 +293,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={memoryRequest} type="request" />
|
||||
<EntityProgressBar value={memoryRequest} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -291,7 +301,10 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/pods#mem-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -307,7 +320,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
entity={InfraMonitoringEntity.PODS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={memoryLimit} type="limit" />
|
||||
<EntityProgressBar value={memoryLimit} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SelectedItemParams } from '../hooks';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
EntityProgressThresholds,
|
||||
GroupedStatusCounts,
|
||||
TextNoData,
|
||||
ValidateColumnValueWrapper,
|
||||
@@ -70,7 +71,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'statefulsetName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="StatefulSet Name"
|
||||
@@ -81,7 +82,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME] || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -165,7 +166,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
{
|
||||
id: 'cpu_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-request" />}
|
||||
>
|
||||
CPU Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -183,7 +187,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
entity={InfraMonitoringEntity.STATEFULSETS}
|
||||
attribute="CPU Request"
|
||||
>
|
||||
<EntityProgressBar value={cpuRequest} type="request" />
|
||||
<EntityProgressBar value={cpuRequest} type="cpu-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -191,7 +195,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
{
|
||||
id: 'cpu_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/statefulsets#cpu-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="cpu-limit" />}
|
||||
>
|
||||
CPU Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -208,7 +215,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
entity={InfraMonitoringEntity.STATEFULSETS}
|
||||
attribute="CPU Limit"
|
||||
>
|
||||
<EntityProgressBar value={cpuLimit} type="limit" />
|
||||
<EntityProgressBar value={cpuLimit} type="cpu-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -242,7 +249,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
{
|
||||
id: 'memory_request',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-req-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-request" />}
|
||||
>
|
||||
Memory Request Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -260,7 +270,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
entity={InfraMonitoringEntity.STATEFULSETS}
|
||||
attribute="Memory Request"
|
||||
>
|
||||
<EntityProgressBar value={memoryRequest} type="request" />
|
||||
<EntityProgressBar value={memoryRequest} type="memory-request" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
@@ -268,7 +278,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
{
|
||||
id: 'memory_limit',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-">
|
||||
<ColumnHeader
|
||||
docPath="/infrastructure-monitoring/kubernetes/statefulsets#mem-limit-usage-"
|
||||
tooltip={<EntityProgressThresholds type="memory-limit" />}
|
||||
>
|
||||
Memory Limit Usage (%)
|
||||
</ColumnHeader>
|
||||
),
|
||||
@@ -285,7 +298,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
entity={InfraMonitoringEntity.STATEFULSETS}
|
||||
attribute="Memory Limit"
|
||||
>
|
||||
<EntityProgressBar value={memoryLimit} type="limit" />
|
||||
<EntityProgressBar value={memoryLimit} type="memory-limit" />
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -64,7 +64,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pvcName',
|
||||
id: INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="PVC Name"
|
||||
@@ -74,7 +74,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
|
||||
),
|
||||
accessorFn: (row): string => row.persistentVolumeClaimName || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableSort: true,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
@@ -195,7 +195,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inodesUsed',
|
||||
id: 'inodes_used',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/volumes#volume-inodes-used">
|
||||
Inodes Used
|
||||
@@ -219,7 +219,7 @@ export const k8sVolumesColumnsConfig: VolumeTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inodesFree',
|
||||
id: 'inodes_free',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/volumes#volume-inodes-free">
|
||||
Inodes Free
|
||||
|
||||
@@ -26,48 +26,6 @@ export function formatBytes(bytes: number, decimals = 2): string {
|
||||
return `${parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stroke color for request utilization parameters according to current value
|
||||
*/
|
||||
export function getStrokeColorForRequestUtilization(value: number): string {
|
||||
const percent = Number((value * 100).toFixed(1));
|
||||
// Orange
|
||||
if (percent <= 50) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
// Green
|
||||
if (percent > 50 && percent <= 100) {
|
||||
return Color.BG_FOREST_500;
|
||||
}
|
||||
// Regular Red
|
||||
if (percent > 100 && percent <= 150) {
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
// Dark Red
|
||||
return Color.BG_CHERRY_600;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stroke color for limit utilization parameters according to current value
|
||||
*/
|
||||
export function getStrokeColorForLimitUtilization(value: number): string {
|
||||
const percent = Number((value * 100).toFixed(1));
|
||||
// Green
|
||||
if (percent <= 60) {
|
||||
return Color.BG_FOREST_500;
|
||||
}
|
||||
// Yellow
|
||||
if (percent > 60 && percent <= 80) {
|
||||
return Color.BG_AMBER_200;
|
||||
}
|
||||
// Orange
|
||||
if (percent > 80 && percent <= 95) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
// Red
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
|
||||
export const POD_STATUS_COLORS: Record<
|
||||
InframonitoringtypesPodStatusDTO,
|
||||
BadgeColor
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import {
|
||||
getMemoryProgressColor,
|
||||
getProgressColor,
|
||||
} from 'container/InfraMonitoringHostsV2/constants';
|
||||
|
||||
import {
|
||||
getStrokeColorForLimitUtilization,
|
||||
getStrokeColorForRequestUtilization,
|
||||
} from '../commonUtils';
|
||||
|
||||
import styles from './EntityProgressBar.module.scss';
|
||||
|
||||
type EntityProgressBarType = 'request' | 'limit' | 'cpu' | 'memory' | 'disk';
|
||||
|
||||
function getStrokeColor(type: EntityProgressBarType, value: number): string {
|
||||
switch (type) {
|
||||
case 'limit':
|
||||
return getStrokeColorForLimitUtilization(value);
|
||||
case 'request':
|
||||
return getStrokeColorForRequestUtilization(value);
|
||||
case 'cpu':
|
||||
return getProgressColor(Number((value * 100).toFixed(1)));
|
||||
case 'memory':
|
||||
return getMemoryProgressColor(Number((value * 100).toFixed(1)));
|
||||
case 'disk':
|
||||
return getProgressColor(Number((value * 100).toFixed(1)));
|
||||
default:
|
||||
return getStrokeColorForRequestUtilization(value);
|
||||
}
|
||||
}
|
||||
import {
|
||||
EntityProgressBarType,
|
||||
getStrokeColor,
|
||||
} from './EntityProgressBar.utils';
|
||||
|
||||
export function EntityProgressBar({
|
||||
value,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
export type EntityProgressBarType =
|
||||
| 'cpu-request'
|
||||
| 'cpu-limit'
|
||||
| 'memory-request'
|
||||
| 'memory-limit'
|
||||
| 'cpu'
|
||||
| 'memory'
|
||||
| 'disk';
|
||||
|
||||
export interface EntityProgressThreshold {
|
||||
matches: (percent: number) => boolean;
|
||||
color: string;
|
||||
range: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CPU_REQUEST_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent <= 50,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '≤ 50%',
|
||||
label: 'Over-requested',
|
||||
description:
|
||||
'CPU usage is at most half of the request. The rest of the request stays reserved on the node.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 100,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '> 50% - 100%',
|
||||
label: 'Right-sized',
|
||||
description: 'CPU usage is close to the request and stays within it.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 150,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '> 100% - 150%',
|
||||
label: 'Over request',
|
||||
description:
|
||||
'CPU usage is above the request. The extra CPU is not guaranteed and depends on spare node capacity.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_CHERRY_600,
|
||||
range: '> 150%',
|
||||
label: 'Request badly undersized',
|
||||
description:
|
||||
'CPU usage is more than 1.5x the request, so most of the CPU in use is not guaranteed.',
|
||||
},
|
||||
];
|
||||
|
||||
const CPU_LIMIT_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent <= 60,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '≤ 60%',
|
||||
label: 'Healthy',
|
||||
description: 'CPU usage is well below the limit.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 80,
|
||||
color: Color.BG_AMBER_200,
|
||||
range: '> 60% - 80%',
|
||||
label: 'Watch',
|
||||
description: 'CPU usage is approaching the limit.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 95,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '> 80% - 95%',
|
||||
label: 'Near limit',
|
||||
description:
|
||||
'CPU usage is close to the limit. Usage above the limit is throttled.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '> 95%',
|
||||
label: 'At limit',
|
||||
description:
|
||||
'CPU usage is at the limit, so the container is likely being throttled.',
|
||||
},
|
||||
];
|
||||
|
||||
const MEMORY_REQUEST_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent <= 50,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '≤ 50%',
|
||||
label: 'Over-requested',
|
||||
description:
|
||||
'Memory usage is at most half of the request. The rest of the request stays reserved on the node.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 100,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '> 50% - 100%',
|
||||
label: 'Right-sized',
|
||||
description: 'Memory usage is close to the request and stays within it.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 150,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '> 100% - 150%',
|
||||
label: 'Over request',
|
||||
description:
|
||||
'Memory usage is above the request. The extra memory is not guaranteed and is reclaimed first under node memory pressure.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_CHERRY_600,
|
||||
range: '> 150%',
|
||||
label: 'Request badly undersized',
|
||||
description:
|
||||
'Memory usage is more than 1.5x the request, so most of the memory in use is not guaranteed.',
|
||||
},
|
||||
];
|
||||
|
||||
const MEMORY_LIMIT_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent <= 60,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '≤ 60%',
|
||||
label: 'Healthy',
|
||||
description: 'Memory usage is well below the limit.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 80,
|
||||
color: Color.BG_AMBER_200,
|
||||
range: '> 60% - 80%',
|
||||
label: 'Watch',
|
||||
description: 'Memory usage is approaching the limit.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent <= 95,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '> 80% - 95%',
|
||||
label: 'Near limit',
|
||||
description:
|
||||
'Memory usage is close to the limit. Unlike CPU, memory is not throttled: reaching the limit ends in an OOM kill.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '> 95%',
|
||||
label: 'At limit',
|
||||
description:
|
||||
'Memory usage is at the limit, so an OOM kill and container restart are likely.',
|
||||
},
|
||||
];
|
||||
|
||||
const CPU_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent < 60,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '< 60%',
|
||||
label: 'Healthy',
|
||||
description: 'CPU usage is well below the available capacity.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent < 90,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '60% - 89.9%',
|
||||
label: 'Elevated',
|
||||
description: 'CPU usage is high relative to the available capacity.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '≥ 90%',
|
||||
label: 'Critical',
|
||||
description: 'CPU usage is close to the available capacity.',
|
||||
},
|
||||
];
|
||||
|
||||
const MEMORY_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent < 60,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '< 60%',
|
||||
label: 'Healthy',
|
||||
description: 'Memory usage is well below the available capacity.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent < 90,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '60% - 89.9%',
|
||||
label: 'Elevated',
|
||||
description: 'Memory usage is high relative to the available capacity.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_CHERRY_500,
|
||||
range: '≥ 90%',
|
||||
label: 'Critical',
|
||||
description:
|
||||
'Memory usage is close to the available capacity. Unlike CPU, memory is not throttled: running out ends in an OOM kill.',
|
||||
},
|
||||
];
|
||||
|
||||
const DISK_THRESHOLDS: EntityProgressThreshold[] = [
|
||||
{
|
||||
matches: (percent): boolean => percent < 60,
|
||||
color: Color.BG_FOREST_500,
|
||||
range: '< 60%',
|
||||
label: 'Healthy',
|
||||
description: 'Most of the volume is still free.',
|
||||
},
|
||||
{
|
||||
matches: (percent): boolean => percent < 90,
|
||||
color: Color.BG_AMBER_500,
|
||||
range: '60% - 89.9%',
|
||||
label: 'Elevated',
|
||||
description: 'Used space is high relative to the volume capacity.',
|
||||
},
|
||||
{
|
||||
matches: (): boolean => true,
|
||||
color: Color.BG_SAKURA_500,
|
||||
range: '≥ 90%',
|
||||
label: 'Critical',
|
||||
description: 'The volume is nearly full. Writes fail once no space is left.',
|
||||
},
|
||||
];
|
||||
|
||||
export const THRESHOLDS_BY_TYPE: Record<
|
||||
EntityProgressBarType,
|
||||
EntityProgressThreshold[]
|
||||
> = {
|
||||
'cpu-request': CPU_REQUEST_THRESHOLDS,
|
||||
'cpu-limit': CPU_LIMIT_THRESHOLDS,
|
||||
'memory-request': MEMORY_REQUEST_THRESHOLDS,
|
||||
'memory-limit': MEMORY_LIMIT_THRESHOLDS,
|
||||
cpu: CPU_THRESHOLDS,
|
||||
memory: MEMORY_THRESHOLDS,
|
||||
disk: DISK_THRESHOLDS,
|
||||
};
|
||||
|
||||
export function getStrokeColorForPercent(
|
||||
type: EntityProgressBarType,
|
||||
percent: number,
|
||||
): string {
|
||||
const thresholds = THRESHOLDS_BY_TYPE[type];
|
||||
const match = thresholds.find((threshold) => threshold.matches(percent));
|
||||
return (match ?? thresholds[thresholds.length - 1]).color;
|
||||
}
|
||||
|
||||
export function getStrokeColor(
|
||||
type: EntityProgressBarType,
|
||||
value: number,
|
||||
): string {
|
||||
return getStrokeColorForPercent(type, Number((value * 100).toFixed(1)));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 320px;
|
||||
text-align: left;
|
||||
text-wrap: wrap;
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.threshold {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 3px;
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--ept-color);
|
||||
}
|
||||
|
||||
.thresholdBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.thresholdHeading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.range {
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import {
|
||||
EntityProgressBarType,
|
||||
THRESHOLDS_BY_TYPE,
|
||||
} from './EntityProgressBar.utils';
|
||||
import styles from './EntityProgressThresholds.module.scss';
|
||||
|
||||
interface EntityProgressThresholdsProps {
|
||||
type: EntityProgressBarType;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export function EntityProgressThresholds({
|
||||
type,
|
||||
note,
|
||||
}: EntityProgressThresholdsProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
data-testid={`entity-progress-thresholds-${type}`}
|
||||
>
|
||||
{note && (
|
||||
<Typography.Text as="p" size="small">
|
||||
{note}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{THRESHOLDS_BY_TYPE[type].map((threshold) => (
|
||||
<div key={threshold.range} className={styles.threshold}>
|
||||
<span
|
||||
className={styles.swatch}
|
||||
style={{ '--ept-color': threshold.color } as React.CSSProperties}
|
||||
/>
|
||||
<div className={styles.thresholdBody}>
|
||||
<div className={styles.thresholdHeading}>
|
||||
<Typography.Text as="span" size="small" weight="medium">
|
||||
{threshold.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
size="small"
|
||||
color="muted"
|
||||
className={styles.range}
|
||||
>
|
||||
{threshold.range}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text as="p" size="small" color="muted">
|
||||
{threshold.description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { THRESHOLDS_BY_TYPE } from '../EntityProgressBar.utils';
|
||||
import { EntityProgressThresholds } from '../EntityProgressThresholds';
|
||||
|
||||
describe('EntityProgressThresholds', () => {
|
||||
it('renders every threshold band for the given type', () => {
|
||||
render(<EntityProgressThresholds type="cpu-limit" />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('entity-progress-thresholds-cpu-limit'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
THRESHOLDS_BY_TYPE['cpu-limit'].forEach((threshold) => {
|
||||
expect(screen.getByText(threshold.label)).toBeInTheDocument();
|
||||
expect(screen.getByText(threshold.range)).toBeInTheDocument();
|
||||
expect(screen.getByText(threshold.description)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the note above the threshold bands when provided', () => {
|
||||
render(
|
||||
<EntityProgressThresholds type="memory" note="Excluding cache memory." />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Excluding cache memory.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export { EntityProgressBar } from './EntityProgressBar';
|
||||
export { EntityProgressThresholds } from './EntityProgressThresholds';
|
||||
export { ValidateColumnValueWrapper } from './ValidateColumnValueWrapper';
|
||||
export { ExpandButtonWrapper } from './ExpandButtonWrapper';
|
||||
export {
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
* This was introduced to fix a sync bug between Nuqs and react-router-dom
|
||||
*
|
||||
* We are using the wrong adapter for nuqs because the correct one only supports v6/v7,
|
||||
* and we are at version v5. This causes the nuqs/react-router-dom to be out of sync.
|
||||
* and we are at version v5. Nuqs writes params straight to the History API, which
|
||||
* react-router v5 never observes, so `useLocation().search` (and `useUrlQuery()`) can
|
||||
* be several nuqs updates behind the real URL.
|
||||
*
|
||||
* We can revert this commit once we migrate react-router-dom to v6, or once we migrate
|
||||
* to DateTimeSelectionV3
|
||||
* Use this whenever you need to build a navigation target on top of the current
|
||||
* params, otherwise stale values get republished and nuqs adopts them back on its
|
||||
* next flush (it snapshots `window.location.search`).
|
||||
*
|
||||
* We can revert this once we migrate react-router-dom to v6.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import getMinAgo from './getStartAndEndTime/getMinAgo';
|
||||
|
||||
const validCustomTimeRegex = /^(\d+)([mhdw])$/;
|
||||
const validCustomTimeRegex = /^(\d+)(months?|[mhdw])$/;
|
||||
|
||||
export const isValidShortHandDateTimeFormat = (time: string): boolean =>
|
||||
validCustomTimeRegex.test(time);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -69,7 +69,12 @@ export const getMetricsExplorerUrl = ({
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
// `unit` must always be present: the query builder provider rewrites (and
|
||||
// pushes a new history entry for) any compositeQuery missing a key of
|
||||
// `initialQueriesMap`, which traps the browser back button.
|
||||
// Since this is only being used by infra-monitoring, I will keep this fix one line
|
||||
// instead of going and update each chart configuration.
|
||||
encodeURIComponent(JSON.stringify({ unit: '', ...query })),
|
||||
);
|
||||
|
||||
if (relativeTime) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user