Compare commits
4 Commits
dual-write
...
alerting/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8595844334 | ||
|
|
fd955f90ac | ||
|
|
ccb032f376 | ||
|
|
cf452c167b |
@@ -1337,11 +1337,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/api/onCallApi.test.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
@@ -1627,11 +1622,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/mocks/server/configure.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/mocks/server/handlers/plugins.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
@@ -1662,16 +1652,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/utils/config.test.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/utils/config.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/utils/datasource.ts": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
|
||||
@@ -54,8 +54,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
|
||||
}
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if c.HasRole(identity.RoleAdmin) &&
|
||||
(s.cfg.StackID == "" || // show OnPrem even when provisioning is disabled
|
||||
s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning)) {
|
||||
s.features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
|
||||
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
|
||||
Text: "Provisioning",
|
||||
Id: "provisioning",
|
||||
|
||||
@@ -108,7 +108,9 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
|
||||
}),
|
||||
|
||||
grafanaNotifiers: build.query<NotifierDTO[], void>({
|
||||
query: () => ({ url: '/api/alert-notifiers' }),
|
||||
// NOTE: version=2 parameter required for versioned schema (PR #109969)
|
||||
// This parameter will be removed in future when v2 becomes default
|
||||
query: () => ({ url: '/api/alert-notifiers?version=2' }),
|
||||
transformResponse: (response: NotifierDTO[]) => {
|
||||
const populateSecureFieldKey = (
|
||||
option: NotificationChannelOption,
|
||||
@@ -121,11 +123,16 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
|
||||
),
|
||||
});
|
||||
|
||||
// Keep versions array intact for version-specific options lookup
|
||||
// Transform options with secureFieldKey population
|
||||
return response.map((notifier) => ({
|
||||
...notifier,
|
||||
options: notifier.options.map((option) => {
|
||||
return populateSecureFieldKey(option, '');
|
||||
}),
|
||||
options: (notifier.options || []).map((option) => populateSecureFieldKey(option, '')),
|
||||
// Also transform options within each version
|
||||
versions: notifier.versions?.map((version) => ({
|
||||
...version,
|
||||
options: (version.options || []).map((option) => populateSecureFieldKey(option, '')),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getIrmIfPresentOrIncidentPluginId } from '../utils/config';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
|
||||
interface IncidentsPluginConfigDto {
|
||||
@@ -7,13 +5,13 @@ interface IncidentsPluginConfigDto {
|
||||
isIncidentCreated: boolean;
|
||||
}
|
||||
|
||||
const getProxyApiUrl = (path: string) => `/api/plugins/${getIrmIfPresentOrIncidentPluginId()}/resources${path}`;
|
||||
const getProxyApiUrl = (path: string, pluginId: string) => `/api/plugins/${pluginId}/resources${path}`;
|
||||
|
||||
export const incidentsApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getIncidentsPluginConfig: build.query<IncidentsPluginConfigDto, void>({
|
||||
query: () => ({
|
||||
url: getProxyApiUrl('/api/ConfigurationTrackerService.GetConfigurationTracker'),
|
||||
getIncidentsPluginConfig: build.query<IncidentsPluginConfigDto, { pluginId: string }>({
|
||||
query: ({ pluginId }) => ({
|
||||
url: getProxyApiUrl('/api/ConfigurationTrackerService.GetConfigurationTracker', pluginId),
|
||||
data: {},
|
||||
method: 'POST',
|
||||
showErrorAlert: false,
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { pluginMeta, pluginMetaToPluginConfig } from '../testSetup/plugins';
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
import { getProxyApiUrl } from './onCallApi';
|
||||
|
||||
describe('getProxyApiUrl', () => {
|
||||
it('should return URL with IRM plugin ID when IRM plugin is present', () => {
|
||||
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
|
||||
|
||||
expect(getProxyApiUrl('/alert_receive_channels/')).toBe(
|
||||
it('should return URL with IRM plugin ID when IRM plugin ID is passed', () => {
|
||||
expect(getProxyApiUrl('/alert_receive_channels/', SupportedPlugin.Irm)).toBe(
|
||||
'/api/plugins/grafana-irm-app/resources/alert_receive_channels/'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return URL with OnCall plugin ID when IRM plugin is not present', () => {
|
||||
config.apps = {
|
||||
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
|
||||
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
|
||||
};
|
||||
|
||||
expect(getProxyApiUrl('/alert_receive_channels/')).toBe(
|
||||
it('should return URL with OnCall plugin ID when OnCall plugin ID is passed', () => {
|
||||
expect(getProxyApiUrl('/alert_receive_channels/', SupportedPlugin.OnCall)).toBe(
|
||||
'/api/plugins/grafana-oncall-app/resources/alert_receive_channels/'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FetchError, isFetchError } from '@grafana/runtime';
|
||||
|
||||
import { GRAFANA_ONCALL_INTEGRATION_TYPE } from '../components/receivers/grafanaAppReceivers/onCall/onCall';
|
||||
import { getIrmIfPresentOrOnCallPluginId } from '../utils/config';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
|
||||
@@ -38,15 +37,15 @@ export interface OnCallConfigChecks {
|
||||
is_integration_chatops_connected: boolean;
|
||||
}
|
||||
|
||||
export function getProxyApiUrl(path: string) {
|
||||
return `/api/plugins/${getIrmIfPresentOrOnCallPluginId()}/resources${path}`;
|
||||
export function getProxyApiUrl(path: string, pluginId: string) {
|
||||
return `/api/plugins/${pluginId}/resources${path}`;
|
||||
}
|
||||
|
||||
export const onCallApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
grafanaOnCallIntegrations: build.query<OnCallIntegrationDTO[], void>({
|
||||
query: () => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/'),
|
||||
grafanaOnCallIntegrations: build.query<OnCallIntegrationDTO[], { pluginId: string }>({
|
||||
query: ({ pluginId }) => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/', pluginId),
|
||||
// legacy_grafana_alerting is necessary for OnCall.
|
||||
// We do NOT need to differentiate between these two on our side
|
||||
params: {
|
||||
@@ -64,31 +63,31 @@ export const onCallApi = alertingApi.injectEndpoints({
|
||||
},
|
||||
providesTags: ['OnCallIntegrations'],
|
||||
}),
|
||||
validateIntegrationName: build.query<boolean, string>({
|
||||
query: (name) => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/validate_name/'),
|
||||
validateIntegrationName: build.query<boolean, { name: string; pluginId: string }>({
|
||||
query: ({ name, pluginId }) => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/validate_name/', pluginId),
|
||||
params: { verbal_name: name },
|
||||
showErrorAlert: false,
|
||||
}),
|
||||
}),
|
||||
createIntegration: build.mutation<NewOnCallIntegrationDTO, CreateIntegrationDTO>({
|
||||
query: (integration) => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/'),
|
||||
createIntegration: build.mutation<NewOnCallIntegrationDTO, CreateIntegrationDTO & { pluginId: string }>({
|
||||
query: ({ pluginId, ...integration }) => ({
|
||||
url: getProxyApiUrl('/alert_receive_channels/', pluginId),
|
||||
data: integration,
|
||||
method: 'POST',
|
||||
showErrorAlert: true,
|
||||
}),
|
||||
invalidatesTags: ['OnCallIntegrations'],
|
||||
}),
|
||||
features: build.query<OnCallFeature[], void>({
|
||||
query: () => ({
|
||||
url: getProxyApiUrl('/features/'),
|
||||
features: build.query<OnCallFeature[], { pluginId: string }>({
|
||||
query: ({ pluginId }) => ({
|
||||
url: getProxyApiUrl('/features/', pluginId),
|
||||
showErrorAlert: false,
|
||||
}),
|
||||
}),
|
||||
onCallConfigChecks: build.query<OnCallConfigChecks, void>({
|
||||
query: () => ({
|
||||
url: getProxyApiUrl('/organization/config-checks/'),
|
||||
onCallConfigChecks: build.query<OnCallConfigChecks, { pluginId: string }>({
|
||||
query: ({ pluginId }) => ({
|
||||
url: getProxyApiUrl('/organization/config-checks/', pluginId),
|
||||
showErrorAlert: false,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -46,6 +46,7 @@ export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'nam
|
||||
state?: PromAlertingRuleState[];
|
||||
title?: string;
|
||||
searchGroupName?: string;
|
||||
searchFolder?: string;
|
||||
type?: 'alerting' | 'recording';
|
||||
ruleMatchers?: string[];
|
||||
plugins?: 'hide' | 'only';
|
||||
@@ -103,6 +104,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
|
||||
title,
|
||||
datasources,
|
||||
searchGroupName,
|
||||
searchFolder,
|
||||
dashboardUid,
|
||||
ruleMatchers,
|
||||
plugins,
|
||||
@@ -123,6 +125,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
|
||||
datasource_uid: datasources,
|
||||
'search.rule_name': title,
|
||||
'search.rule_group': searchGroupName,
|
||||
'search.folder': searchFolder,
|
||||
dashboard_uid: dashboardUid,
|
||||
rule_matcher: ruleMatchers,
|
||||
plugins: plugins,
|
||||
|
||||
@@ -36,6 +36,24 @@ export const ProvisioningAlert = ({ resource, ...rest }: ProvisioningAlertProps)
|
||||
);
|
||||
};
|
||||
|
||||
export const ImportedContactPointAlert = (props: ExtraAlertProps) => {
|
||||
return (
|
||||
<Alert
|
||||
title={t(
|
||||
'alerting.provisioning.title-imported',
|
||||
'This contact point was imported and cannot be edited through the UI'
|
||||
)}
|
||||
severity="info"
|
||||
{...props}
|
||||
>
|
||||
<Trans i18nKey="alerting.provisioning.body-imported">
|
||||
This contact point contains integrations that were imported from an external Alertmanager and is currently
|
||||
read-only. The integrations will become editable after the migration process is complete.
|
||||
</Trans>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProvisioningBadge = ({
|
||||
tooltip,
|
||||
provenance,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, LinkButton, Menu, Tooltip } from '@grafana/ui';
|
||||
|
||||
import { usePluginBridge } from '../../hooks/usePluginBridge';
|
||||
import { getIrmIfPresentOrIncidentPluginId } from '../../utils/config';
|
||||
import { useIrmPlugin } from '../../hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from '../../types/pluginBridges';
|
||||
import { createBridgeURL } from '../PluginBridge';
|
||||
|
||||
interface Props {
|
||||
@@ -11,20 +11,18 @@ interface Props {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const pluginId = getIrmIfPresentOrIncidentPluginId();
|
||||
|
||||
export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: Props) => {
|
||||
const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.Incident);
|
||||
|
||||
const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', {
|
||||
title,
|
||||
severity,
|
||||
url,
|
||||
});
|
||||
|
||||
const { loading, installed, settings } = usePluginBridge(pluginId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading === true && (
|
||||
{loading && (
|
||||
<Button icon="fire" size="sm" type="button" disabled>
|
||||
<Trans i18nKey="alerting.declare-incident-button.declare-incident">Declare Incident</Trans>
|
||||
</Button>
|
||||
@@ -51,17 +49,17 @@ export const DeclareIncidentButton = ({ title = '', severity = '', url = '' }: P
|
||||
};
|
||||
|
||||
export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }: Props) => {
|
||||
const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.Incident);
|
||||
|
||||
const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', {
|
||||
title,
|
||||
severity,
|
||||
url,
|
||||
});
|
||||
|
||||
const { loading, installed, settings } = usePluginBridge(pluginId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading === true && (
|
||||
{loading && (
|
||||
<Menu.Item
|
||||
label={t('alerting.declare-incident-menu-item.label-declare-incident', 'Declare incident')}
|
||||
icon="fire"
|
||||
|
||||
@@ -18,10 +18,10 @@ import { getAPINamespace } from '../../../../../api/utils';
|
||||
import { alertmanagerApi } from '../../api/alertmanagerApi';
|
||||
import { onCallApi } from '../../api/onCallApi';
|
||||
import { useAsync } from '../../hooks/useAsync';
|
||||
import { usePluginBridge } from '../../hooks/usePluginBridge';
|
||||
import { useIrmPlugin } from '../../hooks/usePluginBridge';
|
||||
import { useProduceNewAlertmanagerConfiguration } from '../../hooks/useProduceNewAlertmanagerConfig';
|
||||
import { addReceiverAction, deleteReceiverAction, updateReceiverAction } from '../../reducers/alertmanager/receivers';
|
||||
import { getIrmIfPresentOrOnCallPluginId } from '../../utils/config';
|
||||
import { SupportedPlugin } from '../../types/pluginBridges';
|
||||
|
||||
import { enhanceContactPointsWithMetadata } from './utils';
|
||||
|
||||
@@ -61,8 +61,8 @@ const defaultOptions = {
|
||||
* Otherwise, returns no data
|
||||
*/
|
||||
const useOnCallIntegrations = ({ skip }: Skippable = {}) => {
|
||||
const { installed, loading } = usePluginBridge(getIrmIfPresentOrOnCallPluginId());
|
||||
const oncallIntegrationsResponse = useGrafanaOnCallIntegrationsQuery(undefined, { skip: skip || !installed });
|
||||
const { pluginId, installed, loading } = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
const oncallIntegrationsResponse = useGrafanaOnCallIntegrationsQuery({ pluginId }, { skip: skip || !installed });
|
||||
|
||||
return useMemo(() => {
|
||||
if (installed) {
|
||||
@@ -126,6 +126,10 @@ export const useGrafanaContactPoints = ({
|
||||
}: GrafanaFetchOptions & Skippable = {}) => {
|
||||
const namespace = getAPINamespace();
|
||||
const potentiallySkip = { skip };
|
||||
|
||||
// Get the IRM/OnCall plugin information
|
||||
const irmOrOnCallPlugin = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
|
||||
const onCallResponse = useOnCallIntegrations(potentiallySkip);
|
||||
const alertNotifiers = useGrafanaNotifiersQuery(undefined, potentiallySkip);
|
||||
const contactPointsListResponse = useK8sContactPoints({ namespace }, potentiallySkip);
|
||||
@@ -158,6 +162,7 @@ export const useGrafanaContactPoints = ({
|
||||
status: contactPointsStatusResponse.data,
|
||||
notifiers: alertNotifiers.data,
|
||||
onCallIntegrations: onCallResponse?.data,
|
||||
onCallPluginId: irmOrOnCallPlugin.pluginId,
|
||||
contactPoints: contactPointsListResponse.data || [],
|
||||
alertmanagerConfiguration: alertmanagerConfigResponse.data,
|
||||
});
|
||||
@@ -172,6 +177,7 @@ export const useGrafanaContactPoints = ({
|
||||
contactPointsListResponse,
|
||||
contactPointsStatusResponse,
|
||||
onCallResponse,
|
||||
irmOrOnCallPlugin.pluginId,
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { OnCallIntegrationDTO } from '../../api/onCallApi';
|
||||
import { SupportedPlugin } from '../../types/pluginBridges';
|
||||
import { extractReceivers } from '../../utils/receivers';
|
||||
import { routeAdapter } from '../../utils/routeAdapter';
|
||||
import { ReceiverTypes } from '../receivers/grafanaAppReceivers/onCall/onCall';
|
||||
@@ -113,7 +114,8 @@ export interface ContactPointWithMetadata extends GrafanaManagedContactPoint {
|
||||
type EnhanceContactPointsArgs = {
|
||||
status?: ReceiversStateDTO[];
|
||||
notifiers?: NotifierDTO[];
|
||||
onCallIntegrations?: OnCallIntegrationDTO[] | undefined | null;
|
||||
onCallIntegrations?: OnCallIntegrationDTO[];
|
||||
onCallPluginId?: SupportedPlugin;
|
||||
contactPoints: Receiver[];
|
||||
alertmanagerConfiguration?: AlertManagerCortexConfig;
|
||||
};
|
||||
@@ -130,6 +132,7 @@ export function enhanceContactPointsWithMetadata({
|
||||
status = [],
|
||||
notifiers = [],
|
||||
onCallIntegrations,
|
||||
onCallPluginId,
|
||||
contactPoints,
|
||||
alertmanagerConfiguration,
|
||||
}: EnhanceContactPointsArgs): ContactPointWithMetadata[] {
|
||||
@@ -162,7 +165,7 @@ export function enhanceContactPointsWithMetadata({
|
||||
[RECEIVER_META_KEY]: getNotifierMetadata(notifiers, receiver),
|
||||
// if OnCall plugin is installed, we'll add it to the receiver's plugin metadata
|
||||
[RECEIVER_PLUGIN_META_KEY]: isOnCallReceiver
|
||||
? getOnCallMetadata(onCallIntegrations, receiver, Boolean(alertmanagerConfiguration))
|
||||
? getOnCallMetadata(onCallIntegrations, receiver, Boolean(alertmanagerConfiguration), onCallPluginId)
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'core-js/stable/structured-clone';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||
import { render } from 'test/test-utils';
|
||||
import { render, screen } from 'test/test-utils';
|
||||
import { byRole, byTestId } from 'testing-library-selector';
|
||||
|
||||
import { grafanaAlertNotifiers } from 'app/features/alerting/unified/mockGrafanaNotifiers';
|
||||
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { NotifierDTO } from 'app/features/alerting/unified/types/alerting';
|
||||
|
||||
import { ChannelSubForm } from './ChannelSubForm';
|
||||
import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings';
|
||||
@@ -16,6 +17,7 @@ type TestChannelValues = {
|
||||
type: string;
|
||||
settings: Record<string, unknown>;
|
||||
secureFields: Record<string, boolean>;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type TestReceiverFormValues = {
|
||||
@@ -246,4 +248,241 @@ describe('ChannelSubForm', () => {
|
||||
expect(slackUrl).toBeEnabled();
|
||||
expect(slackUrl).toHaveValue('');
|
||||
});
|
||||
|
||||
describe('version-specific options display', () => {
|
||||
// Create a mock notifier with different options for v0 and v1
|
||||
const legacyOptions = [
|
||||
{
|
||||
element: 'input' as const,
|
||||
inputType: 'text',
|
||||
label: 'Legacy URL',
|
||||
description: 'The legacy endpoint URL',
|
||||
placeholder: '',
|
||||
propertyName: 'legacyUrl',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
];
|
||||
|
||||
const webhookWithVersions: NotifierDTO = {
|
||||
...grafanaAlertNotifiers.webhook,
|
||||
versions: [
|
||||
{
|
||||
version: 'v0mimir1',
|
||||
label: 'Webhook (Legacy)',
|
||||
description: 'Legacy webhook from Mimir',
|
||||
canCreate: false,
|
||||
options: legacyOptions,
|
||||
},
|
||||
{
|
||||
version: 'v0mimir2',
|
||||
label: 'Webhook (Legacy v2)',
|
||||
description: 'Legacy webhook v2 from Mimir',
|
||||
canCreate: false,
|
||||
options: legacyOptions,
|
||||
},
|
||||
{
|
||||
version: 'v1',
|
||||
label: 'Webhook',
|
||||
description: 'Sends HTTP POST request',
|
||||
canCreate: true,
|
||||
options: grafanaAlertNotifiers.webhook.options,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const versionedNotifiers: Notifier[] = [
|
||||
{ dto: webhookWithVersions, meta: { enabled: true, order: 1 } },
|
||||
{ dto: grafanaAlertNotifiers.slack, meta: { enabled: true, order: 2 } },
|
||||
];
|
||||
|
||||
function VersionedTestFormWrapper({
|
||||
defaults,
|
||||
initial,
|
||||
}: {
|
||||
defaults: TestChannelValues;
|
||||
initial?: TestChannelValues;
|
||||
}) {
|
||||
const form = useForm<TestReceiverFormValues>({
|
||||
defaultValues: {
|
||||
name: 'test-contact-point',
|
||||
items: [defaults],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertmanagerProvider accessType="notification">
|
||||
<FormProvider {...form}>
|
||||
<ChannelSubForm
|
||||
defaultValues={defaults}
|
||||
initialValues={initial}
|
||||
pathPrefix={`items.0.`}
|
||||
integrationIndex={0}
|
||||
notifiers={versionedNotifiers}
|
||||
onDuplicate={jest.fn()}
|
||||
commonSettingsComponent={GrafanaCommonChannelSettings}
|
||||
isEditable={true}
|
||||
isTestable={false}
|
||||
canEditProtectedFields={true}
|
||||
/>
|
||||
</FormProvider>
|
||||
</AlertmanagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderVersionedForm(defaults: TestChannelValues, initial?: TestChannelValues) {
|
||||
return render(<VersionedTestFormWrapper defaults={defaults} initial={initial} />);
|
||||
}
|
||||
|
||||
it('should display v1 options when integration has v1 version', () => {
|
||||
const webhookV1: TestChannelValues = {
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
version: 'v1',
|
||||
settings: { url: 'https://example.com' },
|
||||
secureFields: {},
|
||||
};
|
||||
|
||||
renderVersionedForm(webhookV1, webhookV1);
|
||||
|
||||
// Should show v1 URL field (from default options)
|
||||
expect(ui.settings.webhook.url.get()).toBeInTheDocument();
|
||||
// Should NOT show legacy URL field
|
||||
expect(screen.queryByRole('textbox', { name: /Legacy URL/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display v0 options when integration has legacy version', () => {
|
||||
const webhookV0: TestChannelValues = {
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
version: 'v0mimir1',
|
||||
settings: { legacyUrl: 'https://legacy.example.com' },
|
||||
secureFields: {},
|
||||
};
|
||||
|
||||
renderVersionedForm(webhookV0, webhookV0);
|
||||
|
||||
// Should show legacy URL field (from v0 options)
|
||||
expect(screen.getByRole('textbox', { name: /Legacy URL/i })).toBeInTheDocument();
|
||||
// Should NOT show v1 URL field
|
||||
expect(ui.settings.webhook.url.query()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Legacy" badge for v0mimir1 integration', () => {
|
||||
const webhookV0: TestChannelValues = {
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
version: 'v0mimir1',
|
||||
settings: { legacyUrl: 'https://legacy.example.com' },
|
||||
secureFields: {},
|
||||
};
|
||||
|
||||
renderVersionedForm(webhookV0, webhookV0);
|
||||
|
||||
// Should show "Legacy" badge for v0mimir1 integrations
|
||||
expect(screen.getByText('Legacy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Legacy v2" badge for v0mimir2 integration', () => {
|
||||
const webhookV0v2: TestChannelValues = {
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
version: 'v0mimir2',
|
||||
settings: { legacyUrl: 'https://legacy.example.com' },
|
||||
secureFields: {},
|
||||
};
|
||||
|
||||
renderVersionedForm(webhookV0v2, webhookV0v2);
|
||||
|
||||
// Should show "Legacy v2" badge for v0mimir2 integrations
|
||||
expect(screen.getByText('Legacy v2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT display version badge for v1 integration', () => {
|
||||
const webhookV1: TestChannelValues = {
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
version: 'v1',
|
||||
settings: { url: 'https://example.com' },
|
||||
secureFields: {},
|
||||
};
|
||||
|
||||
renderVersionedForm(webhookV1, webhookV1);
|
||||
|
||||
// Should NOT show version badge for non-legacy v1 integrations
|
||||
expect(screen.queryByText('v1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter out notifiers with canCreate: false from dropdown', () => {
|
||||
// Create a notifier that only has v0 versions (cannot be created)
|
||||
const legacyOnlyNotifier: NotifierDTO = {
|
||||
type: 'wechat',
|
||||
name: 'WeChat',
|
||||
heading: 'WeChat settings',
|
||||
description: 'Sends notifications to WeChat',
|
||||
options: [],
|
||||
versions: [
|
||||
{
|
||||
version: 'v0mimir1',
|
||||
label: 'WeChat (Legacy)',
|
||||
description: 'Legacy WeChat',
|
||||
canCreate: false,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const notifiersWithLegacyOnly: Notifier[] = [
|
||||
{ dto: webhookWithVersions, meta: { enabled: true, order: 1 } },
|
||||
{ dto: legacyOnlyNotifier, meta: { enabled: true, order: 2 } },
|
||||
];
|
||||
|
||||
function LegacyOnlyTestWrapper({ defaults }: { defaults: TestChannelValues }) {
|
||||
const form = useForm<TestReceiverFormValues>({
|
||||
defaultValues: {
|
||||
name: 'test-contact-point',
|
||||
items: [defaults],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertmanagerProvider accessType="notification">
|
||||
<FormProvider {...form}>
|
||||
<ChannelSubForm
|
||||
defaultValues={defaults}
|
||||
pathPrefix={`items.0.`}
|
||||
integrationIndex={0}
|
||||
notifiers={notifiersWithLegacyOnly}
|
||||
onDuplicate={jest.fn()}
|
||||
commonSettingsComponent={GrafanaCommonChannelSettings}
|
||||
isEditable={true}
|
||||
isTestable={false}
|
||||
canEditProtectedFields={true}
|
||||
/>
|
||||
</FormProvider>
|
||||
</AlertmanagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<LegacyOnlyTestWrapper
|
||||
defaults={{
|
||||
__id: 'id-0',
|
||||
type: 'webhook',
|
||||
settings: {},
|
||||
secureFields: {},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Webhook should be in dropdown (has v1 with canCreate: true)
|
||||
expect(ui.typeSelector.get()).toHaveTextContent('Webhook');
|
||||
|
||||
// WeChat should NOT be in the options (only has v0 with canCreate: false)
|
||||
// We can't easily check dropdown options without opening it, but the filter should work
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Controller, FieldErrors, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Alert, Badge, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { NotificationChannelOption } from 'app/features/alerting/unified/types/alerting';
|
||||
|
||||
import {
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
GrafanaChannelValues,
|
||||
ReceiverFormValues,
|
||||
} from '../../../types/receiver-form';
|
||||
import {
|
||||
canCreateNotifier,
|
||||
getLegacyVersionLabel,
|
||||
getOptionsForVersion,
|
||||
isLegacyVersion,
|
||||
} from '../../../utils/notifier-versions';
|
||||
import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
|
||||
|
||||
import { ChannelOptions } from './ChannelOptions';
|
||||
@@ -62,6 +68,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
|
||||
const channelFieldPath = `items.${integrationIndex}` as const;
|
||||
const typeFieldPath = `${channelFieldPath}.type` as const;
|
||||
const versionFieldPath = `${channelFieldPath}.version` as const;
|
||||
const settingsFieldPath = `${channelFieldPath}.settings` as const;
|
||||
const secureFieldsPath = `${channelFieldPath}.secureFields` as const;
|
||||
|
||||
@@ -104,6 +111,9 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
|
||||
setValue(settingsFieldPath, defaultNotifierSettings);
|
||||
setValue(secureFieldsPath, {});
|
||||
|
||||
// Reset version when changing type - backend will use its default
|
||||
setValue(versionFieldPath, undefined);
|
||||
}
|
||||
|
||||
// Restore initial value of an existing oncall integration
|
||||
@@ -123,6 +133,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
setValue,
|
||||
settingsFieldPath,
|
||||
typeFieldPath,
|
||||
versionFieldPath,
|
||||
secureFieldsPath,
|
||||
getValues,
|
||||
watch,
|
||||
@@ -164,24 +175,30 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
setValue(`${settingsFieldPath}.${fieldPath}`, undefined);
|
||||
};
|
||||
|
||||
const typeOptions = useMemo(
|
||||
(): SelectableValue[] =>
|
||||
sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map<SelectableValue>(
|
||||
({ dto: { name, type }, meta }) => ({
|
||||
// @ts-expect-error ReactNode is supported
|
||||
const typeOptions = useMemo((): SelectableValue[] => {
|
||||
// Filter out notifiers that can't be created (e.g., v0-only integrations like WeChat)
|
||||
// These are legacy integrations that only exist in Mimir and can't be created in Grafana
|
||||
const creatableNotifiers = notifiers.filter(({ dto }) => canCreateNotifier(dto));
|
||||
|
||||
return sortBy(creatableNotifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map<SelectableValue>(
|
||||
({ dto: { name, type }, meta }) => {
|
||||
return {
|
||||
// ReactNode is supported in Select label, but types don't reflect it
|
||||
/* eslint-disable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */
|
||||
label: (
|
||||
<Stack alignItems="center" gap={1}>
|
||||
{name}
|
||||
{meta?.badge}
|
||||
</Stack>
|
||||
),
|
||||
) as any,
|
||||
/* eslint-enable @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any */
|
||||
value: type,
|
||||
description: meta?.description,
|
||||
isDisabled: meta ? !meta.enabled : false,
|
||||
})
|
||||
),
|
||||
[notifiers]
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [notifiers]);
|
||||
|
||||
const handleTest = async () => {
|
||||
await trigger();
|
||||
@@ -198,10 +215,21 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
// Cloud AM takes no value at all
|
||||
const isParseModeNone = parse_mode === 'None' || !parse_mode;
|
||||
const showTelegramWarning = isTelegram && !isParseModeNone;
|
||||
|
||||
// Check if current integration is a legacy version (canCreate: false)
|
||||
// Legacy integrations are read-only and cannot be edited
|
||||
// Read version from existing integration data (stored in receiver config)
|
||||
const integrationVersion = initialValues?.version || defaultValues.version;
|
||||
const isLegacy = notifier ? isLegacyVersion(notifier.dto, integrationVersion) : false;
|
||||
|
||||
// Get the correct options based on the integration's version
|
||||
// This ensures legacy (v0) integrations display the correct schema
|
||||
const versionedOptions = notifier ? getOptionsForVersion(notifier.dto, integrationVersion) : [];
|
||||
|
||||
// if there are mandatory options defined, optional options will be hidden by a collapse
|
||||
// if there aren't mandatory options, all options will be shown without collapse
|
||||
const mandatoryOptions = notifier?.dto.options.filter((o) => o.required) ?? [];
|
||||
const optionalOptions = notifier?.dto.options.filter((o) => !o.required) ?? [];
|
||||
const mandatoryOptions = versionedOptions.filter((o) => o.required);
|
||||
const optionalOptions = versionedOptions.filter((o) => !o.required);
|
||||
|
||||
const contactPointTypeInputId = `contact-point-type-${pathPrefix}`;
|
||||
return (
|
||||
@@ -214,21 +242,35 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
data-testid={`${pathPrefix}type`}
|
||||
noMargin
|
||||
>
|
||||
<Controller
|
||||
name={typeFieldPath}
|
||||
control={control}
|
||||
defaultValue={defaultValues.type}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<Select
|
||||
disabled={!isEditable}
|
||||
inputId={contactPointTypeInputId}
|
||||
{...field}
|
||||
width={37}
|
||||
options={typeOptions}
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
<Stack direction="row" alignItems="center" gap={1}>
|
||||
<Controller
|
||||
name={typeFieldPath}
|
||||
control={control}
|
||||
defaultValue={defaultValues.type}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<Select
|
||||
disabled={!isEditable}
|
||||
inputId={contactPointTypeInputId}
|
||||
{...field}
|
||||
width={37}
|
||||
options={typeOptions}
|
||||
onChange={(value) => onChange(value?.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isLegacy && integrationVersion && (
|
||||
<Badge
|
||||
text={getLegacyVersionLabel(integrationVersion)}
|
||||
color="orange"
|
||||
icon="exclamation-triangle"
|
||||
tooltip={t(
|
||||
'alerting.channel-sub-form.tooltip-legacy-version',
|
||||
'This is a legacy integration (version: {{version}}). It cannot be modified.',
|
||||
{ version: integrationVersion }
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
@@ -292,7 +334,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
name: notifier.dto.name,
|
||||
})}
|
||||
>
|
||||
{notifier.dto.info !== '' && (
|
||||
{notifier.dto.info && (
|
||||
<Alert title="" severity="info">
|
||||
{notifier.dto.info}
|
||||
</Alert>
|
||||
|
||||
@@ -18,12 +18,13 @@ import {
|
||||
|
||||
import { alertmanagerApi } from '../../../api/alertmanagerApi';
|
||||
import { GrafanaChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
|
||||
import { hasLegacyIntegrations } from '../../../utils/notifier-versions';
|
||||
import {
|
||||
formChannelValuesToGrafanaChannelConfig,
|
||||
formValuesToGrafanaReceiver,
|
||||
grafanaReceiverToFormValues,
|
||||
} from '../../../utils/receiver-form';
|
||||
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
|
||||
import { ImportedContactPointAlert, ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
|
||||
import { ReceiverTypes } from '../grafanaAppReceivers/onCall/onCall';
|
||||
import { useOnCallIntegration } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
|
||||
|
||||
@@ -39,6 +40,8 @@ const defaultChannelValues: GrafanaChannelValues = Object.freeze({
|
||||
secureFields: {},
|
||||
disableResolveMessage: false,
|
||||
type: 'email',
|
||||
// version is intentionally not set here - it will be determined by the notifier's currentVersion
|
||||
// when the integration is created/type is changed. The backend will use its default if not provided.
|
||||
});
|
||||
|
||||
interface Props {
|
||||
@@ -67,7 +70,6 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
||||
} = useOnCallIntegration();
|
||||
|
||||
const { data: grafanaNotifiers = [], isLoading: isLoadingNotifiers } = useGrafanaNotifiersQuery();
|
||||
|
||||
const [testReceivers, setTestReceivers] = useState<Receiver[]>();
|
||||
|
||||
// transform receiver DTO to form values
|
||||
@@ -135,15 +137,20 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
||||
);
|
||||
}
|
||||
|
||||
// Map notifiers to Notifier[] format for ReceiverForm
|
||||
// The grafanaNotifiers include version-specific options via the versions array from the backend
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
const notifiers: Notifier[] = grafanaNotifiers.map((n) => {
|
||||
if (n.type === ReceiverTypes.OnCall) {
|
||||
return {
|
||||
dto: extendOnCallNotifierFeatures(n),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
dto: extendOnCallNotifierFeatures(n as any) as any,
|
||||
meta: onCallNotifierMeta,
|
||||
};
|
||||
}
|
||||
|
||||
return { dto: n };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions
|
||||
return { dto: n as any };
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -163,7 +170,12 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{contactPoint?.provisioned && <ProvisioningAlert resource={ProvisionedResource.ContactPoint} />}
|
||||
{contactPoint?.provisioned && hasLegacyIntegrations(contactPoint, grafanaNotifiers) && (
|
||||
<ImportedContactPointAlert />
|
||||
)}
|
||||
{contactPoint?.provisioned && !hasLegacyIntegrations(contactPoint, grafanaNotifiers) && (
|
||||
<ProvisioningAlert resource={ProvisionedResource.ContactPoint} />
|
||||
)}
|
||||
|
||||
<ReceiverForm<GrafanaChannelValues>
|
||||
contactPointId={contactPoint?.id}
|
||||
|
||||
@@ -6,12 +6,12 @@ import { t } from '@grafana/i18n';
|
||||
import { isFetchError } from '@grafana/runtime';
|
||||
import { Badge } from '@grafana/ui';
|
||||
import { NotifierDTO } from 'app/features/alerting/unified/types/alerting';
|
||||
import { getIrmIfPresentOrOnCallPluginId } from 'app/features/alerting/unified/utils/config';
|
||||
|
||||
import { useAppNotification } from '../../../../../../../core/copy/appNotification';
|
||||
import { Receiver } from '../../../../../../../plugins/datasource/alertmanager/types';
|
||||
import { ONCALL_INTEGRATION_V2_FEATURE, onCallApi } from '../../../../api/onCallApi';
|
||||
import { usePluginBridge } from '../../../../hooks/usePluginBridge';
|
||||
import { useIrmPlugin } from '../../../../hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from '../../../../types/pluginBridges';
|
||||
import { option } from '../../../../utils/notifier-types';
|
||||
|
||||
import { GRAFANA_ONCALL_INTEGRATION_TYPE, ReceiverTypes } from './onCall';
|
||||
@@ -39,16 +39,17 @@ enum OnCallIntegrationStatus {
|
||||
|
||||
function useOnCallPluginStatus() {
|
||||
const {
|
||||
pluginId,
|
||||
installed: isOnCallEnabled,
|
||||
loading: isPluginBridgeLoading,
|
||||
error: pluginError,
|
||||
} = usePluginBridge(getIrmIfPresentOrOnCallPluginId());
|
||||
} = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
|
||||
const {
|
||||
data: onCallFeatures = [],
|
||||
error: onCallFeaturesError,
|
||||
isLoading: isOnCallFeaturesLoading,
|
||||
} = onCallApi.endpoints.features.useQuery(undefined, { skip: !isOnCallEnabled });
|
||||
} = onCallApi.endpoints.features.useQuery({ pluginId }, { skip: !isOnCallEnabled });
|
||||
|
||||
const integrationStatus = useMemo((): OnCallIntegrationStatus => {
|
||||
if (!isOnCallEnabled) {
|
||||
@@ -67,6 +68,7 @@ function useOnCallPluginStatus() {
|
||||
);
|
||||
|
||||
return {
|
||||
pluginId,
|
||||
isOnCallEnabled,
|
||||
integrationStatus,
|
||||
isAlertingV2IntegrationEnabled,
|
||||
@@ -78,8 +80,14 @@ function useOnCallPluginStatus() {
|
||||
export function useOnCallIntegration() {
|
||||
const notifyApp = useAppNotification();
|
||||
|
||||
const { isOnCallEnabled, integrationStatus, isAlertingV2IntegrationEnabled, isOnCallStatusLoading, onCallError } =
|
||||
useOnCallPluginStatus();
|
||||
const {
|
||||
pluginId,
|
||||
isOnCallEnabled,
|
||||
integrationStatus,
|
||||
isAlertingV2IntegrationEnabled,
|
||||
isOnCallStatusLoading,
|
||||
onCallError,
|
||||
} = useOnCallPluginStatus();
|
||||
|
||||
const { useCreateIntegrationMutation, useGrafanaOnCallIntegrationsQuery, useLazyValidateIntegrationNameQuery } =
|
||||
onCallApi;
|
||||
@@ -91,13 +99,13 @@ export function useOnCallIntegration() {
|
||||
data: grafanaOnCallIntegrations = [],
|
||||
isLoading: isLoadingOnCallIntegrations,
|
||||
isError: isIntegrationsQueryError,
|
||||
} = useGrafanaOnCallIntegrationsQuery(undefined, { skip: !isAlertingV2IntegrationEnabled });
|
||||
} = useGrafanaOnCallIntegrationsQuery({ pluginId }, { skip: !isAlertingV2IntegrationEnabled });
|
||||
|
||||
const onCallFormValidators = useMemo(() => {
|
||||
return {
|
||||
integration_name: async (value: string) => {
|
||||
try {
|
||||
await validateIntegrationNameQuery(value).unwrap();
|
||||
await validateIntegrationNameQuery({ name: value, pluginId }).unwrap();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isFetchError(error) && error.status === 409) {
|
||||
@@ -126,7 +134,7 @@ export function useOnCallIntegration() {
|
||||
: t('alerting.irm-integration.integration-required', 'Selection of existing IRM integration is required');
|
||||
},
|
||||
};
|
||||
}, [grafanaOnCallIntegrations, validateIntegrationNameQuery, isAlertingV2IntegrationEnabled, notifyApp]);
|
||||
}, [grafanaOnCallIntegrations, validateIntegrationNameQuery, isAlertingV2IntegrationEnabled, notifyApp, pluginId]);
|
||||
|
||||
const extendOnCallReceivers = useCallback(
|
||||
(receiver: Receiver): Receiver => {
|
||||
@@ -159,6 +167,7 @@ export function useOnCallIntegration() {
|
||||
|
||||
const createNewOnCallIntegrationJobs = newOnCallIntegrations.map(async (c) => {
|
||||
const newIntegration = await createIntegrationMutation({
|
||||
pluginId,
|
||||
integration: GRAFANA_ONCALL_INTEGRATION_TYPE,
|
||||
verbal_name: c.settings[OnCallIntegrationSetting.IntegrationName],
|
||||
}).unwrap();
|
||||
@@ -180,7 +189,7 @@ export function useOnCallIntegration() {
|
||||
});
|
||||
});
|
||||
},
|
||||
[isAlertingV2IntegrationEnabled, createIntegrationMutation]
|
||||
[isAlertingV2IntegrationEnabled, createIntegrationMutation, pluginId]
|
||||
);
|
||||
|
||||
const extendOnCallNotifierFeatures = useCallback(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { GrafanaManagedReceiverConfig } from '../../../../../../plugins/datasource/alertmanager/types';
|
||||
import { OnCallIntegrationDTO } from '../../../api/onCallApi';
|
||||
import { getIrmIfPresentOrOnCallPluginId, getIsIrmPluginPresent } from '../../../utils/config';
|
||||
import { useIrmPlugin } from '../../../hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from '../../../types/pluginBridges';
|
||||
import { createBridgeURL } from '../../PluginBridge';
|
||||
|
||||
import { GRAFANA_APP_RECEIVERS_SOURCE_IMAGE } from './types';
|
||||
@@ -13,20 +16,34 @@ export interface ReceiverPluginMetadata {
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
const onCallReceiverICon = GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[getIrmIfPresentOrOnCallPluginId()];
|
||||
const onCallReceiverTitle = 'Grafana OnCall';
|
||||
|
||||
export const onCallReceiverMeta: ReceiverPluginMetadata = {
|
||||
title: onCallReceiverTitle,
|
||||
icon: onCallReceiverICon,
|
||||
};
|
||||
|
||||
export function getOnCallMetadata(
|
||||
export function useOnCallMetadata(
|
||||
onCallIntegrations: OnCallIntegrationDTO[] | undefined | null,
|
||||
receiver: GrafanaManagedReceiverConfig,
|
||||
hasAlertManagerConfigData = true
|
||||
): ReceiverPluginMetadata {
|
||||
const pluginName = getIsIrmPluginPresent() ? 'IRM' : 'OnCall';
|
||||
const { pluginId } = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
|
||||
return useMemo(
|
||||
() => getOnCallMetadata(onCallIntegrations, receiver, hasAlertManagerConfigData, pluginId),
|
||||
[onCallIntegrations, receiver, hasAlertManagerConfigData, pluginId]
|
||||
);
|
||||
}
|
||||
|
||||
export function getOnCallMetadata(
|
||||
onCallIntegrations: OnCallIntegrationDTO[] | undefined | null,
|
||||
receiver: GrafanaManagedReceiverConfig,
|
||||
hasAlertManagerConfigData = true,
|
||||
onCallPluginId?: SupportedPlugin
|
||||
): ReceiverPluginMetadata {
|
||||
const pluginId = onCallPluginId || SupportedPlugin.OnCall;
|
||||
const pluginName = pluginId === SupportedPlugin.Irm ? 'IRM' : 'OnCall';
|
||||
const onCallReceiverIcon = GRAFANA_APP_RECEIVERS_SOURCE_IMAGE[pluginId];
|
||||
const onCallReceiverTitle = pluginId === SupportedPlugin.Irm ? 'Grafana IRM' : 'Grafana OnCall';
|
||||
|
||||
const onCallReceiverMeta: ReceiverPluginMetadata = {
|
||||
title: onCallReceiverTitle,
|
||||
icon: onCallReceiverIcon,
|
||||
};
|
||||
|
||||
if (!hasAlertManagerConfigData) {
|
||||
return onCallReceiverMeta;
|
||||
@@ -57,7 +74,7 @@ export function getOnCallMetadata(
|
||||
...onCallReceiverMeta,
|
||||
description: matchingOnCallIntegration?.display_name,
|
||||
externalUrl: matchingOnCallIntegration
|
||||
? createBridgeURL(getIrmIfPresentOrOnCallPluginId(), `/integrations/${matchingOnCallIntegration.value}`)
|
||||
? createBridgeURL(pluginId, `/integrations/${matchingOnCallIntegration.value}`)
|
||||
: undefined,
|
||||
warning: matchingOnCallIntegration ? undefined : `${pluginName} Integration no longer exists`,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||
|
||||
import { pluginMeta } from '../testSetup/plugins';
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
import { useIrmPlugin } from './usePluginBridge';
|
||||
|
||||
jest.mock('app/features/plugins/pluginSettings');
|
||||
|
||||
const mockedGetPluginSettings = jest.mocked(getPluginSettings);
|
||||
|
||||
describe('useIrmPlugin', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return IRM plugin ID when IRM plugin is installed', async () => {
|
||||
mockedGetPluginSettings.mockImplementation((pluginId) => {
|
||||
if (pluginId === SupportedPlugin.Irm) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.Irm]);
|
||||
}
|
||||
if (pluginId === SupportedPlugin.OnCall) {
|
||||
return Promise.resolve({ ...pluginMeta[SupportedPlugin.OnCall], enabled: false });
|
||||
}
|
||||
return Promise.reject(new Error('Plugin not found'));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.Irm);
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.settings).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return OnCall plugin ID when IRM plugin is not installed', async () => {
|
||||
mockedGetPluginSettings.mockImplementation((pluginId) => {
|
||||
if (pluginId === SupportedPlugin.OnCall) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.OnCall]);
|
||||
}
|
||||
return Promise.reject(new Error('Plugin not found'));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.OnCall);
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.settings).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return Incident plugin ID when IRM plugin is not installed', async () => {
|
||||
mockedGetPluginSettings.mockImplementation((pluginId) => {
|
||||
if (pluginId === SupportedPlugin.Incident) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.Incident]);
|
||||
}
|
||||
return Promise.reject(new Error('Plugin not found'));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.Incident));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.Incident);
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.settings).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return loading state while fetching plugins', () => {
|
||||
mockedGetPluginSettings.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(pluginMeta[SupportedPlugin.Irm]), 100))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.installed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return installed undefined when neither plugin is installed', async () => {
|
||||
mockedGetPluginSettings.mockRejectedValue(new Error('Plugin not found'));
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.OnCall);
|
||||
expect(result.current.installed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return IRM plugin ID when both IRM and OnCall are installed', async () => {
|
||||
mockedGetPluginSettings.mockImplementation((pluginId) => {
|
||||
if (pluginId === SupportedPlugin.Irm) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.Irm]);
|
||||
}
|
||||
if (pluginId === SupportedPlugin.OnCall) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.OnCall]);
|
||||
}
|
||||
return Promise.reject(new Error('Plugin not found'));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.OnCall));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.Irm);
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.settings).toEqual(pluginMeta[SupportedPlugin.Irm]);
|
||||
});
|
||||
|
||||
it('should return IRM plugin ID when both IRM and Incident are installed', async () => {
|
||||
mockedGetPluginSettings.mockImplementation((pluginId) => {
|
||||
if (pluginId === SupportedPlugin.Irm) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.Irm]);
|
||||
}
|
||||
if (pluginId === SupportedPlugin.Incident) {
|
||||
return Promise.resolve(pluginMeta[SupportedPlugin.Incident]);
|
||||
}
|
||||
return Promise.reject(new Error('Plugin not found'));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIrmPlugin(SupportedPlugin.Incident));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.pluginId).toBe(SupportedPlugin.Irm);
|
||||
expect(result.current.installed).toBe(true);
|
||||
expect(result.current.settings).toEqual(pluginMeta[SupportedPlugin.Irm]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { PluginMeta } from '@grafana/data';
|
||||
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
|
||||
|
||||
import { PluginID } from '../components/PluginBridge';
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
interface PluginBridgeHookResponse {
|
||||
loading: boolean;
|
||||
installed?: boolean;
|
||||
@@ -14,17 +16,54 @@ interface PluginBridgeHookResponse {
|
||||
export function usePluginBridge(plugin: PluginID): PluginBridgeHookResponse {
|
||||
const { loading, error, value } = useAsync(() => getPluginSettings(plugin, { showErrorAlert: false }));
|
||||
|
||||
const installed = value && !error && !loading;
|
||||
const enabled = value?.enabled;
|
||||
const isLoading = loading && !value;
|
||||
|
||||
if (isLoading) {
|
||||
if (loading) {
|
||||
return { loading: true };
|
||||
}
|
||||
|
||||
if (!installed || !enabled) {
|
||||
return { loading: false, installed: false };
|
||||
if (error) {
|
||||
return { loading, error };
|
||||
}
|
||||
|
||||
return { loading, installed: true, settings: value };
|
||||
if (value) {
|
||||
return { loading, installed: value.enabled ?? false, settings: value };
|
||||
}
|
||||
|
||||
return { loading, installed: false };
|
||||
}
|
||||
|
||||
type FallbackPlugin = SupportedPlugin.OnCall | SupportedPlugin.Incident;
|
||||
type IrmWithFallback = SupportedPlugin.Irm | FallbackPlugin;
|
||||
|
||||
export interface PluginBridgeResult {
|
||||
pluginId: IrmWithFallback;
|
||||
loading: boolean;
|
||||
installed?: boolean;
|
||||
error?: Error;
|
||||
settings?: PluginMeta<{}>;
|
||||
}
|
||||
/**
|
||||
* Hook that checks for IRM plugin first, falls back to specified plugin.
|
||||
* IRM replaced both OnCall and Incident - this provides backward compatibility.
|
||||
*
|
||||
* @param fallback - The plugin to use if IRM is not installed (OnCall or Incident)
|
||||
* @returns Bridge result with the active plugin data
|
||||
*
|
||||
* @example
|
||||
* const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
*/
|
||||
export function useIrmPlugin(fallback: FallbackPlugin): PluginBridgeResult {
|
||||
const irmBridge = usePluginBridge(SupportedPlugin.Irm);
|
||||
const fallbackBridge = usePluginBridge(fallback);
|
||||
|
||||
const loading = irmBridge.loading || fallbackBridge.loading;
|
||||
const pluginId = irmBridge.installed ? SupportedPlugin.Irm : fallback;
|
||||
const activeBridge = irmBridge.installed ? irmBridge : fallbackBridge;
|
||||
|
||||
return {
|
||||
pluginId,
|
||||
loading,
|
||||
installed: activeBridge.installed,
|
||||
error: activeBridge.error,
|
||||
settings: activeBridge.settings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type DefaultBodyType, HttpResponse, HttpResponseResolver, PathParams, http } from 'msw';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import server from '@grafana/test-utils/server';
|
||||
import { mockDataSource, mockFolder } from 'app/features/alerting/unified/mocks';
|
||||
import {
|
||||
@@ -10,10 +9,7 @@ import {
|
||||
} from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers';
|
||||
import { getFolderHandler } from 'app/features/alerting/unified/mocks/server/handlers/folders';
|
||||
import { listNamespacedTimeIntervalHandler } from 'app/features/alerting/unified/mocks/server/handlers/k8s/timeIntervals.k8s';
|
||||
import {
|
||||
getDisabledPluginHandler,
|
||||
getPluginMissingHandler,
|
||||
} from 'app/features/alerting/unified/mocks/server/handlers/plugins';
|
||||
import { getDisabledPluginHandler } from 'app/features/alerting/unified/mocks/server/handlers/plugins';
|
||||
import {
|
||||
ALERTING_API_SERVER_BASE_URL,
|
||||
getK8sResponse,
|
||||
@@ -212,12 +208,6 @@ export function setGrafanaPromRules(groups: GrafanaPromRuleGroupDTO[]) {
|
||||
server.use(http.get(`/api/prometheus/grafana/api/v1/rules`, paginatedHandlerFor(groups)));
|
||||
}
|
||||
|
||||
/** Make a given plugin ID respond with a 404, as if it isn't installed at all */
|
||||
export const removePlugin = (pluginId: string) => {
|
||||
delete config.apps[pluginId];
|
||||
server.use(getPluginMissingHandler(pluginId));
|
||||
};
|
||||
|
||||
/** Make a plugin respond with `enabled: false`, as if its installed but disabled */
|
||||
export const disablePlugin = (pluginId: SupportedPlugin) => {
|
||||
clearPluginSettingsCache(pluginId);
|
||||
|
||||
@@ -455,6 +455,25 @@ describe('grafana-managed rules', () => {
|
||||
expect(frontendFilter.ruleMatches(regularRule)).toBe(true);
|
||||
expect(frontendFilter.ruleMatches(pluginRule)).toBe(true);
|
||||
});
|
||||
|
||||
it('should include searchFolder in backend filter when namespace is provided', () => {
|
||||
const { backendFilter } = getGrafanaFilter(getFilter({ namespace: 'my-folder' }));
|
||||
|
||||
expect(backendFilter.searchFolder).toBe('my-folder');
|
||||
});
|
||||
|
||||
it('should skip namespace filtering on frontend when backend filtering is enabled', () => {
|
||||
const group: PromRuleGroupDTO = {
|
||||
name: 'Test Group',
|
||||
file: 'production/alerts',
|
||||
rules: [],
|
||||
interval: 60,
|
||||
};
|
||||
|
||||
const { frontendFilter } = getGrafanaFilter(getFilter({ namespace: 'staging' }));
|
||||
// Should return true because namespace filter is null (handled by backend)
|
||||
expect(frontendFilter.groupMatches(group)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when alertingUIUseBackendFilters is disabled', () => {
|
||||
@@ -537,6 +556,12 @@ describe('grafana-managed rules', () => {
|
||||
expect(backendFilter.searchGroupName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include searchFolder in backend filter', () => {
|
||||
const { backendFilter } = getGrafanaFilter(getFilter({ namespace: 'my-folder' }));
|
||||
|
||||
expect(backendFilter.searchFolder).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should perform groupName filtering on frontend', () => {
|
||||
const group: PromRuleGroupDTO = {
|
||||
name: 'CPU Usage Alerts',
|
||||
@@ -706,8 +731,8 @@ describe('grafana-managed rules', () => {
|
||||
expect(frontendFilter.groupMatches(group)).toBe(true);
|
||||
});
|
||||
|
||||
it('should still apply always-frontend filters (namespace)', () => {
|
||||
// Namespace filter should still work
|
||||
it('should skip namespace filtering on frontend', () => {
|
||||
// Namespace filter should be handled by backend
|
||||
const group: PromRuleGroupDTO = {
|
||||
name: 'Test Group',
|
||||
file: 'production/alerts',
|
||||
@@ -719,7 +744,7 @@ describe('grafana-managed rules', () => {
|
||||
expect(nsFilter.groupMatches(group)).toBe(true);
|
||||
|
||||
const { frontendFilter: nsFilter2 } = getGrafanaFilter(getFilter({ namespace: 'staging' }));
|
||||
expect(nsFilter2.groupMatches(group)).toBe(false);
|
||||
expect(nsFilter2.groupMatches(group)).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip dataSourceNames filtering on frontend (handled by backend)', () => {
|
||||
@@ -807,8 +832,8 @@ describe('grafana-managed rules', () => {
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for client-side only filters', () => {
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
|
||||
it('should return false for namespace filter (handled by backend)', () => {
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for plugins filter (handled by backend when feature toggle is enabled)', () => {
|
||||
@@ -862,8 +887,8 @@ describe('grafana-managed rules', () => {
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
|
||||
|
||||
// Should return true for: always-frontend filters only (namespace)
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
|
||||
// Should return false for: namespace (handled by backend)
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(false);
|
||||
|
||||
// plugins is backend-handled when both feature toggles are enabled
|
||||
expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false);
|
||||
|
||||
@@ -96,6 +96,7 @@ export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
|
||||
datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids,
|
||||
ruleMatchers: ruleMatchersBackendFilter,
|
||||
plugins: ruleFilterConfig.plugins ? undefined : normalizedFilterState.plugins,
|
||||
searchFolder: groupFilterConfig.namespace ? undefined : normalizedFilterState.namespace,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -134,7 +135,7 @@ function buildGrafanaFilterConfigs() {
|
||||
};
|
||||
|
||||
const groupFilterConfig: GroupFilterConfig = {
|
||||
namespace: namespaceFilter,
|
||||
namespace: useBackendFilters ? null : namespaceFilter,
|
||||
groupName: useBackendFilters ? null : groupNameFilter,
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ interface GrafanaPromApiFilter {
|
||||
contactPoint?: string;
|
||||
title?: string;
|
||||
searchGroupName?: string;
|
||||
searchFolder?: string;
|
||||
type?: 'alerting' | 'recording';
|
||||
dashboardUid?: string;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ describe('paginationLimits', () => {
|
||||
{ contactPoint: 'slack' },
|
||||
{ dataSourceNames: ['prometheus'] },
|
||||
{ labels: ['severity=critical'] },
|
||||
{ namespace: 'production' },
|
||||
])(
|
||||
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
|
||||
(filterState) => {
|
||||
@@ -84,16 +85,6 @@ describe('paginationLimits', () => {
|
||||
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
}
|
||||
);
|
||||
|
||||
it.each<Partial<RulesFilter>>([
|
||||
{ namespace: 'production' },
|
||||
{ ruleState: PromAlertingRuleState.Firing, namespace: 'production' },
|
||||
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
|
||||
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
|
||||
|
||||
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => {
|
||||
@@ -158,6 +149,7 @@ describe('paginationLimits', () => {
|
||||
{ contactPoint: 'slack' },
|
||||
{ dataSourceNames: ['prometheus'] },
|
||||
{ labels: ['severity=critical'] },
|
||||
{ namespace: 'production' },
|
||||
])(
|
||||
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
|
||||
(filterState) => {
|
||||
@@ -167,16 +159,6 @@ describe('paginationLimits', () => {
|
||||
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
}
|
||||
);
|
||||
|
||||
it.each<Partial<RulesFilter>>([{ namespace: 'production' }])(
|
||||
'should return large limits for both when frontend filters are used: %p',
|
||||
(filterState) => {
|
||||
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
|
||||
|
||||
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,20 @@ export type CloudNotifierType =
|
||||
| 'jira';
|
||||
|
||||
export type NotifierType = GrafanaNotifierType | CloudNotifierType;
|
||||
|
||||
/**
|
||||
* Represents a specific version of a notifier integration
|
||||
* Used for integration versioning during Single Alert Manager migration
|
||||
*/
|
||||
export interface NotifierVersion {
|
||||
version: string;
|
||||
label: string;
|
||||
description: string;
|
||||
options: NotificationChannelOption[];
|
||||
/** Whether this version can be used to create new integrations */
|
||||
canCreate?: boolean;
|
||||
}
|
||||
|
||||
export interface NotifierDTO<T = NotifierType> {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -88,6 +102,23 @@ export interface NotifierDTO<T = NotifierType> {
|
||||
options: NotificationChannelOption[];
|
||||
info?: string;
|
||||
secure?: boolean;
|
||||
/**
|
||||
* Available versions for this notifier from the backend
|
||||
* Each version contains version-specific options and metadata
|
||||
*/
|
||||
versions?: NotifierVersion[];
|
||||
/**
|
||||
* The default version that the backend will use when creating new integrations.
|
||||
* Returned by the backend from /api/alert-notifiers?version=2
|
||||
*
|
||||
* - "v1" for most notifiers (modern Grafana version)
|
||||
* - "v0mimir1" for legacy-only notifiers (e.g., WeChat)
|
||||
*
|
||||
* Note: Currently not used in the frontend. The backend handles version
|
||||
* selection automatically. Could be used in the future to display
|
||||
* version information or validate notifier capabilities.
|
||||
*/
|
||||
currentVersion?: string;
|
||||
}
|
||||
|
||||
export interface NotificationChannelType {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ControlledField } from '../hooks/useControlledFieldArray';
|
||||
export interface ChannelValues {
|
||||
__id: string; // used to correlate form values to original DTOs
|
||||
type: string;
|
||||
version?: string; // Integration version (e.g. "v0" for Mimir legacy, "v1" for Grafana)
|
||||
settings: Record<string, any>;
|
||||
secureFields: Record<string, boolean | ''>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { pluginMeta, pluginMetaToPluginConfig } from '../testSetup/plugins';
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
import {
|
||||
checkEvaluationIntervalGlobalLimit,
|
||||
getIrmIfPresentOrIncidentPluginId,
|
||||
getIrmIfPresentOrOnCallPluginId,
|
||||
getIsIrmPluginPresent,
|
||||
} from './config';
|
||||
import { checkEvaluationIntervalGlobalLimit } from './config';
|
||||
|
||||
describe('checkEvaluationIntervalGlobalLimit', () => {
|
||||
it('should NOT exceed limit if evaluate every is not valid duration', () => {
|
||||
@@ -59,48 +51,3 @@ describe('checkEvaluationIntervalGlobalLimit', () => {
|
||||
expect(exceedsLimit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIsIrmPluginPresent', () => {
|
||||
it('should return true when IRM plugin is present in config.apps', () => {
|
||||
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
|
||||
expect(getIsIrmPluginPresent()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when IRM plugin is not present in config.apps', () => {
|
||||
config.apps = {
|
||||
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
|
||||
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
|
||||
};
|
||||
expect(getIsIrmPluginPresent()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIrmIfPresentOrIncidentPluginId', () => {
|
||||
it('should return IRM plugin ID when IRM plugin is present', () => {
|
||||
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
|
||||
expect(getIrmIfPresentOrIncidentPluginId()).toBe(SupportedPlugin.Irm);
|
||||
});
|
||||
|
||||
it('should return Incident plugin ID when IRM plugin is not present', () => {
|
||||
config.apps = {
|
||||
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
|
||||
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
|
||||
};
|
||||
expect(getIrmIfPresentOrIncidentPluginId()).toBe(SupportedPlugin.Incident);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIrmIfPresentOrOnCallPluginId', () => {
|
||||
it('should return IRM plugin ID when IRM plugin is present', () => {
|
||||
config.apps = { [SupportedPlugin.Irm]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Irm]) };
|
||||
expect(getIrmIfPresentOrOnCallPluginId()).toBe(SupportedPlugin.Irm);
|
||||
});
|
||||
|
||||
it('should return OnCall plugin ID when IRM plugin is not present', () => {
|
||||
config.apps = {
|
||||
[SupportedPlugin.OnCall]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.OnCall]),
|
||||
[SupportedPlugin.Incident]: pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Incident]),
|
||||
};
|
||||
expect(getIrmIfPresentOrOnCallPluginId()).toBe(SupportedPlugin.OnCall);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { SupportedPlugin } from '../types/pluginBridges';
|
||||
|
||||
import { isValidPrometheusDuration, safeParsePrometheusDuration } from './time';
|
||||
|
||||
export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> {
|
||||
@@ -28,15 +26,3 @@ export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery?: str
|
||||
|
||||
return { globalLimit: evaluateEveryGlobalLimitMs, exceedsLimit };
|
||||
}
|
||||
|
||||
export function getIsIrmPluginPresent() {
|
||||
return SupportedPlugin.Irm in config.apps;
|
||||
}
|
||||
|
||||
export function getIrmIfPresentOrIncidentPluginId() {
|
||||
return getIsIrmPluginPresent() ? SupportedPlugin.Irm : SupportedPlugin.Incident;
|
||||
}
|
||||
|
||||
export function getIrmIfPresentOrOnCallPluginId() {
|
||||
return getIsIrmPluginPresent() ? SupportedPlugin.Irm : SupportedPlugin.OnCall;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { NotificationChannelOption, NotifierDTO, NotifierVersion } from '../types/alerting';
|
||||
|
||||
import {
|
||||
canCreateNotifier,
|
||||
getLegacyVersionLabel,
|
||||
getOptionsForVersion,
|
||||
hasLegacyIntegrations,
|
||||
isLegacyVersion,
|
||||
} from './notifier-versions';
|
||||
|
||||
// Helper to create a minimal NotifierDTO for testing
|
||||
function createNotifier(overrides: Partial<NotifierDTO> = {}): NotifierDTO {
|
||||
return {
|
||||
name: 'Test Notifier',
|
||||
description: 'Test description',
|
||||
type: 'webhook',
|
||||
heading: 'Test heading',
|
||||
options: [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Default Option',
|
||||
description: 'Default option description',
|
||||
placeholder: '',
|
||||
propertyName: 'defaultOption',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a NotifierVersion for testing
|
||||
function createVersion(overrides: Partial<NotifierVersion> = {}): NotifierVersion {
|
||||
return {
|
||||
version: 'v1',
|
||||
label: 'Test Version',
|
||||
description: 'Test version description',
|
||||
options: [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Version Option',
|
||||
description: 'Version option description',
|
||||
placeholder: '',
|
||||
propertyName: 'versionOption',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('notifier-versions utilities', () => {
|
||||
describe('canCreateNotifier', () => {
|
||||
it('should return true if notifier has no versions array', () => {
|
||||
const notifier = createNotifier({ versions: undefined });
|
||||
expect(canCreateNotifier(notifier)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if notifier has empty versions array', () => {
|
||||
const notifier = createNotifier({ versions: [] });
|
||||
expect(canCreateNotifier(notifier)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if at least one version has canCreate: true', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: true }),
|
||||
],
|
||||
});
|
||||
expect(canCreateNotifier(notifier)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if at least one version has canCreate: undefined (defaults to true)', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: undefined }),
|
||||
],
|
||||
});
|
||||
expect(canCreateNotifier(notifier)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if all versions have canCreate: false', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v0mimir2', canCreate: false }),
|
||||
],
|
||||
});
|
||||
expect(canCreateNotifier(notifier)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for notifiers like WeChat that only have legacy versions', () => {
|
||||
const wechatNotifier = createNotifier({
|
||||
name: 'WeChat',
|
||||
type: 'wechat',
|
||||
versions: [createVersion({ version: 'v0mimir1', canCreate: false })],
|
||||
});
|
||||
expect(canCreateNotifier(wechatNotifier)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLegacyVersion', () => {
|
||||
it('should return false if no version is specified', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [createVersion({ version: 'v0mimir1', canCreate: false })],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, undefined)).toBe(false);
|
||||
expect(isLegacyVersion(notifier, '')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if notifier has no versions array', () => {
|
||||
const notifier = createNotifier({ versions: undefined });
|
||||
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if notifier has empty versions array', () => {
|
||||
const notifier = createNotifier({ versions: [] });
|
||||
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if version is not found in versions array', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [createVersion({ version: 'v1', canCreate: true })],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if version has canCreate: true', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [createVersion({ version: 'v1', canCreate: true })],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, 'v1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if version has canCreate: undefined', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [createVersion({ version: 'v1', canCreate: undefined })],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, 'v1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if version has canCreate: false', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: true }),
|
||||
],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly identify legacy versions in a mixed notifier', () => {
|
||||
const notifier = createNotifier({
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v0mimir2', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: true }),
|
||||
],
|
||||
});
|
||||
expect(isLegacyVersion(notifier, 'v0mimir1')).toBe(true);
|
||||
expect(isLegacyVersion(notifier, 'v0mimir2')).toBe(true);
|
||||
expect(isLegacyVersion(notifier, 'v1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOptionsForVersion', () => {
|
||||
const defaultOptions: NotificationChannelOption[] = [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Default URL',
|
||||
description: 'Default URL description',
|
||||
placeholder: '',
|
||||
propertyName: 'url',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
];
|
||||
|
||||
const v0Options: NotificationChannelOption[] = [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Legacy URL',
|
||||
description: 'Legacy URL description',
|
||||
placeholder: '',
|
||||
propertyName: 'legacyUrl',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
];
|
||||
|
||||
const v1Options: NotificationChannelOption[] = [
|
||||
{
|
||||
element: 'input',
|
||||
inputType: 'text',
|
||||
label: 'Modern URL',
|
||||
description: 'Modern URL description',
|
||||
placeholder: '',
|
||||
propertyName: 'modernUrl',
|
||||
required: true,
|
||||
secure: false,
|
||||
showWhen: { field: '', is: '' },
|
||||
validationRule: '',
|
||||
dependsOn: '',
|
||||
},
|
||||
];
|
||||
|
||||
it('should return options from default creatable version if no version is specified', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })],
|
||||
});
|
||||
// When no version specified, should use options from the default creatable version
|
||||
expect(getOptionsForVersion(notifier, undefined)).toBe(v1Options);
|
||||
});
|
||||
|
||||
it('should return default options if no version is specified and empty string is passed', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [createVersion({ version: 'v1', options: v1Options, canCreate: true })],
|
||||
});
|
||||
// Empty string is still a falsy version, so should use default creatable version
|
||||
expect(getOptionsForVersion(notifier, '')).toBe(v1Options);
|
||||
});
|
||||
|
||||
it('should return default options if notifier has no versions array', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: undefined,
|
||||
});
|
||||
expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions);
|
||||
});
|
||||
|
||||
it('should return default options if notifier has empty versions array', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [],
|
||||
});
|
||||
expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions);
|
||||
});
|
||||
|
||||
it('should return default options if version is not found', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [createVersion({ version: 'v1', options: v1Options })],
|
||||
});
|
||||
expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(defaultOptions);
|
||||
});
|
||||
|
||||
it('should return version-specific options when version is found', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', options: v0Options }),
|
||||
createVersion({ version: 'v1', options: v1Options }),
|
||||
],
|
||||
});
|
||||
expect(getOptionsForVersion(notifier, 'v0mimir1')).toBe(v0Options);
|
||||
expect(getOptionsForVersion(notifier, 'v1')).toBe(v1Options);
|
||||
});
|
||||
|
||||
it('should return default options if version found but has no options', () => {
|
||||
const notifier = createNotifier({
|
||||
options: defaultOptions,
|
||||
versions: [
|
||||
{
|
||||
version: 'v1',
|
||||
label: 'V1',
|
||||
description: 'V1 description',
|
||||
options: undefined as unknown as NotificationChannelOption[],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(getOptionsForVersion(notifier, 'v1')).toBe(defaultOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasLegacyIntegrations', () => {
|
||||
// Helper to create a minimal contact point for testing
|
||||
function createContactPoint(overrides: Partial<GrafanaManagedContactPoint> = {}): GrafanaManagedContactPoint {
|
||||
return {
|
||||
name: 'Test Contact Point',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Create notifiers with version info for testing
|
||||
const notifiersWithVersions: NotifierDTO[] = [
|
||||
createNotifier({
|
||||
type: 'slack',
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: true }),
|
||||
],
|
||||
}),
|
||||
createNotifier({
|
||||
type: 'webhook',
|
||||
versions: [
|
||||
createVersion({ version: 'v0mimir1', canCreate: false }),
|
||||
createVersion({ version: 'v0mimir2', canCreate: false }),
|
||||
createVersion({ version: 'v1', canCreate: true }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
it('should return false if contact point is undefined', () => {
|
||||
expect(hasLegacyIntegrations(undefined, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if notifiers is undefined', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [{ type: 'slack', settings: {}, version: 'v0mimir1' }],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if contact point has no integrations', () => {
|
||||
const contactPoint = createContactPoint({ grafana_managed_receiver_configs: undefined });
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if contact point has empty integrations array', () => {
|
||||
const contactPoint = createContactPoint({ grafana_managed_receiver_configs: [] });
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if all integrations have v1 version (canCreate: true)', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [
|
||||
{ type: 'slack', settings: {}, version: 'v1' },
|
||||
{ type: 'webhook', settings: {}, version: 'v1' },
|
||||
],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if all integrations have no version', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [
|
||||
{ type: 'slack', settings: {} },
|
||||
{ type: 'webhook', settings: {} },
|
||||
],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if any integration has a legacy version (canCreate: false)', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [
|
||||
{ type: 'slack', settings: {}, version: 'v0mimir1' },
|
||||
{ type: 'webhook', settings: {}, version: 'v1' },
|
||||
],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if all integrations have legacy versions', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [
|
||||
{ type: 'slack', settings: {}, version: 'v0mimir1' },
|
||||
{ type: 'webhook', settings: {}, version: 'v0mimir2' },
|
||||
],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if notifier type is not found in notifiers array', () => {
|
||||
const contactPoint = createContactPoint({
|
||||
grafana_managed_receiver_configs: [{ type: 'unknown', settings: {}, version: 'v0mimir1' }],
|
||||
});
|
||||
expect(hasLegacyIntegrations(contactPoint, notifiersWithVersions)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLegacyVersionLabel', () => {
|
||||
it('should return "Legacy" for undefined version', () => {
|
||||
expect(getLegacyVersionLabel(undefined)).toBe('Legacy');
|
||||
});
|
||||
|
||||
it('should return "Legacy" for empty string version', () => {
|
||||
expect(getLegacyVersionLabel('')).toBe('Legacy');
|
||||
});
|
||||
|
||||
it('should return "Legacy" for v0mimir1', () => {
|
||||
expect(getLegacyVersionLabel('v0mimir1')).toBe('Legacy');
|
||||
});
|
||||
|
||||
it('should return "Legacy v2" for v0mimir2', () => {
|
||||
expect(getLegacyVersionLabel('v0mimir2')).toBe('Legacy v2');
|
||||
});
|
||||
|
||||
it('should return "Legacy v3" for v0mimir3', () => {
|
||||
expect(getLegacyVersionLabel('v0mimir3')).toBe('Legacy v3');
|
||||
});
|
||||
|
||||
it('should return "Legacy" for v1 (trailing 1)', () => {
|
||||
expect(getLegacyVersionLabel('v1')).toBe('Legacy');
|
||||
});
|
||||
|
||||
it('should return "Legacy v2" for v2 (trailing 2)', () => {
|
||||
expect(getLegacyVersionLabel('v2')).toBe('Legacy v2');
|
||||
});
|
||||
|
||||
it('should return "Legacy" for version strings without trailing number', () => {
|
||||
expect(getLegacyVersionLabel('legacy')).toBe('Legacy');
|
||||
});
|
||||
});
|
||||
});
|
||||
126
public/app/features/alerting/unified/utils/notifier-versions.ts
Normal file
126
public/app/features/alerting/unified/utils/notifier-versions.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Utilities for integration versioning
|
||||
*
|
||||
* These utilities help get version-specific options from the backend response
|
||||
* (via /api/alert-notifiers?version=2)
|
||||
*/
|
||||
|
||||
import { GrafanaManagedContactPoint } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
import { NotificationChannelOption, NotifierDTO } from '../types/alerting';
|
||||
|
||||
/**
|
||||
* Checks if a notifier can be used to create new integrations.
|
||||
* A notifier can be created if it has at least one version with canCreate: true,
|
||||
* or if it has no versions array (legacy behavior).
|
||||
*
|
||||
* @param notifier - The notifier DTO to check
|
||||
* @returns True if the notifier can be used to create new integrations
|
||||
*/
|
||||
export function canCreateNotifier(notifier: NotifierDTO): boolean {
|
||||
// If no versions array, assume it can be created (legacy behavior)
|
||||
if (!notifier.versions || notifier.versions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if any version has canCreate: true (or undefined, which defaults to true)
|
||||
return notifier.versions.some((v) => v.canCreate !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific version is legacy (cannot be created).
|
||||
* A version is legacy if it has canCreate: false in the notifier's versions array.
|
||||
*
|
||||
* @param notifier - The notifier DTO containing versions array
|
||||
* @param version - The version string to check (e.g., 'v0mimir1', 'v1')
|
||||
* @returns True if the version is legacy (canCreate: false)
|
||||
*/
|
||||
export function isLegacyVersion(notifier: NotifierDTO, version?: string): boolean {
|
||||
// If no version specified or no versions array, it's not legacy
|
||||
if (!version || !notifier.versions || notifier.versions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the matching version and check its canCreate property
|
||||
const versionData = notifier.versions.find((v) => v.version === version);
|
||||
|
||||
// A version is legacy if canCreate is explicitly false
|
||||
return versionData?.canCreate === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the options for a specific version of a notifier.
|
||||
* Used to display the correct form fields based on integration version.
|
||||
*
|
||||
* @param notifier - The notifier DTO containing versions array
|
||||
* @param version - The version to get options for (e.g., 'v0', 'v1')
|
||||
* @returns The options for the specified version, or default options if version not found
|
||||
*/
|
||||
export function getOptionsForVersion(notifier: NotifierDTO, version?: string): NotificationChannelOption[] {
|
||||
// If no versions array, use default options
|
||||
if (!notifier.versions || notifier.versions.length === 0) {
|
||||
return notifier.options;
|
||||
}
|
||||
|
||||
// If version is specified, find the matching version
|
||||
if (version) {
|
||||
const versionData = notifier.versions.find((v) => v.version === version);
|
||||
// Return version-specific options if found, otherwise fall back to default
|
||||
return versionData?.options ?? notifier.options;
|
||||
}
|
||||
|
||||
// If no version specified, find the default creatable version (canCreate !== false)
|
||||
const defaultVersion = notifier.versions.find((v) => v.canCreate !== false);
|
||||
return defaultVersion?.options ?? notifier.options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a contact point has any legacy (imported) integrations.
|
||||
* A contact point has legacy integrations if any of its integrations uses a version
|
||||
* with canCreate: false in the corresponding notifier's versions array.
|
||||
*
|
||||
* @param contactPoint - The contact point to check
|
||||
* @param notifiers - Array of notifier DTOs to look up version info
|
||||
* @returns True if the contact point has at least one legacy/imported integration
|
||||
*/
|
||||
export function hasLegacyIntegrations(contactPoint?: GrafanaManagedContactPoint, notifiers?: NotifierDTO[]): boolean {
|
||||
if (!contactPoint?.grafana_managed_receiver_configs || !notifiers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return contactPoint.grafana_managed_receiver_configs.some((config) => {
|
||||
const notifier = notifiers.find((n) => n.type === config.type);
|
||||
return notifier ? isLegacyVersion(notifier, config.version) : false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user-friendly label for a legacy version.
|
||||
* Extracts the version number from the version string and formats it as:
|
||||
* - "Legacy" for version 1 (e.g., v0mimir1)
|
||||
* - "Legacy v2" for version 2 (e.g., v0mimir2)
|
||||
* - etc.
|
||||
*
|
||||
* Precondition: This function assumes the version is already known to be legacy
|
||||
* (i.e., canCreate: false). Use isLegacyVersion() to check before calling this.
|
||||
*
|
||||
* @param version - The version string (e.g., 'v0mimir1', 'v0mimir2')
|
||||
* @returns A user-friendly label like "Legacy" or "Legacy v2"
|
||||
*/
|
||||
export function getLegacyVersionLabel(version?: string): string {
|
||||
if (!version) {
|
||||
return 'Legacy';
|
||||
}
|
||||
|
||||
// Extract trailing number from version string (e.g., v0mimir1 → 1, v0mimir2 → 2)
|
||||
const match = version.match(/(\d+)$/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
if (num === 1) {
|
||||
return 'Legacy';
|
||||
}
|
||||
return `Legacy v${num}`;
|
||||
}
|
||||
|
||||
return 'Legacy';
|
||||
}
|
||||
@@ -185,6 +185,7 @@ function grafanaChannelConfigToFormChannelValues(
|
||||
const values: GrafanaChannelValues = {
|
||||
__id: id,
|
||||
type: channel.type as NotifierType,
|
||||
version: channel.version,
|
||||
provenance: channel.provenance,
|
||||
settings: { ...channel.settings },
|
||||
secureFields: { ...channel.secureFields },
|
||||
@@ -239,6 +240,7 @@ export function formChannelValuesToGrafanaChannelConfig(
|
||||
}),
|
||||
secureFields: secureFieldsFromValues,
|
||||
type: values.type,
|
||||
version: values.version ?? existing?.version,
|
||||
name,
|
||||
disableResolveMessage:
|
||||
values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.disableResolveMessage,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { incidentsApi } from 'app/features/alerting/unified/api/incidentsApi';
|
||||
import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge';
|
||||
import { getIrmIfPresentOrIncidentPluginId } from 'app/features/alerting/unified/utils/config';
|
||||
import { useIrmPlugin } from 'app/features/alerting/unified/hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
|
||||
|
||||
interface IncidentsPluginConfig {
|
||||
isInstalled: boolean;
|
||||
@@ -10,11 +10,13 @@ interface IncidentsPluginConfig {
|
||||
}
|
||||
|
||||
export function useGetIncidentPluginConfig(): IncidentsPluginConfig {
|
||||
const { installed: incidentPluginInstalled, loading: loadingPluginSettings } = usePluginBridge(
|
||||
getIrmIfPresentOrIncidentPluginId()
|
||||
);
|
||||
const {
|
||||
pluginId,
|
||||
installed: incidentPluginInstalled,
|
||||
loading: loadingPluginSettings,
|
||||
} = useIrmPlugin(SupportedPlugin.Incident);
|
||||
const { data: incidentsConfig, isLoading: loadingPluginConfig } =
|
||||
incidentsApi.endpoints.getIncidentsPluginConfig.useQuery();
|
||||
incidentsApi.endpoints.getIncidentsPluginConfig.useQuery({ pluginId });
|
||||
|
||||
return {
|
||||
isInstalled: incidentPluginInstalled ?? false,
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { onCallApi } from 'app/features/alerting/unified/api/onCallApi';
|
||||
import { usePluginBridge } from 'app/features/alerting/unified/hooks/usePluginBridge';
|
||||
import { getIrmIfPresentOrOnCallPluginId } from 'app/features/alerting/unified/utils/config';
|
||||
import { useIrmPlugin } from 'app/features/alerting/unified/hooks/usePluginBridge';
|
||||
import { SupportedPlugin } from 'app/features/alerting/unified/types/pluginBridges';
|
||||
|
||||
export function useGetOnCallIntegrations() {
|
||||
const { installed: onCallPluginInstalled } = usePluginBridge(getIrmIfPresentOrOnCallPluginId());
|
||||
const { pluginId, installed: onCallPluginInstalled } = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
|
||||
const { data: onCallIntegrations } = onCallApi.endpoints.grafanaOnCallIntegrations.useQuery(undefined, {
|
||||
skip: !onCallPluginInstalled,
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
const { data: onCallIntegrations } = onCallApi.endpoints.grafanaOnCallIntegrations.useQuery(
|
||||
{ pluginId },
|
||||
{
|
||||
skip: !onCallPluginInstalled,
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMountOrArgChange: true,
|
||||
}
|
||||
);
|
||||
|
||||
return onCallIntegrations ?? [];
|
||||
}
|
||||
|
||||
function useGetOnCallConfigurationChecks() {
|
||||
const { data: onCallConfigChecks, isLoading } = onCallApi.endpoints.onCallConfigChecks.useQuery(undefined, {
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
const { pluginId } = useIrmPlugin(SupportedPlugin.OnCall);
|
||||
|
||||
const { data: onCallConfigChecks, isLoading } = onCallApi.endpoints.onCallConfigChecks.useQuery(
|
||||
{ pluginId },
|
||||
{
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnMountOrArgChange: true,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FeatureToggles } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { RepositoryViewList } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
export const requiredFeatureToggles: Array<keyof FeatureToggles> = ['provisioning', 'kubernetesDashboards'];
|
||||
export const requiredFeatureToggles: Array<keyof FeatureToggles> = ['kubernetesDashboards'];
|
||||
|
||||
/**
|
||||
* Checks if all required feature toggles are enabled
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
|
||||
import { RouteDescriptor } from 'app/core/navigation/types';
|
||||
import { DashboardRoutes } from 'app/types/dashboard';
|
||||
@@ -6,6 +7,11 @@ import { checkRequiredFeatures } from '../GettingStarted/features';
|
||||
import { CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL, PROVISIONING_URL } from '../constants';
|
||||
|
||||
export function getProvisioningRoutes(): RouteDescriptor[] {
|
||||
const featureToggles = config.featureToggles || {};
|
||||
if (!featureToggles.provisioning) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!checkRequiredFeatures()) {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -85,6 +85,10 @@ export type GrafanaManagedReceiverConfig = {
|
||||
// SecureSettings?: GrafanaManagedReceiverConfigSettings<boolean>;
|
||||
settings: GrafanaManagedReceiverConfigSettings;
|
||||
type: string;
|
||||
/**
|
||||
* Version of the integration (e.g. "v0" for Mimir legacy, "v1" for Grafana)
|
||||
*/
|
||||
version?: string;
|
||||
/**
|
||||
* Name of the _receiver_, which in most cases will be the
|
||||
* same as the contact point's name. This should not be used, and is optional because the
|
||||
|
||||
@@ -807,7 +807,8 @@
|
||||
"label-integration": "Integration",
|
||||
"label-notification-settings": "Notification settings",
|
||||
"label-section": "Optional {{name}} settings",
|
||||
"test": "Test"
|
||||
"test": "Test",
|
||||
"tooltip-legacy-version": "This is a legacy integration (version: {{version}}). It cannot be modified."
|
||||
},
|
||||
"classic-condition-viewer": {
|
||||
"of": "OF",
|
||||
@@ -2176,7 +2177,9 @@
|
||||
"provisioning": {
|
||||
"badge-tooltip-provenance": "This resource has been provisioned via {{provenance}} and cannot be edited through the UI",
|
||||
"badge-tooltip-standard": "This resource has been provisioned and cannot be edited through the UI",
|
||||
"body-imported": "This contact point contains integrations that were imported from an external Alertmanager and is currently read-only. The integrations will become editable after the migration process is complete.",
|
||||
"body-provisioned": "This {{resource}} has been provisioned, that means it was created by config. Please contact your server admin to update this {{resource}}.",
|
||||
"title-imported": "This contact point was imported and cannot be edited through the UI",
|
||||
"title-provisioned": "This {{resource}} cannot be edited through the UI"
|
||||
},
|
||||
"provisioning-badge": {
|
||||
|
||||
Reference in New Issue
Block a user