Compare commits

..

8 Commits

Author SHA1 Message Date
Roberto Jimenez Sanchez 148802cbb5 fix: update BootstrapStep component to remove legacy storage handling and adjust resource counting logic
- Removed legacy storage flag from useResourceStats hook in BootstrapStep.
- Updated BootstrapStepResourceCounting to simplify rendering logic and removed target prop.
- Adjusted tests to reflect changes in resource counting and rendering behavior.
2025-12-17 15:55:16 +01:00
Roberto Jimenez Sanchez 9f139da063 provisioning: fix settings/stats authorization for AccessPolicy identities
The settings and stats endpoints were returning 403 for users accessing via
ST->MT because the AccessPolicy identity was routed to the access checker,
which doesn't know about these resources.

This fix handles 'settings' and 'stats' resources before the access checker
path, routing them to the role-based authorization that allows:
- settings: Viewer role (read-only, needed by frontend)
- stats: Admin role (can leak information)
2025-12-17 15:43:33 +01:00
Roberto Jimenez Sanchez c350f36df8 Merge remote-tracking branch 'origin/main' into feat/round-tripper-extra-audience-option 2025-12-17 14:41:05 +01:00
Victor Cinaglia af85563527 ServiceAccounts: Fix token expiration display & show date on hover (#115449) 2025-12-17 10:22:27 -03:00
Victor Marin a1a665e26b Dashboards: Add values recommendations support for AdHocFilters and GroupBy variables (#114849)
* drilldown recommendations

* cleanup + tests

* refactor

* canary scenes

* update type

* canary scenes

* refactor types

* refactor

* do not pass userId

* canary scenes

* canary scenes

* bump scenes

* export recomendation type
2025-12-17 14:52:59 +02:00
Mustafa Sencer Özcan 40976bb1e4 fix: preserve the order when migrating the playlists (#115485)
fix: preserve the order
2025-12-17 13:48:19 +01:00
Roberto Jimenez Sanchez 5192e038ae fix(operators): add ExtraAudience for dashboards/folders API servers
Operators connecting to dashboards and folders API servers need to include
the provisioning group audience in addition to the target API server's
audience to pass the enforceManagerProperties check.
2025-12-16 18:37:17 +01:00
Roberto Jimenez Sanchez 22c2034a37 feat(auth): add ExtraAudience option to RoundTripper
Add ExtraAudience option to RoundTripper to allow operators to include
additional audiences (e.g., provisioning group) when connecting to the
multitenant aggregator. This ensures tokens include both the target API
server's audience and the provisioning group audience, which is required
to pass the enforceManagerProperties check.

- Add ExtraAudience RoundTripperOption
- Improve documentation and comments
- Add comprehensive test coverage
2025-12-16 18:32:45 +01:00
26 changed files with 212 additions and 385 deletions
+46 -14
View File
@@ -1,3 +1,4 @@
// Package auth provides authentication utilities for the provisioning API.
package auth
import (
@@ -6,7 +7,6 @@ import (
"net/http"
"github.com/grafana/authlib/authn"
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
utilnet "k8s.io/apimachinery/pkg/util/net"
)
@@ -15,29 +15,61 @@ type tokenExchanger interface {
Exchange(ctx context.Context, req authn.TokenExchangeRequest) (*authn.TokenExchangeResponse, error)
}
// RoundTripper injects an exchanged access token for the provisioning API into outgoing requests.
type RoundTripper struct {
client tokenExchanger
transport http.RoundTripper
audience string
// RoundTripperOption configures optional behavior for the RoundTripper.
type RoundTripperOption func(*RoundTripper)
// ExtraAudience appends an additional audience to the token exchange request.
//
// This is primarily used by operators connecting to the multitenant aggregator,
// where the token must include both the target API server's audience (e.g., dashboards,
// folders) and the provisioning group audience. The provisioning group audience is
// required so that the token passes the enforceManagerProperties check, which prevents
// unauthorized updates to provisioned resources.
//
// Example:
//
// authrt.NewRoundTripper(client, rt, "dashboards.grafana.app", authrt.ExtraAudience("provisioning.grafana.app"))
func ExtraAudience(audience string) RoundTripperOption {
return func(rt *RoundTripper) {
rt.extraAudience = audience
}
}
// NewRoundTripper constructs a RoundTripper that exchanges the provided token per request
// and forwards the request to the provided base transport.
func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string) *RoundTripper {
return &RoundTripper{
// RoundTripper is an http.RoundTripper that performs token exchange before each request.
// It exchanges the service's credentials for an access token scoped to the configured
// audience(s), then injects that token into the outgoing request's X-Access-Token header.
type RoundTripper struct {
client tokenExchanger
transport http.RoundTripper
audience string
extraAudience string
}
// NewRoundTripper creates a RoundTripper that exchanges tokens for each outgoing request.
//
// Parameters:
// - tokenExchangeClient: the client used to exchange credentials for access tokens
// - base: the underlying transport to delegate requests to after token injection
// - audience: the primary audience for the token (typically the target API server's group)
// - opts: optional configuration (e.g., ExtraAudience to include additional audiences)
func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string, opts ...RoundTripperOption) *RoundTripper {
rt := &RoundTripper{
client: tokenExchangeClient,
transport: base,
audience: audience,
}
for _, opt := range opts {
opt(rt)
}
return rt
}
// RoundTrip exchanges credentials for an access token and injects it into the request.
// The token is scoped to all configured audiences and the wildcard namespace ("*").
func (t *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// when we want to write resources with the provisioning API, the audience needs to include provisioning
// so that it passes the check in enforceManagerProperties, which prevents others from updating provisioned resources
audiences := []string{t.audience}
if t.audience != v0alpha1.GROUP {
audiences = append(audiences, v0alpha1.GROUP)
if t.extraAudience != "" && t.extraAudience != t.audience {
audiences = append(audiences, t.extraAudience)
}
tokenResponse, err := t.client.Exchange(req.Context(), authn.TokenExchangeRequest{
@@ -71,16 +71,29 @@ func TestRoundTripper_AudiencesAndNamespace(t *testing.T) {
tests := []struct {
name string
audience string
extraAudience string
wantAudiences []string
}{
{
name: "adds group when custom audience",
name: "uses only provided audience by default",
audience: "example-audience",
wantAudiences: []string{"example-audience"},
},
{
name: "uses only group audience by default",
audience: v0alpha1.GROUP,
wantAudiences: []string{v0alpha1.GROUP},
},
{
name: "extra audience adds provisioning group",
audience: "example-audience",
extraAudience: v0alpha1.GROUP,
wantAudiences: []string{"example-audience", v0alpha1.GROUP},
},
{
name: "no duplicate when group audience",
name: "extra audience no duplicate when same as primary",
audience: v0alpha1.GROUP,
extraAudience: v0alpha1.GROUP,
wantAudiences: []string{v0alpha1.GROUP},
},
}
@@ -88,11 +101,15 @@ func TestRoundTripper_AudiencesAndNamespace(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fx := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "abc123"}}
var opts []RoundTripperOption
if tt.extraAudience != "" {
opts = append(opts, ExtraAudience(tt.extraAudience))
}
tr := NewRoundTripper(fx, roundTripperFunc(func(_ *http.Request) (*http.Response, error) {
rr := httptest.NewRecorder()
rr.WriteHeader(http.StatusOK)
return rr.Result(), nil
}), tt.audience)
}), tt.audience, opts...)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil)
resp, err := tr.RoundTrip(req)
+2 -2
View File
@@ -295,8 +295,8 @@
"@grafana/plugin-ui": "^0.11.1",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "6.49.0",
"@grafana/scenes-react": "6.49.0",
"@grafana/scenes": "6.50.0",
"@grafana/scenes-react": "6.50.0",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
+2
View File
@@ -664,6 +664,7 @@ export {
type DataSourceGetTagKeysOptions,
type DataSourceGetTagValuesOptions,
type DataSourceGetDrilldownsApplicabilityOptions,
type DataSourceGetRecommendedDrilldownsOptions,
type MetadataInspectorProps,
type LegacyMetricFindQueryOptions,
type QueryEditorProps,
@@ -681,6 +682,7 @@ export {
type QueryHint,
type MetricFindValue,
type DrilldownsApplicability,
type DrilldownRecommendation,
type DataSourceJsonData,
type DataSourceSettings,
type DataSourceInstanceSettings,
+36 -17
View File
@@ -313,6 +313,13 @@ abstract class DataSourceApi<
options?: DataSourceGetDrilldownsApplicabilityOptions<TQuery>
): Promise<DrilldownsApplicability[]>;
/**
* Get recommended drilldowns for a dashboard
*/
getRecommendedDrilldowns?(
options?: DataSourceGetRecommendedDrilldownsOptions<TQuery>
): Promise<DrilldownRecommendation>;
/**
* Get tag keys for adhoc filters
*/
@@ -398,13 +405,9 @@ abstract class DataSourceApi<
}
/**
* Options argument to DataSourceAPI.getTagKeys
* Base options shared across datasource filtering operations.
*/
export interface DataSourceGetTagKeysOptions<TQuery extends DataQuery = DataQuery> {
/**
* The other existing filters or base filters. New in v10.3
*/
filters: AdHocVariableFilter[];
export interface DataSourceFilteringRequestOptions<TQuery extends DataQuery = DataQuery> {
/**
* Context time range. New in v10.3
*/
@@ -413,21 +416,27 @@ export interface DataSourceGetTagKeysOptions<TQuery extends DataQuery = DataQuer
scopes?: Scope[] | undefined;
}
/**
* Options argument to DataSourceAPI.getTagKeys
*/
export interface DataSourceGetTagKeysOptions<TQuery extends DataQuery = DataQuery>
extends DataSourceFilteringRequestOptions<TQuery> {
/**
* The other existing filters or base filters. New in v10.3
*/
filters: AdHocVariableFilter[];
}
/**
* Options argument to DataSourceAPI.getTagValues
*/
export interface DataSourceGetTagValuesOptions<TQuery extends DataQuery = DataQuery> {
export interface DataSourceGetTagValuesOptions<TQuery extends DataQuery = DataQuery>
extends DataSourceFilteringRequestOptions<TQuery> {
key: string;
/**
* The other existing filters or base filters. New in v10.3
*/
filters: AdHocVariableFilter[];
/**
* Context time range. New in v10.3
*/
timeRange?: TimeRange;
queries?: TQuery[];
scopes?: Scope[] | undefined;
}
export interface MetadataInspectorProps<
@@ -646,12 +655,22 @@ export interface MetricFindValue {
properties?: Record<string, string>;
}
export interface DataSourceGetDrilldownsApplicabilityOptions<TQuery extends DataQuery = DataQuery> {
export interface DataSourceGetDrilldownsApplicabilityOptions<TQuery extends DataQuery = DataQuery>
extends DataSourceFilteringRequestOptions<TQuery> {
filters?: AdHocVariableFilter[];
groupByKeys?: string[];
}
export interface DataSourceGetRecommendedDrilldownsOptions<TQuery extends DataQuery = DataQuery>
extends DataSourceFilteringRequestOptions<TQuery> {
dashboardUid?: string;
filters?: AdHocVariableFilter[];
groupByKeys?: string[];
}
export interface DrilldownRecommendation {
filters?: AdHocVariableFilter[];
groupByKeys?: string[];
timeRange?: TimeRange;
queries?: TQuery[];
scopes?: Scope[] | undefined;
}
export interface DrilldownsApplicability {
+4
View File
@@ -373,6 +373,10 @@ export interface FeatureToggles {
*/
unlimitedLayoutsNesting?: boolean;
/**
* Enables showing recently used drilldowns or recommendations given by the datasource in the AdHocFilters and GroupBy variables
*/
drilldownRecommendations?: boolean;
/**
* Enables viewing non-applicable drilldowns on a panel level
*/
perPanelNonApplicableDrilldowns?: boolean;
+1 -1
View File
@@ -178,7 +178,7 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll
APIPath: "/apis",
Host: url,
WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper {
return authrt.NewRoundTripper(tokenExchangeClient, rt, group)
return authrt.NewRoundTripper(tokenExchangeClient, rt, group, authrt.ExtraAudience(provisioning.GROUP))
}),
Transport: &http.Transport{
MaxConnsPerHost: 100,
@@ -501,7 +501,7 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64,
return nil, err
}
// Group playlist items by playlist ID
// Group playlist items by playlist ID while preserving order
type playlistData struct {
id int64
uid string
@@ -512,7 +512,8 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64,
updatedAt int64
}
playlists := make(map[int64]*playlistData)
playlistIndex := make(map[int64]int) // maps playlist ID to index in playlists slice
playlists := []*playlistData{}
var currentID int64
var orgID int64
var uid, name, interval string
@@ -527,7 +528,8 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64,
}
// Get or create playlist entry
pl, exists := playlists[currentID]
idx, exists := playlistIndex[currentID]
var pl *playlistData
if !exists {
pl = &playlistData{
id: currentID,
@@ -538,7 +540,10 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64,
createdAt: createdAt,
updatedAt: updatedAt,
}
playlists[currentID] = pl
playlistIndex[currentID] = len(playlists)
playlists = append(playlists, pl)
} else {
pl = playlists[idx]
}
// Add item if it exists (LEFT JOIN can return NULL for playlists without items)
@@ -554,7 +559,7 @@ func (a *dashboardSqlAccess) MigratePlaylists(ctx context.Context, orgId int64,
return nil, err
}
// Convert to K8s objects and send to stream
// Convert to K8s objects and send to stream (order is preserved)
for _, pl := range playlists {
playlist := &playlistv0.Playlist{
TypeMeta: metav1.TypeMeta{
@@ -300,6 +300,17 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
}
}
// Handle read-only resources that use role-based authorization.
// These resources are not registered in the access checker, so we handle them separately.
// This allows the frontend to access settings without requiring explicit permissions.
if a.GetResource() == "settings" || a.GetResource() == "stats" {
id, err := identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "failed to find requester", err
}
return b.authorizeResource(ctx, a, id)
}
info, ok := authlib.AuthInfoFrom(ctx)
// when running as standalone API server, the identity type may not always match TypeAccessPolicy
// so we allow it to use the access checker if there is any auth info available
+7
View File
@@ -600,6 +600,13 @@ var (
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
},
{
Name: "drilldownRecommendations",
Description: "Enables showing recently used drilldowns or recommendations given by the datasource in the AdHocFilters and GroupBy variables",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
},
{
Name: "perPanelNonApplicableDrilldowns",
Description: "Enables viewing non-applicable drilldowns on a panel level",
+1
View File
@@ -83,6 +83,7 @@ dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false
kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
panelGroupBy,experimental,@grafana/dashboards-squad,false,false,true
perPanelFiltering,experimental,@grafana/dashboards-squad,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
83 kubernetesDashboardsV2 experimental @grafana/dashboards-squad false false false
84 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
85 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
86 drilldownRecommendations experimental @grafana/dashboards-squad false false true
87 perPanelNonApplicableDrilldowns experimental @grafana/dashboards-squad false false true
88 panelGroupBy experimental @grafana/dashboards-squad false false true
89 perPanelFiltering experimental @grafana/dashboards-squad false false true
+13
View File
@@ -1181,6 +1181,19 @@
"codeowner": "@grafana/grafana-datasources-core-services"
}
},
{
"metadata": {
"name": "drilldownRecommendations",
"resourceVersion": "1764855550769",
"creationTimestamp": "2025-12-04T13:39:10Z"
},
"spec": {
"description": "Enables showing recently used drilldowns or recommendations given by the datasource in the AdHocFilters and GroupBy variables",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad",
"frontend": true
}
},
{
"metadata": {
"name": "elasticsearchCrossClusterSearch",
@@ -316,7 +316,9 @@ exports[`Query and expressions reducer should set data queries 1`] = `
"type": "and",
},
"query": {
"params": [],
"params": [
"A",
],
},
"reducer": {
"params": [],
@@ -9,8 +9,6 @@ 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,
@@ -455,73 +453,4 @@ 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();
});
});
});
@@ -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 { queriesWithRemovedReferences, queriesWithUpdatedReferences, refIdExists } from '../util';
import { 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,17 +101,7 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
});
})
.addCase(setDataQueries, (state, { payload }) => {
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);
}
const expressionQueries = state.queries.filter((query) => isExpressionQuery(query.model));
state.queries = [...payload, ...expressionQueries];
})
.addCase(setRecordingRulesQueries, (state, { payload }) => {
@@ -163,8 +153,7 @@ export const queriesAndExpressionsReducer = createReducer(initialState, (builder
});
})
.addCase(removeExpression, (state, { payload }) => {
const filteredQueries = state.queries.filter((query) => query.refId !== payload);
state.queries = queriesWithRemovedReferences(filteredQueries, payload);
state.queries = state.queries.filter((query) => query.refId !== payload);
})
.addCase(removeExpressions, (state) => {
state.queries = state.queries.filter((query) => !isExpressionQuery(query.model));
@@ -7,9 +7,7 @@ import {
containsPathSeparator,
findRenamedDataQueryReferences,
getThresholdsForQueries,
queriesWithRemovedReferences,
queriesWithUpdatedReferences,
removeMathExpressionRef,
updateMathExpressionRefs,
} from './util';
@@ -230,80 +228,6 @@ 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,70 +75,6 @@ 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 + '}';
@@ -331,6 +331,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
baseFilters: variable.spec.baseFilters ?? [],
defaultKeys: variable.spec.defaultKeys,
useQueriesAsFilterForOptions: true,
drilldownRecommendationsEnabled: config.featureToggles.drilldownRecommendations,
layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined,
supportsMultiValueOperators: Boolean(
getDataSourceSrv().getInstanceSettings({ type: ds?.type })?.meta.multiValueFilterOperators
@@ -459,6 +460,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S
skipUrlSync: variable.spec.skipUrlSync,
isMulti: variable.spec.multi,
hide: transformVariableHideToEnumV1(variable.spec.hide),
drilldownRecommendationsEnabled: config.featureToggles.drilldownRecommendations,
// @ts-expect-error
defaultOptions: variable.options,
});
@@ -162,6 +162,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
defaultKeys: variable.defaultKeys,
allowCustomValue: variable.allowCustomValue,
useQueriesAsFilterForOptions: true,
drilldownRecommendationsEnabled: config.featureToggles.drilldownRecommendations,
layout: config.featureToggles.newFiltersUI ? 'combobox' : undefined,
supportsMultiValueOperators: Boolean(
getDataSourceSrv().getInstanceSettings({ type: variable.datasource?.type })?.meta.multiValueFilterOperators
@@ -291,6 +292,7 @@ export function createSceneVariableFromVariableModel(variable: TypedVariableMode
defaultOptions: variable.options,
defaultValue: variable.defaultValue,
allowCustomValue: variable.allowCustomValue,
drilldownRecommendationsEnabled: config.featureToggles.drilldownRecommendations,
});
// Switch variable
// In the old variable model we are storing the enabled and disabled values in the options:
@@ -1,39 +1,37 @@
import * as React from 'react';
import { CoreApp } from '@grafana/data';
import { CoreApp, SelectableValue } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, Combobox, ComboboxOption, InlineField, InlineFieldRow, Input, TextLink } from '@grafana/ui';
import { Alert, InlineField, InlineFieldRow, Input, Select, TextLink } from '@grafana/ui';
import { ExpressionQuery, ExpressionQuerySettings, ReducerMode, reducerModes, reducerTypes } from '../types';
interface Props {
app?: CoreApp;
labelWidth?: number | 'auto';
refIds: Array<ComboboxOption<string>>;
refIds: Array<SelectableValue<string>>;
query: ExpressionQuery;
onChange: (query: ExpressionQuery) => void;
}
export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Props) => {
const onRefIdChange = (option: ComboboxOption<string> | null) => {
onChange({ ...query, expression: option?.value });
const reducer = reducerTypes.find((o) => o.value === query.reducer);
const onRefIdChange = (value: SelectableValue<string>) => {
onChange({ ...query, expression: value.value });
};
const onSelectReducer = (option: ComboboxOption<string> | null) => {
onChange({ ...query, reducer: option?.value });
const onSelectReducer = (value: SelectableValue<string>) => {
onChange({ ...query, reducer: value.value });
};
const onSettingsChanged = (settings: ExpressionQuerySettings) => {
onChange({ ...query, settings: settings });
};
const onModeChanged = (option: ComboboxOption<ReducerMode> | null) => {
if (!option || option.value === null || option.value === undefined) {
return;
}
const onModeChanged = (value: SelectableValue<ReducerMode>) => {
let newSettings: ExpressionQuerySettings;
switch (option.value) {
switch (value.value) {
case ReducerMode.Strict:
newSettings = { mode: ReducerMode.Strict };
break;
@@ -51,7 +49,7 @@ export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Pr
default:
newSettings = {
mode: option.value,
mode: value.value,
};
}
onSettingsChanged(newSettings);
@@ -103,17 +101,15 @@ export const Reduce = ({ labelWidth = 'auto', onChange, app, refIds, query }: Pr
{strictModeNotification()}
<InlineFieldRow>
<InlineField label={t('expressions.reduce.label-input', 'Input')} labelWidth={labelWidth}>
<Combobox onChange={onRefIdChange} options={refIds} value={query.expression} width={50} />
<Select onChange={onRefIdChange} options={refIds} value={query.expression} width={'auto'} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label={t('expressions.reduce.label-function', 'Function')} labelWidth={labelWidth}>
<Combobox options={reducerTypes} value={query.reducer} onChange={onSelectReducer} width={50} />
<Select options={reducerTypes} value={reducer} onChange={onSelectReducer} width={20} />
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField label={t('expressions.reduce.label-mode', 'Mode')} labelWidth={labelWidth}>
<Combobox onChange={onModeChanged} options={reducerModes} value={mode} width={50} />
<Select onChange={onModeChanged} options={reducerModes} value={mode} width={25} />
</InlineField>
{replaceWithNumber()}
</InlineFieldRow>
+2 -3
View File
@@ -1,5 +1,4 @@
import { DataQuery, ReducerID, SelectableValue } from '@grafana/data';
import { ComboboxOption } from '@grafana/ui';
import { config } from 'app/core/config';
import { EvalFunction } from '../alerting/state/alertDef';
@@ -76,7 +75,7 @@ export const expressionTypes: Array<SelectableValue<ExpressionQueryType>> = [
return true;
});
export const reducerTypes: Array<ComboboxOption<string>> = [
export const reducerTypes: Array<SelectableValue<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' },
@@ -92,7 +91,7 @@ export enum ReducerMode {
DropNonNumbers = 'dropNN',
}
export const reducerModes: Array<ComboboxOption<ReducerMode>> = [
export const reducerModes: Array<SelectableValue<ReducerMode>> = [
{
value: ReducerMode.Strict,
label: 'Strict',
@@ -143,7 +143,7 @@ describe('BootstrapStep', () => {
it('should render correct info for GitHub repository type', async () => {
setup();
expect(screen.getAllByText('External storage')).toHaveLength(2);
expect(screen.getAllByText('Empty')).toHaveLength(3); // Three elements should have the role "Empty" (2 external + 1 unmanaged)
expect(screen.getAllByText('Empty')).toHaveLength(4); // Four elements should have the role "Empty" (2 external + 2 unmanaged - one for each sync option card)
});
it('should render correct info for local file repository type', async () => {
@@ -198,7 +198,9 @@ describe('BootstrapStep', () => {
setup();
expect(await screen.findByText('7 resources')).toBeInTheDocument();
// The resource count string appears twice because BootstrapStepResourceCounting is rendered
// for each enabled sync option (instance and folder), and both show the resource count
expect(await screen.findAllByText('7 resources')).toHaveLength(2);
});
});
@@ -207,21 +209,7 @@ describe('BootstrapStep', () => {
setup();
const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats;
expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', undefined);
});
it('should use useResourceStats hook with legacy storage flag', async () => {
setup({
settingsData: {
legacyStorage: true,
allowImageRendering: true,
items: [],
availableRepositoryTypes: [],
},
});
const mockUseResourceStats = require('./hooks/useResourceStats').useResourceStats;
expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo', true);
expect(mockUseResourceStats).toHaveBeenCalledWith('test-repo');
});
});
@@ -233,39 +221,6 @@ describe('BootstrapStep', () => {
expect(await screen.findByText('Sync external storage to a new Grafana folder')).toBeInTheDocument();
});
it('should only display instance option when legacy storage exists', async () => {
(useModeOptions as jest.Mock).mockReturnValue({
enabledOptions: [
{
target: 'instance',
label: 'Sync all resources with external storage',
description: 'Resources will be synced with external storage',
subtitle: 'Use this option if you want to sync your entire instance',
},
],
disabledOptions: [
{
target: 'folder',
label: 'Sync external storage to a new Grafana folder',
description: 'A new Grafana folder will be created',
subtitle: 'Use this option to sync into a new folder',
},
],
});
setup({
settingsData: {
legacyStorage: true,
allowImageRendering: true,
items: [],
availableRepositoryTypes: [],
},
});
expect(await screen.findByText('Sync all resources with external storage')).toBeInTheDocument();
expect(await screen.findByText('Sync external storage to a new Grafana folder')).not.toBeChecked();
});
it('should allow selecting different sync targets', async () => {
const { user } = setup();
@@ -37,7 +37,7 @@ export const BootstrapStep = memo(function BootstrapStep({ settingsData, repoNam
const repositoryType = watch('repository.type');
const { enabledOptions, disabledOptions } = useModeOptions(repoName, settingsData);
const { target } = enabledOptions?.[0];
const { resourceCountString, fileCountString, isLoading } = useResourceStats(repoName, settingsData?.legacyStorage);
const { resourceCountString, fileCountString, isLoading } = useResourceStats(repoName);
const styles = useStyles2(getStyles);
useEffect(() => {
@@ -103,7 +103,6 @@ export const BootstrapStep = memo(function BootstrapStep({ settingsData, repoNam
<div className={styles.divider} />
<BootstrapStepResourceCounting
target={action.target}
fileCountString={fileCountString}
resourceCountString={resourceCountString}
/>
@@ -1,40 +1,23 @@
import { Trans } from '@grafana/i18n';
import { Stack, Text } from '@grafana/ui';
import { Target } from './types';
export function BootstrapStepResourceCounting({
target,
fileCountString,
resourceCountString,
}: {
target: Target;
fileCountString: string;
resourceCountString: string;
}) {
if (target === 'instance') {
return (
<Stack direction="row" gap={3}>
<Stack gap={1}>
<Trans i18nKey="provisioning.bootstrap-step.external-storage-label">External storage</Trans>
<Text color="primary">{fileCountString}</Text>
</Stack>
<Stack gap={1}>
<Trans i18nKey="provisioning.bootstrap-step.unmanaged-resources-label">Unmanaged resources</Trans>{' '}
<Text color="primary">{resourceCountString}</Text>
</Stack>
</Stack>
);
}
if (target === 'folder') {
return (
return (
<Stack direction="row" gap={3}>
<Stack gap={1}>
<Trans i18nKey="provisioning.bootstrap-step.external-storage-label">External storage</Trans>{' '}
<Trans i18nKey="provisioning.bootstrap-step.external-storage-label">External storage</Trans>
<Text color="primary">{fileCountString}</Text>
</Stack>
);
}
return null;
<Stack gap={1}>
<Trans i18nKey="provisioning.bootstrap-step.unmanaged-resources-label">Unmanaged resources</Trans>{' '}
<Text color="primary">{resourceCountString}</Text>
</Stack>
</Stack>
);
}
@@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css';
import type { JSX } from 'react';
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
import { dateTimeFormat, GrafanaTheme2, TimeZone, dateTimeFormatTimeAgo } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { DeleteButton, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
import { ApiKey } from 'app/types/apiKeys';
@@ -84,9 +84,9 @@ function formatDate(timeZone: TimeZone, expiration?: string): string {
}
function formatSecondsLeftUntilExpiration(secondsUntilExpiration: number): string {
const days = Math.ceil(secondsUntilExpiration / (3600 * 24));
const daysFormat = days > 1 ? `${days} days` : `${days} day`;
return `Expires in ${daysFormat}`;
const expirationTime = Date.now() + secondsUntilExpiration * 1000;
const daysFormat = dateTimeFormatTimeAgo(expirationTime, { timeZone: 'browser' });
return `Expires ${daysFormat}`;
}
const TokenRevoked = () => {
@@ -126,14 +126,14 @@ const TokenExpiration = ({ timeZone, token }: TokenExpirationProps) => {
}
if (token.secondsUntilExpiration) {
return (
<span className={styles.secondsUntilExpiration}>
<span className={styles.secondsUntilExpiration} title={formatDate(timeZone, token.expiration)}>
{formatSecondsLeftUntilExpiration(token.secondsUntilExpiration)}
</span>
);
}
if (token.hasExpired) {
return (
<span className={styles.hasExpired}>
<span className={styles.hasExpired} title={formatDate(timeZone, token.expiration)}>
<Trans i18nKey="serviceaccounts.token-expiration.expired-label">Expired</Trans>
<span className={styles.tooltipContainer}>
<Tooltip
+11 -11
View File
@@ -3604,11 +3604,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:6.49.0":
version: 6.49.0
resolution: "@grafana/scenes-react@npm:6.49.0"
"@grafana/scenes-react@npm:6.50.0":
version: 6.50.0
resolution: "@grafana/scenes-react@npm:6.50.0"
dependencies:
"@grafana/scenes": "npm:6.49.0"
"@grafana/scenes": "npm:6.50.0"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3620,7 +3620,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/0f9ba2ccaf2c8a703f3c4867320852c07f640aea85491e6c1a35d7fd25b48e69740f7782b52280ab4d423a6e87659de41cf66904acc865c9ea1f934ded6c7e64
checksum: 10/9ac9f8a32699f447c7b67dd2aef4e3ca5bc9fc98e94e0dc139e7824274ffa005b7fb3fc42ca5e55bdf89b91e3af0d3807b03e1a261db91c65717ee1763e5e807
languageName: node
linkType: hard
@@ -3650,9 +3650,9 @@ __metadata:
languageName: node
linkType: hard
"@grafana/scenes@npm:6.49.0":
version: 6.49.0
resolution: "@grafana/scenes@npm:6.49.0"
"@grafana/scenes@npm:6.50.0":
version: 6.50.0
resolution: "@grafana/scenes@npm:6.50.0"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3672,7 +3672,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/0e873ceac0834879ade41df56ced5a3e8f6a10e5aba15a6f271327aa804729402bcf44845e54614dd0fb1541a28efbb9a5499acceae04cb8be3b11bcdc230347
checksum: 10/7bc6280ff065bbba37f010e2a1f0a7dc998fe43721ddc0121e27a754c41e824b82a44222100282a69143a52061cf0dce39e6bc8b95292ca444a59c114d4b5a41
languageName: node
linkType: hard
@@ -19484,8 +19484,8 @@ __metadata:
"@grafana/plugin-ui": "npm:^0.11.1"
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/scenes": "npm:6.49.0"
"@grafana/scenes-react": "npm:6.49.0"
"@grafana/scenes": "npm:6.50.0"
"@grafana/scenes-react": "npm:6.50.0"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/test-utils": "workspace:*"