Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync
This commit is contained in:
@@ -111,6 +111,10 @@ Controls whether values are rendered inside the value boxes. Auto will render va
|
||||
|
||||
Controls the height of boxes. 1 = maximum space and 0 = minimum space.
|
||||
|
||||
### Page size (enable pagination)
|
||||
|
||||
The **Page size** option lets you paginate the status history visualization to limit how many series are visible at once. This is useful when you have many series.
|
||||
|
||||
### Column width
|
||||
|
||||
Controls the width of boxes. 1 = maximum space and 0 = minimum space.
|
||||
|
||||
@@ -191,7 +191,6 @@ 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 |
|
||||
| `onPremToCloudMigrationsAuthApiMig` | Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction. |
|
||||
| `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 |
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test('Smoke test: plugin loads', async ({ createDataSourceConfigPage, page }) => {
|
||||
await createDataSourceConfigPage({ type: 'jaeger' });
|
||||
|
||||
await expect(await page.getByText('Type: Jaeger', { exact: true })).toBeVisible();
|
||||
await expect(await page.getByRole('heading', { name: 'Connection', exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test('Smoke test: plugin loads', async ({ createDataSourceConfigPage, page }) => {
|
||||
await createDataSourceConfigPage({ type: 'grafana-postgresql-datasource' });
|
||||
|
||||
await expect(await page.getByText('Type: PostgreSQL', { exact: true })).toBeVisible();
|
||||
await expect(await page.getByRole('heading', { name: 'Connection', exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test('Smoke test: plugin loads', async ({ createDataSourceConfigPage, page }) => {
|
||||
await createDataSourceConfigPage({ type: 'zipkin' });
|
||||
|
||||
await expect(await page.getByText('Type: Zipkin', { exact: true })).toBeVisible();
|
||||
await expect(await page.getByRole('heading', { name: 'Connection', exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -51,11 +51,16 @@ const numericComparer = (a: number, b: number): number => {
|
||||
return a - b;
|
||||
};
|
||||
|
||||
// Using the Intl.Collator object compare method results in much faster
|
||||
// string sorting than .localeCompare
|
||||
const compare = new Intl.Collator('en', { sensitivity: 'base' }).compare;
|
||||
|
||||
const stringComparer = (a: string, b: string): number => {
|
||||
if (!a || !b) {
|
||||
return falsyComparer(a, b);
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
|
||||
return compare(String(a), String(b));
|
||||
};
|
||||
|
||||
const booleanComparer = (a: boolean, b: boolean): number => {
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('Grouping to Matrix', () => {
|
||||
const expected: Field[] = [
|
||||
{
|
||||
name: 'Time\\Time',
|
||||
type: FieldType.string,
|
||||
type: FieldType.time,
|
||||
values: [1000, 1001, 1002],
|
||||
config: {},
|
||||
},
|
||||
@@ -133,7 +133,7 @@ describe('Grouping to Matrix', () => {
|
||||
const expected: Field[] = [
|
||||
{
|
||||
name: 'Time\\Time',
|
||||
type: FieldType.string,
|
||||
type: FieldType.time,
|
||||
values: [1000, 1001],
|
||||
config: {},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { getFieldDisplayName } from '../../field/fieldState';
|
||||
import { DataFrame, Field, FieldType } from '../../types/dataFrame';
|
||||
import { DataFrame, Field } from '../../types/dataFrame';
|
||||
import {
|
||||
SpecialValue,
|
||||
DataTransformerInfo,
|
||||
@@ -106,7 +106,7 @@ export const groupingToMatrixTransformer: DataTransformerInfo<GroupingToMatrixTr
|
||||
{
|
||||
name: rowColumnField,
|
||||
values: rowValues,
|
||||
type: FieldType.string,
|
||||
type: keyRowField.type,
|
||||
config: {},
|
||||
},
|
||||
];
|
||||
@@ -145,12 +145,7 @@ export const groupingToMatrixTransformer: DataTransformerInfo<GroupingToMatrixTr
|
||||
};
|
||||
|
||||
function uniqueValues<T>(values: T[]): T[] {
|
||||
const unique = new Set<T>();
|
||||
|
||||
for (let index = 0; index < values.length; index++) {
|
||||
unique.add(values[index]);
|
||||
}
|
||||
|
||||
const unique = new Set<T>(values);
|
||||
return Array.from(unique);
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,6 @@ export interface FeatureToggles {
|
||||
newFolderPicker?: boolean;
|
||||
jitterAlertRulesWithinGroups?: boolean;
|
||||
onPremToCloudMigrations?: boolean;
|
||||
onPremToCloudMigrationsAuthApiMig?: boolean;
|
||||
alertingSaveStatePeriodic?: boolean;
|
||||
scopeApi?: boolean;
|
||||
promQLScope?: boolean;
|
||||
|
||||
+5
@@ -17,6 +17,10 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui
|
||||
* Controls the column width
|
||||
*/
|
||||
colWidth?: number;
|
||||
/**
|
||||
* Enables pagination when > 0
|
||||
*/
|
||||
perPage?: number;
|
||||
/**
|
||||
* Set the height of the rows
|
||||
*/
|
||||
@@ -29,6 +33,7 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui
|
||||
|
||||
export const defaultOptions: Partial<Options> = {
|
||||
colWidth: 0.9,
|
||||
perPage: 20,
|
||||
rowHeight: 0.9,
|
||||
showValue: ui.VisibilityMode.Auto,
|
||||
};
|
||||
|
||||
@@ -636,7 +636,7 @@ func createAlertRule(t *testing.T, sql *sqlstore.SQLStore, title string, generat
|
||||
rule.PanelID = nil
|
||||
|
||||
ruleStore := store.SetupStoreForTesting(t, sql)
|
||||
ids, err := ruleStore.InsertAlertRules(context.Background(), []ngmodels.AlertRule{rule})
|
||||
ids, err := ruleStore.InsertAlertRules(context.Background(), nil, []ngmodels.AlertRule{rule})
|
||||
require.NoError(t, err)
|
||||
result, err := ruleStore.GetAlertRuleByUID(context.Background(), &ngmodels.GetAlertRuleByUIDQuery{OrgID: rule.OrgID, UID: ids[0].UID})
|
||||
require.NoError(t, err)
|
||||
@@ -668,7 +668,7 @@ func createAlertRuleFromDashboard(t *testing.T, sql *sqlstore.SQLStore, title st
|
||||
rule.PanelID = panelID
|
||||
}
|
||||
ruleStore := store.SetupStoreForTesting(t, sql)
|
||||
ids, err := ruleStore.InsertAlertRules(context.Background(), []ngmodels.AlertRule{rule})
|
||||
ids, err := ruleStore.InsertAlertRules(context.Background(), nil, []ngmodels.AlertRule{rule})
|
||||
require.NoError(t, err)
|
||||
result, err := ruleStore.GetAlertRuleByUID(context.Background(), &ngmodels.GetAlertRuleByUIDQuery{OrgID: rule.OrgID, UID: ids[0].UID})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -168,18 +168,12 @@ func ProvideService(
|
||||
}
|
||||
s.gcomService = gcom.New(gcom.Config{ApiURL: cfg.GrafanaComAPIURL, Token: cfg.CloudMigration.GcomAPIToken}, httpClientGcom)
|
||||
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrationsAuthApiMig) {
|
||||
s.log.Info("using authapi client because feature flag is enabled")
|
||||
httpClientAuthApi, err := httpClientProvider.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http client for AuthApi: %w", err)
|
||||
}
|
||||
// the api token is the same as for gcom
|
||||
s.authApiService = authapi.New(authapi.Config{ApiURL: cfg.CloudMigration.AuthAPIUrl, Token: cfg.CloudMigration.GcomAPIToken}, httpClientAuthApi)
|
||||
} else {
|
||||
s.log.Info("using gcom client for auth")
|
||||
s.authApiService = gcom.New(gcom.Config{ApiURL: cfg.GrafanaComAPIURL, Token: cfg.CloudMigration.GcomAPIToken}, httpClientGcom).(*gcom.GcomClient)
|
||||
httpClientAuthApi, err := httpClientProvider.New()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http client for AuthApi: %w", err)
|
||||
}
|
||||
// the api token is the same as for gcom
|
||||
s.authApiService = authapi.New(authapi.Config{ApiURL: cfg.CloudMigration.AuthAPIUrl, Token: cfg.CloudMigration.GcomAPIToken}, httpClientAuthApi)
|
||||
} else {
|
||||
s.gmsClient = gmsclient.NewInMemoryClient()
|
||||
s.gcomService = &gcomStub{}
|
||||
|
||||
@@ -952,11 +952,26 @@ func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashbo
|
||||
|
||||
func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, query *dashboards.GetDashboardsByPluginIDQuery) ([]*dashboards.Dashboard, error) {
|
||||
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesCliDashboards) {
|
||||
return dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
dashs, err := dr.searchDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{
|
||||
OrgId: query.OrgID,
|
||||
ProvisionedRepo: pluginIDRepoName,
|
||||
ProvisionedPath: query.PluginID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// search only returns the metadata, need to get the dashboard.Data too
|
||||
results := make([]*dashboards.Dashboard, len(dashs))
|
||||
for i, d := range dashs {
|
||||
dash, err := dr.GetDashboard(ctx, &dashboards.GetDashboardQuery{OrgID: d.OrgID, UID: d.UID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results[i] = dash
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
return dr.dashboardStore.GetDashboardsByPluginID(ctx, query)
|
||||
}
|
||||
|
||||
@@ -1020,6 +1020,15 @@ func TestGetDashboardsByPluginID(t *testing.T) {
|
||||
PluginID: "testing",
|
||||
OrgID: 1,
|
||||
}
|
||||
uidUnstructured := &unstructured.Unstructured{Object: map[string]any{
|
||||
"metadata": map[string]any{
|
||||
"name": "uid1",
|
||||
},
|
||||
"spec": map[string]any{
|
||||
"title": "Dashboard 1",
|
||||
},
|
||||
}}
|
||||
|
||||
t.Run("Should fallback to dashboard store if Kubernetes feature flags are not enabled", func(t *testing.T) {
|
||||
service.features = featuremgmt.WithFeatures()
|
||||
fakeStore.On("GetDashboardsByPluginID", mock.Anything, mock.Anything).Return([]*dashboards.Dashboard{}, nil).Once()
|
||||
@@ -1030,6 +1039,7 @@ func TestGetDashboardsByPluginID(t *testing.T) {
|
||||
|
||||
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) {
|
||||
ctx, k8sCliMock := setupK8sDashboardTests(service)
|
||||
k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything).Return(uidUnstructured, nil)
|
||||
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool {
|
||||
return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == "plugin" &&
|
||||
req.Options.Fields[1].Key == "repo.path" && req.Options.Fields[1].Values[0] == "testing"
|
||||
|
||||
@@ -1020,12 +1020,6 @@ var (
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "onPremToCloudMigrationsAuthApiMig",
|
||||
Description: "Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction.",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
},
|
||||
{
|
||||
Name: "alertingSaveStatePeriodic",
|
||||
Description: "Writes the state periodically to the database, asynchronous to rule evaluation",
|
||||
|
||||
@@ -134,7 +134,6 @@ alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false
|
||||
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
|
||||
onPremToCloudMigrationsAuthApiMig,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
alertingSaveStatePeriodic,privatePreview,@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
|
||||
|
||||
|
@@ -547,10 +547,6 @@ const (
|
||||
// Enable the Grafana Migration Assistant, which helps you easily migrate on-prem resources, such as dashboards, folders, and data source configurations, to your Grafana Cloud stack.
|
||||
FlagOnPremToCloudMigrations = "onPremToCloudMigrations"
|
||||
|
||||
// FlagOnPremToCloudMigrationsAuthApiMig
|
||||
// Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction.
|
||||
FlagOnPremToCloudMigrationsAuthApiMig = "onPremToCloudMigrationsAuthApiMig"
|
||||
|
||||
// FlagAlertingSaveStatePeriodic
|
||||
// Writes the state periodically to the database, asynchronous to rule evaluation
|
||||
FlagAlertingSaveStatePeriodic = "alertingSaveStatePeriodic"
|
||||
|
||||
@@ -2721,7 +2721,8 @@
|
||||
"metadata": {
|
||||
"name": "onPremToCloudMigrationsAuthApiMig",
|
||||
"resourceVersion": "1732033809064",
|
||||
"creationTimestamp": "2024-11-21T18:46:06Z"
|
||||
"creationTimestamp": "2024-11-21T18:46:06Z",
|
||||
"deletionTimestamp": "2025-01-22T11:00:18Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the use of auth api instead of gcom for internal token services. Requires `onPremToCloudMigrations` to be enabled in conjunction.",
|
||||
|
||||
@@ -2581,7 +2581,7 @@ func createRule(t *testing.T, store *ngstore.DBstore, folderUID, title string) *
|
||||
gen.WithNamespaceUID(folderUID),
|
||||
gen.WithIntervalSeconds(10),
|
||||
).Generate()
|
||||
ids, err := store.InsertAlertRules(context.Background(), []models.AlertRule{rule})
|
||||
ids, err := store.InsertAlertRules(context.Background(), nil, []models.AlertRule{rule})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := store.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{OrgID: orgID, UID: ids[0].UID})
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
package gcom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authapi"
|
||||
)
|
||||
|
||||
// this will be removed when service in authapi is fully enabled
|
||||
|
||||
type listTokensResponse struct {
|
||||
Items []authapi.TokenView `json:"items"`
|
||||
}
|
||||
|
||||
type listAccessPoliciesResponse struct {
|
||||
Items []authapi.AccessPolicy `json:"items"`
|
||||
}
|
||||
|
||||
var _ authapi.Service = (*GcomClient)(nil)
|
||||
|
||||
func (client *GcomClient) CreateAccessPolicy(ctx context.Context, params authapi.CreateAccessPolicyParams, payload authapi.CreateAccessPolicyPayload) (authapi.AccessPolicy, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies")
|
||||
if err != nil {
|
||||
return authapi.AccessPolicy{}, fmt.Errorf("building gcom access policy url: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&payload)
|
||||
if err != nil {
|
||||
return authapi.AccessPolicy{}, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return authapi.AccessPolicy{}, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return authapi.AccessPolicy{}, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return authapi.AccessPolicy{}, fmt.Errorf("unexpected response when creating access policy: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var accessPolicy authapi.AccessPolicy
|
||||
if err := json.NewDecoder(response.Body).Decode(&accessPolicy); err != nil {
|
||||
return accessPolicy, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return accessPolicy, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) DeleteAccessPolicy(ctx context.Context, params authapi.DeleteAccessPolicyParams) (bool, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies/", params.AccessPolicyID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("building gcom access policy url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusOK || response.StatusCode == http.StatusNoContent {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return false, fmt.Errorf("unexpected response when deleting access policy: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
func (client *GcomClient) ListAccessPolicies(ctx context.Context, params authapi.ListAccessPoliciesParams) ([]authapi.AccessPolicy, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/accesspolicies")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building gcom access policy url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
query.Set("name", params.Name)
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending http request to create access policy: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return nil, fmt.Errorf("unexpected response when listing access policies: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var responseBody listAccessPoliciesResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&responseBody); err != nil {
|
||||
return responseBody.Items, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return responseBody.Items, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) ListTokens(ctx context.Context, params authapi.ListTokenParams) ([]authapi.TokenView, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building gcom tokens url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
query.Set("accessPolicyName", params.AccessPolicyName)
|
||||
query.Set("name", params.TokenName)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending http request to list access tokens: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return nil, fmt.Errorf("unexpected response when fetching access tokens: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var body listTokensResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return body.Items, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) CreateToken(ctx context.Context, params authapi.CreateTokenParams, payload authapi.CreateTokenPayload) (authapi.Token, error) {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens")
|
||||
if err != nil {
|
||||
return authapi.Token{}, fmt.Errorf("building gcom tokens url: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&payload)
|
||||
if err != nil {
|
||||
return authapi.Token{}, fmt.Errorf("marshaling request body: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return authapi.Token{}, fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return authapi.Token{}, fmt.Errorf("sending http request to create access token: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return authapi.Token{}, fmt.Errorf("unexpected response when creating access token: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
var token authapi.Token
|
||||
if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
|
||||
return token, fmt.Errorf("unmarshaling response body: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (client *GcomClient) DeleteToken(ctx context.Context, params authapi.DeleteTokenParams) error {
|
||||
endpoint, err := url.JoinPath(client.cfg.ApiURL, "/v1/tokens", params.TokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building gcom tokens url: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating http request: %w", err)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("region", params.Region)
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
request.Header.Set("x-request-id", params.RequestID)
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.cfg.Token))
|
||||
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending http request to delete access token: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := response.Body.Close(); err != nil {
|
||||
client.log.Error("closing http response body", "err", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("token id: %s %w", params.TokenID, ErrTokenNotFound)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("unexpected response when deleting access token: code=%d body=%s", response.StatusCode, body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -443,7 +443,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey
|
||||
New: *update.New,
|
||||
})
|
||||
}
|
||||
err = srv.store.UpdateAlertRules(tranCtx, updates)
|
||||
err = srv.store.UpdateAlertRules(tranCtx, ngmodels.NewUserUID(c.SignedInUser), updates)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update rules: %w", err)
|
||||
}
|
||||
@@ -454,7 +454,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey
|
||||
for _, rule := range finalChanges.New {
|
||||
inserts = append(inserts, *rule)
|
||||
}
|
||||
added, err := srv.store.InsertAlertRules(tranCtx, inserts)
|
||||
added, err := srv.store.InsertAlertRules(tranCtx, ngmodels.NewUserUID(c.SignedInUser), inserts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add rules: %w", err)
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ type RuleStore interface {
|
||||
|
||||
// InsertAlertRules will insert all alert rules passed into the function
|
||||
// and return the map of uuid to id.
|
||||
InsertAlertRules(ctx context.Context, rule []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error)
|
||||
UpdateAlertRules(ctx context.Context, rule []ngmodels.UpdateRule) error
|
||||
InsertAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error)
|
||||
UpdateAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.UpdateRule) error
|
||||
DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error
|
||||
|
||||
// IncreaseVersionForAllRulesInNamespaces Increases version for all rules that have specified namespace uids
|
||||
|
||||
@@ -243,6 +243,21 @@ func SortAlertRuleGroupWithFolderTitle(g []AlertRuleGroupWithFolderFullpath) {
|
||||
})
|
||||
}
|
||||
|
||||
type UserUID string
|
||||
|
||||
func NewUserUID(requester interface{ GetIdentifier() string }) *UserUID {
|
||||
// use anonymous interface to abstract from identity package, which is part of apimachinery
|
||||
if requester == nil {
|
||||
return nil
|
||||
}
|
||||
identifier := requester.GetIdentifier()
|
||||
if identifier == "" {
|
||||
return nil
|
||||
}
|
||||
userUID := UserUID(identifier)
|
||||
return &userUID
|
||||
}
|
||||
|
||||
// AlertRule is the model for alert rules in unified alerting.
|
||||
type AlertRule struct {
|
||||
ID int64
|
||||
@@ -251,6 +266,7 @@ type AlertRule struct {
|
||||
Condition string
|
||||
Data []AlertQuery
|
||||
Updated time.Time
|
||||
UpdatedBy *UserUID
|
||||
IntervalSeconds int64
|
||||
Version int64
|
||||
UID string
|
||||
@@ -535,7 +551,7 @@ func (alertRule *AlertRule) GetGroupKey() AlertRuleGroupKey {
|
||||
}
|
||||
|
||||
// PreSave sets default values and loads the updated model for each alert query.
|
||||
func (alertRule *AlertRule) PreSave(timeNow func() time.Time) error {
|
||||
func (alertRule *AlertRule) PreSave(timeNow func() time.Time, userUID *UserUID) error {
|
||||
for i, q := range alertRule.Data {
|
||||
err := q.PreSave()
|
||||
if err != nil {
|
||||
@@ -544,6 +560,7 @@ func (alertRule *AlertRule) PreSave(timeNow func() time.Time) error {
|
||||
alertRule.Data[i] = q
|
||||
}
|
||||
alertRule.Updated = timeNow()
|
||||
alertRule.UpdatedBy = userUID
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -445,6 +445,11 @@ func TestDiff(t *testing.T) {
|
||||
assert.Equal(t, rule2.Updated, diff[0].Right.Interface())
|
||||
difCnt++
|
||||
}
|
||||
if rule1.UpdatedBy != rule2.UpdatedBy {
|
||||
diff := diffs.GetDiffsForField("UpdatedBy")
|
||||
assert.Len(t, diff, 1)
|
||||
difCnt++
|
||||
}
|
||||
if rule1.IntervalSeconds != rule2.IntervalSeconds {
|
||||
diff := diffs.GetDiffsForField("IntervalSeconds")
|
||||
assert.Len(t, diff, 1)
|
||||
|
||||
@@ -96,6 +96,11 @@ func (g *AlertRuleGenerator) Generate() AlertRule {
|
||||
ns = append(ns, NotificationSettingsGen()())
|
||||
}
|
||||
|
||||
var updatedBy *UserUID
|
||||
if rand.Int63()%2 == 0 {
|
||||
updatedBy = util.Pointer(UserUID(util.GenerateShortUID()))
|
||||
}
|
||||
|
||||
rule := AlertRule{
|
||||
ID: 0,
|
||||
OrgID: rand.Int63n(1500) + 1, // Prevent OrgID=0 as this does not pass alert rule validation.
|
||||
@@ -103,6 +108,7 @@ func (g *AlertRuleGenerator) Generate() AlertRule {
|
||||
Condition: "A",
|
||||
Data: []AlertQuery{g.GenerateQuery()},
|
||||
Updated: time.Now().Add(-time.Duration(rand.Intn(100) + 1)),
|
||||
UpdatedBy: updatedBy,
|
||||
IntervalSeconds: rand.Int63n(60) + 1,
|
||||
Version: rand.Int63n(1500), // Don't generate a rule ID too big for postgres
|
||||
UID: util.GenerateShortUID(),
|
||||
@@ -538,6 +544,12 @@ func (a *AlertRuleMutators) WithRecordFrom(from string) AlertRuleMutator {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AlertRuleMutators) WithUpdatedBy(uid *UserUID) AlertRuleMutator {
|
||||
return func(r *AlertRule) {
|
||||
r.UpdatedBy = uid
|
||||
}
|
||||
}
|
||||
|
||||
func (g *AlertRuleGenerator) GenerateLabels(min, max int, prefix string) data.Labels {
|
||||
count := max
|
||||
if min > max {
|
||||
@@ -618,6 +630,7 @@ func CopyRule(r *AlertRule, mutators ...AlertRuleMutator) *AlertRule {
|
||||
Title: r.Title,
|
||||
Condition: r.Condition,
|
||||
Updated: r.Updated,
|
||||
UpdatedBy: r.UpdatedBy,
|
||||
IntervalSeconds: r.IntervalSeconds,
|
||||
Version: r.Version,
|
||||
UID: r.UID,
|
||||
|
||||
@@ -232,7 +232,7 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user ident
|
||||
}
|
||||
}
|
||||
err = service.xact.InTransaction(ctx, func(ctx context.Context) error {
|
||||
ids, err := service.ruleStore.InsertAlertRules(ctx, []models.AlertRule{
|
||||
ids, err := service.ruleStore.InsertAlertRules(ctx, models.NewUserUID(user), []models.AlertRule{
|
||||
rule,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -359,7 +359,7 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user ident
|
||||
}
|
||||
}
|
||||
|
||||
return service.ruleStore.UpdateAlertRules(ctx, updateRules)
|
||||
return service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), updateRules)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -511,7 +511,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
|
||||
New: *update.New,
|
||||
})
|
||||
}
|
||||
if err := service.ruleStore.UpdateAlertRules(ctx, updates); err != nil {
|
||||
if err := service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), updates); err != nil {
|
||||
return fmt.Errorf("failed to update alert rules: %w", err)
|
||||
}
|
||||
for _, update := range delta.Update {
|
||||
@@ -522,7 +522,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
|
||||
}
|
||||
|
||||
if len(delta.New) > 0 {
|
||||
uids, err := service.ruleStore.InsertAlertRules(ctx, withoutNilAlertRules(delta.New))
|
||||
uids, err := service.ruleStore.InsertAlertRules(ctx, models.NewUserUID(user), withoutNilAlertRules(delta.New))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert alert rules: %w", err)
|
||||
}
|
||||
@@ -618,7 +618,7 @@ func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user ident
|
||||
return models.AlertRule{}, err
|
||||
}
|
||||
err = service.xact.InTransaction(ctx, func(ctx context.Context) error {
|
||||
err := service.ruleStore.UpdateAlertRules(ctx, []models.UpdateRule{
|
||||
err := service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), []models.UpdateRule{
|
||||
{
|
||||
Existing: storedRule,
|
||||
New: rule,
|
||||
|
||||
@@ -41,8 +41,9 @@ func TestAlertRuleService(t *testing.T) {
|
||||
ruleService := createAlertRuleService(t, nil)
|
||||
var orgID int64 = 1
|
||||
u := &user.SignedInUser{
|
||||
UserID: 1,
|
||||
OrgID: orgID,
|
||||
UserUID: util.GenerateShortUID(),
|
||||
UserID: 1,
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
t.Run("group creation should set the right provenance", func(t *testing.T) {
|
||||
@@ -196,7 +197,7 @@ func TestAlertRuleService(t *testing.T) {
|
||||
},
|
||||
}
|
||||
rule.Metadata = ruleMetadata
|
||||
r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{rule})
|
||||
r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{rule})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r, 1)
|
||||
|
||||
@@ -233,7 +234,7 @@ func TestAlertRuleService(t *testing.T) {
|
||||
},
|
||||
}
|
||||
rule.Metadata = ruleMetadata
|
||||
r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{rule})
|
||||
r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{rule})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r, 1)
|
||||
|
||||
@@ -624,7 +625,7 @@ func TestAlertRuleService(t *testing.T) {
|
||||
|
||||
func TestCreateAlertRule(t *testing.T) {
|
||||
orgID := rand.Int63()
|
||||
u := &user.SignedInUser{OrgID: orgID}
|
||||
u := &user.SignedInUser{OrgID: orgID, UserUID: util.GenerateShortUID()}
|
||||
groupKey := models.GenerateGroupKey(orgID)
|
||||
groupIntervalSeconds := int64(30)
|
||||
gen := models.RuleGen
|
||||
@@ -1008,7 +1009,7 @@ func TestUpdateAlertRule(t *testing.T) {
|
||||
|
||||
rule := models.CopyRule(rules[0])
|
||||
|
||||
_, err := service.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{*rule})
|
||||
_, err := service.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{*rule})
|
||||
require.NoError(t, err)
|
||||
|
||||
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
|
||||
@@ -1626,7 +1627,7 @@ func TestProvisiongWithFullpath(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("for a rule under a root folder should set the right fullpath", func(t *testing.T) {
|
||||
r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{
|
||||
r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(&signedInUser), []models.AlertRule{
|
||||
createTestRule("my-cool-group", "my-cool-group", orgID, namespaceUID),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -1657,7 +1658,7 @@ func TestProvisiongWithFullpath(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{
|
||||
r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(&signedInUser), []models.AlertRule{
|
||||
createTestRule("my-cool-group-2", "my-cool-group-2", orgID, otherNamespaceUID),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -33,8 +33,8 @@ type RuleStore interface {
|
||||
GetAlertRuleByUID(ctx context.Context, query *models.GetAlertRuleByUIDQuery) (*models.AlertRule, error)
|
||||
ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) (models.RulesGroup, error)
|
||||
GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error)
|
||||
InsertAlertRules(ctx context.Context, rule []models.AlertRule) ([]models.AlertRuleKeyWithId, error)
|
||||
UpdateAlertRules(ctx context.Context, rule []models.UpdateRule) error
|
||||
InsertAlertRules(ctx context.Context, user *models.UserUID, rule []models.AlertRule) ([]models.AlertRuleKeyWithId, error)
|
||||
UpdateAlertRules(ctx context.Context, user *models.UserUID, rule []models.UpdateRule) error
|
||||
DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error
|
||||
GetAlertRulesGroupByRuleUID(ctx context.Context, query *models.GetAlertRulesGroupByRuleUIDQuery) ([]*models.AlertRule, error)
|
||||
}
|
||||
|
||||
@@ -642,7 +642,7 @@ func (am *Alertmanager) shouldSendConfig(ctx context.Context, config *apimodels.
|
||||
rc, err := am.mimirClient.GetGrafanaAlertmanagerConfig(ctx)
|
||||
if err != nil {
|
||||
// Log the error and return true so we try to upload our config anyway.
|
||||
am.log.Error("Unable to get the remote Alertmanager configuration for comparison", "err", err)
|
||||
am.log.Warn("Unable to get the remote Alertmanager configuration for comparison, sending the configuration without comparing", "err", err)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -669,7 +669,7 @@ func (am *Alertmanager) shouldSendState(ctx context.Context, state string) bool
|
||||
rs, err := am.mimirClient.GetGrafanaAlertmanagerState(ctx)
|
||||
if err != nil {
|
||||
// Log the error and return true so we try to upload our state anyway.
|
||||
am.log.Error("Unable to get the remote Alertmanager state for comparison", "err", err)
|
||||
am.log.Warn("Unable to get the remote Alertmanager state for comparison, sending the state without comparing", "err", err)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,14 @@ func (am *Alertmanager) IsReadyWithBackoff(ctx context.Context) (bool, error) {
|
||||
|
||||
if status != http.StatusOK {
|
||||
if status >= 400 && status < 500 {
|
||||
if status == http.StatusNotAcceptable {
|
||||
// Mimir returns a 406 when the Alertmanager for the tenant is not running.
|
||||
// This is expected if the Grafana Alertmanager configuration is default or not promoted.
|
||||
// We can still use the endpoints to store and retrieve configuration/state.
|
||||
am.logger.Debug("Remote Alertmanager not initialized for tenant", "attempt", attempts, "status", status)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
am.logger.Debug("Ready check failed with non-retriable status code", "attempt", attempts, "status", status)
|
||||
return false, fmt.Errorf("ready check failed with non-retriable status code %d", status)
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ func TestRuleWithFolderFingerprint(t *testing.T) {
|
||||
excludedFields := map[string]struct{}{
|
||||
"Version": {},
|
||||
"Updated": {},
|
||||
"UpdatedBy": {},
|
||||
"IntervalSeconds": {},
|
||||
"Annotations": {},
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func (st DBstore) GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmode
|
||||
|
||||
// InsertAlertRules is a handler for creating/updating alert rules.
|
||||
// Returns the UID and ID of rules that were created in the same order as the input rules.
|
||||
func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error) {
|
||||
func (st DBstore) InsertAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error) {
|
||||
ids := make([]ngmodels.AlertRuleKeyWithId, 0, len(rules))
|
||||
keys := make([]ngmodels.AlertRuleKey, 0, len(rules))
|
||||
return ids, st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
@@ -200,7 +200,7 @@ func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRu
|
||||
if err := st.validateAlertRule(r); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := (&r).PreSave(TimeNow); err != nil {
|
||||
if err := (&r).PreSave(TimeNow, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRu
|
||||
}
|
||||
|
||||
// UpdateAlertRules is a handler for updating alert rules.
|
||||
func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateRule) error {
|
||||
func (st DBstore) UpdateAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.UpdateRule) error {
|
||||
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
err := st.preventIntermediateUniqueConstraintViolations(sess, rules)
|
||||
if err != nil {
|
||||
@@ -263,7 +263,7 @@ func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateR
|
||||
if err := st.validateAlertRule(r.New); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := (&r.New).PreSave(TimeNow); err != nil {
|
||||
if err := (&r.New).PreSave(TimeNow, user); err != nil {
|
||||
return err
|
||||
}
|
||||
converted, err := alertRuleFromModelsAlertRule(r.New)
|
||||
@@ -956,7 +956,9 @@ func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgI
|
||||
if dryRun {
|
||||
return result, nil, nil
|
||||
}
|
||||
return result, nil, st.UpdateAlertRules(ctx, updates)
|
||||
// Provide empty user identifier to ensure it's clear that the rule update was made by the system
|
||||
// and not by the user who changed the receiver's title.
|
||||
return result, nil, st.UpdateAlertRules(ctx, nil, updates)
|
||||
}
|
||||
|
||||
// RenameTimeIntervalInNotificationSettings renames all rules that use old time interval name to the new name.
|
||||
@@ -1031,7 +1033,9 @@ func (st DBstore) RenameTimeIntervalInNotificationSettings(
|
||||
if dryRun {
|
||||
return result, nil, nil
|
||||
}
|
||||
return result, nil, st.UpdateAlertRules(ctx, updates)
|
||||
// Provide empty user identifier to ensure it's clear that the rule update was made by the system
|
||||
// and not by the user who changed the receiver's title.
|
||||
return result, nil, st.UpdateAlertRules(ctx, nil, updates)
|
||||
}
|
||||
|
||||
func ruleConstraintViolationToErr(sess *db.Session, rule ngmodels.AlertRule, err error, logger log.Logger) error {
|
||||
|
||||
@@ -47,6 +47,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
|
||||
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
|
||||
b := &fakeBus{}
|
||||
store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b)
|
||||
usr := models.UserUID("1234")
|
||||
|
||||
gen := models.RuleGen
|
||||
gen = gen.With(gen.WithIntervalMatching(store.Cfg.BaseInterval))
|
||||
@@ -56,7 +57,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
|
||||
rule := createRule(t, store, gen)
|
||||
newRule := models.CopyRule(rule)
|
||||
newRule.Title = util.GenerateShortUID()
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule,
|
||||
New: *newRule,
|
||||
},
|
||||
@@ -79,7 +80,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
|
||||
newRule := models.CopyRule(rule)
|
||||
newRule.Record.Metric = "new_metric"
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule,
|
||||
New: *newRule,
|
||||
},
|
||||
@@ -104,7 +105,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
|
||||
newRule := models.CopyRule(rule)
|
||||
newRule.Title = util.GenerateShortUID()
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule,
|
||||
New: *newRule,
|
||||
},
|
||||
@@ -125,22 +126,78 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
|
||||
called = true
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
b.publishFn = nil
|
||||
})
|
||||
|
||||
newRule := models.CopyRule(rule)
|
||||
newRule.Title = util.GenerateShortUID()
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule,
|
||||
New: *newRule,
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.True(t, called)
|
||||
})
|
||||
|
||||
t.Run("should set UpdatedBy", func(t *testing.T) {
|
||||
rule := createRule(t, store, gen)
|
||||
newRule := models.CopyRule(rule)
|
||||
newRule.Title = util.GenerateShortUID()
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule,
|
||||
New: *newRule,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
dbrule := &alertRule{}
|
||||
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
exist, err := sess.Table(alertRule{}).ID(rule.ID).Get(dbrule)
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule.ID))
|
||||
return err
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dbrule.UpdatedBy)
|
||||
require.Equal(t, string(usr), *dbrule.UpdatedBy)
|
||||
|
||||
t.Run("should set CreatedBy in rule version table", func(t *testing.T) {
|
||||
dbVersion := &alertRuleVersion{}
|
||||
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
exist, err := sess.Table(alertRuleVersion{}).Where("rule_uid = ? AND version = ?", dbrule.UID, dbrule.Version).Get(dbVersion)
|
||||
require.Truef(t, exist, "new version of the rule does not exist in version table")
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dbVersion.CreatedBy)
|
||||
require.Equal(t, *dbrule.UpdatedBy, *dbVersion.CreatedBy)
|
||||
})
|
||||
|
||||
t.Run("nil identity should be handled correctly", func(t *testing.T) {
|
||||
rule.Version++
|
||||
newRule.Title = util.GenerateShortUID()
|
||||
err = store.UpdateAlertRules(context.Background(), nil, []models.UpdateRule{{
|
||||
Existing: rule,
|
||||
New: *newRule,
|
||||
},
|
||||
})
|
||||
dbrule := &alertRule{}
|
||||
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
exist, err := sess.Table(alertRule{}).ID(rule.ID).Get(dbrule)
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule.ID))
|
||||
return err
|
||||
})
|
||||
assert.Nil(t, dbrule.UpdatedBy)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
usr := models.UserUID("test")
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second}
|
||||
sqlStore := db.InitTestDB(t)
|
||||
@@ -167,7 +224,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T)
|
||||
newRule1.Title = rule2.Title
|
||||
newRule2.Title = util.GenerateShortUID()
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule1,
|
||||
New: *newRule1,
|
||||
}, {
|
||||
@@ -211,7 +268,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T)
|
||||
newRule2.Title = rule3.Title
|
||||
newRule3.Title = rule1.Title
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule1,
|
||||
New: *newRule1,
|
||||
}, {
|
||||
@@ -263,7 +320,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T)
|
||||
newRule1.Title = strings.ToUpper(rule2.Title)
|
||||
newRule2.Title = strings.ToUpper(rule1.Title)
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule1,
|
||||
New: *newRule1,
|
||||
}, {
|
||||
@@ -310,7 +367,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T)
|
||||
newRule3.Title = rule4.Title
|
||||
newRule4.Title = rule3.Title
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule1,
|
||||
New: *newRule1,
|
||||
}, {
|
||||
@@ -372,7 +429,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T)
|
||||
newRule2 := models.CopyRule(rule2)
|
||||
newRule2.Title = newRule1.Title
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule2,
|
||||
New: *newRule2,
|
||||
},
|
||||
@@ -704,6 +761,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
|
||||
}
|
||||
|
||||
orgID := int64(1)
|
||||
usr := models.UserUID("test")
|
||||
sqlStore := db.InitTestDB(t)
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedAlerting.BaseInterval = 1 * time.Second
|
||||
@@ -724,7 +782,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
|
||||
|
||||
rules := append(gen.GenerateMany(5), recordingRulesGen.GenerateMany(5)...)
|
||||
|
||||
ids, err := store.InsertAlertRules(context.Background(), rules)
|
||||
ids, err := store.InsertAlertRules(context.Background(), &usr, rules)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ids, len(rules))
|
||||
|
||||
@@ -776,12 +834,20 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inserted rules should have UpdatedBy set", func(t *testing.T) {
|
||||
for _, rule := range dbRules {
|
||||
if assert.NotNil(t, rule.UpdatedBy) {
|
||||
assert.Equal(t, usr, *rule.UpdatedBy)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inserted recording rules fail validation if metric name is invalid", func(t *testing.T) {
|
||||
t.Run("invalid UTF-8", func(t *testing.T) {
|
||||
invalidMetric := "my_metric\x80"
|
||||
invalidRule := recordingRulesGen.Generate()
|
||||
invalidRule.Record.Metric = invalidMetric
|
||||
_, err := store.InsertAlertRules(context.Background(), []models.AlertRule{invalidRule})
|
||||
_, err := store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{invalidRule})
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
|
||||
require.ErrorContains(t, err, "metric name for recording rule must be a valid utf8 string")
|
||||
})
|
||||
@@ -789,7 +855,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
|
||||
|
||||
t.Run("clears fields that should not exist on recording rules", func(t *testing.T) {
|
||||
rule := recordingRulesGen.Generate()
|
||||
rules, err := store.InsertAlertRules(context.Background(), []models.AlertRule{rule})
|
||||
rules, err := store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{rule})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rules, 1)
|
||||
ruleUID := rules[0].UID
|
||||
@@ -806,13 +872,13 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("fail to insert rules with same ID", func(t *testing.T) {
|
||||
_, err = store.InsertAlertRules(context.Background(), []models.AlertRule{rules[0]})
|
||||
_, err = store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{rules[0]})
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleConflictBase)
|
||||
})
|
||||
t.Run("fail insert rules with the same title in a folder", func(t *testing.T) {
|
||||
cp := models.CopyRule(&rules[0])
|
||||
cp.UID = cp.UID + "-new"
|
||||
_, err = store.InsertAlertRules(context.Background(), []models.AlertRule{*cp})
|
||||
_, err = store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{*cp})
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleConflictBase)
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleUniqueConstraintViolation)
|
||||
require.NotEqual(t, rules[0].UID, "")
|
||||
@@ -825,7 +891,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
|
||||
t.Run("should not let insert rules with the same UID", func(t *testing.T) {
|
||||
cp := models.CopyRule(&rules[0])
|
||||
cp.Title = "unique-test-title"
|
||||
_, err = store.InsertAlertRules(context.Background(), []models.AlertRule{*cp})
|
||||
_, err = store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{*cp})
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleConflictBase)
|
||||
require.ErrorContains(t, err, "rule UID under the same organisation should be unique")
|
||||
})
|
||||
@@ -833,6 +899,9 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
|
||||
t.Run("should emit event when rules are inserted", func(t *testing.T) {
|
||||
rule := gen.Generate()
|
||||
called := false
|
||||
t.Cleanup(func() {
|
||||
b.publishFn = nil
|
||||
})
|
||||
b.publishFn = func(ctx context.Context, msg bus.Msg) error {
|
||||
event, ok := msg.(*RuleChangeEvent)
|
||||
require.True(t, ok)
|
||||
@@ -843,11 +912,23 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
rules, err := store.InsertAlertRules(context.Background(), []models.AlertRule{rule})
|
||||
rules, err := store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{rule})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rules, 1)
|
||||
require.True(t, called)
|
||||
})
|
||||
|
||||
t.Run("nil identity should be handled correctly", func(t *testing.T) {
|
||||
rule := gen.Generate()
|
||||
ids, err = store.InsertAlertRules(context.Background(), nil, []models.AlertRule{rule})
|
||||
require.NoError(t, err)
|
||||
insertedRule, err := store.GetRuleByID(context.Background(), models.GetAlertRuleByIDQuery{
|
||||
ID: ids[0].ID,
|
||||
OrgID: rule.OrgID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, insertedRule.UpdatedBy)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationAlertRulesNotificationSettings(t *testing.T) {
|
||||
@@ -855,6 +936,8 @@ func TestIntegrationAlertRulesNotificationSettings(t *testing.T) {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
usr := models.UserUID("test")
|
||||
|
||||
getKeyMap := func(r []*models.AlertRule) map[models.AlertRuleKey]struct{} {
|
||||
result := make(map[models.AlertRuleKey]struct{}, len(r))
|
||||
for _, rule := range r {
|
||||
@@ -894,7 +977,7 @@ func TestIntegrationAlertRulesNotificationSettings(t *testing.T) {
|
||||
require.NoError(t, store.SetProvenance(context.Background(), rule, rule.OrgID, p))
|
||||
}
|
||||
|
||||
_, err := store.InsertAlertRules(context.Background(), deref)
|
||||
_, err := store.InsertAlertRules(context.Background(), &usr, deref)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("should find rules by receiver name", func(t *testing.T) {
|
||||
@@ -1136,7 +1219,7 @@ func TestIntegrationListNotificationSettings(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
usr := models.UserUID("test")
|
||||
sqlStore := db.InitTestDB(t)
|
||||
folderService := setupFolderService(t, sqlStore, setting.NewCfg(), featuremgmt.WithFeatures())
|
||||
logger := log.New("test-dbstore")
|
||||
@@ -1168,7 +1251,7 @@ func TestIntegrationListNotificationSettings(t *testing.T) {
|
||||
|
||||
orgRules := append(rulesWithNotificationsAndReceiver, rulesWithNotificationsAndTimeInterval...)
|
||||
|
||||
_, err := store.InsertAlertRules(context.Background(), deref)
|
||||
_, err := store.InsertAlertRules(context.Background(), &usr, deref)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := store.ListNotificationSettings(context.Background(), models.ListNotificationSettingsQuery{OrgID: 1})
|
||||
@@ -1258,6 +1341,8 @@ func TestIntegrationGetNamespacesByRuleUID(t *testing.T) {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
usr := models.UserUID("test")
|
||||
|
||||
sqlStore := db.InitTestDB(t)
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedAlerting.BaseInterval = 1 * time.Second
|
||||
@@ -1267,7 +1352,7 @@ func TestIntegrationGetNamespacesByRuleUID(t *testing.T) {
|
||||
store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b)
|
||||
|
||||
rules := models.RuleGen.With(models.RuleMuts.WithOrgID(1), models.RuleMuts.WithRandomRecordingRules()).GenerateMany(5)
|
||||
_, err := store.InsertAlertRules(context.Background(), rules)
|
||||
_, err := store.InsertAlertRules(context.Background(), &usr, rules)
|
||||
require.NoError(t, err)
|
||||
|
||||
uids := make([]string, 0, len(rules))
|
||||
@@ -1306,6 +1391,7 @@ func TestIntegrationRuleGroupsCaseSensitive(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
usr := models.UserUID("test")
|
||||
|
||||
sqlStore := db.InitTestDB(t)
|
||||
cfg := setting.NewCfg()
|
||||
@@ -1329,7 +1415,7 @@ func TestIntegrationRuleGroupsCaseSensitive(t *testing.T) {
|
||||
group2 := gen.With(gen.WithGroupKey(groupKey2)).GenerateMany(1, 3)
|
||||
group3 := gen.With(gen.WithGroupKey(groupKey3)).GenerateMany(1, 3)
|
||||
|
||||
_, err := store.InsertAlertRules(context.Background(), append(append(append(misc, group1...), group2...), group3...))
|
||||
_, err := store.InsertAlertRules(context.Background(), &usr, append(append(append(misc, group1...), group2...), group3...))
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("GetAlertRulesGroupByRuleUID", func(t *testing.T) {
|
||||
@@ -1524,6 +1610,7 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
usr := models.UserUID("test")
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{
|
||||
BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second,
|
||||
@@ -1544,7 +1631,7 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) {
|
||||
rule := createRule(t, store, generator)
|
||||
firstNewRule := models.CopyRule(rule)
|
||||
firstNewRule.Title = util.GenerateShortUID()
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule,
|
||||
New: *firstNewRule,
|
||||
},
|
||||
@@ -1553,7 +1640,7 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) {
|
||||
firstNewRule.Version = firstNewRule.Version + 1
|
||||
secondNewRule := models.CopyRule(firstNewRule)
|
||||
secondNewRule.Title = util.GenerateShortUID()
|
||||
err = store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err = store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: firstNewRule,
|
||||
New: *secondNewRule,
|
||||
},
|
||||
@@ -1592,7 +1679,7 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) {
|
||||
rule := createRule(t, store, generator)
|
||||
oldRule := models.CopyRule(rule)
|
||||
oldRule.Title = "old-record"
|
||||
err := store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule,
|
||||
New: *oldRule,
|
||||
}}) // first entry in `rule_version_history` table happens here
|
||||
@@ -1601,19 +1688,19 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) {
|
||||
rule.Version = rule.Version + 1
|
||||
middleRule := models.CopyRule(rule)
|
||||
middleRule.Title = "middle-record"
|
||||
err = store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err = store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule,
|
||||
New: *middleRule,
|
||||
}}) //second entry in `rule_version_history` table happens here
|
||||
}}) // second entry in `rule_version_history` table happens here
|
||||
require.NoError(t, err)
|
||||
|
||||
rule.Version = rule.Version + 1
|
||||
newerRule := models.CopyRule(rule)
|
||||
newerRule.Title = "newer-record"
|
||||
err = store.UpdateAlertRules(context.Background(), []models.UpdateRule{{
|
||||
err = store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule,
|
||||
New: *newerRule,
|
||||
}}) //second entry in `rule_version_history` table happens here
|
||||
}}) // second entry in `rule_version_history` table happens here
|
||||
require.NoError(t, err)
|
||||
|
||||
// only the `old-record` should be deleted since limit is set to 1 and there are total 2 records
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
@@ -35,6 +36,10 @@ func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, e
|
||||
IsPaused: ar.IsPaused,
|
||||
}
|
||||
|
||||
if ar.UpdatedBy != nil {
|
||||
result.UpdatedBy = util.Pointer(models.UserUID(*ar.UpdatedBy))
|
||||
}
|
||||
|
||||
if ar.NoDataState != "" {
|
||||
result.NoDataState, err = models.NoDataStateFromString(ar.NoDataState)
|
||||
if err != nil {
|
||||
@@ -120,6 +125,10 @@ func alertRuleFromModelsAlertRule(ar models.AlertRule) (alertRule, error) {
|
||||
IsPaused: ar.IsPaused,
|
||||
}
|
||||
|
||||
if ar.UpdatedBy != nil {
|
||||
result.UpdatedBy = util.Pointer(string(*ar.UpdatedBy))
|
||||
}
|
||||
|
||||
// Serialize complex types to JSON strings
|
||||
data, err := json.Marshal(ar.Data)
|
||||
if err != nil {
|
||||
@@ -179,6 +188,7 @@ func alertRuleToAlertRuleVersion(rule alertRule) alertRuleVersion {
|
||||
RestoredFrom: 0,
|
||||
Version: rule.Version,
|
||||
Created: rule.Updated, // assuming the Updated time as the creation time
|
||||
CreatedBy: rule.UpdatedBy,
|
||||
Title: rule.Title,
|
||||
Condition: rule.Condition,
|
||||
Data: rule.Data,
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// AlertRuleFieldsToIgnoreInDiff contains fields that are ignored when calculating the RuleDelta.Diff.
|
||||
var AlertRuleFieldsToIgnoreInDiff = [...]string{"ID", "Version", "Updated"}
|
||||
var AlertRuleFieldsToIgnoreInDiff = [...]string{"ID", "Version", "Updated", "UpdatedBy"}
|
||||
|
||||
type RuleDelta struct {
|
||||
Existing *models.AlertRule
|
||||
|
||||
@@ -10,6 +10,7 @@ type alertRule struct {
|
||||
Condition string
|
||||
Data string
|
||||
Updated time.Time
|
||||
UpdatedBy *string `xorm:"updated_by"`
|
||||
IntervalSeconds int64
|
||||
Version int64 `xorm:"version"` // this tag makes xorm add optimistic lock (see https://xorm.io/docs/chapter-06/1.lock/)
|
||||
UID string `xorm:"uid"`
|
||||
@@ -46,6 +47,7 @@ type alertRuleVersion struct {
|
||||
Version int64
|
||||
|
||||
Created time.Time
|
||||
CreatedBy *string `xorm:"created_by"`
|
||||
Title string
|
||||
Condition string
|
||||
Data string
|
||||
|
||||
@@ -258,7 +258,7 @@ func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func (f *RuleStore) UpdateAlertRules(_ context.Context, q []models.UpdateRule) error {
|
||||
func (f *RuleStore) UpdateAlertRules(_ context.Context, _ *models.UserUID, q []models.UpdateRule) error {
|
||||
f.mtx.Lock()
|
||||
defer f.mtx.Unlock()
|
||||
f.RecordedOps = append(f.RecordedOps, q)
|
||||
@@ -268,7 +268,7 @@ func (f *RuleStore) UpdateAlertRules(_ context.Context, q []models.UpdateRule) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *RuleStore) InsertAlertRules(_ context.Context, q []models.AlertRule) ([]models.AlertRuleKeyWithId, error) {
|
||||
func (f *RuleStore) InsertAlertRules(_ context.Context, _ *models.UserUID, q []models.AlertRule) ([]models.AlertRuleKeyWithId, error) {
|
||||
f.mtx.Lock()
|
||||
defer f.mtx.Unlock()
|
||||
f.RecordedOps = append(f.RecordedOps, q)
|
||||
|
||||
@@ -113,7 +113,7 @@ func CreateTestAlertRuleWithLabels(t testing.TB, ctx context.Context, dbstore *s
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = dbstore.InsertAlertRules(ctx, []models.AlertRule{
|
||||
_, err = dbstore.InsertAlertRules(ctx, models.NewUserUID(user), []models.AlertRule{
|
||||
{
|
||||
|
||||
ID: 0,
|
||||
|
||||
@@ -141,4 +141,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
|
||||
externalsession.AddMigration(mg)
|
||||
|
||||
accesscontrol.AddReceiverCreateScopeMigration(mg)
|
||||
|
||||
ualert.AddAlertRuleUpdatedByMigration(mg)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package ualert
|
||||
|
||||
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
|
||||
// AddRuleNotificationSettingsColumns creates a column for notification settings in the alert_rule and alert_rule_version tables.
|
||||
func AddAlertRuleUpdatedByMigration(mg *migrator.Migrator) {
|
||||
mg.AddMigration("add created_by column to alert_rule_version table", migrator.NewAddColumnMigration(migrator.Table{Name: "alert_rule_version"}, &migrator.Column{
|
||||
Name: "created_by",
|
||||
Type: migrator.DB_Varchar,
|
||||
Length: 40,
|
||||
Nullable: true,
|
||||
}))
|
||||
|
||||
mg.AddMigration("add updated_by column to alert_rule table", migrator.NewAddColumnMigration(migrator.Table{Name: "alert_rule"}, &migrator.Column{
|
||||
Name: "updated_by",
|
||||
Type: migrator.DB_Varchar,
|
||||
Length: 40,
|
||||
Nullable: true,
|
||||
}))
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestDirectSQLStats(t *testing.T) {
|
||||
|
||||
// create an alert rule inside of folder test2
|
||||
ruleStore := ngalertstore.SetupStoreForTesting(t, db)
|
||||
_, err = ruleStore.InsertAlertRules(context.Background(), []ngmodels.AlertRule{
|
||||
_, err = ruleStore.InsertAlertRules(context.Background(), ngmodels.NewUserUID(tempUser), []ngmodels.AlertRule{
|
||||
{
|
||||
DashboardUID: &folder2UID,
|
||||
UID: "test",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
@@ -23,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
func TestBacktesting(t *testing.T) {
|
||||
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||
dir, grafanaPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||
DisableLegacyAlerting: true,
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
@@ -34,7 +33,7 @@ func TestBacktesting(t *testing.T) {
|
||||
EnableLog: false,
|
||||
})
|
||||
|
||||
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
|
||||
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, grafanaPath)
|
||||
|
||||
userId := createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
|
||||
DefaultOrgRole: string(org.RoleAdmin),
|
||||
@@ -44,7 +43,7 @@ func TestBacktesting(t *testing.T) {
|
||||
|
||||
apiCli := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
|
||||
|
||||
input, err := os.ReadFile(filepath.Join("api_backtesting_data.json"))
|
||||
input, err := testData.ReadFile(path.Join("test-data", "api_backtesting_data.json"))
|
||||
require.NoError(t, err)
|
||||
var testData map[string]apimodels.BacktestConfig
|
||||
require.NoError(t, json.Unmarshal(input, &testData))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ package alerting
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -20,11 +21,19 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/api"
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgimpl"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/quota/quotaimpl"
|
||||
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/user/userimpl"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
@@ -53,6 +62,11 @@ const defaultAlertmanagerConfigJSON = `
|
||||
}
|
||||
`
|
||||
|
||||
type Response struct {
|
||||
Message string `json:"message"`
|
||||
TraceID string `json:"traceID"`
|
||||
}
|
||||
|
||||
func getRequest(t *testing.T, url string, expStatusCode int) *http.Response {
|
||||
t.Helper()
|
||||
// nolint:gosec
|
||||
@@ -1008,3 +1022,23 @@ func requireStatusCode(t *testing.T, expected, actual int, response string) {
|
||||
t.Helper()
|
||||
require.Equalf(t, expected, actual, "Unexpected status. Response: %s", response)
|
||||
}
|
||||
|
||||
func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 {
|
||||
t.Helper()
|
||||
|
||||
cfg.AutoAssignOrg = true
|
||||
cfg.AutoAssignOrgId = 1
|
||||
|
||||
quotaService := quotaimpl.ProvideService(db, cfg)
|
||||
orgService, err := orgimpl.ProvideService(db, cfg, quotaService)
|
||||
require.NoError(t, err)
|
||||
usrSvc, err := userimpl.ProvideService(
|
||||
db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(),
|
||||
quotaService, supportbundlestest.NewFakeBundleService(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := usrSvc.Create(context.Background(), &cmd)
|
||||
require.NoError(t, err)
|
||||
return u.ID
|
||||
}
|
||||
|
||||
@@ -160,5 +160,32 @@ export default defineConfig<PluginOptions>({
|
||||
},
|
||||
dependencies: ['authenticate'],
|
||||
},
|
||||
{
|
||||
name: 'jaeger',
|
||||
testDir: path.join(testDirRoot, '/jaeger'),
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/admin.json',
|
||||
},
|
||||
dependencies: ['authenticate'],
|
||||
},
|
||||
{
|
||||
name: 'grafana-postgresql-datasource',
|
||||
testDir: path.join(testDirRoot, '/grafana-postgresql-datasource'),
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/admin.json',
|
||||
},
|
||||
dependencies: ['authenticate'],
|
||||
},
|
||||
{
|
||||
name: 'zipkin',
|
||||
testDir: path.join(testDirRoot, '/zipkin'),
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'playwright/.auth/admin.json',
|
||||
},
|
||||
dependencies: ['authenticate'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -2,10 +2,9 @@ import { Graph } from 'app/core/utils/dag';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import {
|
||||
_createDagFromQueries,
|
||||
_getDescendants,
|
||||
_getOriginsOfRefId,
|
||||
fingerPrintQueries,
|
||||
createDagFromQueries,
|
||||
fingerprintGraph,
|
||||
parseRefsFromMathExpression,
|
||||
} from './dag';
|
||||
@@ -45,7 +44,7 @@ describe('working with dag', () => {
|
||||
},
|
||||
] as AlertQuery[];
|
||||
|
||||
const dag = _createDagFromQueries(queries);
|
||||
const dag = createDagFromQueries(queries);
|
||||
|
||||
expect(Object.keys(dag.nodes)).toHaveLength(4);
|
||||
|
||||
@@ -84,9 +83,9 @@ describe('working with dag', () => {
|
||||
},
|
||||
] as AlertQuery[];
|
||||
|
||||
expect(() => _createDagFromQueries(queries)).not.toThrow();
|
||||
expect(() => createDagFromQueries(queries)).not.toThrow();
|
||||
|
||||
const dag = _createDagFromQueries(queries);
|
||||
const dag = createDagFromQueries(queries);
|
||||
|
||||
expect(Object.keys(dag.nodes)).toHaveLength(1);
|
||||
|
||||
@@ -157,36 +156,4 @@ describe('fingerprints', () => {
|
||||
|
||||
expect(fingerprintGraph(graph)).toMatchInlineSnapshot(`"A:B: B:C:A, D C::B D:B:"`);
|
||||
});
|
||||
|
||||
test('Queries fingerprint', () => {
|
||||
const queries = [
|
||||
{
|
||||
refId: 'A',
|
||||
queryType: 'query',
|
||||
model: {
|
||||
refId: 'A',
|
||||
expression: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
refId: 'B',
|
||||
queryType: 'query',
|
||||
model: {
|
||||
refId: 'B',
|
||||
expression: 'A',
|
||||
},
|
||||
},
|
||||
{
|
||||
refId: 'C',
|
||||
queryType: 'query',
|
||||
model: {
|
||||
refId: 'C',
|
||||
expression: '$B > 0',
|
||||
type: 'math',
|
||||
},
|
||||
},
|
||||
] as AlertQuery[];
|
||||
|
||||
expect(fingerPrintQueries(queries)).toMatchInlineSnapshot(`"Aquery,BAquery,C$B > 0math"`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { compact, memoize, uniq } from 'lodash';
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
import { Edge, Graph, Node } from 'app/core/utils/dag';
|
||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||
import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
// memoized version of _createDagFromQueries to prevent recreating the DAG if no sources or targets are modified
|
||||
export const createDagFromQueries = memoizeOne(
|
||||
_createDagFromQueries,
|
||||
(previous: Parameters<typeof _createDagFromQueries>, next: Parameters<typeof _createDagFromQueries>) => {
|
||||
return fingerPrintQueries(previous[0]) === fingerPrintQueries(next[0]);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Turn the array of alert queries (this means data queries and expressions)
|
||||
* in to a DAG, a directed acyclical graph
|
||||
*/
|
||||
export function _createDagFromQueries(queries: AlertQuery[]): Graph {
|
||||
export function createDagFromQueries(queries: AlertQuery[]): Graph {
|
||||
const graph = new Graph();
|
||||
|
||||
const nodes = queries.map((query) => query.refId);
|
||||
@@ -28,12 +20,9 @@ export function _createDagFromQueries(queries: AlertQuery[]): Graph {
|
||||
return;
|
||||
}
|
||||
const source = query.refId;
|
||||
const isMathExpression = query.model.type === 'math';
|
||||
|
||||
// some expressions have multiple targets (like the math expression)
|
||||
const targets = isMathExpression
|
||||
? parseRefsFromMathExpression(query.model.expression ?? '')
|
||||
: [query.model.expression];
|
||||
const targets = getTargets(query.model);
|
||||
|
||||
targets.forEach((target) => {
|
||||
const isSelf = source === target;
|
||||
@@ -47,6 +36,19 @@ export function _createDagFromQueries(queries: AlertQuery[]): Graph {
|
||||
return graph;
|
||||
}
|
||||
|
||||
function getTargets(model: ExpressionQuery) {
|
||||
const isMathExpression = model.type === ExpressionQueryType.math;
|
||||
const isClassicCondition = model.type === ExpressionQueryType.classic;
|
||||
|
||||
if (isMathExpression) {
|
||||
return parseRefsFromMathExpression(model.expression ?? '');
|
||||
}
|
||||
if (isClassicCondition) {
|
||||
return model.conditions?.map((c) => c.query.params[0]) ?? [];
|
||||
}
|
||||
return [model.expression];
|
||||
}
|
||||
|
||||
/**
|
||||
* parse an expression like "$A > $B" or "${FOO BAR} > 0" to an array of refIds
|
||||
*/
|
||||
@@ -129,13 +131,3 @@ export function fingerprintGraph(graph: Graph) {
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// create a unique fingerprint of the array of queries
|
||||
export function fingerPrintQueries(queries: AlertQuery[]) {
|
||||
return queries
|
||||
.map((query) => {
|
||||
const type = isExpressionQuery(query.model) ? query.model.type : query.queryType;
|
||||
return query.refId + (query.model.expression ?? '') + type;
|
||||
})
|
||||
.join();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useDeleteModal } from 'app/features/alerting/unified/components/rule-vi
|
||||
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
|
||||
import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer';
|
||||
import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules';
|
||||
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { useDispatch } from 'app/types';
|
||||
import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting';
|
||||
|
||||
@@ -126,9 +125,7 @@ export const RuleActionsButtons = ({ compact, showViewButton, rule, rulesSource
|
||||
/>
|
||||
{deleteModal}
|
||||
{isGrafanaAlertingRule(rule.rulerRule) && showSilenceDrawer && (
|
||||
<AlertmanagerProvider accessType="instance">
|
||||
<SilenceGrafanaRuleDrawer rulerRule={rule.rulerRule} onClose={() => setShowSilenceDrawer(false)} />
|
||||
</AlertmanagerProvider>
|
||||
<SilenceGrafanaRuleDrawer rulerRule={rule.rulerRule} onClose={() => setShowSilenceDrawer(false)} />
|
||||
)}
|
||||
{redirectToClone?.identifier && (
|
||||
<RedirectToCloneRule
|
||||
|
||||
+31
-24
@@ -1,9 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Drawer, Stack } from '@grafana/ui';
|
||||
import { SilencesEditor } from 'app/features/alerting/unified/components/silences/SilencesEditor';
|
||||
import { getDefaultSilenceFormValues } from 'app/features/alerting/unified/components/silences/utils';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
|
||||
|
||||
type Props = {
|
||||
rulerRule: RulerGrafanaRuleDTO;
|
||||
onClose: () => void;
|
||||
@@ -12,29 +15,33 @@ type Props = {
|
||||
/**
|
||||
* For a given Grafana managed rule, renders a drawer containing silences editor and Alertmanager selection
|
||||
*/
|
||||
const SilenceGrafanaRuleDrawer = ({ rulerRule, onClose }: Props) => {
|
||||
const { uid } = rulerRule.grafana_alert;
|
||||
const SilenceGrafanaRuleDrawer = React.memo(
|
||||
({ rulerRule, onClose }: Props) => {
|
||||
const { uid } = rulerRule.grafana_alert;
|
||||
|
||||
const formValues = getDefaultSilenceFormValues();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Silence alert rule"
|
||||
subtitle="Configure silences to stop notifications from a particular alert rule."
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
>
|
||||
<Stack direction={'column'}>
|
||||
<SilencesEditor
|
||||
ruleUid={uid}
|
||||
formValues={formValues}
|
||||
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||
onSilenceCreated={onClose}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Drawer
|
||||
title="Silence alert rule"
|
||||
subtitle="Configure silences to stop notifications from a particular alert rule."
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
>
|
||||
<Stack direction={'column'}>
|
||||
<AlertmanagerProvider accessType="instance">
|
||||
<SilencesEditor
|
||||
ruleUid={uid}
|
||||
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
|
||||
onSilenceCreated={onClose}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
</AlertmanagerProvider>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.rulerRule.grafana_alert.uid === nextProps.rulerRule.grafana_alert.uid;
|
||||
}
|
||||
);
|
||||
|
||||
export default SilenceGrafanaRuleDrawer;
|
||||
|
||||
@@ -9,7 +9,6 @@ import { RedirectToCloneRule } from 'app/features/alerting/unified/components/ru
|
||||
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
|
||||
import SilenceGrafanaRuleDrawer from 'app/features/alerting/unified/components/silences/SilenceGrafanaRuleDrawer';
|
||||
import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules';
|
||||
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { useDispatch } from 'app/types';
|
||||
import { Rule, RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
import { RulerRuleDTO } from 'app/types/unified-alerting-dto';
|
||||
@@ -93,9 +92,7 @@ export function RuleActionsButtons({ compact, rule, promRule, groupIdentifier }:
|
||||
/>
|
||||
{deleteModal}
|
||||
{isGrafanaAlertingRule(rule) && showSilenceDrawer && (
|
||||
<AlertmanagerProvider accessType="instance">
|
||||
<SilenceGrafanaRuleDrawer rulerRule={rule} onClose={() => setShowSilenceDrawer(false)} />
|
||||
</AlertmanagerProvider>
|
||||
<SilenceGrafanaRuleDrawer rulerRule={rule} onClose={() => setShowSilenceDrawer(false)} />
|
||||
)}
|
||||
{redirectToClone?.identifier && (
|
||||
<RedirectToCloneRule
|
||||
|
||||
@@ -32,8 +32,8 @@ import { Trans } from 'app/core/internationalization';
|
||||
|
||||
import { BreakdownLabelSelector } from '../BreakdownLabelSelector';
|
||||
import { DataTrail } from '../DataTrail';
|
||||
import { PanelMenu } from '../Menu/PanelMenu';
|
||||
import { MetricScene } from '../MetricScene';
|
||||
import { AddToExplorationButton } from '../MetricSelect/AddToExplorationsButton';
|
||||
import { StatusWrapper } from '../StatusWrapper';
|
||||
import { getAutoQueriesForMetric } from '../autoQuery/getAutoQueriesForMetric';
|
||||
import { AutoQueryDef } from '../autoQuery/types';
|
||||
@@ -478,10 +478,9 @@ export function buildAllLayout(
|
||||
],
|
||||
})
|
||||
)
|
||||
.setHeaderActions([
|
||||
new SelectLabelAction({ labelName: String(option.value) }),
|
||||
new AddToExplorationButton({ labelName: String(option.value) }),
|
||||
])
|
||||
.setHeaderActions([new SelectLabelAction({ labelName: String(option.value) })])
|
||||
.setShowMenuAlways(true)
|
||||
.setMenu(new PanelMenu({ labelName: String(option.value) }))
|
||||
.setUnit(unit)
|
||||
.setBehaviors([fixLegendForUnspecifiedLabelValueBehavior])
|
||||
.build();
|
||||
@@ -532,10 +531,9 @@ function buildNormalLayout(
|
||||
.setTitle(getLabelValue(frame))
|
||||
.setData(new SceneDataNode({ data: { ...data, series: [frame] } }))
|
||||
.setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) })
|
||||
.setHeaderActions([
|
||||
new AddToFiltersGraphAction({ frame }),
|
||||
new AddToExplorationButton({ labelName: getLabelValue(frame) }),
|
||||
])
|
||||
.setHeaderActions([new AddToFiltersGraphAction({ frame })])
|
||||
.setShowMenuAlways(true)
|
||||
.setMenu(new PanelMenu({ labelName: getLabelValue(frame) }))
|
||||
.setUnit(unit)
|
||||
.build();
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { DataFrame, PanelMenuItem } from '@grafana/data';
|
||||
import { getPluginLinkExtensions } from '@grafana/runtime';
|
||||
import {
|
||||
SceneComponentProps,
|
||||
sceneGraph,
|
||||
SceneObject,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
VizPanel,
|
||||
VizPanelMenu,
|
||||
} from '@grafana/scenes';
|
||||
import { getExploreUrl } from 'app/core/utils/explore';
|
||||
import { getQueryRunnerFor } from 'app/features/dashboard-scene/utils/utils';
|
||||
|
||||
import { AddToExplorationButton, extensionPointId } from '../MetricSelect/AddToExplorationsButton';
|
||||
import { getDataSource, getTrailFor } from '../utils';
|
||||
|
||||
const ADD_TO_INVESTIGATION_MENU_TEXT = 'Add to investigation';
|
||||
const ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT = 'investigations_divider'; // Text won't be visible
|
||||
const ADD_TO_INVESTIGATION_MENU_GROUP_TEXT = 'Investigations';
|
||||
|
||||
interface PanelMenuState extends SceneObjectState {
|
||||
body?: VizPanelMenu;
|
||||
frame?: DataFrame;
|
||||
labelName?: string;
|
||||
fieldName?: string;
|
||||
addExplorationsLink?: boolean;
|
||||
explorationsButton?: AddToExplorationButton;
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo the VizPanelMenu interface is overly restrictive, doesn't allow any member functions on this class, so everything is currently inlined
|
||||
*/
|
||||
export class PanelMenu extends SceneObjectBase<PanelMenuState> implements VizPanelMenu, SceneObject {
|
||||
constructor(state: Partial<PanelMenuState>) {
|
||||
super({ ...state, addExplorationsLink: state.addExplorationsLink ?? true });
|
||||
this.addActivationHandler(() => {
|
||||
let exploreUrl: Promise<string | undefined> | undefined;
|
||||
try {
|
||||
const viz = sceneGraph.getAncestor(this, VizPanel);
|
||||
const queryRunner = getQueryRunnerFor(viz);
|
||||
const queries = queryRunner?.state.queries ?? [];
|
||||
queries.forEach((query) => {
|
||||
// removing legendFormat to get verbose legend in Explore
|
||||
delete query.legendFormat;
|
||||
});
|
||||
const trail = getTrailFor(this);
|
||||
const dsValue = getDataSource(trail);
|
||||
const timeRange = sceneGraph.getTimeRange(this);
|
||||
exploreUrl = getExploreUrl({
|
||||
queries,
|
||||
dsRef: { uid: dsValue },
|
||||
timeRange: timeRange.state.value,
|
||||
scopedVars: { __sceneObject: { value: viz } },
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
// Navigation options (all panels)
|
||||
const items: PanelMenuItem[] = [
|
||||
{
|
||||
text: 'Navigation',
|
||||
type: 'group',
|
||||
},
|
||||
{
|
||||
text: 'Explore',
|
||||
iconClassName: 'compass',
|
||||
onClick: () => exploreUrl?.then((url) => url && window.open(url, '_blank')),
|
||||
shortcut: 'p x',
|
||||
},
|
||||
];
|
||||
|
||||
this.setState({
|
||||
body: new VizPanelMenu({
|
||||
items,
|
||||
}),
|
||||
});
|
||||
|
||||
const addToExplorationsButton = new AddToExplorationButton({
|
||||
labelName: this.state.labelName,
|
||||
fieldName: this.state.fieldName,
|
||||
frame: this.state.frame,
|
||||
});
|
||||
this._subs.add(
|
||||
addToExplorationsButton?.subscribeToState(() => {
|
||||
subscribeToAddToExploration(this);
|
||||
})
|
||||
);
|
||||
this.setState({
|
||||
explorationsButton: addToExplorationsButton,
|
||||
});
|
||||
|
||||
if (this.state.addExplorationsLink) {
|
||||
this.state.explorationsButton?.activate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addItem(item: PanelMenuItem): void {
|
||||
if (this.state.body) {
|
||||
this.state.body.addItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
setItems(items: PanelMenuItem[]): void {
|
||||
if (this.state.body) {
|
||||
this.state.body.setItems(items);
|
||||
}
|
||||
}
|
||||
|
||||
public static Component = ({ model }: SceneComponentProps<PanelMenu>) => {
|
||||
const { body } = model.useState();
|
||||
|
||||
if (body) {
|
||||
return <body.Component model={body} />;
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
}
|
||||
|
||||
const getInvestigationLink = (addToExplorations: AddToExplorationButton) => {
|
||||
const links = getPluginLinkExtensions({
|
||||
extensionPointId: extensionPointId,
|
||||
context: addToExplorations.state.context,
|
||||
});
|
||||
|
||||
return links.extensions[0];
|
||||
};
|
||||
|
||||
const onAddToInvestigationClick = (event: React.MouseEvent, addToExplorations: AddToExplorationButton) => {
|
||||
const link = getInvestigationLink(addToExplorations);
|
||||
if (link && link.onClick) {
|
||||
link.onClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
function subscribeToAddToExploration(menu: PanelMenu) {
|
||||
const addToExplorationButton = menu.state.explorationsButton;
|
||||
if (addToExplorationButton) {
|
||||
const link = getInvestigationLink(addToExplorationButton);
|
||||
|
||||
const existingMenuItems = menu.state.body?.state.items ?? [];
|
||||
|
||||
const existingAddToExplorationLink = existingMenuItems.find((item) => item.text === ADD_TO_INVESTIGATION_MENU_TEXT);
|
||||
|
||||
if (link) {
|
||||
if (!existingAddToExplorationLink) {
|
||||
menu.state.body?.addItem({
|
||||
text: ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT,
|
||||
type: 'divider',
|
||||
});
|
||||
menu.state.body?.addItem({
|
||||
text: ADD_TO_INVESTIGATION_MENU_GROUP_TEXT,
|
||||
type: 'group',
|
||||
});
|
||||
menu.state.body?.addItem({
|
||||
text: ADD_TO_INVESTIGATION_MENU_TEXT,
|
||||
iconClassName: 'plus-square',
|
||||
onClick: (e) => onAddToInvestigationClick(e, addToExplorationButton),
|
||||
});
|
||||
} else {
|
||||
if (existingAddToExplorationLink) {
|
||||
menu.state.body?.setItems(
|
||||
existingMenuItems.filter(
|
||||
(item) =>
|
||||
[
|
||||
ADD_TO_INVESTIGATION_MENU_DIVIDER_TEXT,
|
||||
ADD_TO_INVESTIGATION_MENU_GROUP_TEXT,
|
||||
ADD_TO_INVESTIGATION_MENU_TEXT,
|
||||
].includes(item.text) === false
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
} from '../shared';
|
||||
import { getFilters, getTrailFor, isSceneTimeRangeState } from '../utils';
|
||||
|
||||
import { AddToExplorationButton } from './AddToExplorationsButton';
|
||||
import { SelectMetricAction } from './SelectMetricAction';
|
||||
import { getMetricNames } from './api';
|
||||
import { getPreviewPanelFor } from './previewPanel';
|
||||
@@ -424,7 +423,7 @@ export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> i
|
||||
}
|
||||
// refactor this into the query generator in future
|
||||
const isNative = trail.isNativeHistogram(metric.name);
|
||||
const panel = getPreviewPanelFor(metric.name, index, currentFilterCount, description, isNative);
|
||||
const panel = getPreviewPanelFor(metric.name, index, currentFilterCount, description, isNative, true);
|
||||
|
||||
metric.itemRef = panel.getRef();
|
||||
metric.isPanel = true;
|
||||
@@ -655,10 +654,7 @@ function getCardPanelFor(metric: string, description?: string) {
|
||||
return PanelBuilders.text()
|
||||
.setTitle(metric)
|
||||
.setDescription(description)
|
||||
.setHeaderActions([
|
||||
new SelectMetricAction({ metric, title: 'Select' }),
|
||||
new AddToExplorationButton({ labelName: metric }),
|
||||
])
|
||||
.setHeaderActions([new SelectMetricAction({ metric, title: 'Select' })])
|
||||
.setOption('content', '')
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { PromQuery } from '@grafana/prometheus';
|
||||
import { SceneCSSGridItem, SceneQueryRunner, SceneVariableSet } from '@grafana/scenes';
|
||||
|
||||
import { PanelMenu } from '../Menu/PanelMenu';
|
||||
import { getAutoQueriesForMetric } from '../autoQuery/getAutoQueriesForMetric';
|
||||
import { getVariablesWithMetricConstant, MDP_METRIC_PREVIEW, trailDS } from '../shared';
|
||||
import { getColorByIndex } from '../utils';
|
||||
|
||||
import { AddToExplorationButton } from './AddToExplorationsButton';
|
||||
import { NativeHistogramBadge } from './NativeHistogramBadge';
|
||||
import { SelectMetricAction } from './SelectMetricAction';
|
||||
import { hideEmptyPreviews } from './hideEmptyPreviews';
|
||||
@@ -15,24 +15,29 @@ export function getPreviewPanelFor(
|
||||
index: number,
|
||||
currentFilterCount: number,
|
||||
description?: string,
|
||||
nativeHistogram?: boolean
|
||||
nativeHistogram?: boolean,
|
||||
hideMenu?: boolean
|
||||
) {
|
||||
const autoQuery = getAutoQueriesForMetric(metric, nativeHistogram);
|
||||
let actions: Array<SelectMetricAction | AddToExplorationButton | NativeHistogramBadge> = [
|
||||
new SelectMetricAction({ metric, title: 'Select' }),
|
||||
new AddToExplorationButton({ labelName: metric }),
|
||||
];
|
||||
let actions: Array<SelectMetricAction | NativeHistogramBadge> = [new SelectMetricAction({ metric, title: 'Select' })];
|
||||
|
||||
if (nativeHistogram) {
|
||||
actions.unshift(new NativeHistogramBadge({}));
|
||||
}
|
||||
|
||||
const vizPanel = autoQuery.preview
|
||||
let vizPanelBuilder = autoQuery.preview
|
||||
.vizBuilder()
|
||||
.setColor({ mode: 'fixed', fixedColor: getColorByIndex(index) })
|
||||
.setDescription(description)
|
||||
.setHeaderActions(actions)
|
||||
.build();
|
||||
.setShowMenuAlways(true)
|
||||
.setMenu(new PanelMenu({ labelName: metric }));
|
||||
|
||||
if (!hideMenu) {
|
||||
vizPanelBuilder = vizPanelBuilder.setShowMenuAlways(true).setMenu(new PanelMenu({ labelName: metric }));
|
||||
}
|
||||
|
||||
const vizPanel = vizPanelBuilder.build();
|
||||
|
||||
const queries = autoQuery.preview.queries.map((query) =>
|
||||
convertPreviewQueriesToIgnoreUsage(query, currentFilterCount)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SceneObjectState, SceneObjectBase, SceneComponentProps, VizPanel, SceneQueryRunner } from '@grafana/scenes';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneQueryRunner, VizPanel } from '@grafana/scenes';
|
||||
|
||||
import { AddToExplorationButton } from '../../MetricSelect/AddToExplorationsButton';
|
||||
import { PanelMenu } from '../../Menu/PanelMenu';
|
||||
import { getMetricDescription } from '../../helpers/MetricDatasourceHelper';
|
||||
import { MDP_METRIC_OVERVIEW, trailDS } from '../../shared';
|
||||
import { getMetricSceneFor, getTrailFor } from '../../utils';
|
||||
@@ -57,10 +57,9 @@ export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> {
|
||||
})
|
||||
)
|
||||
.setDescription(description)
|
||||
.setHeaderActions([
|
||||
new AutoVizPanelQuerySelector({ queryDef: def, onChangeQuery: this.onChangeQuery }),
|
||||
new AddToExplorationButton({ labelName: metric ?? this.state.metric }),
|
||||
])
|
||||
.setHeaderActions([new AutoVizPanelQuerySelector({ queryDef: def, onChangeQuery: this.onChangeQuery })])
|
||||
.setShowMenuAlways(true)
|
||||
.setMenu(new PanelMenu({ labelName: metric ?? this.state.metric }))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { DashboardCursorSync, DataFrame, PanelProps } from '@grafana/data';
|
||||
import { DashboardCursorSync, PanelProps } from '@grafana/data';
|
||||
import {
|
||||
AxisPlacement,
|
||||
EventBusPlugin,
|
||||
Pagination,
|
||||
TooltipDisplayMode,
|
||||
TooltipPlugin2,
|
||||
usePanelContext,
|
||||
@@ -15,7 +12,6 @@ import {
|
||||
import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2';
|
||||
import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart';
|
||||
import {
|
||||
makeFramePerSeries,
|
||||
prepareTimelineFields,
|
||||
prepareTimelineLegendItems,
|
||||
TimelineMode,
|
||||
@@ -26,73 +22,11 @@ import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
|
||||
import { getTimezones } from '../timeseries/utils';
|
||||
|
||||
import { StateTimelineTooltip2 } from './StateTimelineTooltip2';
|
||||
import { Options, defaultOptions } from './panelcfg.gen';
|
||||
import { Options } from './panelcfg.gen';
|
||||
import { containerStyles, usePagination } from './utils';
|
||||
|
||||
interface TimelinePanelProps extends PanelProps<Options> {}
|
||||
|
||||
const styles = {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
paginationContainer: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
}),
|
||||
paginationElement: css({
|
||||
marginTop: '8px',
|
||||
}),
|
||||
};
|
||||
|
||||
function usePagination(frames?: DataFrame[], perPage?: number) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const [paginationWrapperRef, { height: paginationHeight, width: paginationWidth }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const pagedFrames = useMemo(
|
||||
() => (!perPage || frames == null ? frames : makeFramePerSeries(frames)),
|
||||
[frames, perPage]
|
||||
);
|
||||
|
||||
if (!perPage || pagedFrames == null) {
|
||||
return {
|
||||
paginatedFrames: pagedFrames,
|
||||
paginationRev: 'disabled',
|
||||
paginationElement: undefined,
|
||||
paginationHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
perPage ||= defaultOptions.perPage!;
|
||||
|
||||
const numberOfPages = Math.ceil(pagedFrames.length / perPage);
|
||||
// `perPage` changing might lead to temporarily too large values of `currentPage`.
|
||||
const currentPageCapped = Math.min(currentPage, numberOfPages);
|
||||
const pageOffset = (currentPageCapped - 1) * perPage;
|
||||
const currentPageFrames = pagedFrames.slice(pageOffset, pageOffset + perPage);
|
||||
|
||||
// `paginationRev` needs to change value whenever any of the pagination settings changes.
|
||||
// It's used in to trigger a reconfiguration of the underlying graphs (which is cached,
|
||||
// hence an explicit nudge is required).
|
||||
const paginationRev = `${currentPageCapped}/${perPage}`;
|
||||
|
||||
const showSmallVersion = paginationWidth < 550;
|
||||
const paginationElement = (
|
||||
<div className={styles.paginationContainer} ref={paginationWrapperRef}>
|
||||
<Pagination
|
||||
className={styles.paginationElement}
|
||||
currentPage={currentPageCapped}
|
||||
numberOfPages={numberOfPages}
|
||||
showSmallVersion={showSmallVersion}
|
||||
onNavigate={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return { paginatedFrames: currentPageFrames, paginationRev, paginationElement, paginationHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
@@ -141,7 +75,7 @@ export const StateTimelinePanel = ({
|
||||
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={containerStyles.container}>
|
||||
<TimelineChart
|
||||
theme={theme}
|
||||
frames={paginatedFrames}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { DataFrame } from '@grafana/data';
|
||||
import { Pagination } from '@grafana/ui';
|
||||
import { makeFramePerSeries } from 'app/core/components/TimelineChart/utils';
|
||||
|
||||
import { defaultOptions } from './panelcfg.gen';
|
||||
|
||||
export const containerStyles = {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
};
|
||||
|
||||
const styles = {
|
||||
paginationContainer: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
}),
|
||||
paginationElement: css({
|
||||
marginTop: '8px',
|
||||
}),
|
||||
};
|
||||
|
||||
export function usePagination(frames?: DataFrame[], perPage?: number) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const [paginationWrapperRef, { height: paginationHeight, width: paginationWidth }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const pagedFrames = useMemo(
|
||||
() => (!perPage || frames == null ? frames : makeFramePerSeries(frames)),
|
||||
[frames, perPage]
|
||||
);
|
||||
|
||||
if (!perPage || pagedFrames == null) {
|
||||
return {
|
||||
paginatedFrames: pagedFrames,
|
||||
paginationRev: 'disabled',
|
||||
paginationElement: undefined,
|
||||
paginationHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
perPage ||= defaultOptions.perPage!;
|
||||
|
||||
const numberOfPages = Math.ceil(pagedFrames.length / perPage);
|
||||
// `perPage` changing might lead to temporarily too large values of `currentPage`.
|
||||
const currentPageCapped = Math.min(currentPage, numberOfPages);
|
||||
const pageOffset = (currentPageCapped - 1) * perPage;
|
||||
const currentPageFrames = pagedFrames.slice(pageOffset, pageOffset + perPage);
|
||||
|
||||
// `paginationRev` needs to change value whenever any of the pagination settings changes.
|
||||
// It's used in to trigger a reconfiguration of the underlying graphs (which is cached,
|
||||
// hence an explicit nudge is required).
|
||||
const paginationRev = `${currentPageCapped}/${perPage}`;
|
||||
|
||||
const showSmallVersion = paginationWidth < 550;
|
||||
const paginationElement = (
|
||||
<div className={styles.paginationContainer} ref={paginationWrapperRef}>
|
||||
<Pagination
|
||||
className={styles.paginationElement}
|
||||
currentPage={currentPageCapped}
|
||||
numberOfPages={numberOfPages}
|
||||
showSmallVersion={showSmallVersion}
|
||||
onNavigate={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return { paginatedFrames: currentPageFrames, paginationRev, paginationElement, paginationHeight };
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'app/core/components/TimelineChart/utils';
|
||||
|
||||
import { StateTimelineTooltip2 } from '../state-timeline/StateTimelineTooltip2';
|
||||
import { containerStyles, usePagination } from '../state-timeline/utils';
|
||||
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
|
||||
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
|
||||
import { getTimezones } from '../timeseries/utils';
|
||||
@@ -53,14 +54,19 @@ export const StatusHistoryPanel = ({
|
||||
[data.series, timeRange, theme]
|
||||
);
|
||||
|
||||
const { paginatedFrames, paginationRev, paginationElement, paginationHeight } = usePagination(
|
||||
frames,
|
||||
options.perPage
|
||||
);
|
||||
|
||||
const legendItems = useMemo(
|
||||
() => prepareTimelineLegendItems(frames, options.legend, theme),
|
||||
[frames, options.legend, theme]
|
||||
() => prepareTimelineLegendItems(paginatedFrames, options.legend, theme),
|
||||
[paginatedFrames, options.legend, theme]
|
||||
);
|
||||
|
||||
const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]);
|
||||
|
||||
if (!frames || warn) {
|
||||
if (!paginatedFrames || warn) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>{warn ?? 'No data found in response'}</p>
|
||||
@@ -69,99 +75,103 @@ export const StatusHistoryPanel = ({
|
||||
}
|
||||
|
||||
// Status grid requires some space between values
|
||||
if (frames[0].length > width / 2) {
|
||||
if (paginatedFrames[0].length > width / 2) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>
|
||||
Too many points to visualize properly. <br />
|
||||
Update the query to return fewer points. <br />({frames[0].length} points received)
|
||||
Update the query to return fewer points. <br />({paginatedFrames[0].length} points received)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineChart
|
||||
theme={theme}
|
||||
frames={frames}
|
||||
structureRev={data.structureRev}
|
||||
timeRange={timeRange}
|
||||
timeZone={timezones}
|
||||
width={width}
|
||||
height={height}
|
||||
legendItems={legendItems}
|
||||
{...options}
|
||||
mode={TimelineMode.Samples}
|
||||
replaceVariables={replaceVariables}
|
||||
dataLinkPostProcessor={dataLinkPostProcessor}
|
||||
cursorSync={cursorSync}
|
||||
>
|
||||
{(builder, alignedFrame) => {
|
||||
return (
|
||||
<>
|
||||
{cursorSync !== DashboardCursorSync.Off && (
|
||||
<EventBusPlugin config={builder} eventBus={eventBus} frame={alignedFrame} />
|
||||
)}
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={builder}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
|
||||
}
|
||||
queryZoom={onChangeTimeRange}
|
||||
syncMode={cursorSync}
|
||||
syncScope={eventsScope}
|
||||
getDataLinks={(seriesIdx: number, dataIdx: number) =>
|
||||
alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? []
|
||||
}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
dismiss();
|
||||
return;
|
||||
<div className={containerStyles.container}>
|
||||
<TimelineChart
|
||||
theme={theme}
|
||||
frames={paginatedFrames}
|
||||
structureRev={data.structureRev}
|
||||
paginationRev={paginationRev}
|
||||
timeRange={timeRange}
|
||||
timeZone={timezones}
|
||||
width={width}
|
||||
height={height - paginationHeight}
|
||||
legendItems={legendItems}
|
||||
{...options}
|
||||
mode={TimelineMode.Samples}
|
||||
replaceVariables={replaceVariables}
|
||||
dataLinkPostProcessor={dataLinkPostProcessor}
|
||||
cursorSync={cursorSync}
|
||||
>
|
||||
{(builder, alignedFrame) => {
|
||||
return (
|
||||
<>
|
||||
{cursorSync !== DashboardCursorSync.Off && (
|
||||
<EventBusPlugin config={builder} eventBus={eventBus} frame={alignedFrame} />
|
||||
)}
|
||||
{options.tooltip.mode !== TooltipDisplayMode.None && (
|
||||
<TooltipPlugin2
|
||||
config={builder}
|
||||
hoverMode={
|
||||
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
|
||||
}
|
||||
queryZoom={onChangeTimeRange}
|
||||
syncMode={cursorSync}
|
||||
syncScope={eventsScope}
|
||||
getDataLinks={(seriesIdx: number, dataIdx: number) =>
|
||||
alignedFrame.fields[seriesIdx]!.getLinks?.({ valueRowIndex: dataIdx }) ?? []
|
||||
}
|
||||
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => {
|
||||
if (enableAnnotationCreation && timeRange2 != null) {
|
||||
setNewAnnotationRange(timeRange2);
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
const annotate = () => {
|
||||
let xVal = u.posToVal(u.cursor.left!, 'x');
|
||||
const annotate = () => {
|
||||
let xVal = u.posToVal(u.cursor.left!, 'x');
|
||||
|
||||
setNewAnnotationRange({ from: xVal, to: xVal });
|
||||
dismiss();
|
||||
};
|
||||
setNewAnnotationRange({ from: xVal, to: xVal });
|
||||
dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<StateTimelineTooltip2
|
||||
series={alignedFrame}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
isPinned={isPinned}
|
||||
timeRange={timeRange}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
withDuration={false}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
replaceVariables={replaceVariables}
|
||||
dataLinks={dataLinks}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
/>
|
||||
)}
|
||||
{alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && (
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={builder}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
canvasRegionRendering={false}
|
||||
/>
|
||||
)}
|
||||
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</TimelineChart>
|
||||
return (
|
||||
<StateTimelineTooltip2
|
||||
series={alignedFrame}
|
||||
dataIdxs={dataIdxs}
|
||||
seriesIdx={seriesIdx}
|
||||
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
|
||||
sortOrder={options.tooltip.sort}
|
||||
isPinned={isPinned}
|
||||
timeRange={timeRange}
|
||||
annotate={enableAnnotationCreation ? annotate : undefined}
|
||||
withDuration={false}
|
||||
maxHeight={options.tooltip.maxHeight}
|
||||
replaceVariables={replaceVariables}
|
||||
dataLinks={dataLinks}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
maxWidth={options.tooltip.maxWidth}
|
||||
/>
|
||||
)}
|
||||
{alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && (
|
||||
<AnnotationsPlugin2
|
||||
annotations={data.annotations ?? []}
|
||||
config={builder}
|
||||
timeZone={timeZone}
|
||||
newRange={newAnnotationRange}
|
||||
setNewRange={setNewAnnotationRange}
|
||||
canvasRegionRendering={false}
|
||||
/>
|
||||
)}
|
||||
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</TimelineChart>
|
||||
{paginationElement}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,6 +82,15 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StatusHistoryPanel)
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'perPage',
|
||||
name: 'Page size (enable pagination)',
|
||||
settings: {
|
||||
min: 1,
|
||||
step: 1,
|
||||
integer: true,
|
||||
},
|
||||
});
|
||||
|
||||
commonOptionsBuilder.addLegendOptions(builder, false);
|
||||
|
||||
@@ -35,6 +35,8 @@ composableKinds: PanelCfg: {
|
||||
showValue: ui.VisibilityMode & (*"auto" | _)
|
||||
//Controls the column width
|
||||
colWidth?: float & <=1 | *0.9
|
||||
//Enables pagination when > 0
|
||||
perPage?: number & >=1 | *20
|
||||
} @cuetsy(kind="interface")
|
||||
FieldConfig: {
|
||||
ui.AxisConfig
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui
|
||||
* Controls the column width
|
||||
*/
|
||||
colWidth?: number;
|
||||
/**
|
||||
* Enables pagination when > 0
|
||||
*/
|
||||
perPage?: number;
|
||||
/**
|
||||
* Set the height of the rows
|
||||
*/
|
||||
@@ -27,6 +31,7 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui
|
||||
|
||||
export const defaultOptions: Partial<Options> = {
|
||||
colWidth: 0.9,
|
||||
perPage: 20,
|
||||
rowHeight: 0.9,
|
||||
showValue: ui.VisibilityMode.Auto,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user