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

This commit is contained in:
Ryan McKinley
2025-01-25 07:29:56 +03:00
63 changed files with 2930 additions and 2814 deletions
@@ -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 |
+8
View File
@@ -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();
});
+8
View File
@@ -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();
});
+8
View File
@@ -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;
@@ -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"
-6
View File
@@ -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",
-1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
134 newFolderPicker experimental @grafana/grafana-frontend-platform false false true
135 jitterAlertRulesWithinGroups preview @grafana/alerting-squad false true false
136 onPremToCloudMigrations preview @grafana/grafana-operator-experience-squad false false false
onPremToCloudMigrationsAuthApiMig experimental @grafana/grafana-operator-experience-squad false false false
137 alertingSaveStatePeriodic privatePreview @grafana/alerting-squad false false false
138 scopeApi experimental @grafana/grafana-app-platform-squad false false false
139 promQLScope GA @grafana/oss-big-tent false false false
-4
View File
@@ -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"
+2 -1
View File
@@ -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})
-289
View File
@@ -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
}
+2 -2
View File
@@ -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)
}
+2 -2
View File
@@ -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
+18 -1
View File
@@ -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)
+13
View File
@@ -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)
+2 -2
View File
@@ -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)
}
+2 -2
View File
@@ -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": {},
}
+10 -6
View File
@@ -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 {
+115 -28
View File
@@ -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
+10
View File
@@ -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,
+1 -1
View File
@@ -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
+2
View File
@@ -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
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+34
View File
@@ -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
}
+27
View File
@@ -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
@@ -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,
};