Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 148802cbb5 | |||
| 9f139da063 | |||
| c350f36df8 | |||
| af85563527 | |||
| a1a665e26b | |||
| 40976bb1e4 | |||
| 5192e038ae | |||
| 22c2034a37 |
@@ -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
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
+13
@@ -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",
|
||||
|
||||
+3
-1
@@ -316,7 +316,9 @@ exports[`Query and expressions reducer should set data queries 1`] = `
|
||||
"type": "and",
|
||||
},
|
||||
"query": {
|
||||
"params": [],
|
||||
"params": [
|
||||
"A",
|
||||
],
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
|
||||
-71
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+3
-14
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
Reference in New Issue
Block a user