Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbc2877f1b | ||
|
|
dac46eb41f | ||
|
|
fe49ae05c0 | ||
|
|
d02b2a35cd |
@@ -81,7 +81,15 @@ func (s *SocialGoogle) Validate(ctx context.Context, newSettings ssoModels.SSOSe
|
||||
return validation.Validate(info, requester,
|
||||
validation.MustBeEmptyValidator(info.AuthUrl, "Auth URL"),
|
||||
validation.MustBeEmptyValidator(info.TokenUrl, "Token URL"),
|
||||
validation.MustBeEmptyValidator(info.ApiUrl, "API URL"))
|
||||
validation.MustBeEmptyValidator(info.ApiUrl, "API URL"),
|
||||
loginPromptValidator)
|
||||
}
|
||||
|
||||
func loginPromptValidator(info *social.OAuthInfo, requester identity.Requester) error {
|
||||
if info.UseRefreshToken && !slices.Contains([]string{"", "consent"}, info.LoginPrompt) {
|
||||
return ssosettings.ErrInvalidOAuthConfig("If provided, login_prompt must be set to consent when use_refresh_token is enabled.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SocialGoogle) Reload(ctx context.Context, settings ssoModels.SSOSettings) error {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/login/social"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -871,6 +873,39 @@ func TestSocialGoogle_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
|
||||
},
|
||||
{
|
||||
name: "fails if use_refresh_token is enabled and login prompt is neither empty or 'consent'",
|
||||
settings: ssoModels.SSOSettings{
|
||||
Settings: map[string]any{
|
||||
"client_id": "client-id",
|
||||
"use_refresh_token": "true",
|
||||
"login_prompt": "login",
|
||||
},
|
||||
},
|
||||
wantErr: ssosettings.ErrBaseInvalidOAuthConfig,
|
||||
},
|
||||
{
|
||||
name: "succeeds if use_refresh_token is enabled and login prompt is empty",
|
||||
settings: ssoModels.SSOSettings{
|
||||
Settings: map[string]any{
|
||||
"client_id": "client-id",
|
||||
"use_refresh_token": "true",
|
||||
"login_prompt": "",
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "succeeds if use_refresh_token is enabled and login prompt is consent",
|
||||
settings: ssoModels.SSOSettings{
|
||||
Settings: map[string]any{
|
||||
"client_id": "client-id",
|
||||
"use_refresh_token": "true",
|
||||
"login_prompt": "consent",
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -886,7 +921,13 @@ func TestSocialGoogle_Validate(t *testing.T) {
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if err != nil {
|
||||
var e errutil.Error
|
||||
require.True(t, errors.As(err, &e))
|
||||
require.NoError(t, e, "expected no error, got %v", e.PublicMessage)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1024,3 +1065,102 @@ func TestIsHDAllowed(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocialGoogle_AuthCodeURL(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
info *social.OAuthInfo
|
||||
opts []oauth2.AuthCodeOption
|
||||
state string
|
||||
wantURL *url.URL
|
||||
}{
|
||||
{
|
||||
name: "should return the correct auth code URL",
|
||||
info: &social.OAuthInfo{
|
||||
ClientId: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
AuthUrl: "https://example.com/auth",
|
||||
LoginPrompt: "login",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
},
|
||||
state: "test-state",
|
||||
opts: []oauth2.AuthCodeOption{
|
||||
oauth2.SetAuthURLParam("extra_param", "extra_value"),
|
||||
},
|
||||
wantURL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.com",
|
||||
Path: "/auth",
|
||||
RawQuery: url.Values{
|
||||
"state": {"test-state"},
|
||||
"prompt": {"login"},
|
||||
"response_type": {"code"},
|
||||
"client_id": {"client-id"},
|
||||
"redirect_uri": {"/login/google"},
|
||||
"scope": {"openid email profile"},
|
||||
"extra_param": {"extra_value"},
|
||||
}.Encode(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should add access type offline and approval force if use refresh token is enabled",
|
||||
info: &social.OAuthInfo{
|
||||
ClientId: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
AuthUrl: "https://example.com/auth",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
UseRefreshToken: true,
|
||||
},
|
||||
state: "test-state",
|
||||
wantURL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.com",
|
||||
Path: "/auth",
|
||||
RawQuery: url.Values{
|
||||
"state": {"test-state"},
|
||||
"prompt": {"consent"},
|
||||
"response_type": {"code"},
|
||||
"client_id": {"client-id"},
|
||||
"redirect_uri": {"/login/google"},
|
||||
"scope": {"openid email profile"},
|
||||
"access_type": {"offline"},
|
||||
}.Encode(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should override configured login prompt if use refresh token is enabled",
|
||||
info: &social.OAuthInfo{
|
||||
ClientId: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
AuthUrl: "https://example.com/auth",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
UseRefreshToken: true,
|
||||
},
|
||||
state: "test-state",
|
||||
wantURL: &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "example.com",
|
||||
Path: "/auth",
|
||||
RawQuery: url.Values{
|
||||
"state": {"test-state"},
|
||||
"prompt": {"consent"},
|
||||
"response_type": {"code"},
|
||||
"client_id": {"client-id"},
|
||||
"redirect_uri": {"/login/google"},
|
||||
"scope": {"openid email profile"},
|
||||
"access_type": {"offline"},
|
||||
}.Encode(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := NewGoogleProvider(tc.info, &setting.Cfg{}, nil, ssosettingstests.NewFakeService(), featuremgmt.WithFeatures())
|
||||
gotURL := s.AuthCodeURL(tc.state, tc.opts...)
|
||||
parsedURL, err := url.Parse(gotURL)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, tc.wantURL, parsedURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,11 @@ func (s *SocialBase) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) st
|
||||
func (s *SocialBase) getAuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
if s.info.LoginPrompt != "" {
|
||||
promptOpt := oauth2.SetAuthURLParam("prompt", s.info.LoginPrompt)
|
||||
opts = append(opts, promptOpt)
|
||||
|
||||
// Prepend the prompt option to the opts slice to ensure it is applied last.
|
||||
// This is necessary in case the caller provides an option that overrides the prompt,
|
||||
// such as `oauth2.ApprovalForce`.
|
||||
opts = append([]oauth2.AuthCodeOption{promptOpt}, opts...)
|
||||
}
|
||||
|
||||
return s.Config.AuthCodeURL(state, opts...)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
@@ -33,8 +34,11 @@ func (d dashboardStorageWrapper) Update(ctx context.Context, name string, objInf
|
||||
|
||||
obj, created, err := d.Storage.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
|
||||
if err == nil && ns.OrgID > 0 && d.live != nil {
|
||||
if err := d.live.DashboardSaved(ns.OrgID, name); err != nil {
|
||||
logging.FromContext(ctx).Info("live dashboard update failed", "err", err)
|
||||
m, err := utils.MetaAccessor(obj)
|
||||
if err == nil {
|
||||
if err := d.live.DashboardSaved(ns.OrgID, name, m.GetResourceVersion()); err != nil {
|
||||
logging.FromContext(ctx).Info("live dashboard update failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj, created, err
|
||||
|
||||
@@ -277,6 +277,16 @@ func (r *DualReadWriter) createOrUpdate(ctx context.Context, create bool, opts D
|
||||
// FIXME: to make sure if behaves in the same way as in sync, we should
|
||||
// we should refactor the code to use the same function.
|
||||
if r.shouldUpdateGrafanaDB(opts, parsed) {
|
||||
// HACK: Get the has from repository -- this will avoid an additional RV increment
|
||||
// we should change the signature of Create and Update to return FileInfo instead
|
||||
info, _ = r.repo.Read(ctx, opts.Path, opts.Ref)
|
||||
if info != nil {
|
||||
parsed.Meta.SetSourceProperties(utils.SourceProperties{
|
||||
Path: opts.Path,
|
||||
Checksum: info.Hash,
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil {
|
||||
return nil, fmt.Errorf("ensure folder path exists: %w", err)
|
||||
}
|
||||
|
||||
@@ -26,9 +26,10 @@ const (
|
||||
|
||||
// DashboardEvent events related to dashboards
|
||||
type dashboardEvent struct {
|
||||
UID string `json:"uid"`
|
||||
Action actionType `json:"action"` // saved, editing, deleted
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
UID string `json:"uid"`
|
||||
Action actionType `json:"action"` // saved, editing, deleted
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
ResourceVersion string `json:"rv,omitempty"`
|
||||
}
|
||||
|
||||
// DashboardHandler manages all the `grafana/dashboard/*` channels
|
||||
@@ -105,10 +106,11 @@ func (h *DashboardHandler) publish(orgID int64, event dashboardEvent) error {
|
||||
}
|
||||
|
||||
// DashboardSaved will broadcast to all connected dashboards
|
||||
func (h *DashboardHandler) DashboardSaved(orgID int64, uid string) error {
|
||||
func (h *DashboardHandler) DashboardSaved(orgID int64, uid string, rv string) error {
|
||||
return h.publish(orgID, dashboardEvent{
|
||||
UID: uid,
|
||||
Action: ActionSaved,
|
||||
UID: uid,
|
||||
Action: ActionSaved,
|
||||
ResourceVersion: rv,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -482,7 +482,7 @@ type GrafanaLive struct {
|
||||
// DashboardActivityChannel is a service to advertise dashboard activity
|
||||
type DashboardActivityChannel interface {
|
||||
// Called when a dashboard is saved
|
||||
DashboardSaved(orgID int64, uid string) error
|
||||
DashboardSaved(orgID int64, uid string, rv string) error
|
||||
|
||||
// Called when a dashboard is deleted
|
||||
DashboardDeleted(orgID int64, uid string) error
|
||||
|
||||
@@ -2,6 +2,7 @@ package apistore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
)
|
||||
@@ -195,6 +197,42 @@ func TestPrepareObjectForStorage(t *testing.T) {
|
||||
require.Equal(t, int64(2), meta2.GetGeneration())
|
||||
})
|
||||
|
||||
t.Run("Update should skip incrementing generation when content is unchanged", func(t *testing.T) {
|
||||
dashboard := dashv1.Dashboard{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Name: "test",
|
||||
Generation: 123,
|
||||
Annotations: map[string]string{
|
||||
"A": "B",
|
||||
utils.AnnoKeyUpdatedTimestamp: "2025-12-17T01:01:00Z",
|
||||
},
|
||||
UID: "XXX",
|
||||
},
|
||||
Spec: v0alpha1.Unstructured{
|
||||
Object: map[string]any{
|
||||
"hello": "world",
|
||||
},
|
||||
},
|
||||
}
|
||||
dashboard.Name = "test-name"
|
||||
obj := dashboard.DeepCopyObject()
|
||||
tmp, err := utils.MetaAccessor(obj)
|
||||
tmp.SetGeneration(2)
|
||||
tmp.SetUpdatedTimestampMillis(12345)
|
||||
require.NoError(t, err)
|
||||
|
||||
v, err := s.prepareObjectForUpdate(ctx, obj, &dashboard)
|
||||
require.NoError(t, err)
|
||||
require.False(t, v.hasChanged, "no changes")
|
||||
|
||||
out := &unstructured.Unstructured{}
|
||||
err = json.Unmarshal(v.raw.Bytes(), out)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, int64(123), tmp.GetGeneration())
|
||||
require.Equal(t, "2025-12-17T01:01:00Z", tmp.GetAnnotation(utils.AnnoKeyUpdatedTimestamp))
|
||||
})
|
||||
|
||||
s.opts.RequireDeprecatedInternalID = true
|
||||
t.Run("Should generate internal id", func(t *testing.T) {
|
||||
dashboard := dashv1.Dashboard{}
|
||||
|
||||
+1
-3
@@ -316,9 +316,7 @@ exports[`Query and expressions reducer should set data queries 1`] = `
|
||||
"type": "and",
|
||||
},
|
||||
"query": {
|
||||
"params": [
|
||||
"A",
|
||||
],
|
||||
"params": [],
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
|
||||
+71
@@ -9,6 +9,8 @@ import {
|
||||
import { defaultCondition } from 'app/features/expressions/utils/expressionTypes';
|
||||
import { AlertQuery } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { mockDataQuery, mockReduceExpression, mockThresholdExpression } from '../../../mocks';
|
||||
|
||||
import {
|
||||
QueriesAndExpressionsState,
|
||||
addNewDataQuery,
|
||||
@@ -453,4 +455,73 @@ describe('Query and expressions reducer', () => {
|
||||
);
|
||||
expect(newState).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('dangling reference handling', () => {
|
||||
it('should clear expression reference when removing a data query that is referenced by a reduce expression', () => {
|
||||
const dataQuery = mockDataQuery({ refId: 'A' });
|
||||
const reduceExpr = mockReduceExpression({ refId: 'B', expression: 'A' });
|
||||
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [dataQuery, reduceExpr],
|
||||
};
|
||||
|
||||
// Remove the data query A
|
||||
const newState = queriesAndExpressionsReducer(initialState, removeExpression('A'));
|
||||
|
||||
// The reduce expression should still exist but its reference should be cleared
|
||||
expect(newState.queries).toHaveLength(1);
|
||||
expect(newState.queries[0].refId).toBe('B');
|
||||
expect(newState.queries[0].model.expression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear expression reference when removing a data query via setDataQueries', () => {
|
||||
const dataQuery = mockDataQuery({ refId: 'A' });
|
||||
const mathExpr: AlertQuery<ExpressionQuery> = {
|
||||
refId: 'C',
|
||||
queryType: 'expression',
|
||||
datasourceUid: ExpressionDatasourceUID,
|
||||
model: {
|
||||
refId: 'C',
|
||||
type: ExpressionQueryType.math,
|
||||
expression: '$A + 10', // references data query A
|
||||
datasource: {
|
||||
type: '__expr__',
|
||||
uid: '__expr__',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [dataQuery, mathExpr],
|
||||
};
|
||||
|
||||
// Remove all data queries (simulating user deleting query A)
|
||||
const newState = queriesAndExpressionsReducer(initialState, setDataQueries([]));
|
||||
|
||||
// The math expression should still exist but reference to A should be cleared
|
||||
expect(newState.queries).toHaveLength(1);
|
||||
expect(newState.queries[0].refId).toBe('C');
|
||||
// Math expressions with dangling refs should have them removed from the expression string
|
||||
expect(newState.queries[0].model.expression).not.toContain('$A');
|
||||
});
|
||||
|
||||
it('should clear expression reference when removing an expression that is referenced by another expression', () => {
|
||||
const dataQuery = mockDataQuery({ refId: 'A' });
|
||||
const reduceExpr = mockReduceExpression({ refId: 'B', expression: 'A' });
|
||||
const thresholdExpr = mockThresholdExpression({ refId: 'C', expression: 'B' });
|
||||
|
||||
const initialState: QueriesAndExpressionsState = {
|
||||
queries: [dataQuery, reduceExpr, thresholdExpr],
|
||||
};
|
||||
|
||||
// Remove expression B which is referenced by C
|
||||
const newState = queriesAndExpressionsReducer(initialState, removeExpression('B'));
|
||||
|
||||
// Both A and C should remain, but C's reference to B should be cleared
|
||||
expect(newState.queries).toHaveLength(2);
|
||||
expect(newState.queries.map((q) => q.refId)).toEqual(['A', 'C']);
|
||||
const thresholdQuery = newState.queries.find((q) => q.refId === 'C');
|
||||
expect(thresholdQuery?.model.expression).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+14
-3
@@ -23,7 +23,7 @@ import { logError } from '../../../Analytics';
|
||||
import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource';
|
||||
import { getDefaultQueries, getInstantFromDataQuery } from '../../../utils/rule-form';
|
||||
import { createDagFromQueries, getOriginOfRefId } from '../dag';
|
||||
import { queriesWithUpdatedReferences, refIdExists } from '../util';
|
||||
import { queriesWithRemovedReferences, queriesWithUpdatedReferences, refIdExists } from '../util';
|
||||
|
||||
// this one will be used as the refID when we create a new reducer for the threshold expression
|
||||
export const NEW_REDUCER_REF = 'reducer';
|
||||
@@ -101,7 +101,17 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
|
||||
});
|
||||
})
|
||||
.addCase(setDataQueries, (state, { payload }) => {
|
||||
const expressionQueries = state.queries.filter((query) => isExpressionQuery(query.model));
|
||||
const previousDataQueries = state.queries.filter((query) => !isExpressionQuery(query.model));
|
||||
const removedRefIds = previousDataQueries
|
||||
.filter((q) => !payload.some((p) => p.refId === q.refId))
|
||||
.map((q) => q.refId);
|
||||
|
||||
let expressionQueries = state.queries.filter((query) => isExpressionQuery(query.model));
|
||||
|
||||
for (const removedRefId of removedRefIds) {
|
||||
expressionQueries = queriesWithRemovedReferences(expressionQueries, removedRefId);
|
||||
}
|
||||
|
||||
state.queries = [...payload, ...expressionQueries];
|
||||
})
|
||||
.addCase(setRecordingRulesQueries, (state, { payload }) => {
|
||||
@@ -153,7 +163,8 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
|
||||
});
|
||||
})
|
||||
.addCase(removeExpression, (state, { payload }) => {
|
||||
state.queries = state.queries.filter((query) => query.refId !== payload);
|
||||
const filteredQueries = state.queries.filter((query) => query.refId !== payload);
|
||||
state.queries = queriesWithRemovedReferences(filteredQueries, payload);
|
||||
})
|
||||
.addCase(removeExpressions, (state) => {
|
||||
state.queries = state.queries.filter((query) => !isExpressionQuery(query.model));
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
containsPathSeparator,
|
||||
findRenamedDataQueryReferences,
|
||||
getThresholdsForQueries,
|
||||
queriesWithRemovedReferences,
|
||||
queriesWithUpdatedReferences,
|
||||
removeMathExpressionRef,
|
||||
updateMathExpressionRefs,
|
||||
} from './util';
|
||||
|
||||
@@ -228,6 +230,80 @@ describe('rule-editor', () => {
|
||||
expect(updateMathExpressionRefs('$A3 + $B', 'A', 'C')).toBe('$A3 + $B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('queriesWithRemovedReferences', () => {
|
||||
it('should clear reference in reduce expression when data query is removed', () => {
|
||||
const queries: AlertQuery[] = [dataSource, reduceExpression];
|
||||
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
|
||||
|
||||
expect(updatedQueries[0]).toEqual(dataSource);
|
||||
expect(updatedQueries[1].model.expression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear reference in threshold expression when expression is removed', () => {
|
||||
const queries: AlertQuery[] = [dataSource, reduceExpression, thresholdExpression];
|
||||
const updatedQueries = queriesWithRemovedReferences(queries, 'B');
|
||||
|
||||
expect(updatedQueries[0]).toEqual(dataSource);
|
||||
expect(updatedQueries[1]).toEqual(reduceExpression);
|
||||
expect(updatedQueries[2].model.expression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove reference from math expression', () => {
|
||||
const queries: AlertQuery[] = [dataSource, mathExpression];
|
||||
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
|
||||
|
||||
const mathModel = updatedQueries[1].model as ExpressionQuery;
|
||||
expect(mathModel.expression).not.toContain('$A');
|
||||
expect(mathModel.expression).not.toContain('${A}');
|
||||
});
|
||||
|
||||
it('should remove refId from classic condition params', () => {
|
||||
const queries: AlertQuery[] = [dataSource, classicCondition];
|
||||
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
|
||||
|
||||
const classicModel = updatedQueries[1].model as ExpressionQuery;
|
||||
expect(classicModel.conditions?.[0].query.params).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not modify queries that do not reference the removed refId', () => {
|
||||
const dataSource2 = { ...dataSource, refId: 'B' };
|
||||
const reduceB = { ...reduceExpression, refId: 'C', model: { ...reduceExpression.model, expression: 'B' } };
|
||||
const queries: AlertQuery[] = [dataSource, dataSource2, reduceB];
|
||||
|
||||
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
|
||||
|
||||
expect(updatedQueries[0]).toEqual(dataSource);
|
||||
expect(updatedQueries[1]).toEqual(dataSource2);
|
||||
expect(updatedQueries[2]).toEqual(reduceB);
|
||||
});
|
||||
|
||||
it('should handle resample expressions', () => {
|
||||
const queries: AlertQuery[] = [dataSource, resampleExpression];
|
||||
const updatedQueries = queriesWithRemovedReferences(queries, 'A');
|
||||
|
||||
expect(updatedQueries[1].model.expression).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMathExpressionRef', () => {
|
||||
it('should remove $A pattern', () => {
|
||||
expect(removeMathExpressionRef('$A + 10', 'A')).toBe('+ 10');
|
||||
});
|
||||
|
||||
it('should remove ${A} pattern', () => {
|
||||
expect(removeMathExpressionRef('${A} + 10', 'A')).toBe('+ 10');
|
||||
});
|
||||
|
||||
it('should remove multiple references', () => {
|
||||
const result = removeMathExpressionRef('$A + $A * 2', 'A');
|
||||
expect(result.replace(/\s+/g, ' ').trim()).toBe('+ * 2');
|
||||
});
|
||||
|
||||
it('should not remove partial matches', () => {
|
||||
expect(removeMathExpressionRef('$ABC + 10', 'A')).toBe('$ABC + 10');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsPathSeparator', () => {
|
||||
|
||||
@@ -75,6 +75,70 @@ export function queriesWithUpdatedReferences(
|
||||
});
|
||||
}
|
||||
|
||||
export function queriesWithRemovedReferences(queries: AlertQuery[], removedRefId: string): AlertQuery[] {
|
||||
return queries.map((query) => {
|
||||
if (!isExpressionQuery(query.model)) {
|
||||
return query;
|
||||
}
|
||||
|
||||
const isMathExpression = query.model.type === 'math';
|
||||
const isReduceExpression = query.model.type === 'reduce';
|
||||
const isResampleExpression = query.model.type === 'resample';
|
||||
const isClassicExpression = query.model.type === 'classic_conditions';
|
||||
const isThresholdExpression = query.model.type === 'threshold';
|
||||
const isSqlExpression = query.model.type === 'sql';
|
||||
|
||||
if (isMathExpression) {
|
||||
const updatedExpression = removeMathExpressionRef(query.model.expression ?? '', removedRefId);
|
||||
return {
|
||||
...query,
|
||||
model: {
|
||||
...query.model,
|
||||
expression: updatedExpression || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isResampleExpression || isReduceExpression || isThresholdExpression) {
|
||||
const isReferencing = query.model.expression === removedRefId;
|
||||
return {
|
||||
...query,
|
||||
model: {
|
||||
...query.model,
|
||||
// Set to undefined to clear the dangling reference
|
||||
expression: isReferencing ? undefined : query.model.expression,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isSqlExpression) {
|
||||
// SQL expressions reference table names, not query refIds in the same way
|
||||
// For now, we'll leave SQL expressions unchanged as they work differently
|
||||
return query;
|
||||
}
|
||||
|
||||
if (isClassicExpression) {
|
||||
const conditions = query.model.conditions?.map((condition) => ({
|
||||
...condition,
|
||||
query: {
|
||||
...condition.query,
|
||||
params: condition.query.params.filter((param: string) => param !== removedRefId),
|
||||
},
|
||||
}));
|
||||
|
||||
return { ...query, model: { ...query.model, conditions } };
|
||||
}
|
||||
|
||||
return query;
|
||||
});
|
||||
}
|
||||
|
||||
export function removeMathExpressionRef(expression: string, refIdToRemove: string): string {
|
||||
// Remove both $refId and ${refId} patterns
|
||||
const refPattern = new RegExp('(\\$' + refIdToRemove + '\\b)|(\\${' + refIdToRemove + '})', 'gm');
|
||||
return expression.replace(refPattern, '').trim();
|
||||
}
|
||||
|
||||
export function updateMathExpressionRefs(expression: string, previousRefId: string, newRefId: string): string {
|
||||
const oldExpression = new RegExp('(\\$' + previousRefId + '\\b)|(\\${' + previousRefId + '})', 'gm');
|
||||
const newExpression = '${' + newRefId + '}';
|
||||
|
||||
@@ -35,17 +35,23 @@ export const FieldRenderer = ({
|
||||
const [isSecretConfigured, setIsSecretConfigured] = useState(secretConfigured);
|
||||
const isDependantField = typeof field !== 'string';
|
||||
const name = isDependantField ? field.name : field;
|
||||
const parentValue = isDependantField ? watch(field.dependsOn) : null;
|
||||
const parentValue = isDependantField && field.dependsOn ? watch(field.dependsOn) : null;
|
||||
const fieldData = fieldMap(provider)[name];
|
||||
const theme = useTheme2();
|
||||
|
||||
// Handle disabledWhen configuration
|
||||
const disabledWhen = isDependantField ? field.disabledWhen : undefined;
|
||||
const disabledWhenValue = disabledWhen ? watch(disabledWhen.field) : undefined;
|
||||
const isDisabled = disabledWhen ? disabledWhenValue === disabledWhen.is : false;
|
||||
|
||||
// Unregister a field that depends on a toggle to clear its data
|
||||
useEffect(() => {
|
||||
if (isDependantField) {
|
||||
if (isDependantField && field.dependsOn) {
|
||||
if (!parentValue) {
|
||||
unregister(name);
|
||||
}
|
||||
}
|
||||
}, [unregister, name, parentValue, isDependantField]);
|
||||
}, [unregister, name, parentValue, isDependantField, field]);
|
||||
|
||||
const isNotEmptySelectableValueArray = (
|
||||
current: string | boolean | Record<string, string> | Array<SelectableValue<string>> | undefined
|
||||
@@ -64,6 +70,13 @@ export const FieldRenderer = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Set the value when the field is disabled
|
||||
useEffect(() => {
|
||||
if (isDisabled && disabledWhen?.disabledValue) {
|
||||
setValue(name, disabledWhen.disabledValue.value);
|
||||
}
|
||||
}, [isDisabled, disabledWhen?.disabledValue, name, setValue]);
|
||||
|
||||
if (!field) {
|
||||
console.log('missing field:', name);
|
||||
return null;
|
||||
@@ -74,12 +87,12 @@ export const FieldRenderer = ({
|
||||
}
|
||||
|
||||
// Dependant field means the field depends on another field's value and shouldn't be rendered if the parent field is false
|
||||
if (isDependantField) {
|
||||
const parentValue = watch(field.dependsOn);
|
||||
if (isDependantField && field.dependsOn) {
|
||||
if (!parentValue) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const fieldProps = {
|
||||
label: fieldData.label,
|
||||
required: !!fieldData.validation?.required,
|
||||
@@ -131,10 +144,10 @@ export const FieldRenderer = ({
|
||||
rules={fieldData.validation}
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field: { ref, onChange, ...fieldProps }, fieldState: { invalid } }) => {
|
||||
render={({ field: { ref, onChange, ...controllerFieldProps }, fieldState: { invalid } }) => {
|
||||
return (
|
||||
<Select
|
||||
{...fieldProps}
|
||||
{...controllerFieldProps}
|
||||
placeholder={fieldData.placeholder}
|
||||
isMulti={fieldData.multi}
|
||||
invalid={invalid}
|
||||
@@ -143,6 +156,7 @@ export const FieldRenderer = ({
|
||||
allowCustomValue={!!fieldData.allowCustomValue}
|
||||
defaultValue={fieldData.defaultValue}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
onCreateOption={(v) => {
|
||||
const customValue = { value: v, label: v };
|
||||
onChange([...(options || []), customValue]);
|
||||
|
||||
@@ -142,7 +142,14 @@ export const getSectionFields = (): Section => {
|
||||
'allowSignUp',
|
||||
'autoLogin',
|
||||
'signoutRedirectUrl',
|
||||
'loginPrompt',
|
||||
{
|
||||
name: 'loginPrompt',
|
||||
disabledWhen: {
|
||||
field: 'useRefreshToken',
|
||||
is: true,
|
||||
disabledValue: { value: 'consent', label: t('auth-config.fields.login-prompt-consent', 'Consent') },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -729,10 +736,16 @@ export function fieldMap(provider: string): Record<string, FieldData> {
|
||||
},
|
||||
useRefreshToken: {
|
||||
label: t('auth-config.fields.use-refresh-token-label', 'Use refresh token'),
|
||||
description: t(
|
||||
'auth-config.fields.use-refresh-token-description',
|
||||
'If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.'
|
||||
),
|
||||
description:
|
||||
provider === 'google'
|
||||
? t(
|
||||
'auth-config.fields.use-refresh-token-description-google',
|
||||
'If enabled, Grafana will fetch a new access token using the refresh token provided by Google. This forces the login prompt to "Consent" to ensure Google returns a refresh token.'
|
||||
)
|
||||
: t(
|
||||
'auth-config.fields.use-refresh-token-description',
|
||||
'If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.'
|
||||
),
|
||||
type: 'checkbox',
|
||||
},
|
||||
tlsClientCa: {
|
||||
@@ -922,10 +935,16 @@ export function fieldMap(provider: string): Record<string, FieldData> {
|
||||
loginPrompt: {
|
||||
label: t('auth-config.fields.login-prompt-label', 'Login prompt'),
|
||||
type: 'select',
|
||||
description: t(
|
||||
'auth-config.fields.login-prompt-description',
|
||||
'Indicates the type of user interaction when the user logs in with the IdP.'
|
||||
),
|
||||
description:
|
||||
provider === 'google'
|
||||
? t(
|
||||
'auth-config.fields.login-prompt-description-google',
|
||||
'Indicates the type of user interaction when the user logs in with Google. This is forced to "Consent" when "Use refresh token" is enabled.'
|
||||
)
|
||||
: t(
|
||||
'auth-config.fields.login-prompt-description',
|
||||
'Indicates the type of user interaction when the user logs in with the IdP.'
|
||||
),
|
||||
multi: false,
|
||||
options: [
|
||||
{ value: '', label: '' },
|
||||
|
||||
@@ -134,9 +134,24 @@ export type FieldData = {
|
||||
content?: (setValue: UseFormSetValue<SSOProviderDTO>) => ReactElement;
|
||||
};
|
||||
|
||||
/** Configuration for conditionally disabling a field based on another field's value */
|
||||
export type DisabledWhenConfig = {
|
||||
/** The field name to watch */
|
||||
field: keyof SSOProviderDTO;
|
||||
/** The value that triggers the disabled state */
|
||||
is: boolean | string;
|
||||
/** The value to set when disabled */
|
||||
disabledValue?: SelectableValue<string>;
|
||||
};
|
||||
|
||||
export type SSOSettingsField =
|
||||
| keyof SSOProvider['settings']
|
||||
| { name: keyof SSOProvider['settings']; dependsOn: keyof SSOProvider['settings']; hidden?: boolean };
|
||||
| {
|
||||
name: keyof SSOProvider['settings'];
|
||||
dependsOn?: keyof SSOProvider['settings'];
|
||||
disabledWhen?: DisabledWhenConfig;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export interface ServerDiscoveryFormData {
|
||||
url: string;
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { CoreApp, SelectableValue } from '@grafana/data';
|
||||
import { CoreApp } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Alert, InlineField, InlineFieldRow, Input, Select, TextLink } from '@grafana/ui';
|
||||
import { Alert, Combobox, ComboboxOption, InlineField, InlineFieldRow, Input, TextLink } from '@grafana/ui';
|
||||
|
||||
import { ExpressionQuery, ExpressionQuerySettings, ReducerMode, reducerModes, reducerTypes } from '../types';
|
||||
|
||||
interface Props {
|
||||
app?: CoreApp;
|
||||
labelWidth?: number | 'auto';
|
||||
refIds: Array<SelectableValue<string>>;
|
||||
refIds: Array<ComboboxOption<string>>;
|
||||
query: ExpressionQuery;
|
||||
onChange: (query: ExpressionQuery) => void;
|
||||
}
|
||||
|
||||
export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Props) => {
|
||||
const reducer = reducerTypes.find((o) => o.value === query.reducer);
|
||||
|
||||
const onRefIdChange = (value: SelectableValue<string>) => {
|
||||
onChange({ ...query, expression: value.value });
|
||||
const onRefIdChange = (option: ComboboxOption<string> | null) => {
|
||||
onChange({ ...query, expression: option?.value });
|
||||
};
|
||||
|
||||
const onSelectReducer = (value: SelectableValue<string>) => {
|
||||
onChange({ ...query, reducer: value.value });
|
||||
const onSelectReducer = (option: ComboboxOption<string> | null) => {
|
||||
onChange({ ...query, reducer: option?.value });
|
||||
};
|
||||
|
||||
const onSettingsChanged = (settings: ExpressionQuerySettings) => {
|
||||
onChange({ ...query, settings: settings });
|
||||
};
|
||||
|
||||
const onModeChanged = (value: SelectableValue<ReducerMode>) => {
|
||||
const onModeChanged = (option: ComboboxOption<ReducerMode> | null) => {
|
||||
if (!option || option.value === null || option.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newSettings: ExpressionQuerySettings;
|
||||
switch (value.value) {
|
||||
switch (option.value) {
|
||||
case ReducerMode.Strict:
|
||||
newSettings = { mode: ReducerMode.Strict };
|
||||
break;
|
||||
@@ -49,7 +51,7 @@ export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Pr
|
||||
|
||||
default:
|
||||
newSettings = {
|
||||
mode: value.value,
|
||||
mode: option.value,
|
||||
};
|
||||
}
|
||||
onSettingsChanged(newSettings);
|
||||
@@ -101,15 +103,17 @@ export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Pr
|
||||
{strictModeNotification()}
|
||||
<InlineFieldRow>
|
||||
<InlineField label={t('expressions.reduce.label-input', 'Input')} labelWidth={labelWidth}>
|
||||
<Select onChange={onRefIdChange} options={refIds} value={query.expression} width={'auto'} />
|
||||
<Combobox onChange={onRefIdChange} options={refIds} value={query.expression} width={50} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField label={t('expressions.reduce.label-function', 'Function')} labelWidth={labelWidth}>
|
||||
<Select options={reducerTypes} value={reducer} onChange={onSelectReducer} width={20} />
|
||||
<Combobox options={reducerTypes} value={query.reducer} onChange={onSelectReducer} width={50} />
|
||||
</InlineField>
|
||||
</InlineFieldRow>
|
||||
<InlineFieldRow>
|
||||
<InlineField label={t('expressions.reduce.label-mode', 'Mode')} labelWidth={labelWidth}>
|
||||
<Select onChange={onModeChanged} options={reducerModes} value={mode} width={25} />
|
||||
<Combobox onChange={onModeChanged} options={reducerModes} value={mode} width={50} />
|
||||
</InlineField>
|
||||
{replaceWithNumber()}
|
||||
</InlineFieldRow>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DataQuery, ReducerID, SelectableValue } from '@grafana/data';
|
||||
import { ComboboxOption } from '@grafana/ui';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
import { EvalFunction } from '../alerting/state/alertDef';
|
||||
@@ -75,7 +76,7 @@ export const expressionTypes: Array<SelectableValue<ExpressionQueryType>> = [
|
||||
return true;
|
||||
});
|
||||
|
||||
export const reducerTypes: Array<SelectableValue<string>> = [
|
||||
export const reducerTypes: Array<ComboboxOption<string>> = [
|
||||
{ value: ReducerID.min, label: 'Min', description: 'Get the minimum value' },
|
||||
{ value: ReducerID.max, label: 'Max', description: 'Get the maximum value' },
|
||||
{ value: ReducerID.mean, label: 'Mean', description: 'Get the average value' },
|
||||
@@ -91,7 +92,7 @@ export enum ReducerMode {
|
||||
DropNonNumbers = 'dropNN',
|
||||
}
|
||||
|
||||
export const reducerModes: Array<SelectableValue<ReducerMode>> = [
|
||||
export const reducerModes: Array<ComboboxOption<ReducerMode>> = [
|
||||
{
|
||||
value: ReducerMode.Strict,
|
||||
label: 'Strict',
|
||||
|
||||
@@ -25,9 +25,11 @@ import { DashboardEvent, DashboardEventAction } from './types';
|
||||
const sessionId = uuidv4();
|
||||
|
||||
class DashboardWatcher {
|
||||
private static readonly IGNORE_SAVE_WINDOW_MS = 5000;
|
||||
|
||||
channel?: LiveChannelAddress; // path to the channel
|
||||
uid?: string;
|
||||
ignoreSave?: boolean;
|
||||
ignoreSave = 0; // save any events until this time passes
|
||||
editing = false;
|
||||
lastEditing?: DashboardEvent;
|
||||
subscription?: Unsubscribable;
|
||||
@@ -84,8 +86,9 @@ class DashboardWatcher {
|
||||
this.uid = undefined;
|
||||
}
|
||||
|
||||
// ignore the next 5 seconds of save events
|
||||
ignoreNextSave() {
|
||||
this.ignoreSave = true;
|
||||
this.ignoreSave = Date.now() + DashboardWatcher.IGNORE_SAVE_WINDOW_MS;
|
||||
}
|
||||
|
||||
getRecentEditingEvent() {
|
||||
@@ -115,8 +118,11 @@ class DashboardWatcher {
|
||||
case DashboardEventAction.EditingStarted:
|
||||
case DashboardEventAction.Saved: {
|
||||
if (this.ignoreSave) {
|
||||
this.ignoreSave = false;
|
||||
return;
|
||||
if (this.ignoreSave < Date.now()) {
|
||||
this.ignoreSave = 0; // process the event
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dash = getDashboardSrv().getCurrent();
|
||||
|
||||
@@ -11,4 +11,5 @@ export interface DashboardEvent {
|
||||
message?: string;
|
||||
sessionId?: string;
|
||||
timestamp?: number;
|
||||
rv?: string;
|
||||
}
|
||||
|
||||
+4
@@ -12,6 +12,7 @@ import kbn from 'app/core/utils/kbn';
|
||||
import { Resource } from 'app/features/apiserver/types';
|
||||
import { SaveDashboardFormCommonOptions } from 'app/features/dashboard-scene/saving/SaveDashboardForm';
|
||||
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
|
||||
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
|
||||
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
|
||||
@@ -204,6 +205,9 @@ export function SaveProvisionedDashboardForm({
|
||||
repositoryType: repository?.type ?? 'unknown',
|
||||
});
|
||||
|
||||
// ignore incoming save events
|
||||
dashboardWatcher.ignoreNextSave();
|
||||
|
||||
createOrUpdateFile({
|
||||
// Skip adding ref to the default branch request
|
||||
ref: ref === repository?.branch ? undefined : ref,
|
||||
|
||||
@@ -3329,6 +3329,7 @@
|
||||
"login-attribute-path-label": "Login attribute path",
|
||||
"login-prompt-consent": "Consent",
|
||||
"login-prompt-description": "Indicates the type of user interaction when the user logs in with the IdP.",
|
||||
"login-prompt-description-google": "Indicates the type of user interaction when the user logs in with Google. This is forced to \"Consent\" when \"Use refresh token\" is enabled.",
|
||||
"login-prompt-label": "Login prompt",
|
||||
"login-prompt-login": "Login",
|
||||
"login-prompt-select-account": "Select account",
|
||||
@@ -3386,6 +3387,7 @@
|
||||
"use-pkce-description": "If enabled, Grafana will use <2>Proof Key for Code Exchange (PKCE)</2> with the OAuth2 Authorization Code Grant.",
|
||||
"use-pkce-label": "Use PKCE",
|
||||
"use-refresh-token-description": "If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.",
|
||||
"use-refresh-token-description-google": "If enabled, Grafana will fetch a new access token using the refresh token provided by Google. This forces the login prompt to \"Consent\" to ensure Google returns a refresh token.",
|
||||
"use-refresh-token-label": "Use refresh token",
|
||||
"validate-hosted-domain-description": "If enabled, Grafana will match the Hosted Domain retrieved from the Google ID Token against the \"{{ allowedDomainsLabel }}\" list specified by the user.",
|
||||
"validate-hosted-domain-label": "Validate hosted domain",
|
||||
|
||||
Reference in New Issue
Block a user