Compare commits

..

1 Commits

Author SHA1 Message Date
Konrad Lalik
8595844334 Alerting: Migrate IRM/OnCall/Incident plugin detection to async hooks
Replace synchronous config.apps-based plugin detection with async
hook-based approach using the new useIrmPlugin hook.

Previously, functions like getIrmIfPresentOrOnCallPluginId() read from
config.apps synchronously, which wasn't reliable as it depended on
config being pre-populated. This caused issues where API calls were
made without knowing which plugin was actually installed.

Changes:
- Add useIrmPlugin hook that checks for IRM first, falls back to
  OnCall/Incident. Returns pluginId so callers know which plugin to use
- Update all RTK Query endpoints to accept pluginId as parameter
- Remove synchronous helpers: getIsIrmPluginPresent(),
  getIrmIfPresentOrIncidentPluginId(), getIrmIfPresentOrOnCallPluginId()
- Update all consumers to use useIrmPlugin hook
- Add comprehensive tests for useIrmPlugin hook
- Clean up ESLint suppressions for removed config.apps usage
2026-01-14 16:59:11 +01:00
50 changed files with 368 additions and 511 deletions

View File

@@ -28,7 +28,7 @@ type check struct {
PluginStore pluginstore.Store
PluginContextProvider PluginContextProvider
PluginClient plugins.Client
PluginRepo checks.PluginInfoGetter
PluginRepo repo.Service
GrafanaVersion string
pluginCanBeInstalledCache map[string]bool
pluginExistsCacheMu sync.RWMutex
@@ -39,7 +39,7 @@ func New(
pluginStore pluginstore.Store,
pluginContextProvider PluginContextProvider,
pluginClient plugins.Client,
pluginRepo checks.PluginInfoGetter,
pluginRepo repo.Service,
grafanaVersion string,
) checks.Check {
return &check{

View File

@@ -15,7 +15,7 @@ import (
type missingPluginStep struct {
PluginStore pluginstore.Store
PluginRepo checks.PluginInfoGetter
PluginRepo repo.Service
GrafanaVersion string
}

View File

@@ -5,7 +5,6 @@ import (
"github.com/grafana/grafana-app-sdk/logging"
advisorv0alpha1 "github.com/grafana/grafana/apps/advisor/pkg/apis/advisor/v0alpha1"
"github.com/grafana/grafana/pkg/plugins/repo"
)
// Check returns metadata about the check being executed and the list of Steps
@@ -38,10 +37,3 @@ type Step interface {
// Run executes the step for an item and returns a report
Run(ctx context.Context, log logging.Logger, obj *advisorv0alpha1.CheckSpec, item any) ([]advisorv0alpha1.CheckReportFailure, error)
}
// PluginInfoGetter is a minimal interface for retrieving plugin information from a repository.
// It contains only the GetPluginsInfo method used by plugincheck and datasourcecheck.
type PluginInfoGetter interface {
// GetPluginsInfo will return a list of plugins from grafana.com/api/plugins.
GetPluginsInfo(ctx context.Context, options repo.GetPluginsInfoOptions, compatOpts repo.CompatOpts) ([]repo.PluginInfo, error)
}

View File

@@ -17,7 +17,7 @@ const (
func New(
pluginStore pluginstore.Store,
pluginRepo checks.PluginInfoGetter,
pluginRepo repo.Service,
updateChecker pluginchecker.PluginUpdateChecker,
pluginErrorResolver plugins.ErrorResolver,
grafanaVersion string,
@@ -33,7 +33,7 @@ func New(
type check struct {
PluginStore pluginstore.Store
PluginRepo checks.PluginInfoGetter
PluginRepo repo.Service
updateChecker pluginchecker.PluginUpdateChecker
pluginErrorResolver plugins.ErrorResolver
GrafanaVersion string

View File

@@ -83,7 +83,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `reportingRetries` | Enables rendering retries for the reporting feature |
| `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches |
| `dashboardNewLayouts` | Enables new dashboard layouts |
| `pdfTables` | Enables generating table data as PDF in reporting |
| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. |

View File

@@ -30,9 +30,7 @@ refs:
# Datagrid
{{< admonition type="caution" >}}
Starting with Grafana 12.4, Datagrid is deprecated. It will be removed in version 13.0.
{{< /admonition >}}
{{< docs/experimental product="The datagrid visualization" featureFlag="`enableDatagridEditing`" >}}
Datagrids offer you the ability to create, edit, and fine-tune data within Grafana. As such, this panel can act as a data source for other panels
inside a dashboard.

View File

@@ -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

View File

@@ -293,8 +293,8 @@
"@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "6.52.2",
"@grafana/scenes-react": "6.52.2",
"@grafana/scenes": "v6.52.1",
"@grafana/scenes-react": "v6.52.1",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",

View File

@@ -844,6 +844,7 @@ export {
DataLinkConfigOrigin,
SupportedTransformationType,
type InternalDataLink,
type LinkTarget,
type LinkModel,
type LinkModelSupplier,
VariableOrigin,
@@ -851,7 +852,6 @@ export {
VariableSuggestionsScope,
OneClickMode,
} from './types/dataLink';
export { type LinkTarget } from './types/linkTarget';
export {
type Action,
type ActionModel,

View File

@@ -1,6 +1,5 @@
import { ScopedVars } from './ScopedVars';
import { ExploreCorrelationHelperData, ExplorePanelsState } from './explore';
import { LinkTarget } from './linkTarget';
import { InterpolateFunction } from './panel';
import { DataQuery } from './query';
import { TimeRange } from './time';
@@ -89,6 +88,8 @@ export interface InternalDataLink<T extends DataQuery = any> {
range?: TimeRange;
}
export type LinkTarget = '_blank' | '_self' | undefined;
/**
* Processed Link Model. The values are ready to use
*/

View File

@@ -356,7 +356,7 @@ export interface FeatureToggles {
*/
dashboardScene?: boolean;
/**
* Enables new dashboard layouts
* Enables experimental new dashboard layouts
*/
dashboardNewLayouts?: boolean;
/**
@@ -1251,8 +1251,4 @@ export interface FeatureToggles {
* Enables profiles exemplars support in profiles drilldown
*/
profilesExemplars?: boolean;
/**
* Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods
*/
alertingSyncDispatchTimer?: boolean;
}

View File

@@ -1,4 +0,0 @@
/**
* Target for links - controls whether link opens in new tab or same tab
*/
export type LinkTarget = '_blank' | '_self' | undefined;

View File

@@ -1,7 +1,7 @@
import { ComponentType } from 'react';
import { LinkTarget } from './dataLink';
import { IconName } from './icon';
import { LinkTarget } from './linkTarget';
export interface NavLinkDTO {
id?: string;

View File

@@ -11,7 +11,6 @@ import { DataFrame } from './dataFrame';
import { DataQueryError, DataQueryRequest, DataQueryTimings } from './datasource';
import { FieldConfigSource } from './fieldOverrides';
import { IconName } from './icon';
import { LinkTarget } from './linkTarget';
import { OptionEditorConfig } from './options';
import { PluginMeta } from './plugin';
import { AbsoluteTimeRange, TimeRange, TimeZone } from './time';
@@ -192,7 +191,6 @@ export interface PanelMenuItem {
onClick?: (event: React.MouseEvent) => void;
shortcut?: string;
href?: string;
target?: LinkTarget;
subMenu?: PanelMenuItem[];
}

View File

@@ -574,8 +574,8 @@ var (
},
{
Name: "dashboardNewLayouts",
Description: "Enables new dashboard layouts",
Stage: FeatureStagePublicPreview,
Description: "Enables experimental new dashboard layouts",
Stage: FeatureStageExperimental,
FrontendOnly: false, // The restore backend feature changes behavior based on this flag
Owner: grafanaDashboardsSquad,
},
@@ -981,8 +981,7 @@ var (
Stage: FeatureStageDeprecated,
Owner: grafanaPartnerPluginsSquad,
Expression: "true", // Enabled by default for now
},
{
}, {
Name: "alertingFilterV2",
Description: "Enable the new alerting search experience",
Stage: FeatureStageExperimental,
@@ -2070,14 +2069,6 @@ var (
Owner: grafanaObservabilityTracesAndProfilingSquad,
FrontendOnly: false,
},
{
Name: "alertingSyncDispatchTimer",
Description: "Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
RequiresRestart: true,
HideFromDocs: true,
},
}
)

View File

@@ -79,7 +79,7 @@ annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false
dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true
dashboardScene,GA,@grafana/dashboards-squad,false,false,true
dashboardNewLayouts,preview,@grafana/dashboards-squad,false,false,false
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true
@@ -280,4 +280,3 @@ multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true
smoothingTransformation,experimental,@grafana/datapro,false,false,true
secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false
profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false
alertingSyncDispatchTimer,experimental,@grafana/alerting-squad,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
79 dashboardSceneForViewers GA @grafana/dashboards-squad false false true
80 dashboardSceneSolo GA @grafana/dashboards-squad false false true
81 dashboardScene GA @grafana/dashboards-squad false false true
82 dashboardNewLayouts preview experimental @grafana/dashboards-squad false false false
83 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
84 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
85 drilldownRecommendations experimental @grafana/dashboards-squad false false true
280 smoothingTransformation experimental @grafana/datapro false false true
281 secretsManagementAppPlatformAwsKeeper experimental @grafana/grafana-operator-experience-squad false false false
282 profilesExemplars experimental @grafana/observability-traces-and-profiling false false false
alertingSyncDispatchTimer experimental @grafana/alerting-squad false true false

View File

@@ -260,7 +260,7 @@ const (
FlagAnnotationPermissionUpdate = "annotationPermissionUpdate"
// FlagDashboardNewLayouts
// Enables new dashboard layouts
// Enables experimental new dashboard layouts
FlagDashboardNewLayouts = "dashboardNewLayouts"
// FlagPdfTables
@@ -789,8 +789,4 @@ const (
// FlagProfilesExemplars
// Enables profiles exemplars support in profiles drilldown
FlagProfilesExemplars = "profilesExemplars"
// FlagAlertingSyncDispatchTimer
// Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods
FlagAlertingSyncDispatchTimer = "alertingSyncDispatchTimer"
)

View File

@@ -511,20 +511,6 @@
"frontend": true
}
},
{
"metadata": {
"name": "alertingSyncDispatchTimer",
"resourceVersion": "1766161788928",
"creationTimestamp": "2025-12-19T16:29:48Z"
},
"spec": {
"description": "Use synchronized dispatch timer to minimize duplicate notifications across alertmanager HA pods",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"requiresRestart": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "alertingTriage",
@@ -676,8 +662,7 @@
"metadata": {
"name": "auditLoggingAppPlatform",
"resourceVersion": "1767013056996",
"creationTimestamp": "2025-12-29T12:57:36Z",
"deletionTimestamp": "2026-01-06T09:18:36Z"
"creationTimestamp": "2025-12-29T12:57:36Z"
},
"spec": {
"description": "Enable audit logging with Kubernetes under app platform",
@@ -1030,15 +1015,12 @@
{
"metadata": {
"name": "dashboardNewLayouts",
"resourceVersion": "1768382835527",
"creationTimestamp": "2024-10-23T08:55:45Z",
"annotations": {
"grafana.app/updatedTimestamp": "2026-01-14 09:27:15.527103 +0000 UTC"
}
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-10-23T08:55:45Z"
},
"spec": {
"description": "Enables new dashboard layouts",
"stage": "preview",
"description": "Enables experimental new dashboard layouts",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad"
}
},

View File

@@ -213,9 +213,6 @@ func (ng *AlertNG) init() error {
SkipVerify: ng.Cfg.Smtp.SkipVerify,
StaticHeaders: ng.Cfg.Smtp.StaticHeaders,
}
runtimeConfig := remoteClient.RuntimeConfig{
DispatchTimer: notifier.GetDispatchTimer(ng.FeatureToggles).String(),
}
cfg := remote.AlertmanagerConfig{
BasicAuthPassword: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Password,
@@ -225,7 +222,6 @@ func (ng *AlertNG) init() error {
ExternalURL: ng.Cfg.AppURL,
SmtpConfig: smtpCfg,
Timeout: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Timeout,
RuntimeConfig: runtimeConfig,
}
autogenFn := func(ctx context.Context, logger log.Logger, orgID int64, cfg *definitions.PostableApiAlertingConfig, invalidReceiverAction notifier.InvalidReceiversAction) error {
return notifier.AddAutogenConfig(ctx, logger, ng.store, orgID, cfg, invalidReceiverAction, ng.FeatureToggles)

View File

@@ -33,9 +33,6 @@ const (
// How long we keep silences in the kvstore after they've expired.
silenceRetention = 5 * 24 * time.Hour
// How long we keep flushes in the kvstore after they've expired.
flushRetention = 5 * 24 * time.Hour
)
type AlertingStore interface {
@@ -47,10 +44,8 @@ type AlertingStore interface {
type stateStore interface {
SaveSilences(ctx context.Context, st alertingNotify.State) (int64, error)
SaveNotificationLog(ctx context.Context, st alertingNotify.State) (int64, error)
SaveFlushLog(ctx context.Context, st alertingNotify.State) (int64, error)
GetSilences(ctx context.Context) (string, error)
GetNotificationLog(ctx context.Context) (string, error)
GetFlushLog(ctx context.Context) (string, error)
}
type alertmanager struct {
@@ -106,10 +101,6 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A
if err != nil {
return nil, err
}
flushLog, err := stateStore.GetFlushLog(ctx)
if err != nil {
return nil, err
}
silencesOptions := maintenanceOptions{
initialState: silences,
@@ -132,29 +123,12 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A
}
l := log.New("ngalert.notifier")
dispatchTimer := GetDispatchTimer(featureToggles)
var flushLogOptions *maintenanceOptions
if dispatchTimer == alertingNotify.DispatchTimerSync {
flushLogOptions = &maintenanceOptions{
initialState: flushLog,
retention: flushRetention,
maintenanceFrequency: maintenanceInterval,
maintenanceFunc: func(state alertingNotify.State) (int64, error) {
// Detached context here is to make sure that when the service is shut down the persist operation is executed.
return stateStore.SaveFlushLog(context.Background(), state)
},
}
}
opts := alertingNotify.GrafanaAlertmanagerOpts{
ExternalURL: cfg.AppURL,
AlertStoreCallback: nil,
PeerTimeout: cfg.UnifiedAlerting.HAPeerTimeout,
Silences: silencesOptions,
Nflog: nflogOptions,
FlushLog: flushLogOptions,
DispatchTimer: dispatchTimer,
Limits: alertingNotify.Limits{
MaxSilences: cfg.UnifiedAlerting.AlertmanagerMaxSilencesCount,
MaxSilenceSizeBytes: cfg.UnifiedAlerting.AlertmanagerMaxSilenceSizeBytes,

View File

@@ -1,16 +0,0 @@
package notifier
import (
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
// GetDispatchTimer returns the appropriate dispatch timer based on feature toggles.
func GetDispatchTimer(features featuremgmt.FeatureToggles) (dt alertingNotify.DispatchTimer) {
//nolint:staticcheck // not yet migrated to OpenFeature
enabled := features.IsEnabledGlobally(featuremgmt.FlagAlertingSyncDispatchTimer)
if enabled {
dt = alertingNotify.DispatchTimerSync
}
return
}

View File

@@ -1,36 +0,0 @@
package notifier
import (
"testing"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/stretchr/testify/require"
)
func TestGetDispatchTimer(t *testing.T) {
tests := []struct {
name string
featureFlagValue bool
expected alertingNotify.DispatchTimer
}{
{
name: "feature flag enabled returns sync timer",
featureFlagValue: true,
expected: alertingNotify.DispatchTimerSync,
},
{
name: "feature flag disabled returns default timer",
featureFlagValue: false,
expected: alertingNotify.DispatchTimerDefault,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
features := featuremgmt.WithFeatures(featuremgmt.FlagAlertingSyncDispatchTimer, tt.featureFlagValue)
result := GetDispatchTimer(features)
require.Equal(t, tt.expected, result)
})
}
}

View File

@@ -15,7 +15,6 @@ const (
KVNamespace = "alertmanager"
NotificationLogFilename = "notifications"
SilencesFilename = "silences"
FlushLogFilename = "flushes"
)
// FileStore is in charge of persisting the alertmanager files to the database.
@@ -43,10 +42,6 @@ func (fileStore *FileStore) GetNotificationLog(ctx context.Context) (string, err
return fileStore.contentFor(ctx, NotificationLogFilename)
}
func (fileStore *FileStore) GetFlushLog(ctx context.Context) (string, error) {
return fileStore.contentFor(ctx, FlushLogFilename)
}
// contentFor returns the content for the given Alertmanager kvstore key.
func (fileStore *FileStore) contentFor(ctx context.Context, filename string) (string, error) {
// Then, let's attempt to read it from the database.
@@ -79,11 +74,6 @@ func (fileStore *FileStore) SaveNotificationLog(ctx context.Context, st alerting
return fileStore.persist(ctx, NotificationLogFilename, st)
}
// SaveFlushLog saves the flush log to the database and returns the size of the unencoded state.
func (fileStore *FileStore) SaveFlushLog(ctx context.Context, st alertingNotify.State) (int64, error) {
return fileStore.persist(ctx, FlushLogFilename, st)
}
// persist takes care of persisting the binary representation of internal state to the database as a base64 encoded string.
func (fileStore *FileStore) persist(ctx context.Context, filename string, st alertingNotify.State) (int64, error) {
var size int64

View File

@@ -106,48 +106,3 @@ func TestFileStore_NotificationLog(t *testing.T) {
t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded))
}
}
func TestFileStore_FlushLog(t *testing.T) {
store := fakes.NewFakeKVStore(t)
ctx := context.Background()
var orgId int64 = 1
// Initialize kvstore with empty flush log state.
initialState := flushLogState{} // FlushLog uses the same structure as nflog
decodedState, err := initialState.MarshalBinary()
require.NoError(t, err)
encodedState := base64.StdEncoding.EncodeToString(decodedState)
err = store.Set(ctx, orgId, KVNamespace, FlushLogFilename, encodedState)
require.NoError(t, err)
fs := NewFileStore(orgId, store)
// Load initial (empty).
flushLog, err := fs.GetFlushLog(ctx)
require.NoError(t, err)
decoded, err := decodeFlushLogState(strings.NewReader(flushLog))
require.NoError(t, err)
if !cmp.Equal(initialState, decoded) {
t.Errorf("Unexpected Diff: %v", cmp.Diff(initialState, decoded))
}
// Save new flush log state.
now := time.Now()
oneHour := now.Add(time.Hour)
v1 := createFlushLog(1, now, oneHour)
v2 := createFlushLog(2, now, oneHour)
newState := flushLogState{1: v1, 2: v2}
size, err := fs.SaveFlushLog(ctx, newState)
require.NoError(t, err)
require.Greater(t, size, int64(0))
// Load new.
flushLog, err = fs.GetFlushLog(ctx)
require.NoError(t, err)
decoded, err = decodeFlushLogState(strings.NewReader(flushLog))
require.NoError(t, err)
if !cmp.Equal(newState, decoded) {
t.Errorf("Unexpected Diff: %v", cmp.Diff(newState, decoded))
}
}

View File

@@ -82,7 +82,6 @@ type Alertmanager interface {
type ExternalState struct {
Silences []byte
Nflog []byte
FlushLog []byte
}
// StateMerger describes a type that is able to merge external state (nflog, silences) with its own.
@@ -379,7 +378,7 @@ func (moa *MultiOrgAlertmanager) SyncAlertmanagersForOrgs(ctx context.Context, o
func (moa *MultiOrgAlertmanager) cleanupOrphanLocalOrgState(ctx context.Context,
activeOrganizations map[int64]struct{},
) {
storedFiles := []string{NotificationLogFilename, SilencesFilename, FlushLogFilename}
storedFiles := []string{NotificationLogFilename, SilencesFilename}
for _, fileName := range storedFiles {
keys, err := moa.kvStore.Keys(ctx, kvstore.AllOrganizations, KVNamespace, fileName)
if err != nil {

View File

@@ -5,8 +5,5 @@ func (am *alertmanager) MergeState(state ExternalState) error {
if err := am.Base.MergeNflog(state.Nflog); err != nil {
return err
}
if err := am.Base.MergeSilences(state.Silences); err != nil {
return err
}
return am.Base.MergeFlushLog(state.FlushLog)
return am.Base.MergeSilences(state.Silences)
}

View File

@@ -11,7 +11,6 @@ import (
"time"
"github.com/matttproud/golang_protobuf_extensions/pbutil"
"github.com/prometheus/alertmanager/flushlog/flushlogpb"
"github.com/prometheus/alertmanager/nflog/nflogpb"
"github.com/prometheus/alertmanager/silence/silencepb"
"github.com/prometheus/common/model"
@@ -229,13 +228,15 @@ func (f *FakeOrgStore) FetchOrgIds(_ context.Context) ([]int64, error) {
return f.orgs, nil
}
type NoValidation struct{}
type NoValidation struct {
}
func (n NoValidation) Validate(_ models.NotificationSettings) error {
return nil
}
type RejectingValidation struct{}
type RejectingValidation struct {
}
func (n RejectingValidation) Validate(s models.NotificationSettings) error {
return ErrorReceiverDoesNotExist{ErrorReferenceInvalid: ErrorReferenceInvalid{Reference: s.Receiver}}
@@ -364,51 +365,6 @@ func createNotificationLog(groupKey string, receiverName string, sentAt, expires
}
}
// https://github.com/grafana/prometheus-alertmanager/blob/main/flushlog/flushlog.go#L136-L136
type flushLogState map[uint64]*flushlogpb.MeshFlushLog
func (s flushLogState) MarshalBinary() ([]byte, error) {
var buf bytes.Buffer
for _, e := range s {
if _, err := pbutil.WriteDelimited(&buf, e); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}
func createFlushLog(groupFingerprint uint64, ts, expiresAt time.Time) *flushlogpb.MeshFlushLog {
return &flushlogpb.MeshFlushLog{
FlushLog: &flushlogpb.FlushLog{
GroupFingerprint: groupFingerprint,
Timestamp: ts,
},
ExpiresAt: expiresAt,
}
}
// decodeFlushLogState copied from decodeState in prometheus-alertmanager/flushlog/flushlog.go
func decodeFlushLogState(r io.Reader) (flushLogState, error) {
st := flushLogState{}
for {
var e flushlogpb.MeshFlushLog
_, err := pbutil.ReadDelimited(r, &e)
if err == nil {
if e.FlushLog == nil || e.FlushLog.GroupFingerprint == 0 || e.FlushLog.Timestamp.IsZero() {
return nil, errInvalidState
}
st[e.FlushLog.GroupFingerprint] = &e
continue
}
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
return st, nil
}
type call struct {
Method string
Args []interface{}

View File

@@ -47,7 +47,6 @@ import (
type stateStore interface {
GetSilences(ctx context.Context) (string, error)
GetNotificationLog(ctx context.Context) (string, error)
GetFlushLog(ctx context.Context) (string, error)
}
// AutogenFn is a function that adds auto-generated routes to a configuration.
@@ -87,8 +86,6 @@ type Alertmanager struct {
promoteConfig bool
externalURL string
runtimeConfig remoteClient.RuntimeConfig
}
type AlertmanagerConfig struct {
@@ -114,9 +111,6 @@ type AlertmanagerConfig struct {
// Timeout for the HTTP client.
Timeout time.Duration
// RuntimeConfig specifies runtime behavior settings for the remote Alertmanager.
RuntimeConfig remoteClient.RuntimeConfig
}
func (cfg *AlertmanagerConfig) Validate() error {
@@ -209,7 +203,6 @@ func NewAlertmanager(ctx context.Context, cfg AlertmanagerConfig, store stateSto
externalURL: cfg.ExternalURL,
promoteConfig: cfg.PromoteConfig,
smtp: cfg.SmtpConfig,
runtimeConfig: cfg.RuntimeConfig,
}
// Parse the default configuration once and remember its hash so we can compare it later.
@@ -338,11 +331,10 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea
AlertmanagerConfig: mergeResult.Config,
Templates: templates,
},
CreatedAt: createdAtEpoch,
Promoted: am.promoteConfig,
ExternalURL: am.externalURL,
SmtpConfig: am.smtp,
RuntimeConfig: am.runtimeConfig,
CreatedAt: createdAtEpoch,
Promoted: am.promoteConfig,
ExternalURL: am.externalURL,
SmtpConfig: am.smtp,
}
cfgHash, err := calculateUserGrafanaConfigHash(payload)
@@ -396,8 +388,6 @@ func (am *Alertmanager) GetRemoteState(ctx context.Context) (notifier.ExternalSt
rs.Silences = p.Data
case "nfl":
rs.Nflog = p.Data
case "fls":
rs.FlushLog = p.Data
default:
return rs, fmt.Errorf("unknown part key %q", p.Key)
}
@@ -687,12 +677,6 @@ func (am *Alertmanager) getFullState(ctx context.Context) (string, error) {
}
parts = append(parts, alertingClusterPB.Part{Key: notifier.NotificationLogFilename, Data: []byte(notificationLog)})
flushLog, err := am.state.GetFlushLog(ctx)
if err != nil {
return "", fmt.Errorf("error getting flush log: %w", err)
}
parts = append(parts, alertingClusterPB.Part{Key: notifier.FlushLogFilename, Data: []byte(flushLog)})
fs := alertingClusterPB.FullState{
Parts: parts,
}

View File

@@ -29,10 +29,6 @@ func (u *GrafanaAlertmanagerConfig) MarshalJSON() ([]byte, error) {
return definition.MarshalJSONWithSecrets((*cfg)(u))
}
type RuntimeConfig struct {
DispatchTimer string `json:"dispatch_timer"`
}
type UserGrafanaConfig struct {
GrafanaAlertmanagerConfig GrafanaAlertmanagerConfig `json:"configuration"`
Hash string `json:"configuration_hash"`
@@ -41,7 +37,6 @@ type UserGrafanaConfig struct {
Promoted bool `json:"promoted"`
ExternalURL string `json:"external_url"`
SmtpConfig SmtpConfig `json:"smtp_config"`
RuntimeConfig RuntimeConfig `json:"runtime_config"`
}
func (mc *Mimir) GetGrafanaAlertmanagerConfig(ctx context.Context) (*UserGrafanaConfig, error) {

View File

@@ -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,

View File

@@ -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/'
);
});

View File

@@ -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,
}),
}),

View File

@@ -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"

View File

@@ -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,
]);
};

View File

@@ -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,
};
}),

View File

@@ -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(

View File

@@ -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`,
};

View File

@@ -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]);
});
});

View File

@@ -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,
};
}

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -95,7 +95,7 @@ export const ImportDashboardFormV2 = ({
return (
<Field
label={input.name}
label={input.pluginId}
description={input.description}
key={input.pluginId}
invalid={!!errors[dataSourceOption]}

View File

@@ -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,

View File

@@ -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,

View File

@@ -121,7 +121,7 @@ export const ImportDashboardForm = ({
const current = watchDataSources ?? [];
return (
<Field
label={input.name}
label={input.pluginId}
description={input.description}
key={dataSourceOption}
invalid={errors.dataSources && !!errors.dataSources[index]}

View File

@@ -141,7 +141,6 @@ export const getPluginExtensions: GetExtensions = ({
description: overrides?.description || addedLink.description || '',
path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined,
category: overrides?.category || addedLink.category,
openInNewTab: overrides?.openInNewTab ?? addedLink.openInNewTab,
};
extensions.push(extension);

View File

@@ -420,7 +420,6 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel
href: extension.path,
onClick: extension.onClick,
iconClassName: extension.icon,
target: extension.openInNewTab ? '_blank' : undefined,
});
continue;
}
@@ -434,7 +433,6 @@ export function createExtensionSubMenu(extensions: PluginExtensionLink[]): Panel
href: extension.path,
onClick: extension.onClick,
iconClassName: extension.icon,
target: extension.openInNewTab ? '_blank' : undefined,
});
}

View File

@@ -2,7 +2,7 @@
"type": "panel",
"name": "Datagrid",
"id": "datagrid",
"state": "deprecated",
"state": "beta",
"info": {
"author": {

View File

@@ -3789,11 +3789,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:6.52.2":
version: 6.52.2
resolution: "@grafana/scenes-react@npm:6.52.2"
"@grafana/scenes-react@npm:v6.52.1":
version: 6.52.1
resolution: "@grafana/scenes-react@npm:6.52.1"
dependencies:
"@grafana/scenes": "npm:6.52.2"
"@grafana/scenes": "npm:6.52.1"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3805,7 +3805,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/c393faf6612e78254dab79b15cc970448d74ba9784ccda623953c5dbc21d91a8da94b7ad7d0d294eac51314cc193c419a7cb48295fd50b1f9c4472699669eb3e
checksum: 10/2f7c6ca8e26befd331808afb0cb934e2991e889a4de78be1122c536219676261c59c6204510761a1d4250fd44a3767818f0f225d23b2e7243cfc17baf8ca6ca3
languageName: node
linkType: hard
@@ -3835,9 +3835,9 @@ __metadata:
languageName: node
linkType: hard
"@grafana/scenes@npm:6.52.2":
version: 6.52.2
resolution: "@grafana/scenes@npm:6.52.2"
"@grafana/scenes@npm:6.52.1, @grafana/scenes@npm:v6.52.1":
version: 6.52.1
resolution: "@grafana/scenes@npm:6.52.1"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3857,7 +3857,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/f6dbe20db78bb1aa09cc38025534917887713d73119a172febb44700837ed859363ee0436b5f4bda6bc063f9432115e32519ab4c8da7834cf1fc22d43fea7711
checksum: 10/d6172b51121e03c7dcbf30046772f99fc45922c1f7b360a7c3d2c0391300e378f306cb78251dda3b30895679379c38db30e4d52fee67a56cd95f18f38aadf3fb
languageName: node
linkType: hard
@@ -19790,8 +19790,8 @@ __metadata:
"@grafana/plugin-ui": "npm:^0.11.1"
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:6.52.2"
"@grafana/scenes-react": "npm:6.52.2"
"@grafana/scenes": "npm:v6.52.1"
"@grafana/scenes-react": "npm:v6.52.1"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/test-utils": "workspace:*"