Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
91 files changed
+9922
-339
No files matched your search
+4
-3
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]),
|
||||
)
|
||||
}
|
||||
@@ -541,6 +541,8 @@ type Cfg struct {
|
||||
HttpsSkipVerify bool
|
||||
}
|
||||
|
||||
const UnifiedStorageConfigKeyDashboard = "dashboards.dashboard.grafana.app"
|
||||
|
||||
type UnifiedStorageConfig struct {
|
||||
DualWriterMode rest.DualWriterMode
|
||||
DualWriterPeriodicDataSyncJobEnabled bool
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1815,7 +1815,8 @@
|
||||
},
|
||||
"log-row-message": {
|
||||
"ellipsis": "… ",
|
||||
"more": "more"
|
||||
"more": "more",
|
||||
"see-details": "See log details"
|
||||
},
|
||||
"log-rows": {
|
||||
"disable-popover": {
|
||||
|
||||
@@ -1815,7 +1815,8 @@
|
||||
},
|
||||
"log-row-message": {
|
||||
"ellipsis": "… ",
|
||||
"more": "mőřę"
|
||||
"more": "mőřę",
|
||||
"see-details": "Ŝęę ľőģ đęŧäįľş"
|
||||
},
|
||||
"log-rows": {
|
||||
"disable-popover": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user