Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Mariell Hoversholm committed 2025-01-28 09:15:57 +01:00
commit 848216053b
91 files changed
+9922 -339

No files matched your search

+4 -3
View File
@@ -599,9 +599,6 @@ playwright.config.ts @grafana/plugins-platform-frontend
/public/app/AppWrapper.tsx @grafana/frontend-ops
/public/app/partials/ @grafana/grafana-frontend-platform
/scripts/benchmark-access-control.sh @grafana/access-squad
/scripts/check-breaking-changes.sh @grafana/plugins-platform-frontend
/scripts/ci-* @grafana/grafana-developer-enablement-squad
@@ -740,6 +737,10 @@ embed.go @grafana/grafana-as-code
/public/app/plugins/*gen.go @grafana/grafana-as-code
/cue.mod/ @grafana/grafana-as-code
# Rendered OpenAPI from app platform
# Eventually each file owned by the right team, OR a structure with the rendered value under /apis/{group}/openapi
/openapi/ @grafana/grafana-app-platform-squad
# GitHub Workflows and Templates
/.github/CODEOWNERS @tolzhabayev
/.github/ISSUE_TEMPLATE/ @torkelo @sympatheticmoose
+1
View File
@@ -421,6 +421,7 @@ protobuf: ## Compile protobuf definitions
buf generate pkg/plugins/backendplugin/secretsmanagerplugin --template pkg/plugins/backendplugin/secretsmanagerplugin/buf.gen.yaml
buf generate pkg/storage/unified/resource --template pkg/storage/unified/resource/buf.gen.yaml
buf generate pkg/services/authz/proto/v1 --template pkg/services/authz/proto/v1/buf.gen.yaml
buf generate pkg/services/ngalert/store/proto/v1 --template pkg/services/ngalert/store/proto/v1/buf.gen.yaml
.PHONY: clean
clean: ## Clean up intermediate build artifacts.
@@ -191,6 +191,7 @@ Experimental features might be changed or removed without prior notice.
| `tableSharedCrosshair` | Enables shared crosshair in table panel |
| `kubernetesFeatureToggles` | Use the kubernetes API for feature toggle management in the frontend |
| `newFolderPicker` | Enables the nested folder picker without having nested folders enabled |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage |
| `scopeApi` | In-development feature flag for the scope api using the app platform. |
| `sqlExpressions` | Enables using SQL and DuckDB functions as Expressions. |
| `nodeGraphDotLayout` | Changed the layout algorithm for the node graph |
+1
View File
@@ -1503,6 +1503,7 @@ github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20250115195200-209e052dba64/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
github.com/grafana/authlib v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:V63rh3udd7sqXJeaG+nGUmViwVnM/bY6t8U9Tols2GU=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335/go.mod h1:r+F8H6awwjNQt/KPZ2GNwjk8TvsJ7/gxzkXN26GlL/A=
+3
View File
@@ -0,0 +1,3 @@
This folder contains a rendered OpenAPI for each group/version
The "real" openapi is generated by the running server, but this is used to build static frontend clients
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -223,7 +223,7 @@
"react-select-event": "5.5.1",
"redux-mock-store": "1.5.5",
"rimraf": "6.0.1",
"rudder-sdk-js": "2.48.43",
"rudder-sdk-js": "2.48.44",
"sass": "1.83.4",
"sass-loader": "16.0.4",
"smtp-tester": "^2.1.0",
@@ -355,7 +355,7 @@
"nanoid": "^5.0.4",
"node-forge": "^1.3.1",
"ol": "7.4.0",
"ol-ext": "4.0.24",
"ol-ext": "4.0.25",
"pluralize": "^8.0.0",
"prismjs": "1.29.0",
"rc-slider": "11.1.8",
@@ -387,7 +387,7 @@
"react-virtual": "2.10.4",
"react-virtualized-auto-sizer": "1.0.25",
"react-window": "1.8.11",
"react-window-infinite-loader": "1.0.9",
"react-window-infinite-loader": "1.0.10",
"react-zoom-pan-pinch": "^3.3.0",
"reduce-reducers": "^1.0.4",
"redux": "5.0.1",
@@ -154,6 +154,7 @@ export interface FeatureToggles {
jitterAlertRulesWithinGroups?: boolean;
onPremToCloudMigrations?: boolean;
alertingSaveStatePeriodic?: boolean;
alertingSaveStateCompressed?: boolean;
scopeApi?: boolean;
promQLScope?: boolean;
logQLScope?: boolean;
@@ -6,9 +6,10 @@ import React, { useEffect, useState } from 'react';
import { Alert } from '../Alert/Alert';
import { Field } from '../Forms/Field';
import { Combobox, ComboboxOption, ComboboxProps } from './Combobox';
import { Combobox, ComboboxProps } from './Combobox';
import mdx from './Combobox.mdx';
import { fakeSearchAPI, generateOptions } from './storyUtils';
import { ComboboxOption } from './types';
type PropsAndCustomArgs<T extends string | number = string> = ComboboxProps<T> & {
numberOfOptions: number;
@@ -2,7 +2,8 @@ import { act, render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Combobox, ComboboxOption } from './Combobox';
import { Combobox } from './Combobox';
import { ComboboxOption } from './types';
// Mock data for the Combobox options
const options: ComboboxOption[] = [
@@ -16,15 +16,10 @@ import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
import { AsyncError, NotFoundError } from './MessageRows';
import { fuzzyFind, itemToString } from './filter';
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
import { ComboboxOption } from './types';
import { useComboboxFloat } from './useComboboxFloat';
import { StaleResultError, useLatestAsyncCall } from './useLatestAsyncCall';
export type ComboboxOption<T extends string | number = string> = {
label?: string;
value: T;
description?: string;
};
// TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async),
// then the onChange handler emits ComboboxOption with the label as non-undefined.
export interface ComboboxBaseProps<T extends string | number>
@@ -2,9 +2,9 @@ import { action } from '@storybook/addon-actions';
import { useArgs, useEffect, useState } from '@storybook/preview-api';
import type { Meta, StoryFn, StoryObj } from '@storybook/react';
import { ComboboxOption } from './Combobox';
import { MultiCombobox } from './MultiCombobox';
import { generateOptions } from './storyUtils';
import { ComboboxOption } from './types';
const meta: Meta<typeof MultiCombobox> = {
title: 'Forms/MultiCombobox',
@@ -14,18 +14,17 @@ import { Spinner } from '../Spinner/Spinner';
import { Text } from '../Text/Text';
import { Tooltip } from '../Tooltip';
import { ComboboxOption, ComboboxBaseProps, AutoSizeConditionals, VIRTUAL_OVERSCAN_ITEMS } from './Combobox';
import { ComboboxBaseProps, AutoSizeConditionals, VIRTUAL_OVERSCAN_ITEMS } from './Combobox';
import { NotFoundError } from './MessageRows';
import { OptionListItem } from './OptionListItem';
import { ValuePill } from './ValuePill';
import { itemFilter, itemToString } from './filter';
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
import { useComboboxFloat } from './useComboboxFloat';
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
export const ALL_OPTION_VALUE = '__GRAFANA_INTERNAL_MULTICOMBOBOX_ALL_OPTION__';
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
value?: T[] | Array<ComboboxOption<T>>;
onChange: (items?: T[]) => void;
@@ -1,7 +1,6 @@
import uFuzzy from '@leeoniya/ufuzzy';
import { ComboboxOption } from './Combobox';
import { ALL_OPTION_VALUE } from './MultiCombobox';
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
// https://catonmat.net/my-favorite-regex :)
const REGEXP_NON_ASCII = /[^ -~]/m;
@@ -1,4 +1,4 @@
import { ComboboxOption } from './Combobox';
import { ComboboxOption } from './types';
let fakeApiOptions: Array<ComboboxOption<string>>;
export async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxOption<string>>> {
@@ -0,0 +1,7 @@
export const ALL_OPTION_VALUE = '__GRAFANA_INTERNAL_MULTICOMBOBOX_ALL_OPTION__';
export type ComboboxOption<T extends string | number = string> = {
label?: string;
value: T;
description?: string;
};
@@ -3,7 +3,6 @@ import { useMemo, useRef, useState } from 'react';
import { measureText } from '../../utils';
import { ComboboxOption } from './Combobox';
import {
MENU_ITEM_FONT_SIZE,
MENU_ITEM_FONT_WEIGHT,
@@ -11,6 +10,7 @@ import {
MENU_OPTION_HEIGHT,
POPOVER_MAX_HEIGHT,
} from './getComboboxStyles';
import { ComboboxOption } from './types';
// Only consider the first n items when calculating the width of the popover.
const WIDTH_CALCULATION_LIMIT_ITEMS = 100_000;
@@ -3,7 +3,7 @@ import { useMeasure } from 'react-use';
import { measureText } from '../../utils';
import { ComboboxOption } from './Combobox';
import { ComboboxOption } from './types';
const FONT_SIZE = 12;
const EXTRA_PILL_SIZE = 50;
@@ -3,7 +3,8 @@ import { useCallback, useMemo } from 'react';
import { BootData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Combobox, ComboboxOption } from '../Combobox/Combobox';
import { Combobox } from '../Combobox/Combobox';
import { ComboboxOption } from '../Combobox/types';
export interface Props {
onChange: (weekStart: string) => void;
@@ -1,6 +1,6 @@
import { TimeOption } from '@grafana/data';
import { ComboboxOption } from '../Combobox/Combobox';
import { ComboboxOption } from '../Combobox/types';
export const quickOptions: TimeOption[] = [
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes' },
+2 -1
View File
@@ -239,7 +239,8 @@ export { SelectMenuOptions } from './Select/SelectMenu';
export { getSelectStyles } from './Select/getSelectStyles';
export * from './Select/types';
export { Combobox, type ComboboxOption } from './Combobox/Combobox';
export { Combobox } from './Combobox/Combobox';
export { type ComboboxOption } from './Combobox/types';
export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout';
export { Badge, type BadgeColor, type BadgeProps } from './Badge/Badge';
@@ -19,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/utils"
dashboard "github.com/grafana/grafana/pkg/apis/dashboard"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -54,8 +55,9 @@ type dashboardSqlAccess struct {
provisioning provisioning.ProvisioningService
// Use for writing (not reading)
dashStore dashboards.Store
softDelete bool
dashStore dashboards.Store
softDelete bool
dashboardSearchClient legacysearcher.DashboardSearchClient
// Typically one... the server wrapper
subscribers []chan *resource.WrittenEvent
@@ -68,12 +70,14 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
provisioning provisioning.ProvisioningService,
softDelete bool,
) DashboardAccess {
dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore)
return &dashboardSqlAccess{
sql: sql,
namespacer: namespacer,
dashStore: dashStore,
provisioning: provisioning,
softDelete: softDelete,
sql: sql,
namespacer: namespacer,
dashStore: dashStore,
provisioning: provisioning,
softDelete: softDelete,
dashboardSearchClient: *dashboardSearchClient,
}
}
+2 -34
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
claims "github.com/grafana/authlib/types"
@@ -255,9 +254,8 @@ func (a *dashboardSqlAccess) Read(ctx context.Context, req *resource.ReadRequest
return a.ReadResource(ctx, req), nil
}
// TODO: this needs to be implemented
func (a *dashboardSqlAccess) Search(ctx context.Context, req *resource.ResourceSearchRequest) (*resource.ResourceSearchResponse, error) {
return nil, fmt.Errorf("not yet (filter)")
return a.dashboardSearchClient.Search(ctx, req)
}
func (a *dashboardSqlAccess) ListRepositoryObjects(ctx context.Context, req *resource.ListRepositoryObjectsRequest) (*resource.ListRepositoryObjectsResponse, error) {
@@ -270,35 +268,5 @@ func (a *dashboardSqlAccess) CountRepositoryObjects(context.Context, *resource.C
// GetStats implements ResourceServer.
func (a *dashboardSqlAccess) GetStats(ctx context.Context, req *resource.ResourceStatsRequest) (*resource.ResourceStatsResponse, error) {
info, err := claims.ParseNamespace(req.Namespace)
if err != nil {
return nil, fmt.Errorf("unable to read namespace")
}
if info.OrgID == 0 {
return nil, fmt.Errorf("invalid OrgID found in namespace")
}
if len(req.Kinds) != 1 {
return nil, fmt.Errorf("only can query for dashboard kind in legacy fallback")
}
parts := strings.SplitN(req.Kinds[0], "/", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid kind")
}
count, err := a.dashStore.CountInOrg(ctx, info.OrgID)
if err != nil {
return nil, err
}
return &resource.ResourceStatsResponse{
Stats: []*resource.ResourceStatsResponse_Stats{
{
Group: parts[0],
Resource: parts[1],
Count: count,
},
},
}, nil
return a.dashboardSearchClient.GetStats(ctx, req)
}
@@ -0,0 +1,128 @@
package legacysearcher
import (
"context"
"fmt"
"strings"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"google.golang.org/grpc"
)
type DashboardSearchClient struct {
resource.ResourceIndexClient
dashboardStore dashboards.Store
}
func NewDashboardSearchClient(dashboardStore dashboards.Store) *DashboardSearchClient {
return &DashboardSearchClient{dashboardStore: dashboardStore}
}
func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
if req.Query == "*" {
req.Query = ""
}
// TODO add missing support for the following query params:
// - tag
// - starred (won't support)
// - page (check)
// - type
// - sort
// - deleted
// - permission
// - dashboardIds
// - dashboardUIDs
// - folderIds
// - folderUIDs
// - sort (default by title)
query := &dashboards.FindPersistedDashboardsQuery{
Title: req.Query,
Limit: req.Limit,
// FolderUIDs: req.FolderUIDs,
SignedInUser: user,
}
// TODO need to test this
// emptyResponse, err := a.dashService.GetSharedDashboardUIDsQuery(ctx, query)
// if err != nil {
// return nil, err
// } else if emptyResponse {
// return nil, nil
// }
res, err := c.dashboardStore.FindDashboards(ctx, query)
if err != nil {
return nil, err
}
// TODO sort if query.Sort == "" see sortedHits in services/search/service.go
searchFields := resource.StandardSearchFields()
list := &resource.ResourceSearchResponse{
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
searchFields.Field(resource.SEARCH_FIELD_TITLE),
searchFields.Field(resource.SEARCH_FIELD_FOLDER),
// searchFields.Field(resource.SEARCH_FIELD_TAGS),
},
},
}
for _, dashboard := range res {
list.Results.Rows = append(list.Results.Rows, &resource.ResourceTableRow{
Key: &resource.ResourceKey{
Namespace: "default",
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: dashboard.UID,
},
Cells: [][]byte{[]byte(dashboard.Title), []byte(dashboard.FolderUID)}, // TODO add tag
})
}
return list, nil
}
func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) {
info, err := claims.ParseNamespace(req.Namespace)
if err != nil {
return nil, fmt.Errorf("unable to read namespace")
}
if info.OrgID == 0 {
return nil, fmt.Errorf("invalid OrgID found in namespace")
}
if len(req.Kinds) != 1 {
return nil, fmt.Errorf("only can query for dashboard kind in legacy fallback")
}
parts := strings.SplitN(req.Kinds[0], "/", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid kind")
}
count, err := c.dashboardStore.CountInOrg(ctx, info.OrgID)
if err != nil {
return nil, err
}
return &resource.ResourceStatsResponse{
Stats: []*resource.ResourceStatsResponse_Stats{
{
Group: parts[0],
Resource: parts[1],
Count: count,
},
},
}, nil
}
+4 -3
View File
@@ -21,6 +21,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/util/errhttp"
)
@@ -32,9 +33,10 @@ type SearchHandler struct {
tracer trace.Tracer
}
func NewSearchHandler(client resource.ResourceIndexClient, tracer trace.Tracer) *SearchHandler {
func NewSearchHandler(client resource.ResourceIndexClient, tracer trace.Tracer, cfg *setting.Cfg, legacyDashboardSearcher resource.ResourceIndexClient) *SearchHandler {
searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, client, legacyDashboardSearcher)
return &SearchHandler{
client: client,
client: searchClient,
log: log.New("grafana-apiserver.dashboards.search"),
tracer: tracer,
}
@@ -332,7 +334,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, namesFilter...)
}
// Run the query
result, err := s.client.Search(ctx, searchRequest)
if err != nil {
errhttp.Write(ctx, err, w)
+160
View File
@@ -7,13 +7,173 @@ import (
"testing"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"google.golang.org/grpc"
)
func TestSearchFallback(t *testing.T) {
t.Run("should hit legacy search handler on mode 0", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode0},
},
}
searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
if mockLegacyClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
})
t.Run("should hit legacy search handler on mode 1", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode1},
},
}
searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
if mockLegacyClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
})
t.Run("should hit legacy search handler on mode 2", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode2},
},
}
searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
if mockLegacyClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
})
t.Run("should hit unified storage search handler on mode 3", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode3},
},
}
searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
if mockLegacyClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
})
t.Run("should hit unified storage search handler on mode 4", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode4},
},
}
searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
if mockLegacyClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
})
t.Run("should hit unified storage search handler on mode 5", func(t *testing.T) {
mockClient := &MockClient{}
mockLegacyClient := &MockClient{}
cfg := &setting.Cfg{
UnifiedStorage: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
},
}
searchHandler := NewSearchHandler(mockClient, tracing.NewNoopTracerService(), cfg, mockLegacyClient)
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
searchHandler.DoSearch(rr, req)
if mockClient.LastSearchRequest == nil {
t.Fatalf("expected Search to be called, but it was not")
}
if mockLegacyClient.LastSearchRequest != nil {
t.Fatalf("expected Search NOT to be called, but it was")
}
})
}
func TestSearchHandlerFields(t *testing.T) {
// Create a mock client
mockClient := &MockClient{}
@@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/dashboard"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
@@ -71,6 +72,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
softDelete := features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore)
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore)
builder := &DashboardsAPIBuilder{
log: log.New("grafana-apiserver.dashboards.v0alpha1"),
DashboardsAPIBuilder: dashboard.DashboardsAPIBuilder{
@@ -80,7 +82,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
features: features,
accessControl: accessControl,
unified: unified,
search: dashboard.NewSearchHandler(unified, tracing),
search: dashboard.NewSearchHandler(unified, tracing, cfg, legacyDashboardSearcher),
legacy: &dashboard.DashboardStorage{
Resource: dashboardv0alpha1.DashboardResourceInfo,
+7 -2
View File
@@ -12,8 +12,11 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacysearcher"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
k8sUser "k8s.io/apiserver/pkg/authentication/user"
k8sRequest "k8s.io/apiserver/pkg/endpoints/request"
@@ -40,12 +43,14 @@ type k8sHandler struct {
searcher resource.ResourceIndexClient
}
func NewK8sHandler(namespacer request.NamespaceMapper, gvr schema.GroupVersionResource, restConfigProvider apiserver.RestConfigProvider, searcher resource.ResourceIndexClient) K8sHandler {
func NewK8sHandler(cfg *setting.Cfg, namespacer request.NamespaceMapper, gvr schema.GroupVersionResource, restConfigProvider apiserver.RestConfigProvider, searcher resource.ResourceIndexClient, dashStore dashboards.Store) K8sHandler {
legacySearcher := legacysearcher.NewDashboardSearchClient(dashStore)
searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, searcher, legacySearcher)
return &k8sHandler{
namespacer: namespacer,
gvr: gvr,
restConfigProvider: restConfigProvider,
searcher: searcher,
searcher: searchClient,
}
}
@@ -12,6 +12,11 @@ import (
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
@@ -43,11 +48,8 @@ import (
secretsfakes "github.com/grafana/grafana/pkg/services/secrets/fakes"
secretskv "github.com/grafana/grafana/pkg/services/secrets/kvstore"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func Test_NoopServiceDoesNothing(t *testing.T) {
@@ -874,7 +876,7 @@ func setUpServiceTest(t *testing.T, withDashboardMock bool, cfgOverrides ...conf
cfg, featureToggles, nil, nil, rr, sqlStore, kvStore, nil, nil, quotatest.New(false, nil),
secretsService, nil, alertMetrics, mockFolder, fakeAccessControl, dashboardService, nil, bus, fakeAccessControlService,
annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore,
httpclient.NewProvider(), ngalertfakes.NewFakeReceiverPermissionsService(),
httpclient.NewProvider(), ngalertfakes.NewFakeReceiverPermissionsService(), usertest.NewUserServiceFake(),
)
require.NoError(t, err)
@@ -91,7 +91,7 @@ func ProvideDashboardServiceImpl(
restConfigProvider apiserver.RestConfigProvider, userService user.Service, unified resource.ResourceClient,
quotaService quota.Service, orgService org.Service, publicDashboardService publicdashboards.ServiceWrapper,
) (*DashboardServiceImpl, error) {
k8sHandler := client.NewK8sHandler(request.GetNamespaceMapper(cfg), v0alpha1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider, unified)
k8sHandler := client.NewK8sHandler(cfg, request.GetNamespaceMapper(cfg), v0alpha1.DashboardResourceInfo.GroupVersionResource(), restConfigProvider, unified, dashboardStore)
dashSvc := &DashboardServiceImpl{
cfg: cfg,
@@ -1074,7 +1074,9 @@ func (dr *DashboardServiceImpl) GetDashboardUIDByID(ctx context.Context, query *
return nil, err
}
if len(result) != 1 {
if len(result) == 0 {
return nil, dashboards.ErrDashboardNotFound
} else if len(result) > 1 {
return nil, fmt.Errorf("unexpected number of dashboards found: %d. desired: 1", len(result))
}
+7
View File
@@ -1027,6 +1027,13 @@ var (
FrontendOnly: false,
Owner: grafanaAlertingSquad,
},
{
Name: "alertingSaveStateCompressed",
Description: "Enables the compressed protobuf-based alert state storage",
Stage: FeatureStageExperimental,
FrontendOnly: false,
Owner: grafanaAlertingSquad,
},
{
Name: "scopeApi",
Description: "In-development feature flag for the scope api using the app platform.",
+1
View File
@@ -135,6 +135,7 @@ newFolderPicker,experimental,@grafana/grafana-frontend-platform,false,false,true
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
onPremToCloudMigrations,preview,@grafana/grafana-operator-experience-squad,false,false,false
alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false
alertingSaveStateCompressed,experimental,@grafana/alerting-squad,false,false,false
scopeApi,experimental,@grafana/grafana-app-platform-squad,false,false,false
promQLScope,GA,@grafana/oss-big-tent,false,false,false
logQLScope,privatePreview,@grafana/observability-logs,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
135 jitterAlertRulesWithinGroups preview @grafana/alerting-squad false true false
136 onPremToCloudMigrations preview @grafana/grafana-operator-experience-squad false false false
137 alertingSaveStatePeriodic privatePreview @grafana/alerting-squad false false false
138 alertingSaveStateCompressed experimental @grafana/alerting-squad false false false
139 scopeApi experimental @grafana/grafana-app-platform-squad false false false
140 promQLScope GA @grafana/oss-big-tent false false false
141 logQLScope privatePreview @grafana/observability-logs false false false
+4
View File
@@ -551,6 +551,10 @@ const (
// Writes the state periodically to the database, asynchronous to rule evaluation
FlagAlertingSaveStatePeriodic = "alertingSaveStatePeriodic"
// FlagAlertingSaveStateCompressed
// Enables the compressed protobuf-based alert state storage
FlagAlertingSaveStateCompressed = "alertingSaveStateCompressed"
// FlagScopeApi
// In-development feature flag for the scope api using the app platform.
FlagScopeApi = "scopeApi"
+15
View File
@@ -339,6 +339,21 @@
"expression": "false"
}
},
{
"metadata": {
"name": "alertingSaveStateCompressed",
"resourceVersion": "1737472824047",
"creationTimestamp": "2025-01-17T18:17:20Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-01-21 15:20:24.047499 +0000 UTC"
}
},
"spec": {
"description": "Enables the compressed protobuf-based alert state storage",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad"
}
},
{
"metadata": {
"name": "alertingSaveStatePeriodic",
+52 -23
View File
@@ -36,16 +36,6 @@ SELECT DISTINCT
, (SELECT COUNT(connection_id) FROM ` + model.LibraryElementConnectionTableName + ` WHERE element_id = le.id AND kind=1) AS connected_dashboards`
)
// redundant SELECT to trick mysql's optimizer
const deleteInvalidConnections = `
DELETE FROM library_element_connection
WHERE connection_id IN (
SELECT connection_id FROM (
SELECT connection_id as id FROM library_element_connection
WHERE element_id=? AND connection_id NOT IN (SELECT id as connection_id from dashboard)
) as dummy
)`
func getFromLibraryElementDTOWithMeta(dialect migrator.Dialect) string {
user := dialect.Quote("user")
userJoin := `
@@ -89,15 +79,12 @@ func syncFieldsWithModel(libraryElement *model.LibraryElement) error {
return nil
}
func GetLibraryElement(dialect migrator.Dialect, session *db.Session, uid string, orgID int64) (model.LibraryElementWithMeta, error) {
func (l *LibraryElementService) GetLibraryElement(c context.Context, signedInUser identity.Requester, session *db.Session, uid string) (model.LibraryElementWithMeta, error) {
elements := make([]model.LibraryElementWithMeta, 0)
sql := selectLibraryElementDTOWithMeta +
", coalesce(dashboard.title, 'General') AS folder_name" +
", coalesce(dashboard.uid, '') AS folder_uid" +
getFromLibraryElementDTOWithMeta(dialect) +
" LEFT JOIN dashboard AS dashboard ON dashboard.id = le.folder_id" +
getFromLibraryElementDTOWithMeta(l.SQLStore.GetDialect()) +
" WHERE le.uid=? AND le.org_id=?"
sess := session.SQL(sql, uid, orgID)
sess := session.SQL(sql, uid, signedInUser.GetOrgID())
err := sess.Find(&elements)
if err != nil {
return model.LibraryElementWithMeta{}, err
@@ -109,6 +96,19 @@ func GetLibraryElement(dialect migrator.Dialect, session *db.Session, uid string
return model.LibraryElementWithMeta{}, fmt.Errorf("found %d elements, while expecting at most one", len(elements))
}
// get the folder title
f, err := l.folderService.Get(c, &folder.GetFolderQuery{
OrgID: elements[0].OrgID,
UID: &elements[0].FolderUID,
SignedInUser: signedInUser,
})
if err == nil {
elements[0].FolderName = f.Title
} else {
// default to General if we cannot find the folder
elements[0].FolderName = "General"
}
return elements[0], nil
}
@@ -230,7 +230,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn
func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedInUser identity.Requester, uid string) (int64, error) {
var elementID int64
err := l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
element, err := GetLibraryElement(l.SQLStore.GetDialect(), session, uid, signedInUser.GetOrgID())
element, err := l.GetLibraryElement(c, signedInUser, session, uid)
if err != nil {
return err
}
@@ -243,11 +243,37 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn
}
}
// Delete any hanging/invalid connections
if _, err = session.Exec(deleteInvalidConnections, element.ID); err != nil {
dashboardIDs := []int64{}
// get all connections for this element
if err := session.SQL("SELECT connection_id FROM library_element_connection where element_id = ?", element.ID).Find(&dashboardIDs); err != nil {
return err
}
// then find the dashboards that were supposed to be connected to this element
_, requester := identity.WithServiceIdentitiy(c, signedInUser.GetOrgID())
dashs, err := l.dashboardsService.FindDashboards(c, &dashboards.FindPersistedDashboardsQuery{
OrgId: signedInUser.GetOrgID(),
DashboardIds: dashboardIDs,
SignedInUser: requester, // a user may be able to delete a library element but not read all dashboards. We still need to run this check, so we don't allow deleting elements if dashboards are connected
})
if err != nil {
return err
}
foundDashes := make([]int64, len(dashs))
for i, d := range dashs {
foundDashes[i] = d.ID
}
// delete any connections that are orphaned for this element (i.e. the dashboard was deleted)
session.Table("library_element_connection")
session.Where("element_id = ?", element.ID)
session.NotIn("connection_id", foundDashes)
if _, err = session.Delete(model.LibraryElementConnectionWithMeta{}); err != nil {
return err
}
// now try to delete the element, but fail if it is connected to any dashboards
var connectionIDs []struct {
ConnectionID int64 `xorm:"connection_id"`
}
@@ -577,7 +603,7 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU
return model.LibraryElementDTO{}, err
}
err := l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
elementInDB, err := GetLibraryElement(l.SQLStore.GetDialect(), session, uid, signedInUser.GetOrgID())
elementInDB, err := l.GetLibraryElement(c, signedInUser, session, uid)
if err != nil {
return err
}
@@ -594,7 +620,7 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU
return model.ErrLibraryElementUIDTooLong
}
_, err := GetLibraryElement(l.SQLStore.GetDialect(), session, updateUID, signedInUser.GetOrgID())
_, err := l.GetLibraryElement(c, signedInUser, session, updateUID)
if !errors.Is(err, model.ErrLibraryElementNotFound) {
return model.ErrLibraryElementAlreadyExists
}
@@ -689,7 +715,7 @@ func (l *LibraryElementService) getConnections(c context.Context, signedInUser i
}
err = l.SQLStore.WithDbSession(c, func(session *db.Session) error {
element, err := GetLibraryElement(l.SQLStore.GetDialect(), session, uid, signedInUser.GetOrgID())
element, err := l.GetLibraryElement(c, signedInUser, session, uid)
if err != nil {
return err
}
@@ -722,6 +748,9 @@ func (l *LibraryElementService) getConnections(c context.Context, signedInUser i
}
ds, err := l.dashboardsService.GetDashboardUIDByID(c, &dashboards.GetDashboardRefByIDQuery{ID: connection.ConnectionID})
if err != nil {
if errors.Is(err, dashboards.ErrDashboardNotFound) {
continue
}
return err
}
connections = append(connections, model.LibraryElementConnectionDTO{
@@ -813,7 +842,7 @@ func (l *LibraryElementService) connectElementsToDashboardID(c context.Context,
return err
}
for _, elementUID := range elementUIDs {
element, err := GetLibraryElement(l.SQLStore.GetDialect(), session, elementUID, signedInUser.GetOrgID())
element, err := l.GetLibraryElement(c, signedInUser, session, elementUID)
if err != nil {
return err
}
@@ -82,4 +82,14 @@ func TestDeleteLibraryElement(t *testing.T) {
resp := sc.service.deleteHandler(sc.reqContext)
require.Equal(t, 403, resp.Status())
})
scenarioWithPanel(t, "When an admin tries to delete a library panel that is connected to a non-existent dashboard, it should succeed",
func(t *testing.T, sc scenarioContext) {
err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 9999999)
require.NoError(t, err)
sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": sc.initialResult.Result.UID})
resp := sc.service.deleteHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
})
}
@@ -1,12 +1,15 @@
package libraryelements
import (
"encoding/json"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/kinds/librarypanel"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/web"
@@ -91,6 +94,37 @@ func TestGetLibraryElement(t *testing.T) {
}
})
scenarioWithPanel(t, "When an admin tries to get a library panel that exists, but the original folder does not, it should succeed and return correct result",
func(t *testing.T, sc scenarioContext) {
b, err := json.Marshal(map[string]string{"test": "test"})
require.NoError(t, err)
newFolder := createFolder(t, sc, "NewFolder", nil)
sc.reqContext.SignedInUser.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersRead] = []string{dashboards.ScopeFoldersAll}
sc.reqContext.SignedInUser.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersDelete] = []string{dashboards.ScopeFoldersAll}
result, err := sc.service.createLibraryElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, model.CreateLibraryElementCommand{
FolderID: newFolder.ID, // nolint:staticcheck
FolderUID: &newFolder.UID,
Name: "Testing Library Panel With Deleted Folder",
Kind: 1,
Model: b,
UID: "panel-with-deleted-folder",
})
require.NoError(t, err)
err = sc.service.folderService.Delete(sc.reqContext.Req.Context(), &folder.DeleteFolderCommand{
UID: newFolder.UID,
OrgID: sc.reqContext.OrgID,
SignedInUser: sc.reqContext.SignedInUser,
})
require.NoError(t, err)
err = sc.sqlStore.WithDbSession(sc.reqContext.Req.Context(), func(session *db.Session) error {
elem, err := sc.service.GetLibraryElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, session, result.UID)
require.NoError(t, err)
require.Equal(t, elem.FolderName, "General")
return nil
})
require.NoError(t, err)
})
scenarioWithPanel(t, "When an admin tries to get a connected library panel, it should succeed and return correct connected dashboards",
func(t *testing.T, sc scenarioContext) {
dashJSON := map[string]any{
@@ -32,10 +32,13 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
ngstore "github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/org/orgimpl"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/quota/quotatest"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
@@ -166,6 +169,10 @@ func TestGetLibraryPanelConnections(t *testing.T) {
err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID)
require.NoError(t, err)
// add a connection where the dashboard doesn't exist. Shouldn't be returned in the list
err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, 99999999)
require.NoError(t, err)
var expected = func(res model.LibraryElementConnectionsResponse) model.LibraryElementConnectionsResponse {
return model.LibraryElementConnectionsResponse{
Result: []model.LibraryElementConnectionDTO{
@@ -194,6 +201,39 @@ func TestGetLibraryPanelConnections(t *testing.T) {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithPanel(t, "When an admin tries to create a connection with an element that exists, but the original folder does not, it should still succeed",
func(t *testing.T, sc scenarioContext) {
b, err := json.Marshal(map[string]string{"test": "test"})
require.NoError(t, err)
newFolder := createFolder(t, sc, "NewFolder", nil)
sc.reqContext.SignedInUser.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersRead] = []string{dashboards.ScopeFoldersAll}
sc.reqContext.SignedInUser.Permissions[sc.reqContext.OrgID][dashboards.ActionFoldersDelete] = []string{dashboards.ScopeFoldersAll}
_, err = sc.service.createLibraryElement(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, model.CreateLibraryElementCommand{
FolderID: newFolder.ID, // nolint:staticcheck
FolderUID: &newFolder.UID,
Name: "Testing Library Panel With Deleted Folder",
Kind: 1,
Model: b,
UID: "panel-with-deleted-folder",
})
require.NoError(t, err)
err = sc.service.folderService.Delete(sc.reqContext.Req.Context(), &folder.DeleteFolderCommand{
UID: newFolder.UID,
OrgID: sc.reqContext.OrgID,
SignedInUser: sc.reqContext.SignedInUser,
})
require.NoError(t, err)
dash := dashboards.Dashboard{
Title: "Testing create element",
Data: simplejson.NewFromAny(map[string]any{}),
}
// nolint:staticcheck
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID)
err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID)
require.NoError(t, err)
})
}
type libraryElement struct {
@@ -473,9 +513,15 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
dashboardPermissions := acmock.NewMockedPermissionsService()
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
fStore := folderimpl.ProvideStore(sqlStore)
publicDash := &publicdashboards.FakePublicDashboardServiceWrapper{}
publicDash.On("DeleteByDashboardUIDs", mock.Anything, mock.Anything, mock.Anything).Return(nil)
folderSvc := folderimpl.ProvideService(
fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore,
nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest())
nil, sqlStore, features, supportbundlestest.NewFakeBundleService(), publicDash, cfg, nil, tracing.InitializeTracerForTest())
alertStore, err := ngstore.ProvideDBStore(cfg, features, sqlStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac, bus.ProvideBus(tracing.InitializeTracerForTest()))
require.NoError(t, err)
err = folderSvc.RegisterService(alertStore)
require.NoError(t, err)
dashService, dashSvcErr := dashboardservice.ProvideDashboardServiceImpl(
cfg, dashboardStore, folderStore,
features, folderPermissions, ac,
+3
View File
@@ -24,6 +24,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/state"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -78,6 +79,7 @@ type API struct {
Historian Historian
Tracer tracing.Tracer
AppUrl *url.URL
UserService user.Service
// Hooks can be used to replace API handlers for specific paths.
Hooks *Hooks
@@ -135,6 +137,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
amConfigStore: api.AlertingStore,
amRefresher: api.MultiOrgAlertmanager,
featureManager: api.FeatureManager,
userService: api.UserService,
},
), m)
api.RegisterTestingApiEndpoints(NewTestingApi(
+60 -1
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/grafana/grafana/pkg/api/response"
@@ -19,6 +20,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/util"
alertmanager_config "github.com/prometheus/alertmanager/config"
)
const disableProvenanceHeaderName = "X-Disable-Provenance"
@@ -600,10 +602,67 @@ func escapeAlertingFileExport(body definitions.AlertingFileExport) definitions.A
for i, group := range body.Groups {
body.Groups[i] = escapeRuleGroup(group)
}
// TODO: implement escaping for the other export fields
for i, cp := range body.ContactPoints {
body.ContactPoints[i] = escapeContactPoint(cp)
}
for i, np := range body.Policies {
body.Policies[i] = escapeNotificationPolicy(np)
}
return body
}
func escapeRouteExport(r *definitions.RouteExport) {
r.Receiver = addEscapeCharactersToString(r.Receiver)
if r.GroupByStr != nil {
groupByStr := make([]string, len(*r.GroupByStr))
for i, groupBy := range *r.GroupByStr {
groupByStr[i] = addEscapeCharactersToString(groupBy)
}
r.GroupByStr = &groupByStr
}
for k, v := range r.Match {
r.Match[k] = addEscapeCharactersToString(v)
}
for k, v := range r.MatchRE {
// convert regex to string, escape then covert back to regex
stringRepr := addEscapeCharactersToString(v.String())
mutated := regexp.MustCompile(stringRepr)
r.MatchRE[k] = alertmanager_config.Regexp{Regexp: mutated}
}
if r.MuteTimeIntervals != nil {
muteTimeIntervals := make([]string, len(*r.MuteTimeIntervals))
for i, muteTimeInterval := range *r.MuteTimeIntervals {
muteTimeIntervals[i] = addEscapeCharactersToString(muteTimeInterval)
}
r.MuteTimeIntervals = &muteTimeIntervals
}
for i := range r.Routes {
escapeRouteExport(r.Routes[i])
}
}
func escapeNotificationPolicy(np definitions.NotificationPolicyExport) definitions.NotificationPolicyExport {
escapeRouteExport(np.RouteExport)
return np
}
func escapeContactPoint(cp definitions.ContactPointExport) definitions.ContactPointExport {
cp.Name = addEscapeCharactersToString(cp.Name)
for i, receiver := range cp.Receivers {
settingsJson, err := receiver.Settings.MarshalJSON()
if err != nil {
// This should never happen, as the settings are already marshaled to JSON in the API
panic(fmt.Errorf("failed to marshal settings to JSON: %w", err))
}
settingsEscaped := []byte(addEscapeCharactersToString(string(settingsJson)))
if err := cp.Receivers[i].Settings.UnmarshalJSON(settingsEscaped); err != nil {
// This should never happen, as the settings are already marshaled to JSON in the API
panic(fmt.Errorf("failed to unmarshal settings from JSON: %w", err))
}
}
return cp
}
// escape all strings except:
// Alert rule annotations: groups[].rules[].annotations
// Alert rule time range: groups[].rules[].relativeTimeRange
+35 -7
View File
@@ -27,6 +27,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -53,6 +54,7 @@ type RulerSrv struct {
cfg *setting.UnifiedAlertingSettings
conditionValidator ConditionValidator
authz RuleAccessControlService
userService user.Service
amConfigStore AMConfigStore
amRefresher AMRefresher
@@ -211,7 +213,7 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, nam
result := apimodels.NamespaceConfigResponse{}
for groupKey, rules := range ruleGroups {
result[namespace.Fullpath] = append(result[namespace.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
result[namespace.Fullpath] = append(result[namespace.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords, srv.resolveUserIdToNameFn(c.Req.Context())))
}
return response.JSON(http.StatusAccepted, result)
@@ -246,7 +248,7 @@ func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespa
result := apimodels.RuleGroupConfigResponse{
// nolint:staticcheck
GettableRuleGroupConfig: toGettableRuleGroupConfig(finalRuleGroup, rules, provenanceRecords),
GettableRuleGroupConfig: toGettableRuleGroupConfig(finalRuleGroup, rules, provenanceRecords, srv.resolveUserIdToNameFn(c.Req.Context())),
}
return response.JSON(http.StatusAccepted, result)
}
@@ -300,7 +302,7 @@ func (srv RulerSrv) RouteGetRulesConfig(c *contextmodel.ReqContext) response.Res
srv.log.Error("Namespace not visible to the user", "user", id, "userNamespace", userNamespace, "namespace", groupKey.NamespaceUID)
continue
}
result[folder.Fullpath] = append(result[folder.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
result[folder.Fullpath] = append(result[folder.Fullpath], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords, srv.resolveUserIdToNameFn(c.Req.Context())))
}
return response.JSON(http.StatusOK, result)
}
@@ -323,7 +325,7 @@ func (srv RulerSrv) RouteGetRuleByUID(c *contextmodel.ReqContext, ruleUID string
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get rule provenance", err)
}
result := toGettableExtendedRuleNode(rule, map[string]ngmodels.Provenance{rule.ResourceID(): provenance})
result := toGettableExtendedRuleNode(rule, map[string]ngmodels.Provenance{rule.ResourceID(): provenance}, srv.resolveUserIdToNameFn(ctx))
return response.JSON(http.StatusOK, result)
}
@@ -533,7 +535,7 @@ func changesToResponse(finalChanges *store.GroupDelta) response.Response {
return response.JSON(http.StatusAccepted, body)
}
func toGettableRuleGroupConfig(groupName string, rules ngmodels.RulesGroup, provenanceRecords map[string]ngmodels.Provenance) apimodels.GettableRuleGroupConfig {
func toGettableRuleGroupConfig(groupName string, rules ngmodels.RulesGroup, provenanceRecords map[string]ngmodels.Provenance, userIdToName userIDToUserInfoFn) apimodels.GettableRuleGroupConfig {
rules.SortByGroupIndex()
ruleNodes := make([]apimodels.GettableExtendedRuleNode, 0, len(rules))
var interval time.Duration
@@ -541,7 +543,7 @@ func toGettableRuleGroupConfig(groupName string, rules ngmodels.RulesGroup, prov
interval = time.Duration(rules[0].IntervalSeconds) * time.Second
}
for _, r := range rules {
ruleNodes = append(ruleNodes, toGettableExtendedRuleNode(*r, provenanceRecords))
ruleNodes = append(ruleNodes, toGettableExtendedRuleNode(*r, provenanceRecords, userIdToName))
}
return apimodels.GettableRuleGroupConfig{
Name: groupName,
@@ -550,7 +552,7 @@ func toGettableRuleGroupConfig(groupName string, rules ngmodels.RulesGroup, prov
}
}
func toGettableExtendedRuleNode(r ngmodels.AlertRule, provenanceRecords map[string]ngmodels.Provenance) apimodels.GettableExtendedRuleNode {
func toGettableExtendedRuleNode(r ngmodels.AlertRule, provenanceRecords map[string]ngmodels.Provenance, userIdToName userIDToUserInfoFn) apimodels.GettableExtendedRuleNode {
provenance := ngmodels.ProvenanceNone
if prov, exists := provenanceRecords[r.ResourceID()]; exists {
provenance = prov
@@ -564,6 +566,7 @@ func toGettableExtendedRuleNode(r ngmodels.AlertRule, provenanceRecords map[stri
Condition: r.Condition,
Data: ApiAlertQueriesFromAlertQueries(r.Data),
Updated: r.Updated,
UpdatedBy: userIdToName(r.UpdatedBy),
IntervalSeconds: r.IntervalSeconds,
Version: r.Version,
UID: r.UID,
@@ -724,3 +727,28 @@ func (srv RulerSrv) searchAuthorizedAlertRules(ctx context.Context, q authorized
}
return byGroupKey, totalGroups, nil
}
type userIDToUserInfoFn func(id *ngmodels.UserUID) *apimodels.UserInfo
// getIdentityName returns name of either user or service account
func (srv RulerSrv) resolveUserIdToNameFn(ctx context.Context) userIDToUserInfoFn {
return func(id *ngmodels.UserUID) *apimodels.UserInfo {
if id == nil {
return nil
}
u, err := srv.userService.GetByUID(ctx, &user.GetUserByUIDQuery{
UID: string(*id),
})
var result string
if err != nil {
srv.log.FromContext(ctx).Warn("Failed to get user by uid. Defaulting to an empty name", "uid", id, "error", err)
}
if u != nil {
result = u.NameOrFallback()
}
return &apimodels.UserInfo{
UID: string(*id),
Name: result,
}
}
}
@@ -32,7 +32,9 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/cmputil"
"github.com/grafana/grafana/pkg/web"
)
@@ -357,6 +359,72 @@ func TestRouteGetRuleByUID(t *testing.T) {
require.Equal(t, expectedRule.Title, result.GrafanaManagedAlert.Title)
require.True(t, result.GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedQueryAndExpressionsSection)
require.True(t, result.GrafanaManagedAlert.Metadata.EditorSettings.SimplifiedNotificationsSection)
t.Run("should resolve Updated_by with user service", func(t *testing.T) {
testcases := []struct {
desc string
UpdatedBy *models.UserUID
User *user.User
UserServiceError error
Expected *apimodels.UserInfo
}{
{
desc: "nil if UpdatedBy is nil",
UpdatedBy: nil,
User: nil,
UserServiceError: nil,
Expected: nil,
},
{
desc: "just UID if user is not found",
UpdatedBy: util.Pointer(models.UserUID("test-uid")),
User: nil,
UserServiceError: nil,
Expected: &apimodels.UserInfo{
UID: "test-uid",
},
},
{
desc: "just UID if error",
UpdatedBy: util.Pointer(models.UserUID("test-uid")),
UserServiceError: errors.New("error"),
Expected: &apimodels.UserInfo{
UID: "test-uid",
},
},
{
desc: "login if it's known user",
UpdatedBy: util.Pointer(models.UserUID("test-uid")),
User: &user.User{
Login: "Test",
},
UserServiceError: nil,
Expected: &apimodels.UserInfo{
UID: "test-uid",
Name: "Test",
},
},
}
for _, tc := range testcases {
t.Run(tc.desc, func(t *testing.T) {
expectedRule.UpdatedBy = tc.UpdatedBy
svc := createService(ruleStore)
usvc := usertest.NewUserServiceFake()
usvc.ExpectedUser = tc.User
usvc.ExpectedError = tc.UserServiceError
svc.userService = usvc
response := svc.RouteGetRuleByUID(req, expectedRule.UID)
require.Equal(t, http.StatusOK, response.Status())
result := &apimodels.GettableExtendedRuleNode{}
require.NoError(t, json.Unmarshal(response.Body(), result))
require.NotNil(t, result)
require.Equal(t, tc.Expected, result.GrafanaManagedAlert.UpdatedBy)
})
}
})
})
t.Run("error when fetching rule with non-existent UID", func(t *testing.T) {
@@ -659,6 +727,7 @@ func createService(store *fakes.RuleStore) *RulerSrv {
amConfigStore: &fakeAMRefresher{},
amRefresher: &fakeAMRefresher{},
featureManager: featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRules),
userService: usertest.NewUserServiceFake(),
}
}
@@ -364,7 +364,7 @@ const (
type PostableExtendedRuleNode struct {
// note: this works with yaml v3 but not v2 (the inline tag isn't accepted on pointers in v2)
*ApiRuleNode `yaml:",inline"`
//GrafanaManagedAlert yaml.Node `yaml:"grafana_alert,omitempty"`
// GrafanaManagedAlert yaml.Node `yaml:"grafana_alert,omitempty"`
GrafanaManagedAlert *PostableGrafanaRule `yaml:"grafana_alert,omitempty" json:"grafana_alert,omitempty"`
}
@@ -401,7 +401,7 @@ func (n *PostableExtendedRuleNode) validate() error {
type GettableExtendedRuleNode struct {
// note: this works with yaml v3 but not v2 (the inline tag isn't accepted on pointers in v2)
*ApiRuleNode `yaml:",inline"`
//GrafanaManagedAlert yaml.Node `yaml:"grafana_alert,omitempty"`
// GrafanaManagedAlert yaml.Node `yaml:"grafana_alert,omitempty"`
GrafanaManagedAlert *GettableGrafanaRule `yaml:"grafana_alert,omitempty" json:"grafana_alert,omitempty"`
}
@@ -541,6 +541,7 @@ type GettableGrafanaRule struct {
Condition string `json:"condition" yaml:"condition"`
Data []AlertQuery `json:"data" yaml:"data"`
Updated time.Time `json:"updated" yaml:"updated"`
UpdatedBy *UserInfo `json:"updated_by" yaml:"updated_by"`
IntervalSeconds int64 `json:"intervalSeconds" yaml:"intervalSeconds"`
Version int64 `json:"version" yaml:"version"`
UID string `json:"uid" yaml:"uid"`
@@ -555,6 +556,12 @@ type GettableGrafanaRule struct {
Metadata *AlertRuleMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"`
}
// UserInfo represents user-related information, including a unique identifier and a name.
type UserInfo struct {
UID string `json:"uid"`
Name string `json:"name"`
}
// AlertQuery represents a single query associated with an alert definition.
type AlertQuery struct {
// RefID is the unique identifier of the query, set by the frontend call.
+62 -10
View File
@@ -49,6 +49,7 @@ import (
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
@@ -78,6 +79,7 @@ func ProvideService(
ruleStore *store.DBstore,
httpClientProvider httpclient.Provider,
resourcePermissions accesscontrol.ReceiverPermissionsService,
userService user.Service,
) (*AlertNG, error) {
ng := &AlertNG{
Cfg: cfg,
@@ -106,6 +108,7 @@ func ProvideService(
store: ruleStore,
httpClientProvider: httpClientProvider,
ResourcePermissions: resourcePermissions,
userService: userService,
}
if ng.IsDisabled() {
@@ -144,6 +147,7 @@ type AlertNG struct {
dashboardService dashboards.DashboardService
Api *api.API
httpClientProvider httpclient.Provider
InstanceStore state.InstanceStore
// Alerting notification services
MultiOrgAlertmanager *notifier.MultiOrgAlertmanager
@@ -153,6 +157,7 @@ type AlertNG struct {
ResourcePermissions accesscontrol.ReceiverPermissionsService
annotationsRepo annotations.Repository
store *store.DBstore
userService user.Service
bus bus.Bus
pluginsStore pluginstore.Store
@@ -398,11 +403,14 @@ func (ng *AlertNG) init() error {
if err != nil {
return err
}
cfg := state.ManagerCfg{
ng.InstanceStore = initInstanceStore(ng.store.SQLStore, ng.Log.New("ngalert.state.instancestore"), ng.FeatureToggles)
stateManagerCfg := state.ManagerCfg{
Metrics: ng.Metrics.GetStateMetrics(),
ExternalURL: appUrl,
DisableExecution: !ng.Cfg.UnifiedAlerting.ExecuteAlerts,
InstanceStore: ng.store,
InstanceStore: ng.InstanceStore,
Images: ng.ImageService,
Clock: clk,
Historian: history,
@@ -415,13 +423,8 @@ func (ng *AlertNG) init() error {
Log: log.New("ngalert.state.manager"),
ResolvedRetention: ng.Cfg.UnifiedAlerting.ResolvedAlertRetention,
}
logger := log.New("ngalert.state.manager.persist")
statePersister := state.NewSyncStatePersisiter(logger, cfg)
if ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStatePeriodic) {
ticker := clock.New().Ticker(ng.Cfg.UnifiedAlerting.StatePeriodicSaveInterval)
statePersister = state.NewAsyncStatePersister(logger, ticker, cfg)
}
stateManager := state.NewManager(cfg, statePersister)
statePersister := initStatePersister(ng.Cfg.UnifiedAlerting, stateManagerCfg, ng.FeatureToggles)
stateManager := state.NewManager(stateManagerCfg, statePersister)
scheduler := schedule.NewScheduler(schedCfg, stateManager)
// if it is required to include folder title to the alerts, we need to subscribe to changes of alert title
@@ -497,6 +500,7 @@ func (ng *AlertNG) init() error {
Historian: history,
Hooks: api.NewHooks(ng.Log),
Tracer: ng.tracer,
UserService: ng.userService,
}
ng.Api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics())
@@ -515,6 +519,54 @@ func (ng *AlertNG) init() error {
return DeclareFixedRoles(ng.AccesscontrolService, ng.FeatureToggles)
}
func initInstanceStore(sqlStore db.DB, logger log.Logger, featureToggles featuremgmt.FeatureToggles) state.InstanceStore {
var instanceStore state.InstanceStore
if featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStateCompressed) {
logger.Info("Using protobuf-based alert instance store")
instanceStore = store.ProtoInstanceDBStore{
SQLStore: sqlStore,
Logger: logger,
FeatureToggles: featureToggles,
}
// If FlagAlertingSaveStateCompressed is enabled, ProtoInstanceDBStore is used,
// which functions differently from InstanceDBStore. FlagAlertingSaveStatePeriodic is
// not applicable to ProtoInstanceDBStore, so a warning is logged if it is set.
if featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStatePeriodic) {
logger.Warn("alertingSaveStatePeriodic is not used when alertingSaveStateCompressed feature flag enabled")
}
} else {
logger.Info("Using simple database alert instance store")
instanceStore = store.InstanceDBStore{
SQLStore: sqlStore,
Logger: logger,
FeatureToggles: featureToggles,
}
}
return instanceStore
}
func initStatePersister(uaCfg setting.UnifiedAlertingSettings, cfg state.ManagerCfg, featureToggles featuremgmt.FeatureToggles) state.StatePersister {
logger := log.New("ngalert.state.manager.persist")
var statePersister state.StatePersister
if featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStateCompressed) {
logger.Info("Using rule state persister")
statePersister = state.NewSyncRuleStatePersisiter(logger, cfg)
} else if featureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStatePeriodic) {
logger.Info("Using periodic state persister")
ticker := clock.New().Ticker(uaCfg.StatePeriodicSaveInterval)
statePersister = state.NewAsyncStatePersister(logger, ticker, cfg)
} else {
logger.Info("Using sync state persister")
statePersister = state.NewSyncStatePersisiter(logger, cfg)
}
return statePersister
}
func subscribeToFolderChanges(logger log.Logger, bus bus.Bus, dbStore api.RuleStore) {
// if full path to the folder is changed, we update all alert rules in that folder to make sure that all peers (in HA mode) will update folder title and
// clean up the current state
@@ -553,7 +605,7 @@ func (ng *AlertNG) Run(ctx context.Context) error {
// Also note that this runs synchronously to ensure state is loaded
// before rule evaluation begins, hence we use ctx and not subCtx.
//
ng.stateManager.Warm(ctx, ng.store, ng.store)
ng.stateManager.Warm(ctx, ng.store, ng.InstanceStore)
children.Go(func() error {
return ng.schedule.Run(subCtx)
+95
View File
@@ -14,12 +14,16 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
acfakes "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes"
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/state"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@@ -192,3 +196,94 @@ grafana_alerting_state_history_info{backend="noop"} 0
require.NoError(t, err)
})
}
type mockDB struct {
db.DB
}
func TestInitInstanceStore(t *testing.T) {
sqlStore := &mockDB{}
logger := log.New()
tests := []struct {
name string
ft featuremgmt.FeatureToggles
expectedInstanceStoreType interface{}
}{
{
name: "Compressed flag enabled, no periodic flag",
ft: featuremgmt.WithFeatures(
featuremgmt.FlagAlertingSaveStateCompressed,
),
expectedInstanceStoreType: store.ProtoInstanceDBStore{},
},
{
name: "Compressed flag enabled with periodic flag",
ft: featuremgmt.WithFeatures(
featuremgmt.FlagAlertingSaveStateCompressed,
featuremgmt.FlagAlertingSaveStatePeriodic,
),
expectedInstanceStoreType: store.ProtoInstanceDBStore{},
},
{
name: "Compressed flag disabled",
ft: featuremgmt.WithFeatures(),
expectedInstanceStoreType: store.InstanceDBStore{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
instanceStore := initInstanceStore(sqlStore, logger, tt.ft)
assert.IsType(t, tt.expectedInstanceStoreType, instanceStore)
})
}
}
func TestInitStatePersister(t *testing.T) {
ua := setting.UnifiedAlertingSettings{
StatePeriodicSaveInterval: 1 * time.Minute,
}
cfg := state.ManagerCfg{}
tests := []struct {
name string
ft featuremgmt.FeatureToggles
expectedStatePersisterType state.StatePersister
}{
{
name: "Compressed flag enabled",
ft: featuremgmt.WithFeatures(
featuremgmt.FlagAlertingSaveStateCompressed,
),
expectedStatePersisterType: &state.SyncRuleStatePersister{},
},
{
name: "Periodic flag enabled",
ft: featuremgmt.WithFeatures(
featuremgmt.FlagAlertingSaveStatePeriodic,
),
expectedStatePersisterType: &state.AsyncStatePersister{},
},
{
name: "No flags enabled",
ft: featuremgmt.WithFeatures(),
expectedStatePersisterType: &state.SyncStatePersister{},
},
{
name: "Both flags enabled - compressed takes precedence",
ft: featuremgmt.WithFeatures(
featuremgmt.FlagAlertingSaveStateCompressed,
featuremgmt.FlagAlertingSaveStatePeriodic,
),
expectedStatePersisterType: &state.SyncRuleStatePersister{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
statePersister := initStatePersister(ua, cfg, tt.ft)
assert.IsType(t, tt.expectedStatePersisterType, statePersister)
})
}
}
+25 -25
View File
@@ -47,7 +47,7 @@ func TestWarmStateCache(t *testing.T) {
evaluationTime, err := time.Parse("2006-01-02", "2021-03-25")
require.NoError(t, err)
ctx := context.Background()
_, dbstore := tests.SetupTestEnv(t, 1)
ng, dbstore := tests.SetupTestEnv(t, 1)
const mainOrgID int64 = 1
rule := tests.CreateTestAlertRule(t, ctx, dbstore, 600, mainOrgID)
@@ -216,13 +216,13 @@ func TestWarmStateCache(t *testing.T) {
ResultFingerprint: data.Fingerprint(2).String(),
})
for _, instance := range instances {
_ = dbstore.SaveAlertInstance(ctx, instance)
_ = ng.InstanceStore.SaveAlertInstance(ctx, instance)
}
cfg := state.ManagerCfg{
Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(),
ExternalURL: nil,
InstanceStore: dbstore,
InstanceStore: ng.InstanceStore,
Images: &state.NoopImageService{},
Clock: clock.NewMock(),
Historian: &state.FakeHistorian{},
@@ -230,7 +230,7 @@ func TestWarmStateCache(t *testing.T) {
Log: log.New("ngalert.state.manager"),
}
st := state.NewManager(cfg, state.NewNoopPersister())
st.Warm(ctx, dbstore, dbstore)
st.Warm(ctx, dbstore, ng.InstanceStore)
t.Run("instance cache has expected entries", func(t *testing.T) {
for _, entry := range expectedEntries {
@@ -250,7 +250,7 @@ func TestDashboardAnnotations(t *testing.T) {
require.NoError(t, err)
ctx := context.Background()
_, dbstore := tests.SetupTestEnv(t, 1)
ng, dbstore := tests.SetupTestEnv(t, 1)
fakeAnnoRepo := annotationstest.NewFakeAnnotationsRepo()
historianMetrics := metrics.NewHistorianMetrics(prometheus.NewRegistry(), metrics.Subsystem)
@@ -261,7 +261,7 @@ func TestDashboardAnnotations(t *testing.T) {
cfg := state.ManagerCfg{
Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(),
ExternalURL: nil,
InstanceStore: dbstore,
InstanceStore: ng.InstanceStore,
Images: &state.NoopImageService{},
Clock: clock.New(),
Historian: hist,
@@ -277,7 +277,7 @@ func TestDashboardAnnotations(t *testing.T) {
"test2": "{{ $labels.instance_label }}",
})
st.Warm(ctx, dbstore, dbstore)
st.Warm(ctx, dbstore, ng.InstanceStore)
bValue := float64(42)
cValue := float64(1)
_ = st.ProcessEvalResults(ctx, evaluationTime, rule, eval.Results{{
@@ -1697,7 +1697,7 @@ func TestStaleResultsHandler(t *testing.T) {
interval := time.Minute
ctx := context.Background()
_, dbstore := tests.SetupTestEnv(t, 1)
ng, dbstore := tests.SetupTestEnv(t, 1)
const mainOrgID int64 = 1
rule := tests.CreateTestAlertRule(t, ctx, dbstore, int64(interval.Seconds()), mainOrgID)
@@ -1751,7 +1751,7 @@ func TestStaleResultsHandler(t *testing.T) {
}
for _, instance := range instances {
_ = dbstore.SaveAlertInstance(ctx, instance)
_ = ng.InstanceStore.SaveAlertInstance(ctx, instance)
}
testCases := []struct {
@@ -1805,7 +1805,7 @@ func TestStaleResultsHandler(t *testing.T) {
cfg := state.ManagerCfg{
Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(),
ExternalURL: nil,
InstanceStore: dbstore,
InstanceStore: ng.InstanceStore,
Images: &state.NoopImageService{},
Clock: clock.New(),
Historian: &state.FakeHistorian{},
@@ -1813,7 +1813,7 @@ func TestStaleResultsHandler(t *testing.T) {
Log: log.New("ngalert.state.manager"),
}
st := state.NewManager(cfg, state.NewNoopPersister())
st.Warm(ctx, dbstore, dbstore)
st.Warm(ctx, dbstore, ng.InstanceStore)
existingStatesForRule := st.GetStatesForRuleUID(rule.OrgID, rule.UID)
// We have loaded the expected number of entries from the db
@@ -1978,7 +1978,7 @@ func TestStaleResults(t *testing.T) {
func TestDeleteStateByRuleUID(t *testing.T) {
interval := time.Minute
ctx := context.Background()
_, dbstore := tests.SetupTestEnv(t, 1)
ng, dbstore := tests.SetupTestEnv(t, 1)
const mainOrgID int64 = 1
rule := tests.CreateTestAlertRule(t, ctx, dbstore, int64(interval.Seconds()), mainOrgID)
@@ -2009,7 +2009,7 @@ func TestDeleteStateByRuleUID(t *testing.T) {
}
for _, instance := range instances {
_ = dbstore.SaveAlertInstance(ctx, instance)
_ = ng.InstanceStore.SaveAlertInstance(ctx, instance)
}
testCases := []struct {
@@ -2025,7 +2025,7 @@ func TestDeleteStateByRuleUID(t *testing.T) {
}{
{
desc: "all states/instances are removed from cache and DB",
instanceStore: dbstore,
instanceStore: ng.InstanceStore,
expectedStates: []*state.State{
{
AlertRuleUID: rule.UID,
@@ -2065,7 +2065,7 @@ func TestDeleteStateByRuleUID(t *testing.T) {
cfg := state.ManagerCfg{
Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(),
ExternalURL: nil,
InstanceStore: dbstore,
InstanceStore: ng.InstanceStore,
Images: &state.NoopImageService{},
Clock: clk,
Historian: &state.FakeHistorian{},
@@ -2073,9 +2073,9 @@ func TestDeleteStateByRuleUID(t *testing.T) {
Log: log.New("ngalert.state.manager"),
}
st := state.NewManager(cfg, state.NewNoopPersister())
st.Warm(ctx, dbstore, dbstore)
st.Warm(ctx, dbstore, ng.InstanceStore)
q := &models.ListAlertInstancesQuery{RuleOrgID: rule.OrgID, RuleUID: rule.UID}
alerts, _ := dbstore.ListAlertInstances(ctx, q)
alerts, _ := ng.InstanceStore.ListAlertInstances(ctx, q)
existingStatesForRule := st.GetStatesForRuleUID(rule.OrgID, rule.UID)
// We have loaded the expected number of entries from the db
@@ -2107,7 +2107,7 @@ func TestDeleteStateByRuleUID(t *testing.T) {
}
q = &models.ListAlertInstancesQuery{RuleOrgID: rule.OrgID, RuleUID: rule.UID}
alertInstances, _ := dbstore.ListAlertInstances(ctx, q)
alertInstances, _ := ng.InstanceStore.ListAlertInstances(ctx, q)
existingStatesForRule = st.GetStatesForRuleUID(rule.OrgID, rule.UID)
// The expected number of state entries remains after states are deleted
@@ -2120,7 +2120,7 @@ func TestDeleteStateByRuleUID(t *testing.T) {
func TestResetStateByRuleUID(t *testing.T) {
interval := time.Minute
ctx := context.Background()
_, dbstore := tests.SetupTestEnv(t, 1)
ng, dbstore := tests.SetupTestEnv(t, 1)
const mainOrgID int64 = 1
rule := tests.CreateTestAlertRule(t, ctx, dbstore, int64(interval.Seconds()), mainOrgID)
@@ -2151,7 +2151,7 @@ func TestResetStateByRuleUID(t *testing.T) {
}
for _, instance := range instances {
_ = dbstore.SaveAlertInstance(ctx, instance)
_ = ng.InstanceStore.SaveAlertInstance(ctx, instance)
}
testCases := []struct {
@@ -2168,7 +2168,7 @@ func TestResetStateByRuleUID(t *testing.T) {
}{
{
desc: "all states/instances are removed from cache and DB and saved in historian",
instanceStore: dbstore,
instanceStore: ng.InstanceStore,
expectedStates: []*state.State{
{
AlertRuleUID: rule.UID,
@@ -2206,7 +2206,7 @@ func TestResetStateByRuleUID(t *testing.T) {
cfg := state.ManagerCfg{
Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(),
ExternalURL: nil,
InstanceStore: dbstore,
InstanceStore: ng.InstanceStore,
Images: &state.NoopImageService{},
Clock: clk,
Historian: fakeHistorian,
@@ -2214,9 +2214,9 @@ func TestResetStateByRuleUID(t *testing.T) {
Log: log.New("ngalert.state.manager"),
}
st := state.NewManager(cfg, state.NewNoopPersister())
st.Warm(ctx, dbstore, dbstore)
st.Warm(ctx, dbstore, ng.InstanceStore)
q := &models.ListAlertInstancesQuery{RuleOrgID: rule.OrgID, RuleUID: rule.UID}
alerts, _ := dbstore.ListAlertInstances(ctx, q)
alerts, _ := ng.InstanceStore.ListAlertInstances(ctx, q)
existingStatesForRule := st.GetStatesForRuleUID(rule.OrgID, rule.UID)
// We have loaded the expected number of entries from the db
@@ -2251,7 +2251,7 @@ func TestResetStateByRuleUID(t *testing.T) {
assert.Equal(t, transitions, fakeHistorian.StateTransitions)
q = &models.ListAlertInstancesQuery{RuleOrgID: rule.OrgID, RuleUID: rule.UID}
alertInstances, _ := dbstore.ListAlertInstances(ctx, q)
alertInstances, _ := ng.InstanceStore.ListAlertInstances(ctx, q)
existingStatesForRule = st.GetStatesForRuleUID(rule.OrgID, rule.UID)
// The expected number of state entries remains after states are deleted
@@ -9,14 +9,21 @@ import (
"time"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
type InstanceDBStore struct {
SQLStore db.DB
Logger log.Logger
FeatureToggles featuremgmt.FeatureToggles
}
// ListAlertInstances is a handler for retrieving alert instances within specific organisation
// based on various filters.
func (st DBstore) ListAlertInstances(ctx context.Context, cmd *models.ListAlertInstancesQuery) (result []*models.AlertInstance, err error) {
func (st InstanceDBStore) ListAlertInstances(ctx context.Context, cmd *models.ListAlertInstancesQuery) (result []*models.AlertInstance, err error) {
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
alertInstances := make([]*models.AlertInstance, 0)
@@ -51,7 +58,7 @@ func (st DBstore) ListAlertInstances(ctx context.Context, cmd *models.ListAlertI
}
// SaveAlertInstance is a handler for saving a new alert instance.
func (st DBstore) SaveAlertInstance(ctx context.Context, alertInstance models.AlertInstance) error {
func (st InstanceDBStore) SaveAlertInstance(ctx context.Context, alertInstance models.AlertInstance) error {
return st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
if err := models.ValidateAlertInstance(alertInstance); err != nil {
return err
@@ -89,7 +96,7 @@ func (st DBstore) SaveAlertInstance(ctx context.Context, alertInstance models.Al
})
}
func (st DBstore) FetchOrgIds(ctx context.Context) ([]int64, error) {
func (st InstanceDBStore) FetchOrgIds(ctx context.Context) ([]int64, error) {
orgIds := []int64{}
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
@@ -113,7 +120,7 @@ func (st DBstore) FetchOrgIds(ctx context.Context) ([]int64, error) {
}
// DeleteAlertInstances deletes instances with the provided keys in a single transaction.
func (st DBstore) DeleteAlertInstances(ctx context.Context, keys ...models.AlertInstanceKey) error {
func (st InstanceDBStore) DeleteAlertInstances(ctx context.Context, keys ...models.AlertInstanceKey) error {
if len(keys) == 0 {
return nil
}
@@ -212,12 +219,13 @@ func (st DBstore) DeleteAlertInstances(ctx context.Context, keys ...models.Alert
}
// SaveAlertInstancesForRule is not implemented for instance database store.
func (st DBstore) SaveAlertInstancesForRule(ctx context.Context, key models.AlertRuleKeyWithGroup, instances []models.AlertInstance) error {
func (st InstanceDBStore) SaveAlertInstancesForRule(ctx context.Context, key models.AlertRuleKeyWithGroup, instances []models.AlertInstance) error {
st.Logger.Error("SaveAlertInstancesForRule is not implemented for instance database store.")
return errors.New("method SaveAlertInstancesForRule is not implemented for instance database store")
}
func (st DBstore) DeleteAlertInstancesByRule(ctx context.Context, key models.AlertRuleKeyWithGroup) error {
// DeleteAlertInstancesByRule deletes all instances for a given rule.
func (st InstanceDBStore) DeleteAlertInstancesByRule(ctx context.Context, key models.AlertRuleKeyWithGroup) error {
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
_, err := sess.Exec("DELETE FROM alert_instance WHERE rule_org_id = ? AND rule_uid = ?", key.OrgID, key.UID)
return err
@@ -230,7 +238,7 @@ func (st DBstore) DeleteAlertInstancesByRule(ctx context.Context, key models.Ale
//
// The batchSize parameter controls how many instances are inserted per batch. Increasing batchSize can improve
// performance for large datasets, but can also increase load on the database.
func (st DBstore) FullSync(ctx context.Context, instances []models.AlertInstance, batchSize int) error {
func (st InstanceDBStore) FullSync(ctx context.Context, instances []models.AlertInstance, batchSize int) error {
if len(instances) == 0 {
return nil
}
@@ -267,7 +275,7 @@ func (st DBstore) FullSync(ctx context.Context, instances []models.AlertInstance
})
}
func (st DBstore) insertInstancesBatch(sess *sqlstore.DBSession, batch []models.AlertInstance) error {
func (st InstanceDBStore) insertInstancesBatch(sess *sqlstore.DBSession, batch []models.AlertInstance) error {
// If the batch is empty, nothing to insert.
if len(batch) == 0 {
return nil
@@ -0,0 +1,105 @@
package store_test
import (
"context"
"flag"
"fmt"
"testing"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/tests"
)
var saveStateCompressed = flag.Bool("save-state-compressed", false, "Save state compressed")
func BenchmarkSaveAlertInstances(b *testing.B) {
ctx := context.Background()
opts := []tests.TestEnvOption{}
if *saveStateCompressed {
opts = append(opts, tests.WithFeatureToggles(
featuremgmt.WithFeatures(featuremgmt.FlagAlertingSaveStateCompressed)),
)
}
benchmarkRun := func(b *testing.B, instanceCount, labelCount int) {
ng, dbstore := tests.SetupTestEnv(b, baseIntervalSeconds, opts...)
const mainOrgID int64 = 1
alertRule := tests.CreateTestAlertRule(b, ctx, dbstore, 60, mainOrgID)
// Create some instances to write down and then delete.
instances := make([]models.AlertInstance, 0, instanceCount)
keys := make([]models.AlertInstanceKey, 0, instanceCount)
for i := 0; i < instanceCount; i++ {
labels := models.InstanceLabels{"instance": fmt.Sprintf("instance-%d", i)}
for li := 0; li < labelCount; li++ {
labels[fmt.Sprintf("label-%d", li)] = fmt.Sprintf("value-%d", li)
}
_, labelsHash, _ := labels.StringAndHash()
instance := models.AlertInstance{
AlertInstanceKey: models.AlertInstanceKey{
RuleOrgID: alertRule.OrgID,
RuleUID: alertRule.UID,
LabelsHash: labelsHash,
},
CurrentState: models.InstanceStateFiring,
CurrentReason: string(models.InstanceStateError),
Labels: labels,
}
instances = append(instances, instance)
keys = append(keys, instance.AlertInstanceKey)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
var err error
if *saveStateCompressed {
err = ng.InstanceStore.SaveAlertInstancesForRule(ctx, alertRule.GetKeyWithGroup(), instances)
if err != nil {
b.Fatalf("error: %s", err)
}
// Clean up instances.
b.StopTimer()
err = ng.InstanceStore.DeleteAlertInstancesByRule(ctx, alertRule.GetKeyWithGroup())
if err != nil {
b.Fatalf("error: %s", err)
}
b.StartTimer()
} else {
for _, instance := range instances {
err = ng.InstanceStore.SaveAlertInstance(ctx, instance)
if err != nil {
b.Fatalf("error: %s", err)
}
}
// Clean up instances.
b.StopTimer()
err = ng.InstanceStore.DeleteAlertInstances(ctx, keys...)
if err != nil {
b.Fatalf("error: %s", err)
}
b.StartTimer()
}
}
}
b.Run("100 instances with 10 labels each", func(b *testing.B) {
benchmarkRun(b, 100, 10)
})
b.Run("100 instances with 100 labels each", func(b *testing.B) {
benchmarkRun(b, 100, 100)
})
b.Run("1000 instances with 10 labels each", func(b *testing.B) {
benchmarkRun(b, 1000, 10)
})
}
@@ -1,57 +1,188 @@
package store_test
import (
"bytes"
"context"
"fmt"
"testing"
"time"
"github.com/golang/snappy"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ngalert/models"
pb "github.com/grafana/grafana/pkg/services/ngalert/store/proto/v1"
"github.com/grafana/grafana/pkg/services/ngalert/tests"
"github.com/grafana/grafana/pkg/util"
)
const baseIntervalSeconds = 10
func BenchmarkAlertInstanceOperations(b *testing.B) {
b.StopTimer()
func TestIntegration_CompressedAlertRuleStateOperations(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
_, dbstore := tests.SetupTestEnv(b, baseIntervalSeconds)
ng, dbstore := tests.SetupTestEnv(
t,
baseIntervalSeconds,
tests.WithFeatureToggles(
featuremgmt.WithFeatures(featuremgmt.FlagAlertingSaveStateCompressed),
),
)
const mainOrgID int64 = 1
alertRule := tests.CreateTestAlertRule(b, ctx, dbstore, 60, mainOrgID)
alertRule1 := tests.CreateTestAlertRule(t, ctx, dbstore, 60, mainOrgID)
orgID := alertRule1.OrgID
alertRule2 := tests.CreateTestAlertRule(t, ctx, dbstore, 60, mainOrgID)
require.Equal(t, orgID, alertRule2.OrgID)
// Create some instances to write down and then delete.
count := 10_003
instances := make([]models.AlertInstance, 0, count)
keys := make([]models.AlertInstanceKey, 0, count)
for i := 0; i < count; i++ {
labels := models.InstanceLabels{"test": fmt.Sprint(i)}
_, labelsHash, _ := labels.StringAndHash()
instance := models.AlertInstance{
AlertInstanceKey: models.AlertInstanceKey{
RuleOrgID: alertRule.OrgID,
RuleUID: alertRule.UID,
LabelsHash: labelsHash,
tests := []struct {
name string
setupInstances func() []models.AlertInstance
listQuery *models.ListAlertInstancesQuery
validate func(t *testing.T, alerts []*models.AlertInstance)
}{
{
name: "can save and read alert rule state",
setupInstances: func() []models.AlertInstance {
return []models.AlertInstance{
createAlertInstance(alertRule1.OrgID, alertRule1.UID, "labelsHash1", string(models.InstanceStateError), models.InstanceStateFiring),
}
},
CurrentState: models.InstanceStateFiring,
CurrentReason: string(models.InstanceStateError),
Labels: labels,
}
instances = append(instances, instance)
keys = append(keys, instance.AlertInstanceKey)
listQuery: &models.ListAlertInstancesQuery{
RuleOrgID: alertRule1.OrgID,
RuleUID: alertRule1.UID,
},
validate: func(t *testing.T, alerts []*models.AlertInstance) {
require.Len(t, alerts, 1)
require.Equal(t, "labelsHash1", alerts[0].LabelsHash)
},
},
{
name: "can save and read alert rule state with multiple instances",
setupInstances: func() []models.AlertInstance {
return []models.AlertInstance{
createAlertInstance(alertRule1.OrgID, alertRule1.UID, "hash1", "", models.InstanceStateFiring),
createAlertInstance(alertRule1.OrgID, alertRule1.UID, "hash2", "", models.InstanceStateFiring),
}
},
listQuery: &models.ListAlertInstancesQuery{
RuleOrgID: alertRule1.OrgID,
RuleUID: alertRule1.UID,
},
validate: func(t *testing.T, alerts []*models.AlertInstance) {
require.Len(t, alerts, 2)
containsHash(t, alerts, "hash1")
containsHash(t, alerts, "hash2")
},
},
}
b.StartTimer()
for i := 0; i < b.N; i++ {
for _, instance := range instances {
_ = dbstore.SaveAlertInstance(ctx, instance)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
instances := tc.setupInstances()
err := ng.InstanceStore.SaveAlertInstancesForRule(ctx, alertRule1.GetKeyWithGroup(), instances)
require.NoError(t, err)
alerts, err := ng.InstanceStore.ListAlertInstances(ctx, tc.listQuery)
require.NoError(t, err)
tc.validate(t, alerts)
})
}
}
func TestIntegration_CompressedAlertRuleStateOperations_NoNormalState(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
ng, dbstore := tests.SetupTestEnv(
t,
baseIntervalSeconds,
tests.WithFeatureToggles(
featuremgmt.WithFeatures(
featuremgmt.FlagAlertingSaveStateCompressed,
featuremgmt.FlagAlertingNoNormalState,
),
),
)
const mainOrgID int64 = 1
alertRule1 := tests.CreateTestAlertRule(t, ctx, dbstore, 60, mainOrgID)
orgID := alertRule1.OrgID
tests := []struct {
name string
setupInstances func() []models.AlertInstance
listQuery *models.ListAlertInstancesQuery
validate func(t *testing.T, alerts []*models.AlertInstance)
}{
{
name: "should ignore Normal state with no reason if feature flag is enabled",
setupInstances: func() []models.AlertInstance {
return []models.AlertInstance{
createAlertInstance(orgID, util.GenerateShortUID(), util.GenerateShortUID(), "", models.InstanceStateNormal),
createAlertInstance(orgID, util.GenerateShortUID(), "errorHash", "error", models.InstanceStateNormal),
}
},
listQuery: &models.ListAlertInstancesQuery{
RuleOrgID: orgID,
},
validate: func(t *testing.T, alerts []*models.AlertInstance) {
require.Len(t, alerts, 1)
containsHash(t, alerts, "errorHash")
for _, instance := range alerts {
if instance.CurrentState == models.InstanceStateNormal && instance.CurrentReason == "" {
require.Fail(t, "List operation expected to return all states except Normal but the result contains Normal states")
}
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
instances := tc.setupInstances()
err := ng.InstanceStore.SaveAlertInstancesForRule(ctx, alertRule1.GetKeyWithGroup(), instances)
require.NoError(t, err)
alerts, err := ng.InstanceStore.ListAlertInstances(ctx, tc.listQuery)
require.NoError(t, err)
tc.validate(t, alerts)
})
}
}
// containsHash is a helper function to check if an instance with
// a given labels hash exists in the list of alert instances.
func containsHash(t *testing.T, instances []*models.AlertInstance, hash string) {
t.Helper()
for _, i := range instances {
if i.LabelsHash == hash {
return
}
_ = dbstore.DeleteAlertInstances(ctx, keys...)
}
require.Fail(t, fmt.Sprintf("%v does not contain an instance with hash %s", instances, hash))
}
func createAlertInstance(orgID int64, ruleUID, labelsHash, reason string, state models.InstanceStateType) models.AlertInstance {
return models.AlertInstance{
AlertInstanceKey: models.AlertInstanceKey{
RuleOrgID: orgID,
RuleUID: ruleUID,
LabelsHash: labelsHash,
},
CurrentState: state,
CurrentReason: reason,
Labels: models.InstanceLabels{"label1": "value1"},
}
}
@@ -60,20 +191,10 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
t.Skip("skipping integration test")
}
ctx := context.Background()
_, dbstore := tests.SetupTestEnv(t, baseIntervalSeconds)
ng, dbstore := tests.SetupTestEnv(t, baseIntervalSeconds)
const mainOrgID int64 = 1
containsHash := func(t *testing.T, instances []*models.AlertInstance, hash string) {
t.Helper()
for _, i := range instances {
if i.LabelsHash == hash {
return
}
}
require.Fail(t, "%v does not contain an instance with hash %s", instances, hash)
}
alertRule1 := tests.CreateTestAlertRule(t, ctx, dbstore, 60, mainOrgID)
orgID := alertRule1.OrgID
@@ -99,14 +220,14 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
CurrentReason: string(models.InstanceStateError),
Labels: labels,
}
err := dbstore.SaveAlertInstance(ctx, instance)
err := ng.InstanceStore.SaveAlertInstance(ctx, instance)
require.NoError(t, err)
listCmd := &models.ListAlertInstancesQuery{
RuleOrgID: instance.RuleOrgID,
RuleUID: instance.RuleUID,
}
alerts, err := dbstore.ListAlertInstances(ctx, listCmd)
alerts, err := ng.InstanceStore.ListAlertInstances(ctx, listCmd)
require.NoError(t, err)
require.Len(t, alerts, 1)
@@ -128,7 +249,7 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
CurrentState: models.InstanceStateNormal,
Labels: labels,
}
err := dbstore.SaveAlertInstance(ctx, instance)
err := ng.InstanceStore.SaveAlertInstance(ctx, instance)
require.NoError(t, err)
listCmd := &models.ListAlertInstancesQuery{
@@ -136,7 +257,7 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
RuleUID: instance.RuleUID,
}
alerts, err := dbstore.ListAlertInstances(ctx, listCmd)
alerts, err := ng.InstanceStore.ListAlertInstances(ctx, listCmd)
require.NoError(t, err)
require.Len(t, alerts, 1)
@@ -158,7 +279,7 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
Labels: labels,
}
err := dbstore.SaveAlertInstance(ctx, instance1)
err := ng.InstanceStore.SaveAlertInstance(ctx, instance1)
require.NoError(t, err)
labels = models.InstanceLabels{"test": "testValue2"}
@@ -172,7 +293,7 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
CurrentState: models.InstanceStateFiring,
Labels: labels,
}
err = dbstore.SaveAlertInstance(ctx, instance2)
err = ng.InstanceStore.SaveAlertInstance(ctx, instance2)
require.NoError(t, err)
listQuery := &models.ListAlertInstancesQuery{
@@ -180,7 +301,7 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
RuleUID: instance1.RuleUID,
}
alerts, err := dbstore.ListAlertInstances(ctx, listQuery)
alerts, err := ng.InstanceStore.ListAlertInstances(ctx, listQuery)
require.NoError(t, err)
require.Len(t, alerts, 2)
@@ -191,13 +312,21 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
RuleOrgID: orgID,
}
alerts, err := dbstore.ListAlertInstances(ctx, listQuery)
alerts, err := ng.InstanceStore.ListAlertInstances(ctx, listQuery)
require.NoError(t, err)
require.Len(t, alerts, 4)
})
t.Run("should ignore Normal state with no reason if feature flag is enabled", func(t *testing.T) {
ng, _ := tests.SetupTestEnv(
t,
baseIntervalSeconds,
tests.WithFeatureToggles(
featuremgmt.WithFeatures(featuremgmt.FlagAlertingNoNormalState),
),
)
labels := models.InstanceLabels{"test": util.GenerateShortUID()}
instance1 := models.AlertInstance{
AlertInstanceKey: models.AlertInstanceKey{
@@ -219,27 +348,16 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
CurrentReason: models.StateReasonError,
Labels: labels,
}
err := dbstore.SaveAlertInstance(ctx, instance1)
err := ng.InstanceStore.SaveAlertInstance(ctx, instance1)
require.NoError(t, err)
err = dbstore.SaveAlertInstance(ctx, instance2)
err = ng.InstanceStore.SaveAlertInstance(ctx, instance2)
require.NoError(t, err)
listQuery := &models.ListAlertInstancesQuery{
RuleOrgID: orgID,
}
alerts, err := dbstore.ListAlertInstances(ctx, listQuery)
require.NoError(t, err)
containsHash(t, alerts, instance1.LabelsHash)
f := dbstore.FeatureToggles
dbstore.FeatureToggles = featuremgmt.WithFeatures(featuremgmt.FlagAlertingNoNormalState)
t.Cleanup(func() {
dbstore.FeatureToggles = f
})
alerts, err = dbstore.ListAlertInstances(ctx, listQuery)
alerts, err := ng.InstanceStore.ListAlertInstances(ctx, listQuery)
require.NoError(t, err)
containsHash(t, alerts, instance2.LabelsHash)
@@ -264,7 +382,7 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
Labels: labels,
}
err := dbstore.SaveAlertInstance(ctx, instance1)
err := ng.InstanceStore.SaveAlertInstance(ctx, instance1)
require.NoError(t, err)
instance2 := models.AlertInstance{
@@ -276,7 +394,7 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
CurrentState: models.InstanceStateNormal,
Labels: instance1.Labels,
}
err = dbstore.SaveAlertInstance(ctx, instance2)
err = ng.InstanceStore.SaveAlertInstance(ctx, instance2)
require.NoError(t, err)
listQuery := &models.ListAlertInstancesQuery{
@@ -284,7 +402,7 @@ func TestIntegrationAlertInstanceOperations(t *testing.T) {
RuleUID: alertRule4.UID,
}
alerts, err := dbstore.ListAlertInstances(ctx, listQuery)
alerts, err := ng.InstanceStore.ListAlertInstances(ctx, listQuery)
require.NoError(t, err)
require.Len(t, alerts, 1)
@@ -300,7 +418,7 @@ func TestIntegrationFullSync(t *testing.T) {
batchSize := 1
ctx := context.Background()
_, dbstore := tests.SetupTestEnv(t, baseIntervalSeconds)
ng, _ := tests.SetupTestEnv(t, baseIntervalSeconds)
orgID := int64(1)
@@ -312,10 +430,10 @@ func TestIntegrationFullSync(t *testing.T) {
}
t.Run("Should do a proper full sync", func(t *testing.T) {
err := dbstore.FullSync(ctx, instances, batchSize)
err := ng.InstanceStore.FullSync(ctx, instances, batchSize)
require.NoError(t, err)
res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
res, err := ng.InstanceStore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
RuleOrgID: orgID,
})
require.NoError(t, err)
@@ -335,10 +453,10 @@ func TestIntegrationFullSync(t *testing.T) {
})
t.Run("Should remove non existing entries on sync", func(t *testing.T) {
err := dbstore.FullSync(ctx, instances[1:], batchSize)
err := ng.InstanceStore.FullSync(ctx, instances[1:], batchSize)
require.NoError(t, err)
res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
res, err := ng.InstanceStore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
RuleOrgID: orgID,
})
require.NoError(t, err)
@@ -352,10 +470,10 @@ func TestIntegrationFullSync(t *testing.T) {
t.Run("Should add new entries on sync", func(t *testing.T) {
newRuleUID := "y"
err := dbstore.FullSync(ctx, append(instances, generateTestAlertInstance(orgID, newRuleUID)), batchSize)
err := ng.InstanceStore.FullSync(ctx, append(instances, generateTestAlertInstance(orgID, newRuleUID)), batchSize)
require.NoError(t, err)
res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
res, err := ng.InstanceStore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
RuleOrgID: orgID,
})
require.NoError(t, err)
@@ -377,10 +495,10 @@ func TestIntegrationFullSync(t *testing.T) {
t.Run("Should save all instances when batch size is bigger than 1", func(t *testing.T) {
batchSize = 2
newRuleUID := "y"
err := dbstore.FullSync(ctx, append(instances, generateTestAlertInstance(orgID, newRuleUID)), batchSize)
err := ng.InstanceStore.FullSync(ctx, append(instances, generateTestAlertInstance(orgID, newRuleUID)), batchSize)
require.NoError(t, err)
res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
res, err := ng.InstanceStore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
RuleOrgID: orgID,
})
require.NoError(t, err)
@@ -405,16 +523,16 @@ func TestIntegrationFullSync(t *testing.T) {
generateTestAlertInstance(orgID, "preexisting-1"),
generateTestAlertInstance(orgID, "preexisting-2"),
}
err := dbstore.FullSync(ctx, initialInstances, 5)
err := ng.InstanceStore.FullSync(ctx, initialInstances, 5)
require.NoError(t, err)
// Now call FullSync with no instances. According to the code, this should return nil
// and should not delete anything in the table.
err = dbstore.FullSync(ctx, []models.AlertInstance{}, 5)
err = ng.InstanceStore.FullSync(ctx, []models.AlertInstance{}, 5)
require.NoError(t, err)
// Check that the previously inserted instances are still present.
res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
res, err := ng.InstanceStore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
RuleOrgID: orgID,
})
require.NoError(t, err)
@@ -441,11 +559,11 @@ func TestIntegrationFullSync(t *testing.T) {
// Make the invalid instance actually invalid
invalidInstance.AlertInstanceKey.RuleUID = ""
err := dbstore.FullSync(ctx, []models.AlertInstance{validInstance, invalidInstance}, 2)
err := ng.InstanceStore.FullSync(ctx, []models.AlertInstance{validInstance, invalidInstance}, 2)
require.NoError(t, err)
// Only the valid instance should be saved.
res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
res, err := ng.InstanceStore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
RuleOrgID: orgID,
})
require.NoError(t, err)
@@ -460,10 +578,10 @@ func TestIntegrationFullSync(t *testing.T) {
generateTestAlertInstance(orgID, "batch-test2"),
}
err := dbstore.FullSync(ctx, smallSet, 100)
err := ng.InstanceStore.FullSync(ctx, smallSet, 100)
require.NoError(t, err)
res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
res, err := ng.InstanceStore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
RuleOrgID: orgID,
})
require.NoError(t, err)
@@ -483,7 +601,7 @@ func TestIntegrationFullSync(t *testing.T) {
t.Run("Should handle a large set of instances with a moderate batchSize", func(t *testing.T) {
// Clear everything first.
err := dbstore.FullSync(ctx, []models.AlertInstance{}, 1)
err := ng.InstanceStore.FullSync(ctx, []models.AlertInstance{}, 1)
require.NoError(t, err)
largeCount := 300
@@ -492,10 +610,10 @@ func TestIntegrationFullSync(t *testing.T) {
largeSet[i] = generateTestAlertInstance(orgID, fmt.Sprintf("large-%d", i))
}
err = dbstore.FullSync(ctx, largeSet, 50)
err = ng.InstanceStore.FullSync(ctx, largeSet, 50)
require.NoError(t, err)
res, err := dbstore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
res, err := ng.InstanceStore.ListAlertInstances(ctx, &models.ListAlertInstancesQuery{
RuleOrgID: orgID,
})
require.NoError(t, err)
@@ -503,6 +621,77 @@ func TestIntegrationFullSync(t *testing.T) {
})
}
func TestIntegration_ProtoInstanceDBStore_VerifyCompressedData(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
ctx := context.Background()
ng, dbstore := tests.SetupTestEnv(
t,
baseIntervalSeconds,
tests.WithFeatureToggles(
featuremgmt.WithFeatures(
featuremgmt.FlagAlertingSaveStateCompressed,
),
),
)
alertRule := tests.CreateTestAlertRule(t, ctx, dbstore, 60, 1)
labelsHash := "hash1"
reason := "reason"
state := models.InstanceStateFiring
instances := []models.AlertInstance{
createAlertInstance(alertRule.OrgID, alertRule.UID, labelsHash, reason, state),
}
err := ng.InstanceStore.SaveAlertInstancesForRule(ctx, alertRule.GetKeyWithGroup(), instances)
require.NoError(t, err)
// Query raw data from the database
type compressedRow struct {
OrgID int64 `xorm:"org_id"`
RuleUID string `xorm:"rule_uid"`
Data []byte `xorm:"data"`
}
var rawData compressedRow
err = dbstore.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.SQL("SELECT * FROM alert_rule_state").Get(&rawData)
return err
})
require.NoError(t, err)
// Decompress and compare
require.NotNil(t, rawData)
decompressedInstances, err := decompressAlertInstances(rawData.Data)
require.NoError(t, err)
require.Len(t, decompressedInstances, 1)
require.Equal(t, instances[0].LabelsHash, decompressedInstances[0].LabelsHash)
require.Equal(t, string(instances[0].CurrentState), decompressedInstances[0].CurrentState)
require.Equal(t, instances[0].CurrentReason, decompressedInstances[0].CurrentReason)
}
func decompressAlertInstances(compressed []byte) ([]*pb.AlertInstance, error) {
if len(compressed) == 0 {
return nil, nil
}
reader := snappy.NewReader(bytes.NewReader(compressed))
var b bytes.Buffer
if _, err := b.ReadFrom(reader); err != nil {
return nil, fmt.Errorf("failed to read compressed data: %w", err)
}
var instances pb.AlertInstances
if err := proto.Unmarshal(b.Bytes(), &instances); err != nil {
return nil, fmt.Errorf("failed to unmarshal protobuf: %w", err)
}
return instances.Instances, nil
}
func generateTestAlertInstance(orgID int64, ruleID string) models.AlertInstance {
return models.AlertInstance{
AlertInstanceKey: models.AlertInstanceKey{
@@ -0,0 +1,301 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.1
// protoc (unknown)
// source: alert_rule_state.proto
package v1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type AlertInstance struct {
state protoimpl.MessageState `protogen:"open.v1"`
LabelsHash string `protobuf:"bytes,1,opt,name=labels_hash,json=labelsHash,proto3" json:"labels_hash,omitempty"`
Labels map[string]string `protobuf:"bytes,2,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
CurrentState string `protobuf:"bytes,3,opt,name=current_state,json=currentState,proto3" json:"current_state,omitempty"`
CurrentReason string `protobuf:"bytes,4,opt,name=current_reason,json=currentReason,proto3" json:"current_reason,omitempty"`
CurrentStateSince *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=current_state_since,json=currentStateSince,proto3" json:"current_state_since,omitempty"`
CurrentStateEnd *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=current_state_end,json=currentStateEnd,proto3" json:"current_state_end,omitempty"`
LastEvalTime *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=last_eval_time,json=lastEvalTime,proto3" json:"last_eval_time,omitempty"`
LastSentAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_sent_at,json=lastSentAt,proto3" json:"last_sent_at,omitempty"`
ResolvedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=resolved_at,json=resolvedAt,proto3" json:"resolved_at,omitempty"`
ResultFingerprint string `protobuf:"bytes,10,opt,name=result_fingerprint,json=resultFingerprint,proto3" json:"result_fingerprint,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AlertInstance) Reset() {
*x = AlertInstance{}
mi := &file_alert_rule_state_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AlertInstance) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AlertInstance) ProtoMessage() {}
func (x *AlertInstance) ProtoReflect() protoreflect.Message {
mi := &file_alert_rule_state_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AlertInstance.ProtoReflect.Descriptor instead.
func (*AlertInstance) Descriptor() ([]byte, []int) {
return file_alert_rule_state_proto_rawDescGZIP(), []int{0}
}
func (x *AlertInstance) GetLabelsHash() string {
if x != nil {
return x.LabelsHash
}
return ""
}
func (x *AlertInstance) GetLabels() map[string]string {
if x != nil {
return x.Labels
}
return nil
}
func (x *AlertInstance) GetCurrentState() string {
if x != nil {
return x.CurrentState
}
return ""
}
func (x *AlertInstance) GetCurrentReason() string {
if x != nil {
return x.CurrentReason
}
return ""
}
func (x *AlertInstance) GetCurrentStateSince() *timestamppb.Timestamp {
if x != nil {
return x.CurrentStateSince
}
return nil
}
func (x *AlertInstance) GetCurrentStateEnd() *timestamppb.Timestamp {
if x != nil {
return x.CurrentStateEnd
}
return nil
}
func (x *AlertInstance) GetLastEvalTime() *timestamppb.Timestamp {
if x != nil {
return x.LastEvalTime
}
return nil
}
func (x *AlertInstance) GetLastSentAt() *timestamppb.Timestamp {
if x != nil {
return x.LastSentAt
}
return nil
}
func (x *AlertInstance) GetResolvedAt() *timestamppb.Timestamp {
if x != nil {
return x.ResolvedAt
}
return nil
}
func (x *AlertInstance) GetResultFingerprint() string {
if x != nil {
return x.ResultFingerprint
}
return ""
}
type AlertInstances struct {
state protoimpl.MessageState `protogen:"open.v1"`
Instances []*AlertInstance `protobuf:"bytes,1,rep,name=instances,proto3" json:"instances,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AlertInstances) Reset() {
*x = AlertInstances{}
mi := &file_alert_rule_state_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AlertInstances) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AlertInstances) ProtoMessage() {}
func (x *AlertInstances) ProtoReflect() protoreflect.Message {
mi := &file_alert_rule_state_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AlertInstances.ProtoReflect.Descriptor instead.
func (*AlertInstances) Descriptor() ([]byte, []int) {
return file_alert_rule_state_proto_rawDescGZIP(), []int{1}
}
func (x *AlertInstances) GetInstances() []*AlertInstance {
if x != nil {
return x.Instances
}
return nil
}
var File_alert_rule_state_proto protoreflect.FileDescriptor
var file_alert_rule_state_proto_rawDesc = []byte{
0x0a, 0x16, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x5f, 0x73, 0x74, 0x61,
0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x10, 0x6e, 0x67, 0x61, 0x6c, 0x65, 0x72,
0x74, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2e, 0x76, 0x31, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65,
0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xfc, 0x04, 0x0a, 0x0d,
0x41, 0x6c, 0x65, 0x72, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x1f, 0x0a,
0x0b, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01,
0x28, 0x09, 0x52, 0x0a, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x48, 0x61, 0x73, 0x68, 0x12, 0x43,
0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b,
0x2e, 0x6e, 0x67, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2e, 0x76,
0x31, 0x2e, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x2e,
0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62,
0x65, 0x6c, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x73,
0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x75, 0x72, 0x72,
0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x72, 0x72,
0x65, 0x6e, 0x74, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0d, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12,
0x4a, 0x0a, 0x13, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65,
0x5f, 0x73, 0x69, 0x6e, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x11, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x53, 0x69, 0x6e, 0x63, 0x65, 0x12, 0x46, 0x0a, 0x11, 0x63,
0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x65, 0x6e, 0x64,
0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
0x6d, 0x70, 0x52, 0x0f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65,
0x45, 0x6e, 0x64, 0x12, 0x40, 0x0a, 0x0e, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x65, 0x76, 0x61, 0x6c,
0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69,
0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x45, 0x76, 0x61,
0x6c, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x3c, 0x0a, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x65,
0x6e, 0x74, 0x5f, 0x61, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69,
0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x6c, 0x61, 0x73, 0x74, 0x53, 0x65, 0x6e,
0x74, 0x41, 0x74, 0x12, 0x3b, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x64, 0x5f,
0x61, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x72, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x64, 0x41, 0x74,
0x12, 0x2d, 0x0a, 0x12, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x66, 0x69, 0x6e, 0x67, 0x65,
0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x72, 0x65,
0x73, 0x75, 0x6c, 0x74, 0x46, 0x69, 0x6e, 0x67, 0x65, 0x72, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x1a,
0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79,
0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x4f, 0x0a, 0x0e, 0x41, 0x6c,
0x65, 0x72, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x3d, 0x0a, 0x09,
0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x1f, 0x2e, 0x6e, 0x67, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x2e, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x2e,
0x76, 0x31, 0x2e, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65,
0x52, 0x09, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x42, 0x40, 0x5a, 0x3e, 0x67,
0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e,
0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x65,
0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2f, 0x6e, 0x67, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x2f, 0x73,
0x74, 0x6f, 0x72, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_alert_rule_state_proto_rawDescOnce sync.Once
file_alert_rule_state_proto_rawDescData = file_alert_rule_state_proto_rawDesc
)
func file_alert_rule_state_proto_rawDescGZIP() []byte {
file_alert_rule_state_proto_rawDescOnce.Do(func() {
file_alert_rule_state_proto_rawDescData = protoimpl.X.CompressGZIP(file_alert_rule_state_proto_rawDescData)
})
return file_alert_rule_state_proto_rawDescData
}
var file_alert_rule_state_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_alert_rule_state_proto_goTypes = []any{
(*AlertInstance)(nil), // 0: ngalert.store.v1.AlertInstance
(*AlertInstances)(nil), // 1: ngalert.store.v1.AlertInstances
nil, // 2: ngalert.store.v1.AlertInstance.LabelsEntry
(*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp
}
var file_alert_rule_state_proto_depIdxs = []int32{
2, // 0: ngalert.store.v1.AlertInstance.labels:type_name -> ngalert.store.v1.AlertInstance.LabelsEntry
3, // 1: ngalert.store.v1.AlertInstance.current_state_since:type_name -> google.protobuf.Timestamp
3, // 2: ngalert.store.v1.AlertInstance.current_state_end:type_name -> google.protobuf.Timestamp
3, // 3: ngalert.store.v1.AlertInstance.last_eval_time:type_name -> google.protobuf.Timestamp
3, // 4: ngalert.store.v1.AlertInstance.last_sent_at:type_name -> google.protobuf.Timestamp
3, // 5: ngalert.store.v1.AlertInstance.resolved_at:type_name -> google.protobuf.Timestamp
0, // 6: ngalert.store.v1.AlertInstances.instances:type_name -> ngalert.store.v1.AlertInstance
7, // [7:7] is the sub-list for method output_type
7, // [7:7] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_alert_rule_state_proto_init() }
func file_alert_rule_state_proto_init() {
if File_alert_rule_state_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_alert_rule_state_proto_rawDesc,
NumEnums: 0,
NumMessages: 3,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_alert_rule_state_proto_goTypes,
DependencyIndexes: file_alert_rule_state_proto_depIdxs,
MessageInfos: file_alert_rule_state_proto_msgTypes,
}.Build()
File_alert_rule_state_proto = out.File
file_alert_rule_state_proto_rawDesc = nil
file_alert_rule_state_proto_goTypes = nil
file_alert_rule_state_proto_depIdxs = nil
}
@@ -0,0 +1,24 @@
syntax = "proto3";
package ngalert.store.v1;
import "google/protobuf/timestamp.proto";
option go_package = "github.com/grafana/grafana/pkg/services/ngalert/store/proto/v1";
message AlertInstance {
string labels_hash = 1;
map<string, string> labels = 2;
string current_state = 3;
string current_reason = 4;
google.protobuf.Timestamp current_state_since = 5;
google.protobuf.Timestamp current_state_end = 6;
google.protobuf.Timestamp last_eval_time = 7;
google.protobuf.Timestamp last_sent_at = 8;
google.protobuf.Timestamp resolved_at = 9;
string result_fingerprint = 10;
}
message AlertInstances {
repeated AlertInstance instances = 1;
}
@@ -0,0 +1,5 @@
version: v1
plugins:
- plugin: go
out: pkg/services/ngalert/store/proto/v1
opt: paths=source_relative
@@ -0,0 +1,7 @@
version: v2
lint:
use:
- DEFAULT
breaking:
use:
- FILE
@@ -0,0 +1,261 @@
package store
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/golang/snappy"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ngalert/models"
pb "github.com/grafana/grafana/pkg/services/ngalert/store/proto/v1"
)
// ProtoInstanceDBStore is a store for alert instances that stores state of a rule as a single
// row in the database with alert instances as a compressed protobuf message.
type ProtoInstanceDBStore struct {
SQLStore db.DB
Logger log.Logger
FeatureToggles featuremgmt.FeatureToggles
}
func (st ProtoInstanceDBStore) ListAlertInstances(ctx context.Context, cmd *models.ListAlertInstancesQuery) (result []*models.AlertInstance, err error) {
logger := st.Logger.FromContext(ctx)
logger.Debug("ListAlertInstances called", "rule_uid", cmd.RuleUID, "org_id", cmd.RuleOrgID)
alertInstances := make([]*models.AlertInstance, 0)
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
s := strings.Builder{}
params := make([]any, 0)
addToQuery := func(stmt string, p ...any) {
s.WriteString(stmt)
params = append(params, p...)
}
addToQuery("SELECT * FROM alert_rule_state WHERE org_id = ?", cmd.RuleOrgID)
if cmd.RuleUID != "" {
addToQuery(" AND rule_uid = ?", cmd.RuleUID)
}
// Execute query to get compressed instances
type compressedRow struct {
OrgID int64 `xorm:"org_id"`
RuleUID string `xorm:"rule_uid"`
Data []byte `xorm:"data"`
}
rows := make([]compressedRow, 0)
if err := sess.SQL(s.String(), params...).Find(&rows); err != nil {
return fmt.Errorf("failed to query alert_rule_state: %w", err)
}
for _, row := range rows {
instances, err := decompressAlertInstances(row.Data)
if err != nil {
return fmt.Errorf("failed to decompress alert instances for rule %s: %w", row.RuleUID, err)
}
// Convert proto instances to model instances
for _, protoInstance := range instances {
modelInstance := alertInstanceProtoToModel(row.RuleUID, row.OrgID, protoInstance)
if modelInstance != nil {
// If FlagAlertingNoNormalState is enabled, we should not return instances with normal state and no reason.
if st.FeatureToggles.IsEnabled(ctx, featuremgmt.FlagAlertingNoNormalState) {
if modelInstance.CurrentState == models.InstanceStateNormal && modelInstance.CurrentReason == "" {
continue
}
}
alertInstances = append(alertInstances, modelInstance)
}
}
}
return nil
})
logger.Debug("ListAlertInstances completed", "instances", len(alertInstances))
return alertInstances, err
}
func (st ProtoInstanceDBStore) SaveAlertInstance(ctx context.Context, alertInstance models.AlertInstance) error {
st.Logger.Error("SaveAlertInstance called and not implemented")
return errors.New("save alert instance is not implemented for proto instance database store")
}
func (st ProtoInstanceDBStore) FetchOrgIds(ctx context.Context) ([]int64, error) {
orgIds := []int64{}
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
s := strings.Builder{}
params := make([]any, 0)
addToQuery := func(stmt string, p ...any) {
s.WriteString(stmt)
params = append(params, p...)
}
addToQuery("SELECT DISTINCT org_id FROM alert_rule_state")
if err := sess.SQL(s.String(), params...).Find(&orgIds); err != nil {
return err
}
return nil
})
return orgIds, err
}
func (st ProtoInstanceDBStore) DeleteAlertInstances(ctx context.Context, keys ...models.AlertInstanceKey) error {
logger := st.Logger.FromContext(ctx)
logger.Error("DeleteAlertInstances called and not implemented")
return errors.New("delete alert instances is not implemented for proto instance database store")
}
func (st ProtoInstanceDBStore) SaveAlertInstancesForRule(ctx context.Context, key models.AlertRuleKeyWithGroup, instances []models.AlertInstance) error {
logger := st.Logger.FromContext(ctx)
logger.Debug("SaveAlertInstancesForRule called", "rule_uid", key.UID, "org_id", key.OrgID, "instances", len(instances))
alert_instances_proto := make([]*pb.AlertInstance, len(instances))
for i, instance := range instances {
alert_instances_proto[i] = alertInstanceModelToProto(instance)
}
compressedAlertInstances, err := compressAlertInstances(alert_instances_proto)
if err != nil {
return fmt.Errorf("failed to compress alert instances: %w", err)
}
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
params := []any{key.OrgID, key.UID, compressedAlertInstances, time.Now()}
upsertSQL := st.SQLStore.GetDialect().UpsertSQL(
"alert_rule_state",
[]string{"org_id", "rule_uid"},
[]string{"org_id", "rule_uid", "data", "updated_at"},
)
_, err = sess.SQL(upsertSQL, params...).Query()
return err
})
}
func (st ProtoInstanceDBStore) DeleteAlertInstancesByRule(ctx context.Context, key models.AlertRuleKeyWithGroup) error {
logger := st.Logger.FromContext(ctx)
logger.Debug("DeleteAlertInstancesByRule called", "rule_uid", key.UID, "org_id", key.OrgID)
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
_, err := sess.Exec("DELETE FROM alert_rule_state WHERE org_id = ? AND rule_uid = ?", key.OrgID, key.UID)
return err
})
}
func (st ProtoInstanceDBStore) FullSync(ctx context.Context, instances []models.AlertInstance, batchSize int) error {
logger := st.Logger.FromContext(ctx)
logger.Error("FullSync called and not implemented")
return errors.New("fullsync is not implemented for proto instance database store")
}
func alertInstanceModelToProto(modelInstance models.AlertInstance) *pb.AlertInstance {
return &pb.AlertInstance{
Labels: modelInstance.Labels,
LabelsHash: modelInstance.LabelsHash,
CurrentState: string(modelInstance.CurrentState),
CurrentStateSince: timestamppb.New(modelInstance.CurrentStateSince),
CurrentStateEnd: timestamppb.New(modelInstance.CurrentStateEnd),
CurrentReason: modelInstance.CurrentReason,
LastEvalTime: timestamppb.New(modelInstance.LastEvalTime),
LastSentAt: nullableTimeToTimestamp(modelInstance.LastSentAt),
ResolvedAt: nullableTimeToTimestamp(modelInstance.ResolvedAt),
ResultFingerprint: modelInstance.ResultFingerprint,
}
}
func compressAlertInstances(instances []*pb.AlertInstance) ([]byte, error) {
mProto, err := proto.Marshal(&pb.AlertInstances{Instances: instances})
if err != nil {
return nil, fmt.Errorf("failed to marshal protobuf: %w", err)
}
var b bytes.Buffer
writer := snappy.NewBufferedWriter(&b)
if _, err := writer.Write(mProto); err != nil {
return nil, fmt.Errorf("failed to write compressed data: %w", err)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("failed to close snappy writer: %w", err)
}
return b.Bytes(), nil
}
func alertInstanceProtoToModel(ruleUID string, ruleOrgID int64, protoInstance *pb.AlertInstance) *models.AlertInstance {
if protoInstance == nil {
return nil
}
return &models.AlertInstance{
AlertInstanceKey: models.AlertInstanceKey{
RuleOrgID: ruleOrgID,
RuleUID: ruleUID,
LabelsHash: protoInstance.LabelsHash,
},
Labels: protoInstance.Labels,
CurrentState: models.InstanceStateType(protoInstance.CurrentState),
CurrentStateSince: protoInstance.CurrentStateSince.AsTime(),
CurrentStateEnd: protoInstance.CurrentStateEnd.AsTime(),
CurrentReason: protoInstance.CurrentReason,
LastEvalTime: protoInstance.LastEvalTime.AsTime(),
LastSentAt: nullableTimestampToTime(protoInstance.LastSentAt),
ResolvedAt: nullableTimestampToTime(protoInstance.ResolvedAt),
ResultFingerprint: protoInstance.ResultFingerprint,
}
}
func decompressAlertInstances(compressed []byte) ([]*pb.AlertInstance, error) {
if len(compressed) == 0 {
return nil, nil
}
reader := snappy.NewReader(bytes.NewReader(compressed))
var b bytes.Buffer
if _, err := b.ReadFrom(reader); err != nil {
return nil, fmt.Errorf("failed to read compressed data: %w", err)
}
var instances pb.AlertInstances
if err := proto.Unmarshal(b.Bytes(), &instances); err != nil {
return nil, fmt.Errorf("failed to unmarshal protobuf: %w", err)
}
return instances.Instances, nil
}
// nullableTimeToTimestamp converts a nullable time.Time to nil, if it is nil, otherwise it converts to timestamppb.Timestamp.
func nullableTimeToTimestamp(t *time.Time) *timestamppb.Timestamp {
if t == nil {
return nil
}
return timestamppb.New(*t)
}
// nullableTimestampToTime converts a nullable timestamppb.Timestamp to nil, if it is nil, otherwise it converts to time.Time.
func nullableTimestampToTime(ts *timestamppb.Timestamp) *time.Time {
if ts == nil {
return nil
}
t := ts.AsTime()
return &t
}
@@ -0,0 +1,176 @@
package store
import (
"reflect"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/grafana/grafana/pkg/services/ngalert/models"
pb "github.com/grafana/grafana/pkg/services/ngalert/store/proto/v1"
)
func TestAlertInstanceModelToProto(t *testing.T) {
currentStateSince := time.Now()
currentStateEnd := currentStateSince.Add(time.Minute)
lastEvalTime := currentStateSince.Add(-time.Minute)
lastSentAt := currentStateSince.Add(-2 * time.Minute)
resolvedAt := currentStateSince.Add(-3 * time.Minute)
tests := []struct {
name string
input models.AlertInstance
expected *pb.AlertInstance
}{
{
name: "valid instance",
input: models.AlertInstance{
Labels: map[string]string{"key": "value"},
AlertInstanceKey: models.AlertInstanceKey{
RuleUID: "rule-uid-1",
RuleOrgID: 1,
LabelsHash: "hash123",
},
CurrentState: models.InstanceStateFiring,
CurrentStateSince: currentStateSince,
CurrentStateEnd: currentStateEnd,
CurrentReason: "Some reason",
LastEvalTime: lastEvalTime,
LastSentAt: &lastSentAt,
ResolvedAt: &resolvedAt,
ResultFingerprint: "fingerprint",
},
expected: &pb.AlertInstance{
Labels: map[string]string{"key": "value"},
LabelsHash: "hash123",
CurrentState: "Alerting",
CurrentStateSince: timestamppb.New(currentStateSince),
CurrentStateEnd: timestamppb.New(currentStateEnd),
CurrentReason: "Some reason",
LastEvalTime: timestamppb.New(lastEvalTime),
LastSentAt: toProtoTimestampPtr(&lastSentAt),
ResolvedAt: toProtoTimestampPtr(&resolvedAt),
ResultFingerprint: "fingerprint",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := alertInstanceModelToProto(tt.input)
require.Equal(t, tt.expected, result)
})
}
}
func TestAlertInstanceProtoToModel(t *testing.T) {
currentStateSince := time.Now().UTC()
currentStateEnd := currentStateSince.Add(time.Minute).UTC()
lastEvalTime := currentStateSince.Add(-time.Minute).UTC()
lastSentAt := currentStateSince.Add(-2 * time.Minute).UTC()
resolvedAt := currentStateSince.Add(-3 * time.Minute).UTC()
ruleUID := "rule-uid-1"
orgID := int64(1)
tests := []struct {
name string
input *pb.AlertInstance
expected *models.AlertInstance
}{
{
name: "valid instance",
input: &pb.AlertInstance{
Labels: map[string]string{"key": "value"},
LabelsHash: "hash123",
CurrentState: "Alerting",
CurrentStateSince: timestamppb.New(currentStateSince),
CurrentStateEnd: timestamppb.New(currentStateEnd),
LastEvalTime: timestamppb.New(lastEvalTime),
LastSentAt: toProtoTimestampPtr(&lastSentAt),
ResolvedAt: toProtoTimestampPtr(&resolvedAt),
ResultFingerprint: "fingerprint",
},
expected: &models.AlertInstance{
Labels: map[string]string{"key": "value"},
AlertInstanceKey: models.AlertInstanceKey{
RuleUID: ruleUID,
RuleOrgID: orgID,
LabelsHash: "hash123",
},
CurrentState: models.InstanceStateFiring,
CurrentStateSince: currentStateSince,
CurrentStateEnd: currentStateEnd,
LastEvalTime: lastEvalTime,
LastSentAt: &lastSentAt,
ResolvedAt: &resolvedAt,
ResultFingerprint: "fingerprint",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := alertInstanceProtoToModel(ruleUID, orgID, tt.input)
require.Equal(t, tt.expected, result)
})
}
}
func TestModelAlertInstanceMatchesProtobuf(t *testing.T) {
// The AlertInstance protobuf must always contain the same information
// as the model, so that it's preserved between the Grafana restarts.
//
// If the AlertInstance model changes, review the protobuf and the test
// and update them accordingly.
t.Run("when AlertInstance model changes", func(t *testing.T) {
modelType := reflect.TypeOf(models.AlertInstance{})
require.Equal(t, 10, modelType.NumField(), "AlertInstance model has changed, update the protobuf")
})
}
func TestCompressAndDecompressAlertInstances(t *testing.T) {
now := time.Now()
alertInstances := []*pb.AlertInstance{
{
Labels: map[string]string{"label-1": "value-1"},
LabelsHash: "hash-1",
CurrentState: "normal",
CurrentStateSince: timestamppb.New(now),
CurrentStateEnd: timestamppb.New(now.Add(time.Hour)),
CurrentReason: "reason-1",
LastEvalTime: timestamppb.New(now.Add(-time.Minute)),
ResolvedAt: timestamppb.New(now.Add(time.Hour * 2)),
ResultFingerprint: "fingerprint-1",
},
{
Labels: map[string]string{"label-2": "value-2"},
LabelsHash: "hash-2",
CurrentState: "firing",
CurrentStateSince: timestamppb.New(now),
CurrentReason: "reason-2",
LastEvalTime: timestamppb.New(now.Add(-time.Minute * 2)),
},
}
compressedData, err := compressAlertInstances(alertInstances)
require.NoError(t, err)
decompressedInstances, err := decompressAlertInstances(compressedData)
require.NoError(t, err)
// Compare the original and decompressed instances
require.Equal(t, len(alertInstances), len(decompressedInstances))
require.EqualExportedValues(t, alertInstances[0], decompressedInstances[0])
require.EqualExportedValues(t, alertInstances[1], decompressedInstances[1])
}
func toProtoTimestampPtr(tm *time.Time) *timestamppb.Timestamp {
if tm == nil {
return nil
}
return timestamppb.New(*tm)
}
+28 -7
View File
@@ -37,14 +37,35 @@ import (
"github.com/grafana/grafana/pkg/services/secrets/database"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
type TestEnvOptions struct {
featureToggles featuremgmt.FeatureToggles
}
type TestEnvOption func(*TestEnvOptions)
func WithFeatureToggles(toggles featuremgmt.FeatureToggles) TestEnvOption {
return func(opts *TestEnvOptions) {
opts.featureToggles = toggles
}
}
// SetupTestEnv initializes a store to used by the tests.
func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, *store.DBstore) {
func SetupTestEnv(tb testing.TB, baseInterval time.Duration, opts ...TestEnvOption) (*ngalert.AlertNG, *store.DBstore) {
tb.Helper()
options := TestEnvOptions{
featureToggles: featuremgmt.WithFeatures(),
}
for _, opt := range opts {
opt(&options)
}
cfg := setting.NewCfg()
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{
BaseInterval: setting.SchedulerBaseInterval,
@@ -64,18 +85,18 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG,
bus := bus.ProvideBus(tracer)
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
dashboardService, dashboardStore := testutil.SetupDashboardService(tb, sqlStore, folderStore, cfg)
features := featuremgmt.WithFeatures()
folderService := testutil.SetupFolderService(tb, cfg, sqlStore, dashboardStore, folderStore, bus, features, ac)
ruleStore, err := store.ProvideDBStore(cfg, featuremgmt.WithFeatures(), sqlStore, folderService, &dashboards.FakeDashboardService{}, ac, bus)
folderService := testutil.SetupFolderService(tb, cfg, sqlStore, dashboardStore, folderStore, bus, options.featureToggles, ac)
ruleStore, err := store.ProvideDBStore(cfg, options.featureToggles, sqlStore, folderService, &dashboards.FakeDashboardService{}, ac, bus)
require.NoError(tb, err)
ng, err := ngalert.ProvideService(
cfg, features, nil, nil, routing.NewRouteRegister(), sqlStore, kvstore.NewFakeKVStore(), nil, nil, quotatest.New(false, nil),
cfg, options.featureToggles, nil, nil, routing.NewRouteRegister(), sqlStore, kvstore.NewFakeKVStore(), nil, nil, quotatest.New(false, nil),
secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil, bus, ac,
annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, httpclient.NewProvider(), ngalertfakes.NewFakeReceiverPermissionsService(),
annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, httpclient.NewProvider(), ngalertfakes.NewFakeReceiverPermissionsService(), usertest.NewUserServiceFake(),
)
require.NoError(tb, err)
return ng, &store.DBstore{
FeatureToggles: features,
FeatureToggles: options.featureToggles,
SQLStore: ng.SQLStore,
Cfg: setting.UnifiedAlertingSettings{
BaseInterval: baseInterval * time.Second,
+2 -1
View File
@@ -50,6 +50,7 @@ import (
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/userimpl"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -512,7 +513,7 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe
_, err = ngalert.ProvideService(
cfg, featuremgmt.WithFeatures(), nil, nil, routing.NewRouteRegister(), sqlStore, ngalertfakes.NewFakeKVStore(t), nil, nil, quotaService,
secretsService, nil, m, &foldertest.FakeService{}, &acmock.Mock{}, &dashboards.FakeDashboardService{}, nil, b, &acmock.Mock{},
annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, httpclient.NewProvider(), ngalertfakes.NewFakeReceiverPermissionsService(),
annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore, httpclient.NewProvider(), ngalertfakes.NewFakeReceiverPermissionsService(), usertest.NewUserServiceFake(),
)
require.NoError(t, err)
_, err = storesrv.ProvideService(sqlStore, featuremgmt.WithFeatures(), cfg, quotaService, storesrv.ProvideSystemUsersService())
@@ -143,4 +143,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
accesscontrol.AddReceiverCreateScopeMigration(mg)
ualert.AddAlertRuleUpdatedByMigration(mg)
ualert.AddAlertRuleStateTable(mg)
}
@@ -0,0 +1,29 @@
package ualert
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
// AddAlertRuleStateTable adds column to store alert rule state data.
func AddAlertRuleStateTable(mg *migrator.Migrator) {
alertStateTable := migrator.Table{
Name: "alert_rule_state",
Columns: []*migrator.Column{
{Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "org_id", Type: migrator.DB_BigInt, Nullable: false},
{Name: "rule_uid", Type: migrator.DB_NVarchar, Length: UIDMaxLength, Nullable: false},
{Name: "data", Type: migrator.DB_LongBlob, Nullable: false},
{Name: "updated_at", Type: migrator.DB_DateTime, Nullable: false},
},
Indices: []*migrator.Index{
{Cols: []string{"org_id", "rule_uid"}, Type: migrator.UniqueIndex},
},
}
mg.AddMigration(
"add alert_rule_state table",
migrator.NewAddTableMigration(alertStateTable),
)
mg.AddMigration(
"add index to alert_rule_state on org_id and rule_uid columns",
migrator.NewAddIndexMigration(alertStateTable, alertStateTable.Indices[0]),
)
}
+2
View File
@@ -541,6 +541,8 @@ type Cfg struct {
HttpsSkipVerify bool
}
const UnifiedStorageConfigKeyDashboard = "dashboards.dashboard.grafana.app"
type UnifiedStorageConfig struct {
DualWriterMode rest.DualWriterMode
DualWriterPeriodicDataSyncJobEnabled bool
+1 -1
View File
@@ -17,6 +17,7 @@ require (
github.com/grafana/grafana v11.4.0-00010101000000-000000000000+incompatible
github.com/grafana/grafana-plugin-sdk-go v0.263.0
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250121113133-e747350fee2d
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/prometheus/client_golang v1.20.5
@@ -121,7 +122,6 @@ require (
github.com/grafana/grafana-app-sdk/logging v0.29.0 // indirect
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6 // indirect
github.com/grafana/grafana/pkg/apiserver v0.0.0-20250121113133-e747350fee2d // indirect
github.com/grafana/otel-profiling-go v0.5.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect
github.com/grafana/sqlds/v4 v4.1.3 // indirect
@@ -0,0 +1,20 @@
package resource
import (
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/setting"
)
func NewSearchClient(cfg *setting.Cfg, unifiedStorageConfigKey string, unifiedClient ResourceIndexClient, legacyClient ResourceIndexClient) ResourceIndexClient {
config, ok := cfg.UnifiedStorage[unifiedStorageConfigKey]
if !ok {
return legacyClient
}
switch config.DualWriterMode {
case rest.Mode0, rest.Mode1, rest.Mode2:
return legacyClient
default:
return unifiedClient
}
}
+1 -1
View File
@@ -575,7 +575,7 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resource.Res
if req.Query != "" && req.Query != "*" {
searchrequest.Fields = append(searchrequest.Fields, resource.SEARCH_FIELD_SCORE)
// mimic the behavior of the sql search
query := req.Query
query := strings.ToLower(req.Query)
if !strings.Contains(query, "*") {
query = "*" + query + "*"
}
@@ -877,4 +877,136 @@ func TestIntegrationExportFileProvision(t *testing.T) {
require.YAMLEq(t, string(expectedYaml), exportRaw)
})
})
t.Run("when provisioning mute times from files", func(t *testing.T) {
// add file provisioned mute times
fileProvisionedMuteTimings, err := testData.ReadFile(path.Join("test-data", "provisioning-mute-times.yaml"))
require.NoError(t, err)
var expected definitions.AlertingFileExport
require.NoError(t, yaml.Unmarshal(fileProvisionedMuteTimings, &expected))
expected.MuteTimings[0].OrgID = 1 // HACK to deal with weird goyaml behavior
expectedYamlRaw, err := yaml.Marshal(expected)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(alertingDir, "provisioning-mute-times.yaml"), fileProvisionedMuteTimings, 0750)
require.NoError(t, err)
apiClient.ReloadAlertingFileProvisioning(t)
t.Run("exported mute times shouldn't escape $ characters", func(t *testing.T) {
// call export endpoint
exportRaw := apiClient.ExportMuteTiming(t, "$mute_time_a", "yaml")
var export definitions.AlertingFileExport
require.NoError(t, yaml.Unmarshal([]byte(exportRaw), &export))
expectedYaml := string(expectedYamlRaw)
// verify the file exported matches the file provisioned thing
require.Len(t, export.MuteTimings, 1)
require.YAMLEq(t, expectedYaml, exportRaw)
})
})
}
func TestIntegrationExportFileProvisionMixed(t *testing.T) {
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
})
provisioningDir := filepath.Join(dir, "conf", "provisioning")
alertingDir := filepath.Join(provisioningDir, "alerting")
err := os.MkdirAll(alertingDir, 0750)
require.NoError(t, err)
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleAdmin),
Password: "admin",
Login: "admin",
IsAdmin: true,
})
apiClient.ReloadCachedPermissions(t)
t.Run("when provisioning mixed set of alerting configurations from files", func(t *testing.T) {
// add file provisioned mixed set of alerting configurations
fileProvisionedResources, err := testData.ReadFile(path.Join("test-data", "provisioning-mixed-set.yaml"))
require.NoError(t, err)
var expected definitions.AlertingFileExport
require.NoError(t, yaml.Unmarshal(fileProvisionedResources, &expected))
expected.MuteTimings[0].OrgID = 1 // HACK to deal with weird goyaml behavior
err = os.WriteFile(filepath.Join(alertingDir, "provisioning-mixed-set.yaml"), fileProvisionedResources, 0750)
require.NoError(t, err)
apiClient.ReloadAlertingFileProvisioning(t)
t.Run("exported notification policy matches imported", func(t *testing.T) {
notificationPolicyExpected := expected
notificationPolicyExpected.MuteTimings = nil
notificationPolicyExpected.ContactPoints = nil
notificationPolicyExpected.Groups = nil
serializedExpected, err := yaml.Marshal(notificationPolicyExpected)
require.NoError(t, err)
actual := apiClient.ExportNotificationPolicy(t, "yaml")
require.YAMLEq(t, string(serializedExpected), actual)
})
})
}
func TestIntegrationExportFileProvisionContactPoints(t *testing.T) {
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
})
provisioningDir := filepath.Join(dir, "conf", "provisioning")
alertingDir := filepath.Join(provisioningDir, "alerting")
err := os.MkdirAll(alertingDir, 0750)
require.NoError(t, err)
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleAdmin),
Password: "admin",
Login: "admin",
IsAdmin: true,
})
apiClient.ReloadCachedPermissions(t)
t.Run("when provisioning contact points from files", func(t *testing.T) {
// add file provisioned contact points
fileProvisionedContactPoints, err := testData.ReadFile(path.Join("test-data", "provisioning-contact-points.yaml"))
require.NoError(t, err)
var expected definitions.AlertingFileExport
require.NoError(t, yaml.Unmarshal(fileProvisionedContactPoints, &expected))
expectedYaml, err := yaml.Marshal(expected)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(alertingDir, "provisioning-contact-points.yaml"), fileProvisionedContactPoints, 0750)
require.NoError(t, err)
apiClient.ReloadAlertingFileProvisioning(t)
t.Run("exported contact points should escape $ characters", func(t *testing.T) {
// call export endpoint
exportRaw := apiClient.ExportReceiver(t, "cp_1_$escaped", "yaml", true)
var export definitions.AlertingFileExport
require.NoError(t, yaml.Unmarshal([]byte(exportRaw), &export))
// verify the file exported matches the file provisioned thing
require.Len(t, export.ContactPoints, 1)
require.YAMLEq(t, string(expectedYaml), exportRaw)
})
})
}
+133 -17
View File
@@ -9,7 +9,6 @@ import (
"math/rand"
"net/http"
"path"
"regexp"
"slices"
"strings"
"testing"
@@ -121,6 +120,7 @@ func TestIntegrationAlertRulePermissions(t *testing.T) {
pathsToIgnore := []string{
"GrafanaManagedAlert.Updated",
"GrafanaManagedAlert.UpdatedBy",
"GrafanaManagedAlert.UID",
"GrafanaManagedAlert.ID",
"GrafanaManagedAlert.Data.Model",
@@ -422,6 +422,7 @@ func TestIntegrationAlertRuleNestedPermissions(t *testing.T) {
pathsToIgnore := []string{
"GrafanaManagedAlert.Updated",
"GrafanaManagedAlert.UpdatedBy",
"GrafanaManagedAlert.UID",
"GrafanaManagedAlert.ID",
"GrafanaManagedAlert.Data.Model",
@@ -1144,6 +1145,10 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
}
}],
"updated": "2021-02-21T01:10:30Z",
"updated_by": {
"uid": "uid",
"name": "grafana"
},
"intervalSeconds": 60,
"is_paused": false,
"version": 1,
@@ -1183,6 +1188,10 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
}
}],
"updated": "2021-02-21T01:10:30Z",
"updated_by": {
"uid": "uid",
"name": "grafana"
},
"intervalSeconds": 60,
"is_paused": false,
"version": 1,
@@ -1234,6 +1243,10 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) {
}
}],
"updated": "2021-02-21T01:10:30Z",
"updated_by": {
"uid": "uid",
"name": "grafana"
},
"intervalSeconds": 60,
"is_paused": false,
"version": 1,
@@ -1498,8 +1511,9 @@ func TestIntegrationRuleCreate(t *testing.T) {
client.CreateFolder(t, namespaceUID, namespaceUID)
cases := []struct {
name string
config apimodels.PostableRuleGroupConfig
name string
config apimodels.PostableRuleGroupConfig
expected apimodels.GettableRuleGroupConfig
}{{
name: "can create a rule with UTF-8",
config: apimodels.PostableRuleGroupConfig{
@@ -1514,8 +1528,7 @@ func TestIntegrationRuleCreate(t *testing.T) {
"_bar1": "baz🙂",
},
Annotations: map[string]string{
"Προμηθέας": "prom", // Prometheus in Greek
"犬": "Shiba Inu", // Dog in Japanese
"Προμηθέας": "prom", // Prometheus in Greek
},
},
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
@@ -1536,6 +1549,52 @@ func TestIntegrationRuleCreate(t *testing.T) {
},
},
},
expected: apimodels.GettableRuleGroupConfig{
Name: "test1",
Interval: model.Duration(time.Minute),
Rules: []apimodels.GettableExtendedRuleNode{
{
ApiRuleNode: &apimodels.ApiRuleNode{
For: util.Pointer(model.Duration(2 * time.Minute)),
Labels: map[string]string{
"foo🙂": "bar",
"_bar1": "baz🙂",
},
Annotations: map[string]string{
"Προμηθέας": "prom", // Prometheus in Greek
},
},
GrafanaManagedAlert: &apimodels.GettableGrafanaRule{
OrgID: 1,
Title: "test1 rule1",
Condition: "A",
Data: []apimodels.AlertQuery{
{
RefID: "A",
RelativeTimeRange: apimodels.RelativeTimeRange{
From: apimodels.Duration(0),
To: apimodels.Duration(15 * time.Minute),
},
DatasourceUID: expr.DatasourceUID,
Model: json.RawMessage(`{"expression":"1","intervalMs":1000,"maxDataPoints":43200,"type":"math"}`),
},
},
UpdatedBy: &apimodels.UserInfo{
Name: "admin",
},
IntervalSeconds: 60,
Version: 1,
NamespaceUID: namespaceUID,
RuleGroup: "test1",
NoDataState: "NoData",
ExecErrState: "Alerting",
Provenance: "",
IsPaused: false,
Metadata: &apimodels.AlertRuleMetadata{},
},
},
},
},
}}
for _, tc := range cases {
@@ -1545,6 +1604,27 @@ func TestIntegrationRuleCreate(t *testing.T) {
require.Len(t, resp.Created, 1)
require.Len(t, resp.Updated, 0)
require.Len(t, resp.Deleted, 0)
got, _, _ := client.GetRulesGroupWithStatus(t, namespaceUID, tc.config.Name)
pathsToIgnore := []string{
"GrafanaManagedAlert.Updated",
"GrafanaManagedAlert.UpdatedBy.UID",
"GrafanaManagedAlert.UID",
"GrafanaManagedAlert.ID",
"GrafanaManagedAlert.NamespaceID",
}
// compare expected and actual and ignore the dynamic fields
diff := cmp.Diff(tc.expected, got.GettableRuleGroupConfig, cmp.FilterPath(func(path cmp.Path) bool {
for _, s := range pathsToIgnore {
if strings.HasSuffix(path.String(), s) {
return true
}
}
return false
}, cmp.Ignore()))
require.Empty(t, diff)
})
}
}
@@ -1696,6 +1776,18 @@ func TestIntegrationRuleUpdate(t *testing.T) {
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
})
})
t.Run("should set updated_by", func(t *testing.T) {
group := generateAlertRuleGroup(1, alertRuleGen())
expected := model.Duration(10 * time.Second)
group.Rules[0].ApiRuleNode.For = &expected
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
require.NotNil(t, getGroup.Rules[0].GrafanaManagedAlert.UpdatedBy)
assert.NotEmpty(t, getGroup.Rules[0].GrafanaManagedAlert.UpdatedBy.UID)
assert.Equal(t, "grafana", getGroup.Rules[0].GrafanaManagedAlert.UpdatedBy.Name)
})
}
func TestIntegrationAlertAndGroupsQuery(t *testing.T) {
@@ -2438,6 +2530,10 @@ func TestIntegrationQuota(t *testing.T) {
}
],
"updated":"2021-02-21T01:10:30Z",
"updated_by": {
"uid": "uid",
"name": "grafana"
},
"intervalSeconds":60,
"is_paused": false,
"version":2,
@@ -2510,13 +2606,8 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
re := regexp.MustCompile(`"uid":"([\w|-]+)"`)
b = re.ReplaceAll(b, []byte(`"uid":""`))
re = regexp.MustCompile(`"updated":"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)"`)
b = re.ReplaceAll(b, []byte(`"updated":"2021-05-19T19:47:55Z"`))
expectedGetRulesResponseBody := fmt.Sprintf(`{
body, _ := rulesNamespaceWithoutVariableValues(t, b)
expectedGetRulesResponseBody := `{
"default": [
{
"name": "arulegroup",
@@ -2553,12 +2644,16 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) {
}
}
],
"updated": "2021-05-19T19:47:55Z",
"updated": "2021-02-21T01:10:30Z",
"updated_by" : {
"uid": "uid",
"name": "editor"
},
"intervalSeconds": 60,
"is_paused": false,
"version": 1,
"uid": "",
"namespace_uid": %q,
"uid": "uid",
"namespace_uid": "nsuid",
"rule_group": "arulegroup",
"no_data_state": "NoData",
"exec_err_state": "Alerting",
@@ -2573,8 +2668,8 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) {
]
}
]
}`, namespaceUID)
assert.JSONEq(t, expectedGetRulesResponseBody, string(b))
}`
assert.JSONEq(t, expectedGetRulesResponseBody, body)
})
t.Run("editor can not delete the folder because it contains Grafana 8 alerts", func(t *testing.T) {
u := fmt.Sprintf("http://editor:editor@%s/api/folders/%s", grafanaListedAddr, namespaceUID)
@@ -3033,6 +3128,10 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
}
],
"updated":"2021-02-21T01:10:30Z",
"updated_by": {
"uid": "uid",
"name": "grafana"
},
"intervalSeconds":60,
"is_paused": false,
"version":1,
@@ -3075,6 +3174,10 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
}
],
"updated":"2021-02-21T01:10:30Z",
"updated_by": {
"uid": "uid",
"name": "grafana"
},
"intervalSeconds":60,
"is_paused": false,
"version":1,
@@ -3389,6 +3492,10 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
}
],
"updated":"2021-02-21T01:10:30Z",
"updated_by": {
"uid": "uid",
"name": "grafana"
},
"intervalSeconds":60,
"is_paused": false,
"version":2,
@@ -3504,6 +3611,10 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
}
],
"updated":"2021-02-21T01:10:30Z",
"updated_by": {
"uid": "uid",
"name": "grafana"
},
"intervalSeconds":60,
"is_paused":false,
"version":3,
@@ -3598,6 +3709,10 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) {
}
],
"updated":"2021-02-21T01:10:30Z",
"updated_by": {
"uid": "uid",
"name": "grafana"
},
"intervalSeconds":60,
"is_paused":false,
"version":3,
@@ -4289,6 +4404,7 @@ func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) (string, map[st
rule.GrafanaManagedAlert.UID = "uid"
rule.GrafanaManagedAlert.NamespaceUID = "nsuid"
rule.GrafanaManagedAlert.Updated = time.Date(2021, time.Month(2), 21, 1, 10, 30, 0, time.UTC)
rule.GrafanaManagedAlert.UpdatedBy.UID = "uid"
}
}
}
@@ -0,0 +1,21 @@
# config file version
apiVersion: 1
# List of contact points to import or update
contactPoints:
# <int> organization ID, default = 1
- orgId: 1
# <string, required> name of the contact point
name: cp_1_$$escaped
receivers:
# <string, required> unique identifier for the receiver. Should not exceed 40 symbols. Only letters, numbers, - (hyphen), and _ (underscore) allowed.
- uid: first_uid
# <string, required> type of the receiver
type: prometheus-alertmanager
# <bool, optional> Disable the additional [Incident Resolved] follow-up alert, default = false
disableResolveMessage: false
# <object, required> settings for the specific receiver type
settings:
url: http://test:9000
something: $$escaped
@@ -0,0 +1,159 @@
# config file version
apiVersion: 1
contactPoints:
# <int> organization ID, default = 1
- orgId: 1
# <string, required> name of the contact point
name: $$xyz
receivers:
# <string, required> unique identifier for the receiver. Should not exceed 40 symbols. Only letters, numbers, - (hyphen), and _ (underscore) allowed.
- uid: first_uid
# <string, required> type of the receiver
type: prometheus-alertmanager
# <bool, optional> Disable the additional [Incident Resolved] follow-up alert, default = false
disableResolveMessage: false
# <object, required> settings for the specific receiver type
settings:
url: http://test:9000
something: $$escaped
muteTimes:
# <int> organization ID, default = 1
- orgId: 1
# <string, required> name of the mute time interval, must be unique
name: $mute_time_1
# <list> time intervals that should trigger the muting
# refer to https://prometheus.io/docs/alerting/latest/configuration/#time_interval-0
time_intervals:
- times:
- start_time: "06:00"
end_time: "23:59"
location: "UTC"
weekdays: ["monday:wednesday", "saturday", "sunday"]
months: ["1:3", "may:august", "december"]
years: ["2020:2022", "2030"]
days_of_month: ["1:5", "-3:-1"]
- orgId: 1
# <string, required> name of the mute time interval, must be unique
name: $mute_time_2
# <list> time intervals that should trigger the muting
# refer to https://prometheus.io/docs/alerting/latest/configuration/#time_interval-0
time_intervals:
- times:
- start_time: "09:00"
end_time: "10:00"
location: "UTC"
weekdays: ["monday:wednesday", "saturday", "sunday"]
months: ["1:3", "may:august", "december"]
years: ["2020:2022", "2030"]
days_of_month: ["1:5", "-3:-1"]
# ONLY THESE PATHS ARE NOT TEMPLATED and therefore don't need escaping:
# Alert rule annotations: groups[].rules[].annotations
# Alert rule time range: groups[].rules[].relativeTimeRange
# Alert rule query model: groups[].rules[].data.model
groups:
# <int> organization ID, default = 1
- orgId: 1
# <string, required> name of the rule group
name: my_rule_group
# <string, required> name of the folder the rule group will be stored in
folder: my_first_folder_with_$$escaped_symbols
# <duration, required> interval that the rule group should evaluated at
interval: 60s
# <list, required> list of rules that are part of the rule group
rules:
# <string, required> unique identifier for the rule. Should not exceed 40 symbols. Only letters, numbers, - (hyphen), and _ (underscore) allowed.
- uid: my_id_1
# <string, required> title of the rule that will be displayed in the UI
title: my_first_rule_with_$$escaped_symbols
# <string, required> which query should be used for the condition
condition: A
# <list, required> list of query objects that should be executed on each
# evaluation - should be obtained through the API
data:
- refId: A
datasourceUid: "__expr__"
model:
conditions:
- evaluator:
params:
- 3
type: gt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: "__expr__"
expression: 1==0
intervalMs: 1000
maxDataPoints: 43200
refId: A
type: math
# <string> UID of a dashboard that the alert rule should be linked to
dashboardUid: my_dashboard
# <int> ID of the panel that the alert rule should be linked to
panelId: 123
# <string> the state the alert rule will have when no data is returned
# possible values: "NoData", "Alerting", "OK", default = NoData
noDataState: Alerting
# <string> the state the alert rule will have when the query execution
# failed - possible values: "Error", "Alerting", "OK"
# default = Alerting
execErrState: Alerting
# <duration, required> for how long should the alert fire before alerting
for: 60s
# <map<string, string>> a map of strings to pass around any data
annotations:
some_key: some_value
$no_escaping_needed: $no_escaping_needed
# <map<string, string> a map of strings that can be used to filter and
# route alerts
labels:
team: sre_team_1
label_keys_not_$escaped: $$escaped_value
something: "escaped in the middle of things $$value"
templated: "{{ $$labels.team }}"
middle: "u$$ing_escaped_symbols"
notification_settings:
receiver: $$xyz
group_by:
- label_keys_not_$$escaped
- something
group_wait: 5m
group_interval: 10m
repeat_interval: 10m
mute_time_intervals:
- $mute_time_1
- $mute_time_2
policies:
# <int> organization ID, default = 1
- orgId: 1
# <string> name of the contact point that should be used for this route
receiver: $$xyz
group_by:
- label_keys_not_$$escaped
# <list> a list of prometheus-like matchers that an alert rule has to fulfill to match the node (allowed chars
# [a-zA-Z_:])
matchers:
- alertname = Watchdog
- service_id_X = serviceX
- severity =~ "warning|critical"
# <list> a list of grafana-like matchers that an alert rule has to fulfill to match the node
object_matchers:
- ["alertname", "=", "CPUUsage"]
- ["service_id-X", "=", "serviceX"]
- ["severity", "=~", "warning|critical"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes: []
@@ -0,0 +1,20 @@
# config file version
apiVersion: 1
# ONLY THESE PATHS ARE NOT TEMPLATED and therefore don't need escaping:
# Mute timings name: muteTimes[].name
# Mute timings time intervals: muteTimes[].time_intervals[]
muteTimes:
- orgId: 1
name: $mute_time_a
# <list> time intervals that should trigger the muting
# refer to https://prometheus.io/docs/alerting/latest/configuration/#time_interval-0
time_intervals:
- times:
- start_time: "06:00"
end_time: "23:59"
location: "UTC"
weekdays: ["monday:wednesday", "saturday", "sunday"]
months: ["1:3", "may:august", "december"]
years: ["2020:2022", "2030"]
days_of_month: ["1:5", "-3:-1"]
+52
View File
@@ -839,6 +839,32 @@ func (a apiClient) DeleteMuteTimingWithStatus(t *testing.T, name string) (int, s
return resp.StatusCode, string(body)
}
func (a apiClient) ExportMuteTiming(t *testing.T, name string, format string) string {
t.Helper()
u, err := url.Parse(fmt.Sprintf("%s/api/v1/provisioning/mute-timings/%s/export", a.url, name))
require.NoError(t, err)
q := url.Values{}
q.Set("format", format)
u.RawQuery = q.Encode()
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
require.NoError(t, err)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
requireStatusCode(t, http.StatusOK, resp.StatusCode, string(body))
return string(body)
}
func (a apiClient) GetRouteWithStatus(t *testing.T) (apimodels.Route, int, string) {
t.Helper()
@@ -883,6 +909,32 @@ func (a apiClient) UpdateRouteWithStatus(t *testing.T, route apimodels.Route, no
return resp.StatusCode, string(body)
}
func (a apiClient) ExportNotificationPolicy(t *testing.T, format string) string {
t.Helper()
u, err := url.Parse(fmt.Sprintf("%s/api/v1/provisioning/policies/export", a.url))
require.NoError(t, err)
q := url.Values{}
q.Set("format", format)
u.RawQuery = q.Encode()
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
require.NoError(t, err)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
requireStatusCode(t, http.StatusOK, resp.StatusCode, string(body))
return string(body)
}
func (a apiClient) UpdateRoute(t *testing.T, route apimodels.Route, noProvenance bool) {
t.Helper()
status, data := a.UpdateRouteWithStatus(t, route, noProvenance)
+120
View File
@@ -0,0 +1,120 @@
package core
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/version"
apimachineryversion "k8s.io/apimachinery/pkg/version"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestIntegrationOpenAPIs(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
check := []schema.GroupVersion{{
Group: "dashboard.grafana.app",
Version: "v0alpha1",
}, {
Group: "folder.grafana.app",
Version: "v0alpha1",
}, {
Group: "peakq.grafana.app",
Version: "v0alpha1",
}}
h := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
EnableFeatureToggles: []string{
featuremgmt.FlagKubernetesFoldersServiceV2, // Will be default on by G12
featuremgmt.FlagQueryService, // Query Library
},
})
t.Run("check valid version response", func(t *testing.T) {
disco := h.NewDiscoveryClient()
req := disco.RESTClient().Get().
Prefix("version").
SetHeader("Accept", "application/json")
result := req.Do(context.Background())
require.NoError(t, result.Error())
raw, err := result.Raw()
require.NoError(t, err)
info := apimachineryversion.Info{}
err = json.Unmarshal(raw, &info)
require.NoError(t, err)
// Make sure the gitVersion is parsable
v, err := version.Parse(info.GitVersion)
require.NoError(t, err)
require.Equal(t, info.Major, fmt.Sprintf("%d", v.Major()))
require.Equal(t, info.Minor, fmt.Sprintf("%d", v.Minor()))
})
t.Run("build open", func(t *testing.T) {
// Now write each OpenAPI spec to a static file
dir := filepath.Join("..", "..", "..", "..", "openapi")
for _, gv := range check {
path := fmt.Sprintf("/openapi/v3/apis/%s/%s", gv.Group, gv.Version)
rsp := apis.DoRequest(h, apis.RequestParams{
Method: http.MethodGet,
Path: path,
User: h.Org1.Admin,
}, &apis.AnyResource{})
require.NotNil(t, rsp.Response)
require.Equal(t, 200, rsp.Response.StatusCode, path)
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, rsp.Body, "", " ")
require.NoError(t, err)
pretty := prettyJSON.String()
write := false
fpath := filepath.Join(dir, fmt.Sprintf("%s-%s.json", gv.Group, gv.Version))
// nolint:gosec
// We can ignore the gosec G304 warning since this is a test and the function is only called with explicit paths
body, err := os.ReadFile(fpath)
if err == nil {
if !assert.JSONEq(t, string(body), pretty) {
t.Logf("openapi spec has changed: %s", path)
t.Fail()
write = true
}
} else {
t.Errorf("missing openapi spec for: %s", path)
write = true
}
if write {
e2 := os.WriteFile(fpath, []byte(pretty), 0644)
if e2 != nil {
t.Errorf("error writing file: %s", e2.Error())
}
}
}
})
}
-27
View File
@@ -22,9 +22,7 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer/yaml"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/version"
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
apimachineryversion "k8s.io/apimachinery/pkg/version"
"k8s.io/client-go/discovery"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
@@ -653,31 +651,6 @@ func (c *K8sTestHelper) NewDiscoveryClient() *discovery.DiscoveryClient {
return client
}
func (c *K8sTestHelper) GetVersionInfo() apimachineryversion.Info {
c.t.Helper()
disco := c.NewDiscoveryClient()
req := disco.RESTClient().Get().
Prefix("version").
SetHeader("Accept", "application/json")
result := req.Do(context.Background())
require.NoError(c.t, result.Error())
raw, err := result.Raw()
require.NoError(c.t, err)
info := apimachineryversion.Info{}
err = json.Unmarshal(raw, &info)
require.NoError(c.t, err)
// Make sure the gitVersion is parsable
v, err := version.Parse(info.GitVersion)
require.NoError(c.t, err)
require.Equal(c.t, info.Major, fmt.Sprintf("%d", v.Major()))
require.Equal(c.t, info.Minor, fmt.Sprintf("%d", v.Minor()))
return info
}
func (c *K8sTestHelper) GetGroupVersionInfoJSON(group string) string {
c.t.Helper()
-3
View File
@@ -49,9 +49,6 @@ func TestIntegrationPlaylist(t *testing.T) {
EnableFeatureToggles: []string{},
}))
// Ensure the k8s version is valid
_ = h.GetVersionInfo()
// The accepted verbs will change when dual write is enabled
disco := h.GetGroupVersionInfoJSON("playlist.grafana.app")
// fmt.Printf("%s", disco)
@@ -223,7 +223,7 @@ async function buildTestSceneWithLibraryPanel() {
title: 'Panel A',
pluginId: 'table',
key: 'panel-12',
$behaviors: [new LibraryPanelBehavior({ name: 'LibraryPanel A', uid: '111' })],
$behaviors: [new LibraryPanelBehavior({ title: 'LibraryPanel A title', name: 'LibraryPanel A', uid: '111' })],
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
$data: new SceneDataTransformer({
transformations: [
@@ -200,6 +200,7 @@ describe('PanelEditor', () => {
const libPanelBehavior = new LibraryPanelBehavior({
isLoaded: true,
title: libraryPanelModel.title,
uid: libraryPanelModel.uid,
name: libraryPanelModel.name,
_loadedPanel: libraryPanelModel,
@@ -238,7 +239,7 @@ describe('PanelEditor', () => {
// Wait for mock api to return and update the library panel
expect(libPanelBehavior.state._loadedPanel?.version).toBe(2);
expect(libPanelBehavior.state.name).toBe('changed name');
expect(panel.state.title).toBe('changed title');
expect(libPanelBehavior.state.title).toBe('changed title');
expect((gridItem.state.body as VizPanel).state.title).toBe('changed title');
});
@@ -257,6 +258,7 @@ describe('PanelEditor', () => {
const libPanelBehavior = new LibraryPanelBehavior({
isLoaded: true,
title: libraryPanelModel.title,
uid: libraryPanelModel.uid,
name: libraryPanelModel.name,
_loadedPanel: libraryPanelModel,
@@ -177,6 +177,7 @@ describe('PanelOptions', () => {
const libraryPanel = new LibraryPanelBehavior({
isLoaded: true,
title: libraryPanelModel.title,
uid: libraryPanelModel.uid,
name: libraryPanelModel.name,
_loadedPanel: libraryPanelModel,
@@ -96,7 +96,7 @@ describe('AddLibraryPanelWidget', () => {
title: 'Panel Title',
pluginId: 'table',
key: 'panel-1',
$behaviors: [new LibraryPanelBehavior({ name: 'LibraryPanel A', uid: 'uid' })],
$behaviors: [new LibraryPanelBehavior({ title: 'LibraryPanel A title', name: 'LibraryPanel A', uid: 'uid' })],
});
addLibPanelDrawer = new AddLibraryPanelDrawer({ panelToReplaceRef: libPanel.getRef() });
@@ -408,6 +408,7 @@ describe('DashboardDatasourceBehaviour', () => {
it('should re-run queries when library panel re-runs query', async () => {
const libPanelBehavior = new LibraryPanelBehavior({
isLoaded: false,
title: 'Panel title',
uid: 'fdcvggvfy2qdca',
name: 'My Library Panel',
_loadedPanel: undefined,
@@ -468,6 +469,7 @@ describe('DashboardDatasourceBehaviour', () => {
jest.spyOn(console, 'error').mockImplementation();
const libPanelBehavior = new LibraryPanelBehavior({
isLoaded: false,
title: 'Panel title',
uid: 'fdcvggvfy2qdca',
name: 'My Library Panel',
_loadedPanel: undefined,
@@ -517,6 +519,7 @@ describe('DashboardDatasourceBehaviour', () => {
// Simulate library panel being loaded
libPanelBehavior.setState({
isLoaded: true,
title: 'Panel title',
uid: 'fdcvggvfy2qdca',
name: 'My Library Panel',
_loadedPanel: undefined,
@@ -468,7 +468,7 @@ describe('DashboardScene', () => {
title: 'Library Panel',
pluginId: 'table',
key: 'panel-4',
$behaviors: [new LibraryPanelBehavior({ name: 'libraryPanel', uid: 'uid' })],
$behaviors: [new LibraryPanelBehavior({ title: 'Library Panel', name: 'libraryPanel', uid: 'uid' })],
});
scene.copyPanel(libVizPanel);
@@ -523,7 +523,7 @@ describe('DashboardScene', () => {
title: 'Library Panel',
pluginId: 'table',
key: 'panel-4',
$behaviors: [new LibraryPanelBehavior({ name: 'libraryPanel', uid: 'uid' })],
$behaviors: [new LibraryPanelBehavior({ title: 'Library Panel', name: 'libraryPanel', uid: 'uid' })],
}),
})
);
@@ -542,7 +542,7 @@ describe('DashboardScene', () => {
const libPanel = new VizPanel({
title: 'Panel B',
pluginId: 'table',
$behaviors: [new LibraryPanelBehavior({ name: 'lib panel', uid: 'abc', isLoaded: true })],
$behaviors: [new LibraryPanelBehavior({ title: 'title', name: 'lib panel', uid: 'abc', isLoaded: true })],
});
const scene = buildTestScene({
@@ -907,7 +907,7 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
title: 'Library Panel',
pluginId: 'table',
key: 'panel-5',
$behaviors: [new LibraryPanelBehavior({ name: 'libraryPanel', uid: 'uid' })],
$behaviors: [new LibraryPanelBehavior({ title: 'Library Panel', name: 'libraryPanel', uid: 'uid' })],
}),
}),
],
@@ -925,7 +925,7 @@ function buildTestScene(overrides?: Partial<DashboardSceneState>) {
title: 'Library Panel',
pluginId: 'table',
key: 'panel-6',
$behaviors: [new LibraryPanelBehavior({ name: 'libraryPanel', uid: 'uid' })],
$behaviors: [new LibraryPanelBehavior({ title: 'Library Panel', name: 'libraryPanel', uid: 'uid' })],
}),
}),
],
@@ -167,7 +167,7 @@ describe('LibraryPanelBehavior', () => {
});
async function buildTestSceneWithLibraryPanel() {
const behavior = new LibraryPanelBehavior({ name: 'LibraryPanel A', uid: '111' });
const behavior = new LibraryPanelBehavior({ title: 'LibraryPanel A title', name: 'LibraryPanel A', uid: '111' });
const vizPanel = new VizPanel({
title: 'Panel A',
@@ -17,6 +17,8 @@ import { AngularDeprecation } from './angular/AngularDeprecation';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
export interface LibraryPanelBehaviorState extends SceneObjectState {
// Library panels use title from dashboard JSON's panel model, not from library panel definition, hence we pass it.
title?: string;
uid: string;
name: string;
isLoaded?: boolean;
@@ -64,7 +66,7 @@ export class LibraryPanelBehavior extends SceneObjectBase<LibraryPanelBehaviorSt
titleItems.push(new PanelNotices());
const vizPanelState: VizPanelState = {
title: vizPanel.state.title ?? libPanelModel.title,
title: libPanelModel.title,
options: libPanelModel.options ?? {},
fieldConfig: libPanelModel.fieldConfig,
pluginId: libPanelModel.type,
@@ -86,7 +88,7 @@ export class LibraryPanelBehavior extends SceneObjectBase<LibraryPanelBehaviorSt
vizPanel.setState(vizPanelState);
vizPanel.changePluginType(libPanelModel.type, vizPanelState.options, vizPanelState.fieldConfig);
this.setState({ _loadedPanel: libPanel, isLoaded: true, name: libPanel.name });
this.setState({ _loadedPanel: libPanel, isLoaded: true, name: libPanel.name, title: libPanelModel.title });
const layoutElement = vizPanel.parent!;
@@ -355,6 +355,7 @@ describe('transformSceneToSaveModel', () => {
$behaviors: [
new LibraryPanelBehavior({
name: 'Some lib panel panel',
title: 'A panel',
uid: 'lib-panel-uid',
}),
],
@@ -398,7 +399,7 @@ describe('transformSceneToSaveModel', () => {
x: 0,
y: 0,
});
expect(result.title).toBe('Panel blahh blah');
expect(result.title).toBe('A panel');
expect(result.transformations).toBeUndefined();
expect(result.fieldConfig).toBeUndefined();
expect(result.options).toBeUndefined();
@@ -850,6 +851,7 @@ describe('transformSceneToSaveModel', () => {
$behaviors: [
new LibraryPanelBehavior({
name: 'Some lib panel panel',
title: 'A panel',
uid: 'lib-panel-uid',
}),
],
@@ -863,7 +865,7 @@ describe('transformSceneToSaveModel', () => {
expect(result[0]).toMatchObject({
id: 4,
title: 'Panel blahh blah',
title: 'A panel',
libraryPanel: {
name: 'Some lib panel panel',
uid: 'lib-panel-uid',
@@ -188,7 +188,7 @@ export function vizPanelToPanel(
panel = {
id: getPanelIdForVizPanel(vizPanel),
title: vizPanel.state.title,
title: libPanel!.state.title,
gridPos: gridPos,
libraryPanel: {
name: libPanel!.state.name,
@@ -17,6 +17,7 @@ describe('PanelModelCompatibilityWrapper', () => {
const libPanel = new LibraryPanelBehavior({
uid: 'a',
name: 'aa',
title: 'a',
});
panel.setState({
@@ -14,6 +14,7 @@ import {
import { reportInteraction } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import { Icon, PopoverContent, Tooltip, useTheme2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { checkLogsError, checkLogsSampled, escapeUnescapedString } from '../utils';
@@ -244,7 +245,13 @@ export const LogRow = ({
className={enableLogDetails ? styles.logsRowToggleDetails : ''}
>
{enableLogDetails && (
<Icon className={styles.topVerticalAlign} name={showDetails ? 'angle-down' : 'angle-right'} />
<button
aria-label={t('logs.log-row-message.see-details', `See log details`)}
className={styles.detailsToggle}
aria-expanded={showDetails}
>
<Icon className={styles.topVerticalAlign} name={showDetails ? 'angle-down' : 'angle-right'} />
</button>
)}
</td>
{showTime && <td className={styles.logsRowLocalTime}>{timestamp}</td>}
@@ -157,6 +157,7 @@ export const LogRowMessage = memo((props: Props) => {
[raw, prettifyLogMessage, wrapLogMessage, expanded]
);
const shouldShowMenu = mouseIsOver || pinned;
return (
<>
{
@@ -165,9 +166,9 @@ export const LogRowMessage = memo((props: Props) => {
}
<td className={styles.logsRowMessage}>
<div className={wrapLogMessage ? styles.positionRelative : styles.horizontalScroll}>
<button className={`${styles.logLine} ${styles.positionRelative}`}>
<div className={`${styles.logLine} ${styles.positionRelative}`}>
<LogMessage hasAnsi={hasAnsi} entry={restructuredEntry} highlights={row.searchWords} styles={styles} />
</button>
</div>
</div>
</td>
<td className={`log-row-menu-cell ${styles.logRowMenuCell}`}>
@@ -100,6 +100,11 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
cursor: 'pointer',
verticalAlign: 'top',
'&:focus-within': {
outline: `2px solid ${theme.colors.primary.border}`,
outlineOffset: '-2px',
},
'&:hover': {
'.log-row-menu': {
zIndex: 1,
@@ -139,7 +144,6 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
logsRowToggleDetails: css({
label: 'logs-row-toggle-details__level',
fontSize: '9px',
paddingTop: '5px',
maxWidth: '15px',
}),
logsRowLocalTime: css({
@@ -221,6 +225,29 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
backgroundColor: hoverBgColor,
},
}),
detailsToggle: css({
appearance: 'none',
background: 'none',
border: 'none',
padding: 0,
// Don't increase the height of the row
maxHeight: '19px',
// Don't show default button box-shadow on focus, we apply outline to the entire row instead
'&:focus-visible': {
boxShadow: 'none',
},
'&:focus': {
outline: 0,
},
'&:after': {
content: '""',
inset: 0,
position: 'absolute',
},
}),
// Log row
topVerticalAlign: css({
label: 'topVerticalAlign',
@@ -287,9 +314,6 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
},
}),
logLine: css({
backgroundColor: 'transparent',
border: 'none',
diplay: 'inline',
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
letterSpacing: theme.typography.bodySmall.letterSpacing,
+2 -1
View File
@@ -1815,7 +1815,8 @@
},
"log-row-message": {
"ellipsis": "… ",
"more": "more"
"more": "more",
"see-details": "See log details"
},
"log-rows": {
"disable-popover": {
+2 -1
View File
@@ -1815,7 +1815,8 @@
},
"log-row-message": {
"ellipsis": "… ",
"more": "mőřę"
"more": "mőřę",
"see-details": "Ŝęę ľőģ đęŧäįľş"
},
"log-rows": {
"disable-popover": {
+20 -20
View File
@@ -17970,7 +17970,7 @@ __metadata:
node-notifier: "npm:10.0.1"
nx: "npm:19.8.2"
ol: "npm:7.4.0"
ol-ext: "npm:4.0.24"
ol-ext: "npm:4.0.25"
pluralize: "npm:^8.0.0"
postcss: "npm:8.5.1"
postcss-loader: "npm:8.1.1"
@@ -18010,7 +18010,7 @@ __metadata:
react-virtual: "npm:2.10.4"
react-virtualized-auto-sizer: "npm:1.0.25"
react-window: "npm:1.8.11"
react-window-infinite-loader: "npm:1.0.9"
react-window-infinite-loader: "npm:1.0.10"
react-zoom-pan-pinch: "npm:^3.3.0"
reduce-reducers: "npm:^1.0.4"
redux: "npm:5.0.1"
@@ -18019,7 +18019,7 @@ __metadata:
regenerator-runtime: "npm:0.14.1"
reselect: "npm:5.1.1"
rimraf: "npm:6.0.1"
rudder-sdk-js: "npm:2.48.43"
rudder-sdk-js: "npm:2.48.44"
rxjs: "npm:7.8.1"
sass: "npm:1.83.4"
sass-loader: "npm:16.0.4"
@@ -20940,8 +20940,8 @@ __metadata:
linkType: hard
"knip@npm:^5.10.0":
version: 5.43.1
resolution: "knip@npm:5.43.1"
version: 5.43.5
resolution: "knip@npm:5.43.5"
dependencies:
"@nodelib/fs.walk": "npm:3.0.1"
"@snyk/github-codeowners": "npm:1.1.0"
@@ -20965,7 +20965,7 @@ __metadata:
bin:
knip: bin/knip.js
knip-bun: bin/knip-bun.js
checksum: 10/068e4145371cf3a4434d07a206eddf8f1d509541482d76252440484562f0b989c11c3efb9c4083d8b5854a90758d3bbcc4a228fe935f6e90ecc9ef2c9f9da8a7
checksum: 10/d6d7b6561b07fc1696aa109c822c4653c4cfdfd4aafb3bbb27e508415ff8b6e7d2fd14158cea800d59aa783e8fee25c641d1348374af86f743e2dee8140722d0
languageName: node
linkType: hard
@@ -23335,12 +23335,12 @@ __metadata:
languageName: node
linkType: hard
"ol-ext@npm:4.0.24":
version: 4.0.24
resolution: "ol-ext@npm:4.0.24"
"ol-ext@npm:4.0.25":
version: 4.0.25
resolution: "ol-ext@npm:4.0.25"
peerDependencies:
ol: ">= 5.3.0"
checksum: 10/76ab4ee468429171be7495998c0ae80853733f9ff6241dfd7b66208f265e180357bb80c304b2d185c50a4afa635a36ed8ed67fcd5374b49d790e71b9be039df9
checksum: 10/e3c8282fc67d9511b37c540f97217594a28f16b7ebeee5baf828eb56fdefa024fad6c1f846ac3cfd093f3b1e2cab5b6a56a97087152389fe4e35a2eb9b42f415
languageName: node
linkType: hard
@@ -26553,13 +26553,13 @@ __metadata:
languageName: node
linkType: hard
"react-window-infinite-loader@npm:1.0.9":
version: 1.0.9
resolution: "react-window-infinite-loader@npm:1.0.9"
"react-window-infinite-loader@npm:1.0.10":
version: 1.0.10
resolution: "react-window-infinite-loader@npm:1.0.10"
peerDependencies:
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0
checksum: 10/4a11ad949443cd76f9304d0558516c53474f53ca370e54bc29c2c362657b43545c89aec3e7df42d8e8511df379aed79b6fdd2c8fc52e25a9559b64e41ac7aefe
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10/4f4c097a2948f8da71d13199289d85c89720e41888ea50039d2b8e6d7cf160300e97f66421c53420d8cb95b3ece74940c07ebd23bddd7a705a679d7e2dc2957e
languageName: node
linkType: hard
@@ -27482,10 +27482,10 @@ __metadata:
languageName: node
linkType: hard
"rudder-sdk-js@npm:2.48.43":
version: 2.48.43
resolution: "rudder-sdk-js@npm:2.48.43"
checksum: 10/183888b1bd922d3e811fae5729b6a0ac93d5dbb44e8ee753ced80ae496d19c9127ef8de8908718d43ed4b33e4e9b738c3bbc562ea6cb02c050702f9b850ae2a5
"rudder-sdk-js@npm:2.48.44":
version: 2.48.44
resolution: "rudder-sdk-js@npm:2.48.44"
checksum: 10/538bc405c0cb7faf33b910a39d51bd6df53fbdb176d4e83c33821e7a454178b65ced216ad0f055b6dffd201ccc45cead814138e20683e15424a02fb54b894e15
languageName: node
linkType: hard