Compare commits

..

6 Commits

Author SHA1 Message Date
yesoreyeram
2f0764d1a0 addded unit tests 2026-01-14 12:42:13 +00:00
yesoreyeram
05ad955c7b added other fields 2026-01-14 11:37:11 +00:00
yesoreyeram
2b82490e88 Revert "convert dataframe response to metricFindValues with properties"
This reverts commit c5bff2df50.
2026-01-13 16:17:02 +00:00
yesoreyeram
c5bff2df50 convert dataframe response to metricFindValues with properties 2026-01-12 11:21:56 +00:00
yesoreyeram
c621dbc325 added field mapping selector for variables 2026-01-08 13:09:02 +00:00
yesoreyeram
ecd3f0b490 added SQLVariableSupport to @grafana/sql package 2026-01-08 07:33:19 +00:00
81 changed files with 861 additions and 2538 deletions

View File

@@ -135,12 +135,9 @@ You can use the **Span Limit** field in **Options** section of the TraceQL query
This field sets the maximum number of spans to return for each span set.
By default, the maximum value that you can set for the **Span Limit** value (or the spss query) is 100.
In Tempo configuration, this value is controlled by the `max_spans_per_span_set` parameter and can be modified by your Tempo administrator.
Grafana Cloud users can contact Grafana Support to request a change.
Entering a value higher than the default results in an error.
{{< admonition type="note" >}}
Changing the value of `max_spans_per_span_set` isn't supported in Grafana Cloud.
{{< /admonition >}}
### Focus on traces or spans
Under **Options**, you can choose to display the table as **Traces** or **Spans** focused.

4
go.mod
View File

@@ -33,14 +33,12 @@ require (
github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad
github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // @grafana/grafana-operator-experience-squad
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // @grafana/grafana-operator-experience-squad
github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources
github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group
github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend
@@ -345,6 +343,7 @@ require (
github.com/at-wat/mqtt-go v0.19.6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
@@ -359,6 +358,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect

View File

@@ -165,17 +165,9 @@ describe('DateMath', () => {
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf());
});
it.each([
['-2d-6h', [2014, 1, 5], [2014, 1, 2, 18]],
['-30m-2d', [2014, 1, 5], [2014, 1, 2, 23, 30]],
['-2d-1d', [2014, 1, 5], [2014, 1, 2]],
['-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 5, 10, 30]],
['-1d-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 4, 10, 30]],
['+1d-6h', [2014, 1, 5], [2014, 1, 5, 18]],
['-1w-1d', [2014, 1, 14], [2014, 1, 6]],
])('should handle multiple math expressions: %s', (expression, inputDate, expectedDate) => {
const date = dateMath.parseDateMath(expression, dateTime(inputDate));
expect(date!.valueOf()).toEqual(dateTime(expectedDate).valueOf());
it('should handle multiple math expressions', () => {
const date = dateMath.parseDateMath('-2d-6h', dateTime([2014, 1, 5]));
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 2, 18]).valueOf());
});
it('should return false when invalid expression', () => {

View File

@@ -547,11 +547,6 @@ export interface FeatureToggles {
*/
alertingCentralAlertHistory?: boolean;
/**
* Enable new grouped navigation structure for Alerting
* @default false
*/
alertingNavigationV2?: boolean;
/**
* Preserve plugin proxy trailing slash.
* @default false
*/

View File

@@ -0,0 +1,199 @@
import { Field, FieldType } from '@grafana/data';
import { EditorMode } from '@grafana/plugin-ui';
import { migrateVariableQuery, convertOriginalFieldsToVariableFields } from './SQLVariableSupport';
import { QueryFormat, SQLQuery, SQLQueryMeta } from './types';
const refId = 'SQLVariableQueryEditor-VariableQuery';
const sampleQuery = 'SELECT * FROM users';
describe('migrateVariableQuery', () => {
it('should handle string query', () => {
const result = migrateVariableQuery(sampleQuery);
expect(result).toMatchObject({
refId,
rawSql: sampleQuery,
query: sampleQuery,
editorMode: EditorMode.Code,
format: QueryFormat.Table,
});
});
it('should handle empty string query', () => {
const result = migrateVariableQuery('');
expect(result).toMatchObject({
refId,
rawSql: '',
query: '',
editorMode: EditorMode.Builder,
format: QueryFormat.Table,
});
});
it('should handle SQLQuery object with rawSql', () => {
const rawQuery: SQLQuery = {
refId: 'A',
rawSql: sampleQuery,
format: QueryFormat.Timeseries,
editorMode: EditorMode.Code,
};
const result = migrateVariableQuery(rawQuery);
expect(result).toStrictEqual({ ...rawQuery, query: sampleQuery });
});
it('should preserve all other properties from SQLQuery', () => {
const rawQuery: SQLQuery = {
refId: 'C',
rawSql: sampleQuery,
alias: 'test_alias',
dataset: 'test_dataset',
table: 'test_table',
meta: { textField: 'name', valueField: 'id' },
};
const result = migrateVariableQuery(rawQuery);
expect(result).toStrictEqual({ ...rawQuery, query: sampleQuery });
});
});
const field = (name: string, type: FieldType = FieldType.string, values: unknown[] = [1, 2, 3]): Field => ({
name,
type,
values,
config: {},
});
describe('convertOriginalFieldsToVariableFields', () => {
it('should throw error when no fields provided', () => {
expect(() => convertOriginalFieldsToVariableFields([])).toThrow('at least one field expected for variable');
});
it('should handle fields with __text and __value names', () => {
const fields = [field('__text'), field('__value'), field('other_field')];
expect(convertOriginalFieldsToVariableFields(fields).map((r) => r.name)).toStrictEqual([
'text',
'value',
'__text',
'__value',
'other_field',
]);
});
it('should handle fields with only __text', () => {
const fields = [field('__text'), field('other_field')];
expect(convertOriginalFieldsToVariableFields(fields).map((r) => r.name)).toStrictEqual([
'text',
'value',
'__text',
'other_field',
]);
});
it('should handle fields with only __value', () => {
const fields = [field('__value'), field('other_field')];
expect(convertOriginalFieldsToVariableFields(fields).map((r) => r.name)).toStrictEqual([
'text',
'value',
'__value',
'other_field',
]);
});
it('should use first field when no __text or __value present', () => {
const fields = [field('id'), field('name'), field('category')];
expect(convertOriginalFieldsToVariableFields(fields).map((r) => r.name)).toStrictEqual([
'text',
'value',
'id',
'name',
'category',
]);
});
it('should respect meta.textField and meta.valueField', () => {
const fields = [field('id', FieldType.number, [3, 4]), field('display_name'), field('category')];
const meta: SQLQueryMeta = {
textField: 'display_name',
valueField: 'id',
};
const result = convertOriginalFieldsToVariableFields(fields, meta);
expect(convertOriginalFieldsToVariableFields(fields).map((r) => r.name)).toStrictEqual([
'text',
'value',
'id',
'display_name',
'category',
]);
expect(result[0]).toStrictEqual({ ...fields[1], name: 'text' }); // display_name -> text
expect(result[1]).toStrictEqual({ ...fields[0], name: 'value' }); // id -> value
});
it('should handle meta with non-existent field names', () => {
const fields = [field('id'), field('name')];
const meta: SQLQueryMeta = {
textField: 'non_existent_field',
valueField: 'also_non_existent',
};
const result = convertOriginalFieldsToVariableFields(fields, meta);
expect(result.map((r) => r.name)).toStrictEqual(['text', 'value', 'id', 'name']);
expect(result[0]).toStrictEqual({ ...fields[0], name: 'text' });
expect(result[1]).toStrictEqual({ ...fields[0], name: 'value' });
});
it('should handle partial meta (only textField)', () => {
const fields = [field('id'), field('label'), field('description')];
const meta: SQLQueryMeta = {
textField: 'label',
};
const result = convertOriginalFieldsToVariableFields(fields, meta);
expect(result.map((r) => r.name)).toStrictEqual(['text', 'value', 'id', 'label', 'description']);
expect(result[0]).toStrictEqual({ ...fields[1], name: 'text' }); // label -> text
expect(result[1]).toStrictEqual({ ...fields[0], name: 'value' }); // fallback to text field
});
it('should handle partial meta (only valueField)', () => {
const fields = [field('name'), field('id', FieldType.number), field('type')];
const meta: SQLQueryMeta = {
valueField: 'id',
};
const result = convertOriginalFieldsToVariableFields(fields, meta);
expect(result.map((r) => r.name)).toStrictEqual(['text', 'value', 'name', 'id', 'type']);
expect(result[0]).toStrictEqual({ ...fields[0], name: 'text', type: FieldType.number }); // fallback to value field
expect(result[1]).toStrictEqual({ ...fields[1], name: 'value' }); // id -> value
});
it('should not include duplicate "value" or "text" fields in otherFields', () => {
const fields = [field('value'), field('text'), field('other')];
expect(convertOriginalFieldsToVariableFields(fields).map((r) => r.name)).toStrictEqual(['text', 'value', 'other']);
});
it('should preserve field types and configurations', () => {
const fields = [
{
name: 'id',
type: FieldType.number,
config: { unit: 'short', displayName: 'ID' },
values: [1, 2, 3],
},
{
name: 'name',
type: FieldType.string,
config: { displayName: 'Name' },
values: ['A', 'B', 'C'],
},
];
const meta: SQLQueryMeta = {
textField: 'name',
valueField: 'id',
};
const result = convertOriginalFieldsToVariableFields(fields, meta);
expect(result[0]).toStrictEqual({
name: 'text',
type: FieldType.string,
config: { displayName: 'Name' },
values: ['A', 'B', 'C'],
});
expect(result[1]).toStrictEqual({
name: 'value',
type: FieldType.number,
config: { unit: 'short', displayName: 'ID' },
values: [1, 2, 3],
});
});
});

View File

@@ -0,0 +1,155 @@
import { useEffect, useState } from 'react';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import {
CustomVariableSupport,
DataQueryRequest,
DataQueryResponse,
QueryEditorProps,
Field,
DataFrame,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { EditorMode, EditorRows, EditorRow, EditorField } from '@grafana/plugin-ui';
import { Combobox, ComboboxOption } from '@grafana/ui';
import { SqlQueryEditorLazy } from './components/QueryEditorLazy';
import { SqlDatasource } from './datasource/SqlDatasource';
import { applyQueryDefaults } from './defaults';
import { QueryFormat, type SQLQuery, type SQLOptions, type SQLQueryMeta } from './types';
type SQLVariableQuery = { query: string } & SQLQuery;
const refId = 'SQLVariableQueryEditor-VariableQuery';
export class SQLVariableSupport extends CustomVariableSupport<SqlDatasource, SQLQuery> {
constructor(readonly datasource: SqlDatasource) {
super();
}
editor = SQLVariablesQueryEditor;
query(request: DataQueryRequest<SQLQuery>): Observable<DataQueryResponse> {
if (request.targets.length < 1) {
throw new Error('no variable query found');
}
const updatedQuery = migrateVariableQuery(request.targets[0]);
return this.datasource.query({ ...request, targets: [updatedQuery] }).pipe(
map((d: DataQueryResponse) => {
return {
...d,
data: (d.data || []).map((frame: DataFrame) => ({
...frame,
fields: convertOriginalFieldsToVariableFields(frame.fields, updatedQuery.meta),
})),
};
})
);
}
getDefaultQuery(): Partial<SQLQuery> {
return applyQueryDefaults({ refId, editorMode: EditorMode.Builder, format: QueryFormat.Table });
}
}
type SQLVariableQueryEditorProps = QueryEditorProps<SqlDatasource, SQLQuery, SQLOptions>;
const SQLVariablesQueryEditor = (props: SQLVariableQueryEditorProps) => {
const query = migrateVariableQuery(props.query);
return (
<>
<SqlQueryEditorLazy {...props} query={query} />
<FieldMapping {...props} query={query} />
</>
);
};
const FieldMapping = (props: SQLVariableQueryEditorProps) => {
const { query, datasource, onChange } = props;
const [choices, setChoices] = useState<ComboboxOption[]>([]);
useEffect(() => {
let isActive = true;
// eslint-disable-next-line
const subscription = datasource.query({ targets: [query] } as DataQueryRequest<SQLQuery>).subscribe({
next: (response) => {
if (!isActive) {
return;
}
const fieldNames = (response.data[0] || { fields: [] }).fields.map((f: Field) => f.name);
setChoices(fieldNames.map((f: Field) => ({ value: f, label: f })));
},
error: () => {
if (isActive) {
setChoices([]);
}
},
});
return () => {
isActive = false;
subscription.unsubscribe();
};
}, [datasource, query]);
const onMetaPropChange = <Key extends keyof SQLQueryMeta, Value extends SQLQueryMeta[Key]>(
key: Key,
value: Value,
meta = query.meta || {}
) => {
onChange({ ...query, meta: { ...meta, [key]: value } });
};
return (
<EditorRows>
<EditorRow>
<EditorField label={t('grafana-sql.components.query-meta.variables.valueField', 'Value Field')}>
<Combobox
isClearable
value={query.meta?.valueField}
onChange={(e) => onMetaPropChange('valueField', e?.value)}
width={40}
options={choices}
/>
</EditorField>
<EditorField label={t('grafana-sql.components.query-meta.variables.textField', 'Text Field')}>
<Combobox
isClearable
value={query.meta?.textField}
onChange={(e) => onMetaPropChange('textField', e?.value)}
width={40}
options={choices}
/>
</EditorField>
</EditorRow>
</EditorRows>
);
};
export const migrateVariableQuery = (rawQuery: string | SQLQuery): SQLVariableQuery => {
if (typeof rawQuery !== 'string') {
return {
...rawQuery,
refId: rawQuery.refId || refId,
query: rawQuery.rawSql || '',
};
}
return {
...applyQueryDefaults({
refId,
rawSql: rawQuery,
editorMode: rawQuery ? EditorMode.Code : EditorMode.Builder,
}),
query: rawQuery,
};
};
export const convertOriginalFieldsToVariableFields = (original_fields: Field[], meta?: SQLQueryMeta): Field[] => {
if (original_fields.length < 1) {
throw new Error('at least one field expected for variable');
}
let tf = original_fields.find((f) => f.name === '__text');
let vf = original_fields.find((f) => f.name === '__value');
if (meta) {
tf = meta.textField ? original_fields.find((f) => f.name === meta.textField) : undefined;
vf = meta.valueField ? original_fields.find((f) => f.name === meta.valueField) : undefined;
}
const textField = tf || vf || original_fields[0];
const valueField = vf || tf || original_fields[0];
const otherFields = original_fields.filter((f: Field) => f.name !== 'value' && f.name !== 'text');
return [{ ...textField, name: 'text' }, { ...valueField, name: 'value' }, ...otherFields];
};

View File

@@ -21,6 +21,7 @@ export { TLSSecretsConfig } from './components/configuration/TLSSecretsConfig';
export { useMigrateDatabaseFields } from './components/configuration/useMigrateDatabaseFields';
export { SqlQueryEditorLazy } from './components/QueryEditorLazy';
export type { QueryHeaderProps } from './components/QueryHeader';
export { SQLVariableSupport } from './SQLVariableSupport';
export { createSelectClause, haveColumns } from './utils/sql.utils';
export { applyQueryDefaults } from './defaults';
export { makeVariable } from './utils/testHelpers';

View File

@@ -69,6 +69,12 @@
"placeholder-select-format": "Select format",
"run-query": "Run query"
},
"query-meta": {
"variables": {
"textField": "Text Field",
"valueField": "Value Field"
}
},
"query-toolbox": {
"content-hit-ctrlcmdreturn-to-run-query": "Hit CTRL/CMD+Return to run query",
"tooltip-collapse": "Collapse editor",

View File

@@ -50,6 +50,8 @@ export enum QueryFormat {
Table = 'table',
}
export type SQLQueryMeta = { valueField?: string; textField?: string };
export interface SQLQuery extends DataQuery {
alias?: string;
format?: QueryFormat;
@@ -59,6 +61,7 @@ export interface SQLQuery extends DataQuery {
sql?: SQLExpression;
editorMode?: EditorMode;
rawQuery?: boolean;
meta?: SQLQueryMeta;
}
export interface NameValue {

View File

@@ -204,7 +204,7 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response.
func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("GetDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds())
}()
ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID())
@@ -240,7 +240,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re
func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("DeleteDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds())
}()
uid := web.Params(c.Req)[":uid"]
@@ -375,7 +375,7 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error {
func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("AddDataSource"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds())
}()
cmd := datasources.AddDataSourceCommand{}
@@ -497,7 +497,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("UpdateDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds())
}()
cmd := datasources.UpdateDataSourceCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {

View File

@@ -91,7 +91,7 @@ func setupDsConfigHandlerMetrics() (prometheus.Registerer, *prometheus.Histogram
Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"handler"})
}, []string{"code_path", "handler"})
promRegister.MustRegister(dsConfigHandlerRequestsDuration)
return promRegister, dsConfigHandlerRequestsDuration
}

View File

@@ -387,7 +387,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"handler"}),
}, []string{"code_path", "handler"}),
}
promRegister.MustRegister(hs.htmlHandlerRequestsDuration)

View File

@@ -928,10 +928,9 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *contextmodel.ReqContext, cfg
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
features := featuremgmt.WithFeatures()
quotaService := quotatest.New(false, nil)
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
require.NoError(t, err)
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
require.NoError(t, err)
@@ -1051,11 +1050,9 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secrets
var routes []*plugins.Route
features := featuremgmt.WithFeatures()
quotaService := quotatest.New(false, nil)
var sqlStore db.DB = nil
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
require.NoError(t, err)
proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
require.NoError(t, err)
@@ -1109,11 +1106,9 @@ func setupDSProxyTest(t *testing.T, ctx *contextmodel.ReqContext, ds *datasource
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger())
features := featuremgmt.WithFeatures()
var sqlStore db.DB = nil
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
&actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
require.NoError(t, err)
tracer := tracing.InitializeTracerForTest()

View File

@@ -11,9 +11,6 @@ import (
_ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault"
_ "github.com/Azure/go-autorest/autorest"
_ "github.com/Azure/go-autorest/autorest/adal"
_ "github.com/aws/aws-sdk-go-v2/credentials"
_ "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
_ "github.com/aws/aws-sdk-go-v2/service/sts"
_ "github.com/beevik/etree"
_ "github.com/blugelabs/bluge"
_ "github.com/blugelabs/bluge_segment_api"
@@ -49,6 +46,7 @@ import (
_ "sigs.k8s.io/randfill"
_ "xorm.io/builder"
_ "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
_ "github.com/grafana/authlib/authn"
_ "github.com/grafana/authlib/authz"
_ "github.com/grafana/authlib/cache"

View File

@@ -209,7 +209,7 @@ func (ots *TracingService) initSampler() (tracesdk.Sampler, error) {
case "rateLimiting":
return newRateLimiter(ots.cfg.SamplerParam), nil
case "remote":
return jaegerremote.New(ots.cfg.ServiceName,
return jaegerremote.New("grafana",
jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL),
jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)),
), nil

View File

@@ -57,12 +57,6 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec
}
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.List"), time.Since(start).Seconds())
}()
}
return s.datasources.ListDataSources(ctx)
}
@@ -70,7 +64,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Get"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
}()
}
@@ -82,7 +76,7 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Create"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
}
@@ -98,7 +92,7 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Update"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
}
@@ -141,7 +135,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Delete"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
}()
}
@@ -151,13 +145,6 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
// DeleteCollection implements rest.CollectionDeleter.
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.DeleteCollection"), time.Since(start).Seconds())
}()
}
dss, err := s.datasources.ListDataSources(ctx)
if err != nil {
return nil, err

View File

@@ -21,7 +21,6 @@ import (
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/manager/sources"
@@ -70,10 +69,10 @@ func RegisterAPIService(
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "grafana",
Name: "ds_config_handler_apis_requests_duration_seconds",
Help: "Duration of requests handled by new k8s style APIs datasource configuration handlers",
}, []string{"handler"})
regErr := metrics.ProvideRegisterer().Register(dataSourceCRUDMetric)
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"code_path", "handler"})
regErr := reg.Register(dataSourceCRUDMetric)
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
return nil, regErr
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
@@ -37,13 +36,9 @@ func ProvideAppInstaller(
pluginStore pluginstore.Store,
pluginAssetsService *pluginassets.Service,
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
features featuremgmt.FeatureToggles,
) (*AppInstaller, error) {
//nolint:staticcheck // not yet migrated to OpenFeature
if features.IsEnabledGlobally(featuremgmt.FlagPluginStoreServiceLoading) {
if err := registerAccessControlRoles(accessControlService); err != nil {
return nil, fmt.Errorf("registering access control roles: %w", err)
}
if err := registerAccessControlRoles(accessControlService); err != nil {
return nil, fmt.Errorf("registering access control roles: %w", err)
}
localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService)

View File

@@ -330,7 +330,6 @@ var wireBasicSet = wire.NewSet(
dashsnapstore.ProvideStore,
wire.Bind(new(dashboardsnapshots.Service), new(*dashsnapsvc.ServiceImpl)),
dashsnapsvc.ProvideService,
datasourceservice.ProvideDataSourceRetriever,
datasourceservice.ProvideService,
wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)),
datasourceservice.ProvideLegacyDataSourceLookup,

12
pkg/server/wire_gen.go generated

File diff suppressed because one or more lines are too long

View File

@@ -3,6 +3,7 @@ package authorizer
import (
"context"
"github.com/grafana/grafana/pkg/setting"
"k8s.io/apimachinery/pkg/runtime/schema"
k8suser "k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
@@ -28,9 +29,9 @@ type GrafanaAuthorizer struct {
// 4. We check authorizer that is configured speficially for an api.
// 5. As a last fallback we check Role, this will only happen if an api have not configured
// an authorizer or return authorizer.DecisionNoOpinion
func NewGrafanaBuiltInSTAuthorizer() *GrafanaAuthorizer {
func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer {
authorizers := []authorizer.Authorizer{
NewImpersonationAuthorizer(),
newImpersonationAuthorizer(),
authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup),
newNamespaceAuthorizer(),
}

View File

@@ -8,7 +8,7 @@ import (
var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil)
func NewImpersonationAuthorizer() *impersonationAuthorizer {
func newImpersonationAuthorizer() *impersonationAuthorizer {
return &impersonationAuthorizer{}
}

View File

@@ -76,7 +76,19 @@ var PathRewriters = []filters.PathRewriter{
func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc {
return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler {
handler := filters.WithTracingHTTPLoggingAttributes(delegateHandler)
requestHandler, err := GetCustomRoutesHandler(
delegateHandler,
c.LoopbackClientConfig,
builders,
reg,
c.MergedResourceConfig,
)
if err != nil {
panic(fmt.Sprintf("could not build the request handler for specified API builders: %s", err.Error()))
}
// Needs to run last in request chain to function as expected, hence we register it first.
handler := filters.WithTracingHTTPLoggingAttributes(requestHandler)
// filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context
handler = filters.WithRequester(handler)

View File

@@ -3,306 +3,146 @@ package builder
import (
"fmt"
"net/http"
"strings"
"github.com/emicklei/go-restful/v3"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus"
serverstorage "k8s.io/apiserver/pkg/server/storage"
restclient "k8s.io/client-go/rest"
klog "k8s.io/klog/v2"
"k8s.io/kube-openapi/pkg/spec3"
)
// convertHandlerToRouteFunction converts an http.HandlerFunc to a restful.RouteFunction
// It extracts path parameters from restful.Request and populates them in the request context
// so that mux.Vars can read them (for backward compatibility with handlers that use mux.Vars)
func convertHandlerToRouteFunction(handler http.HandlerFunc) restful.RouteFunction {
return func(req *restful.Request, resp *restful.Response) {
// Extract path parameters from restful.Request and populate mux.Vars
// This is needed for backward compatibility with handlers that use mux.Vars(r)
vars := make(map[string]string)
// Get all path parameters from the restful.Request
// The restful.Request has PathParameters() method that returns a map
pathParams := req.PathParameters()
for key, value := range pathParams {
vars[key] = value
}
// Set the vars in the request context using mux.SetURLVars
// This makes mux.Vars(r) work correctly
if len(vars) > 0 {
req.Request = mux.SetURLVars(req.Request, vars)
}
handler(resp.ResponseWriter, req.Request)
}
type requestHandler struct {
router *mux.Router
}
// AugmentWebServicesWithCustomRoutes adds custom routes from builders to existing WebServices
// in the container.
func AugmentWebServicesWithCustomRoutes(
container *restful.Container,
builders []APIGroupBuilder,
metricsRegistry prometheus.Registerer,
apiResourceConfig *serverstorage.ResourceConfig,
) error {
if container == nil {
return fmt.Errorf("container cannot be nil")
}
func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer, apiResourceConfig *serverstorage.ResourceConfig) (http.Handler, error) {
useful := false // only true if any routes exist anywhere
router := mux.NewRouter()
metrics := NewCustomRouteMetrics(metricsRegistry)
// Build a map of existing WebServices by root path
existingWebServices := make(map[string]*restful.WebService)
for _, ws := range container.RegisteredWebServices() {
existingWebServices[ws.RootPath()] = ws
}
for _, b := range builders {
provider, ok := b.(APIGroupRouteProvider)
for _, builder := range builders {
provider, ok := builder.(APIGroupRouteProvider)
if !ok || provider == nil {
continue
}
for _, gv := range GetGroupVersions(b) {
// Filter out disabled API groups
for _, gv := range GetGroupVersions(builder) {
// filter out api groups that are disabled in APIEnablementOptions
gvr := gv.WithResource("")
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) {
klog.InfoS("Skipping custom routes for disabled group version", "gv", gv.String())
klog.InfoS("Skipping custom route handler for disabled group version", "gv", gv.String())
continue
}
routes := provider.GetAPIRoutes(gv)
if routes == nil {
continue
}
// Find or create WebService for this group version
rootPath := "/apis/" + gv.String()
ws, exists := existingWebServices[rootPath]
if !exists {
// Create a new WebService if one doesn't exist
ws = new(restful.WebService)
ws.Path(rootPath)
container.Add(ws)
existingWebServices[rootPath] = ws
}
prefix := "/apis/" + gv.String()
// Add root handlers using OpenAPI specs
// Root handlers
var sub *mux.Router
for _, route := range routes.Root {
if sub == nil {
sub = router.PathPrefix(prefix).Subrouter()
sub.MethodNotAllowedHandler = &methodNotAllowedHandler{}
}
useful = true
methods, err := methodsFromSpec(route.Path, route.Spec)
if err != nil {
return nil, err
}
instrumentedHandler := metrics.InstrumentHandler(
gv.Group,
gv.Version,
route.Path,
route.Path, // Use path as resource identifier
route.Handler,
)
routeFunction := convertHandlerToRouteFunction(instrumentedHandler)
// Use OpenAPI spec to configure routes properly
if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, false); err != nil {
return fmt.Errorf("failed to add root route %s: %w", route.Path, err)
}
sub.HandleFunc("/"+route.Path, instrumentedHandler).
Methods(methods...)
}
// Add namespace handlers using OpenAPI specs
// Namespace handlers
sub = nil
prefix += "/namespaces/{namespace}"
for _, route := range routes.Namespace {
if sub == nil {
sub = router.PathPrefix(prefix).Subrouter()
sub.MethodNotAllowedHandler = &methodNotAllowedHandler{}
}
useful = true
methods, err := methodsFromSpec(route.Path, route.Spec)
if err != nil {
return nil, err
}
instrumentedHandler := metrics.InstrumentHandler(
gv.Group,
gv.Version,
route.Path,
route.Path, // Use path as resource identifier
route.Handler,
)
routeFunction := convertHandlerToRouteFunction(instrumentedHandler)
// Use OpenAPI spec to configure routes properly
if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, true); err != nil {
return fmt.Errorf("failed to add namespace route %s: %w", route.Path, err)
}
sub.HandleFunc("/"+route.Path, instrumentedHandler).
Methods(methods...)
}
}
}
return nil
if !useful {
return delegateHandler, nil
}
// Per Gorilla Mux issue here: https://github.com/gorilla/mux/issues/616#issuecomment-798807509
// default handler must come last
router.PathPrefix("/").Handler(delegateHandler)
return &requestHandler{
router: router,
}, nil
}
// addRouteFromSpec adds routes to a WebService using OpenAPI specs
func addRouteFromSpec(ws *restful.WebService, routePath string, pathProps *spec3.PathProps, handler restful.RouteFunction, isNamespaced bool) error {
if pathProps == nil {
return fmt.Errorf("pathProps cannot be nil for route %s", routePath)
}
// Build the full path (relative to WebService root)
var fullPath string
if isNamespaced {
fullPath = "/namespaces/{namespace}/" + routePath
} else {
fullPath = "/" + routePath
}
// Add routes for each HTTP method defined in the OpenAPI spec
operations := map[string]*spec3.Operation{
"GET": pathProps.Get,
"POST": pathProps.Post,
"PUT": pathProps.Put,
"PATCH": pathProps.Patch,
"DELETE": pathProps.Delete,
}
for method, operation := range operations {
if operation == nil {
continue
}
// Create route builder for this method
var routeBuilder *restful.RouteBuilder
switch method {
case "GET":
routeBuilder = ws.GET(fullPath)
case "POST":
routeBuilder = ws.POST(fullPath)
case "PUT":
routeBuilder = ws.PUT(fullPath)
case "PATCH":
routeBuilder = ws.PATCH(fullPath)
case "DELETE":
routeBuilder = ws.DELETE(fullPath)
}
// Set operation ID from OpenAPI spec (with K8s verb prefix if needed)
operationID := operation.OperationId
if operationID == "" {
// Generate from path if not specified
operationID = generateOperationNameFromPath(routePath)
}
operationID = prefixRouteIDWithK8sVerbIfNotPresent(operationID, method)
routeBuilder = routeBuilder.Operation(operationID)
// Add description from OpenAPI spec
if operation.Description != "" {
routeBuilder = routeBuilder.Doc(operation.Description)
}
// Check if namespace parameter is already in the OpenAPI spec
hasNamespaceParam := false
if operation.Parameters != nil {
for _, param := range operation.Parameters {
if param.Name == "namespace" && param.In == "path" {
hasNamespaceParam = true
break
}
}
}
// Add namespace parameter for namespaced routes if not already in spec
if isNamespaced && !hasNamespaceParam {
routeBuilder = routeBuilder.Param(restful.PathParameter("namespace", "object name and auth scope, such as for teams and projects"))
}
// Add parameters from OpenAPI spec
if operation.Parameters != nil {
for _, param := range operation.Parameters {
switch param.In {
case "path":
routeBuilder = routeBuilder.Param(restful.PathParameter(param.Name, param.Description))
case "query":
routeBuilder = routeBuilder.Param(restful.QueryParameter(param.Name, param.Description))
case "header":
routeBuilder = routeBuilder.Param(restful.HeaderParameter(param.Name, param.Description))
}
}
}
// Note: Request/response schemas are already defined in the OpenAPI spec from builders
// and will be added to the OpenAPI document via addBuilderRoutes in openapi.go.
// We don't duplicate that information here since restful uses the route metadata
// for OpenAPI generation, which is handled separately in this codebase.
// Register the route with handler
ws.Route(routeBuilder.To(handler))
}
return nil
func (h *requestHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.router.ServeHTTP(w, req)
}
func prefixRouteIDWithK8sVerbIfNotPresent(operationID string, method string) string {
for _, verb := range allowedK8sVerbs {
if len(operationID) > len(verb) && operationID[:len(verb)] == verb {
return operationID
}
}
return fmt.Sprintf("%s%s", httpMethodToK8sVerb[strings.ToUpper(method)], operationID)
}
var allowedK8sVerbs = []string{
"get", "log", "read", "replace", "patch", "delete", "deletecollection", "watch", "connect", "proxy", "list", "create", "patch",
}
var httpMethodToK8sVerb = map[string]string{
http.MethodGet: "get",
http.MethodPost: "create",
http.MethodPut: "replace",
http.MethodPatch: "patch",
http.MethodDelete: "delete",
http.MethodConnect: "connect",
http.MethodOptions: "connect", // No real equivalent to options and head
http.MethodHead: "connect",
}
// generateOperationNameFromPath creates an operation name from a route path.
// The operation name is used by the OpenAPI generator and should be descriptive.
// It uses meaningful path segments to create readable yet unique operation names.
// Examples:
// - "/search" -> "Search"
// - "/snapshots/create" -> "SnapshotsCreate"
// - "ofrep/v1/evaluate/flags" -> "OfrepEvaluateFlags"
// - "ofrep/v1/evaluate/flags/{flagKey}" -> "OfrepEvaluateFlagsFlagKey"
func generateOperationNameFromPath(routePath string) string {
// Remove leading slash and split by path segments
parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
// Filter to keep meaningful segments and path parameters
var nameParts []string
skipPrefixes := map[string]bool{
"namespaces": true,
"apis": true,
func methodsFromSpec(slug string, props *spec3.PathProps) ([]string, error) {
if props == nil {
return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, nil
}
for _, part := range parts {
if part == "" {
continue
}
// Extract parameter name from {paramName} format
if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
paramName := part[1 : len(part)-1]
// Skip generic parameters like {namespace}, but keep specific ones like {flagKey}
if paramName != "namespace" && paramName != "name" {
nameParts = append(nameParts, strings.ToUpper(paramName[:1])+paramName[1:])
}
continue
}
// Skip common prefixes
if skipPrefixes[strings.ToLower(part)] {
continue
}
// Skip version segments like v1, v0alpha1, v2beta1, etc.
if strings.HasPrefix(strings.ToLower(part), "v") &&
(len(part) <= 3 || strings.Contains(strings.ToLower(part), "alpha") || strings.Contains(strings.ToLower(part), "beta")) {
continue
}
// Capitalize first letter and add to parts
if len(part) > 0 {
nameParts = append(nameParts, strings.ToUpper(part[:1])+part[1:])
}
methods := make([]string, 0)
if props.Get != nil {
methods = append(methods, "GET")
}
if props.Post != nil {
methods = append(methods, "POST")
}
if props.Put != nil {
methods = append(methods, "PUT")
}
if props.Patch != nil {
methods = append(methods, "PATCH")
}
if props.Delete != nil {
methods = append(methods, "DELETE")
}
if len(nameParts) == 0 {
return "Route"
if len(methods) == 0 {
return nil, fmt.Errorf("invalid OpenAPI Spec for slug=%s without any methods in PathProps", slug)
}
return strings.Join(nameParts, "")
return methods, nil
}
type methodNotAllowedHandler struct{}
func (h *methodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(405) // method not allowed
}

View File

@@ -5,6 +5,7 @@ import (
"net"
"path/filepath"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/services/apiserver/options"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -40,6 +41,15 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
runtimeConfig := apiserverCfg.Key("runtime_config").String()
runtimeConfigSplit := strings.Split(runtimeConfig, ",")
// TODO: temporary fix to allow disabling local features service and still being able to use its authz handler
if !cfg.OpenFeature.APIEnabled {
runtimeConfigSplit = append(runtimeConfigSplit, "features.grafana.app/v0alpha1=false")
}
runtimeConfig = strings.Join(runtimeConfigSplit, ",")
if runtimeConfig != "" {
if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil {
return fmt.Errorf("failed to set runtime config: %w", err)

View File

@@ -155,7 +155,7 @@ func ProvideService(
features: features,
rr: rr,
builders: []builder.APIGroupBuilder{},
authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(),
authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(cfg),
tracing: tracing,
db: db, // For Unified storage
metrics: reg,
@@ -443,19 +443,6 @@ func (s *service) start(ctx context.Context) error {
return err
}
// Augment existing WebServices with custom routes from builders
// This directly adds routes to existing WebServices using the OpenAPI specs from builders
if server.Handler != nil && server.Handler.GoRestfulContainer != nil {
if err := builder.AugmentWebServicesWithCustomRoutes(
server.Handler.GoRestfulContainer,
builders,
s.metrics,
serverConfig.MergedResourceConfig,
); err != nil {
return fmt.Errorf("failed to augment web services with custom routes: %w", err)
}
}
// stash the options for later use
s.options = o

View File

@@ -51,7 +51,6 @@ type Service struct {
pluginStore pluginstore.Store
pluginClient plugins.Client
basePluginContextProvider plugincontext.BasePluginContextProvider
retriever DataSourceRetriever
ptc proxyTransportCache
}
@@ -71,7 +70,6 @@ func ProvideService(
features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService,
quotaService quota.Service, pluginStore pluginstore.Store, pluginClient plugins.Client,
basePluginContextProvider plugincontext.BasePluginContextProvider,
retriever DataSourceRetriever,
) (*Service, error) {
dslogger := log.New("datasources")
store := &SqlStore{db: db, logger: dslogger, features: features}
@@ -91,7 +89,6 @@ func ProvideService(
pluginStore: pluginStore,
pluginClient: pluginClient,
basePluginContextProvider: basePluginContextProvider,
retriever: retriever,
}
ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store))
@@ -178,11 +175,11 @@ func NewIDScopeResolver(db DataSourceRetriever) (string, accesscontrol.ScopeAttr
}
func (s *Service) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
return s.retriever.GetDataSource(ctx, query)
return s.SQLStore.GetDataSource(ctx, query)
}
func (s *Service) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) {
return s.retriever.GetDataSourceInNamespace(ctx, namespace, name, group)
return s.SQLStore.GetDataSourceInNamespace(ctx, namespace, name, group)
}
func (s *Service) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) {

View File

@@ -832,9 +832,8 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) {
quotaService := quotatest.New(false, nil)
permissionSvc := acmock.NewMockedPermissionsService()
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
cmd := &datasources.DeleteDataSourceCommand{
@@ -858,9 +857,7 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) {
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
cfg := &setting.Cfg{}
enableRBACManagedPermissions(t, cfg)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
// First add the datasource
@@ -1127,9 +1124,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider)
@@ -1166,9 +1161,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
ds := datasources.DataSource{
@@ -1219,9 +1212,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
ds := datasources.DataSource{
@@ -1269,9 +1260,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
ds := datasources.DataSource{
@@ -1327,9 +1316,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
ds := datasources.DataSource{
@@ -1364,9 +1351,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
ds := datasources.DataSource{
@@ -1435,9 +1420,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
ds := datasources.DataSource{
@@ -1516,9 +1499,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
ds := datasources.DataSource{
@@ -1541,9 +1522,7 @@ func TestIntegrationService_getProxySettings(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
t.Run("Should default to disabled", func(t *testing.T) {
@@ -1641,9 +1620,7 @@ func TestIntegrationService_getTimeout(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
for _, tc := range testCases {
@@ -1668,9 +1645,7 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
jsonData := map[string]string{
@@ -1698,9 +1673,7 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
jsonData := map[string]string{
@@ -1726,9 +1699,7 @@ func TestIntegrationDataSource_CustomHeaders(t *testing.T) {
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
quotaService := quotatest.New(false, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err)
dsService.cfg = setting.NewCfg()
@@ -1817,9 +1788,7 @@ func initDSService(t *testing.T) *Service {
quotaService := quotatest.New(false, nil)
mockPermission := acmock.NewMockedPermissionsService()
mockPermission.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{
PluginList: []pluginstore.Plugin{{
JSONData: plugins.JSONData{
ID: "test",
@@ -1839,7 +1808,7 @@ func initDSService(t *testing.T) *Service {
ObjectBytes: req.ObjectBytes,
}, nil
},
}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
require.NoError(t, err)
return dsService

View File

@@ -1,34 +0,0 @@
package service
import (
"context"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
// DataSourceRetrieverImpl implements DataSourceRetriever by delegating to a Store.
type DataSourceRetrieverImpl struct {
store Store
}
var _ DataSourceRetriever = (*DataSourceRetrieverImpl)(nil)
// ProvideDataSourceRetriever creates a DataSourceRetriever for wire injection.
func ProvideDataSourceRetriever(db db.DB, features featuremgmt.FeatureToggles) DataSourceRetriever {
dslogger := log.New("datasources-retriever")
store := &SqlStore{db: db, logger: dslogger, features: features}
return &DataSourceRetrieverImpl{store: store}
}
// GetDataSource gets a datasource.
func (r *DataSourceRetrieverImpl) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
return r.store.GetDataSource(ctx, query)
}
// GetDataSourceInNamespace gets a datasource by namespace, name (datasource uid), and group (datasource type).
func (r *DataSourceRetrieverImpl) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) {
return r.store.GetDataSourceInNamespace(ctx, namespace, name, group)
}

View File

@@ -907,14 +907,6 @@ var (
Owner: grafanaAlertingSquad,
FrontendOnly: false, // changes navtree from backend
},
{
Name: "alertingNavigationV2",
Description: "Enable new grouped navigation structure for Alerting",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
FrontendOnly: false, // changes navtree from backend
Expression: "false", // Off by default
},
{
Name: "pluginProxyPreserveTrailingSlash",
Description: "Preserve plugin proxy trailing slash.",

View File

@@ -125,7 +125,6 @@ alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,false
alertingNavigationV2,experimental,@grafana/alerting-squad,false,false,false
pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false
azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
125 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
126 preserveDashboardStateWhenNavigating experimental @grafana/dashboards-squad false false false
127 alertingCentralAlertHistory experimental @grafana/alerting-squad false false false
alertingNavigationV2 experimental @grafana/alerting-squad false false false
128 pluginProxyPreserveTrailingSlash GA @grafana/plugins-platform-backend false false false
129 azureMonitorPrometheusExemplars GA @grafana/partner-datasources false false false
130 authZGRPCServer experimental @grafana/identity-access-team false false false

View File

@@ -379,10 +379,6 @@ const (
// Enables the new central alert history.
FlagAlertingCentralAlertHistory = "alertingCentralAlertHistory"
// FlagAlertingNavigationV2
// Enable new grouped navigation structure for Alerting
FlagAlertingNavigationV2 = "alertingNavigationV2"
// FlagPluginProxyPreserveTrailingSlash
// Preserve plugin proxy trailing slash.
FlagPluginProxyPreserveTrailingSlash = "pluginProxyPreserveTrailingSlash"

View File

@@ -348,19 +348,6 @@
"expression": "true"
}
},
{
"metadata": {
"name": "alertingNavigationV2",
"resourceVersion": "1767827323622",
"creationTimestamp": "2026-01-07T23:08:43Z"
},
"spec": {
"description": "Enable new grouped navigation structure for Alerting",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"expression": "false"
}
},
{
"metadata": {
"name": "alertingNotificationHistory",

View File

@@ -433,214 +433,6 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
}
func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.NavLink {
//nolint:staticcheck // not yet migrated to OpenFeature
if !s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingNavigationV2) {
return s.buildAlertNavLinksLegacy(c)
}
// V2 Navigation - New grouped structure
hasAccess := ac.HasAccess(s.accessControl, c)
var alertChildNavs []*navtree.NavLink
// 1. Alert activity (parent with tabs: Alerts, Active notifications)
//nolint:staticcheck // not yet migrated to OpenFeature
var alertActivityChildren []*navtree.NavLink
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
// Alerts tab
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
alertActivityChildren = append(alertActivityChildren, &navtree.NavLink{
Text: "Alerts", SubTitle: "Visualize active and pending alerts", Id: "alert-activity-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell",
})
}
// Active notifications tab
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceRead), ac.EvalPermission(ac.ActionAlertingInstancesExternalRead))) {
alertActivityChildren = append(alertActivityChildren, &navtree.NavLink{
Text: "Active notifications", SubTitle: "See grouped alerts with active notifications", Id: "alert-activity-groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group",
})
}
if len(alertActivityChildren) > 0 {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert activity",
SubTitle: "Visualize active and pending alerts",
Id: "alert-activity",
Url: s.cfg.AppSubURL + "/alerting/alerts",
Icon: "bell",
IsNew: true,
Children: alertActivityChildren,
})
}
}
// 2. Alert rules (parent with tabs: Alert rules, Recently deleted)
var alertRulesChildren []*navtree.NavLink
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
alertRulesChildren = append(alertRulesChildren, &navtree.NavLink{
Text: "Alert rules", SubTitle: "Rules that determine whether an alert will fire", Id: "alert-rules-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
})
}
//nolint:staticcheck // not yet migrated to OpenFeature
if c.GetOrgRole() == org.RoleAdmin && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertRuleRestore) && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingRuleRecoverDeleted) {
alertRulesChildren = append(alertRulesChildren, &navtree.NavLink{
Text: "Recently deleted",
SubTitle: "Any items listed here for more than 30 days will be automatically deleted.",
Id: "alert-rules-recently-deleted",
Url: s.cfg.AppSubURL + "/alerting/recently-deleted",
})
}
if len(alertRulesChildren) > 0 {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules",
SubTitle: "Manage alert and recording rules",
Id: "alert-rules",
Url: s.cfg.AppSubURL + "/alerting/list",
Icon: "list-ul",
Children: alertRulesChildren,
})
}
// 3. Notification configuration (parent with tabs: Contact points, Notification policies, Templates, Time intervals)
var notificationConfigChildren []*navtree.NavLink
contactPointsPerms := []ac.Evaluator{
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
ac.EvalPermission(ac.ActionAlertingReceiversRead),
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets),
ac.EvalPermission(ac.ActionAlertingReceiversCreate),
ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesRead),
ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesWrite),
ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesDelete),
}
if hasAccess(ac.EvalAny(contactPointsPerms...)) {
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
Text: "Contact points", SubTitle: "Choose how to notify your contact points when an alert instance fires", Id: "notification-config-contact-points", Url: s.cfg.AppSubURL + "/alerting/notifications", Icon: "comment-alt-share",
})
}
if hasAccess(ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
ac.EvalPermission(ac.ActionAlertingRoutesRead),
ac.EvalPermission(ac.ActionAlertingRoutesWrite),
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead),
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite),
)) {
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
Text: "Notification policies", SubTitle: "Determine how alerts are routed to contact points", Id: "notification-config-policies", Url: s.cfg.AppSubURL + "/alerting/routes", Icon: "sitemap",
})
}
// Templates
if hasAccess(ac.EvalAny(contactPointsPerms...)) {
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
Text: "Notification templates", SubTitle: "Manage notification templates", Id: "notification-config-templates", Url: s.cfg.AppSubURL + "/alerting/notifications/templates", Icon: "file-alt",
})
}
// Time intervals
if hasAccess(ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
ac.EvalPermission(ac.ActionAlertingRoutesRead),
ac.EvalPermission(ac.ActionAlertingRoutesWrite),
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead),
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite),
)) {
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
Text: "Time intervals", SubTitle: "Configure time intervals for notification policies", Id: "notification-config-time-intervals", Url: s.cfg.AppSubURL + "/alerting/routes?tab=time_intervals", Icon: "clock-nine",
})
}
if len(notificationConfigChildren) > 0 {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Notification configuration",
SubTitle: "Configure how alerts are notified",
Id: "notification-config",
Url: s.cfg.AppSubURL + "/alerting/notifications",
Icon: "cog",
Children: notificationConfigChildren,
})
}
// 4. Insights (parent with tabs: System Insights, Alert state history)
var insightsChildren []*navtree.NavLink
// System Insights
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
insightsChildren = append(insightsChildren, &navtree.NavLink{
Text: "System Insights", SubTitle: "View system insights and analytics", Id: "insights-system", Url: s.cfg.AppSubURL + "/alerting/insights", Icon: "chart-line",
})
}
// Alert state history
//nolint:staticcheck // not yet migrated to OpenFeature
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) {
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead))) {
insightsChildren = append(insightsChildren, &navtree.NavLink{
Text: "Alert state history",
SubTitle: "View a history of all alert events generated by your Grafana-managed alert rules. All alert events are displayed regardless of whether silences or mute timings are set.",
Id: "insights-history",
Url: s.cfg.AppSubURL + "/alerting/history",
Icon: "history",
})
}
}
if len(insightsChildren) > 0 {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Insights",
SubTitle: "Analytics and history for alerting",
Id: "insights",
Url: s.cfg.AppSubURL + "/alerting/insights",
Icon: "chart-line",
Children: insightsChildren,
})
}
// 5. Settings (parent with tab: Settings)
if c.GetOrgRole() == org.RoleAdmin {
settingsChildren := []*navtree.NavLink{
{
Text: "Settings", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin", Icon: "cog",
},
}
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Settings",
SubTitle: "Alerting configuration and administration",
Id: "alerting-settings",
Url: s.cfg.AppSubURL + "/alerting/admin",
Icon: "cog",
Children: settingsChildren,
})
}
// Create alert rule (hidden from tabs)
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Create alert rule", SubTitle: "Create an alert rule", Id: "alert",
Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, IsCreateAction: true,
})
}
if len(alertChildNavs) > 0 {
var alertNav = navtree.NavLink{
Text: "Alerting",
SubTitle: "Learn about problems in your systems moments after they occur",
Id: navtree.NavIDAlerting,
Icon: "bell",
Children: alertChildNavs,
SortWeight: navtree.WeightAlerting,
Url: s.cfg.AppSubURL + "/alerting",
}
return &alertNav
}
return nil
}
func (s *ServiceImpl) buildAlertNavLinksLegacy(c *contextmodel.ReqContext) *navtree.NavLink {
hasAccess := ac.HasAccess(s.accessControl, c)
var alertChildNavs []*navtree.NavLink
@@ -648,7 +440,7 @@ func (s *ServiceImpl) buildAlertNavLinksLegacy(c *contextmodel.ReqContext) *navt
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert activity", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true,
Text: "Alerts", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true,
})
}
}

View File

@@ -1,234 +0,0 @@
package navtreeimpl
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/navtree"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
// Test fixtures
func setupTestContext() *contextmodel.ReqContext {
httpReq, _ := http.NewRequest(http.MethodGet, "", nil)
return &contextmodel.ReqContext{
SignedInUser: &user.SignedInUser{
UserID: 1,
OrgID: 1,
OrgRole: org.RoleAdmin,
},
Context: &web.Context{Req: httpReq},
}
}
func setupTestService(permissions []ac.Permission, featureFlags ...string) ServiceImpl {
// Convert string slice to []any for WithFeatures
flags := make([]any, len(featureFlags))
for i, flag := range featureFlags {
flags[i] = flag
}
return ServiceImpl{
log: log.New("navtree"),
cfg: setting.NewCfg(),
accessControl: accesscontrolmock.New().WithPermissions(permissions),
features: featuremgmt.WithFeatures(flags...),
}
}
func fullPermissions() []ac.Permission {
return []ac.Permission{
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
{Action: ac.ActionAlertingNotificationsRead, Scope: "*"},
{Action: ac.ActionAlertingRoutesRead, Scope: "*"},
{Action: ac.ActionAlertingInstanceRead, Scope: "*"},
}
}
// Helper to find a nav link by ID
func findNavLink(navLink *navtree.NavLink, id string) *navtree.NavLink {
if navLink == nil {
return nil
}
if navLink.Id == id {
return navLink
}
for _, child := range navLink.Children {
if found := findNavLink(child, id); found != nil {
return found
}
}
return nil
}
// Helper to check if a nav link has a child with given ID
func hasChildWithId(parent *navtree.NavLink, childId string) bool {
if parent == nil {
return false
}
for _, child := range parent.Children {
if child.Id == childId {
return true
}
}
return false
}
func TestBuildAlertNavLinks_FeatureToggle(t *testing.T) {
reqCtx := setupTestContext()
permissions := fullPermissions()
t.Run("Should use legacy navigation when flag is off", func(t *testing.T) {
service := setupTestService(permissions) // No feature flags
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
require.Equal(t, "Alerting", navLink.Text)
require.Equal(t, navtree.NavIDAlerting, navLink.Id)
// Legacy structure: flat children without nested items
require.NotEmpty(t, navLink.Children)
alertList := findNavLink(navLink, "alert-list")
receivers := findNavLink(navLink, "receivers")
require.NotNil(t, alertList, "Should have alert-list in legacy navigation")
require.NotNil(t, receivers, "Should have receivers in legacy navigation")
require.Empty(t, alertList.Children, "Legacy items should not have nested children")
require.Empty(t, receivers.Children, "Legacy items should not have nested children")
})
t.Run("Should use V2 navigation when flag is on", func(t *testing.T) {
service := setupTestService(permissions, "alertingNavigationV2")
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
require.Equal(t, "Alerting", navLink.Text)
require.Equal(t, navtree.NavIDAlerting, navLink.Id)
// V2 structure: grouped parents with nested children
require.NotEmpty(t, navLink.Children)
// Verify all expected parent items exist with children
expectedParents := []string{"alert-rules", "notification-config", "insights", "alerting-settings"}
for _, parentId := range expectedParents {
parent := findNavLink(navLink, parentId)
require.NotNil(t, parent, "Should have %s parent in V2 navigation", parentId)
require.NotEmpty(t, parent.Children, "V2 parent %s should have children", parentId)
}
// Verify alert-rules has expected tab
alertRules := findNavLink(navLink, "alert-rules")
require.True(t, hasChildWithId(alertRules, "alert-rules-list"), "Should have alert-rules-list tab")
})
}
func TestBuildAlertNavLinks_Legacy(t *testing.T) {
reqCtx := setupTestContext()
t.Run("Should include all expected items in legacy navigation", func(t *testing.T) {
service := setupTestService(fullPermissions())
navLink := service.buildAlertNavLinksLegacy(reqCtx)
require.NotNil(t, navLink)
expectedIds := []string{"alert-list", "receivers", "am-routes", "alerting-admin"}
for _, expectedId := range expectedIds {
require.NotNil(t, findNavLink(navLink, expectedId), "Should have %s in legacy navigation", expectedId)
}
})
t.Run("Should respect permissions in legacy navigation", func(t *testing.T) {
limitedPermissions := []ac.Permission{
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
}
limitedService := setupTestService(limitedPermissions)
navLink := limitedService.buildAlertNavLinksLegacy(reqCtx)
require.NotNil(t, navLink)
require.NotNil(t, findNavLink(navLink, "alert-list"), "Should have alert rules with read permission")
require.Nil(t, findNavLink(navLink, "receivers"), "Should not have contact points without notification permissions")
})
}
func TestBuildAlertNavLinks_V2(t *testing.T) {
reqCtx := setupTestContext()
allFeatureFlags := []string{"alertingNavigationV2", "alertingTriage", "alertingCentralAlertHistory", "alertRuleRestore", "alertingRuleRecoverDeleted"}
service := setupTestService(fullPermissions(), allFeatureFlags...)
t.Run("Should have correct parent structure in V2 navigation", func(t *testing.T) {
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
require.NotEmpty(t, navLink.Children)
// Verify all parent items exist with children
parentIds := []string{"alert-rules", "notification-config", "insights", "alerting-settings"}
for _, parentId := range parentIds {
parent := findNavLink(navLink, parentId)
require.NotNil(t, parent, "Should have parent %s in V2 navigation", parentId)
require.NotEmpty(t, parent.Children, "Parent %s should have children", parentId)
}
})
t.Run("Should have correct tabs under each parent", func(t *testing.T) {
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
// Table-driven test for tab verification
tests := []struct {
parentId string
expectedTabs []string
}{
{"alert-rules", []string{"alert-rules-list", "alert-rules-recently-deleted"}},
{"notification-config", []string{"notification-config-contact-points", "notification-config-policies", "notification-config-templates", "notification-config-time-intervals"}},
{"insights", []string{"insights-system", "insights-history"}},
}
for _, tt := range tests {
parent := findNavLink(navLink, tt.parentId)
require.NotNil(t, parent, "Should have %s parent", tt.parentId)
for _, expectedTab := range tt.expectedTabs {
require.True(t, hasChildWithId(parent, expectedTab), "Parent %s should have tab %s", tt.parentId, expectedTab)
}
}
})
t.Run("Should respect permissions in V2 navigation", func(t *testing.T) {
limitedPermissions := []ac.Permission{
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
}
limitedService := setupTestService(limitedPermissions, "alertingNavigationV2")
navLink := limitedService.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
// Should not have notification-config without notification permissions
require.Nil(t, findNavLink(navLink, "notification-config"), "Should not have notification-config without permissions")
})
t.Run("Should exclude future items from V2 navigation", func(t *testing.T) {
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
// Verify future items are not present
futureIds := []string{
"alert-rules-recording-rules",
"alert-rules-evaluation-chains",
"insights-alert-optimizer",
"insights-notification-history",
}
for _, futureId := range futureIds {
require.Nil(t, findNavLink(navLink, futureId), "Should not have future item %s", futureId)
}
})
}

View File

@@ -542,10 +542,9 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe
dashService.RegisterDashboardPermissions(acmock.NewMockedPermissionsService())
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, featuremgmt.WithFeatures())
_, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(),
quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, plugincontext.
ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
require.NoError(t, err)
m := metrics.NewNGAlert(prometheus.NewRegistry())

View File

@@ -37,10 +37,9 @@ func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvS
features := featuremgmt.WithFeatures()
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
quotaService := quotatest.New(false, nil)
dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, features)
dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(),
acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
require.NoError(t, err)
migService := ProvideDataSourceMigrationService(dsService, kvStore, features)
return migService

View File

@@ -293,15 +293,15 @@ overrides_path = overrides.yaml
overrides_reload_period = 5s
```
To override the default quota for a tenant, add the following to the `overrides.yaml` file:
To overrides the default quota for a tenant, add the following to the overrides.yaml file:
```yaml
overrides:
<NAMESPACE>:
quotas:
<GROUP>/<RESOURCE>:
<GROUP>.<RESOURCE>:
limit: 10
```
Unless otherwise set, the `NAMESPACE` when running locally is `default`.
Unless otherwise set, the NAMESPACE when running locally is `default`.
To access quotas, use the following API endpoint:
```
@@ -806,10 +806,8 @@ flowchart TD
#### Setting Dual Writer Mode
```ini
; [unified_storage.{resource}.{group}]
[unified_storage.dashboards.dashboard.grafana.app]
; modes {0-5}
dualWriterMode = 0
[unified_storage.{resource}.{kind}.{group}]
dualWriterMode = {0-5}
```
#### Background Sync Configuration
@@ -1378,3 +1376,4 @@ disable_data_migrations = false
### Documentation
For detailed information about migration architecture, validators, and troubleshooting, refer to [migrations/README.md](./migrations/README.md).

View File

@@ -11,7 +11,7 @@ INSERT INTO {{ .Ident "resource" }}
{{ .Ident "previous_resource_version" }}
)
VALUES (
(SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
COALESCE({{ .Arg .Value }}, ""),
{{ .Arg .GUID }},
{{ .Arg .Group }},
{{ .Arg .Resource }},
@@ -19,5 +19,13 @@ VALUES (
{{ .Arg .Name }},
{{ .Arg .Action }},
{{ .Arg .Folder }},
{{ .Arg .PreviousRV }}
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
SELECT {{ .Ident "resource_version" }}
FROM {{ .Ident "resource" }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }}
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
) END
);

View File

@@ -7,7 +7,9 @@ INSERT INTO {{ .Ident "resource_history" }}
{{ .Ident "namespace" }},
{{ .Ident "name" }},
{{ .Ident "action" }},
{{ .Ident "folder" }}
{{ .Ident "folder" }},
{{ .Ident "previous_resource_version" }},
{{ .Ident "generation" }}
)
VALUES (
COALESCE({{ .Arg .Value }}, ""),
@@ -17,5 +19,26 @@ VALUES (
{{ .Arg .Namespace }},
{{ .Arg .Name }},
{{ .Arg .Action }},
{{ .Arg .Folder }}
{{ .Arg .Folder }},
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
SELECT {{ .Ident "resource_version" }}
FROM {{ .Ident "resource_history" }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }}
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
) END,
CASE
WHEN {{ .Arg .Action }} = 1 THEN 1
WHEN {{ .Arg .Action }} = 3 THEN 0
ELSE 1 + (
SELECT COUNT(1)
FROM {{ .Ident "resource_history" }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
AND {{ .Ident "name" }} = {{ .Arg .Name }}
)
END
);

View File

@@ -1,10 +1,8 @@
UPDATE {{ .Ident "resource" }}
SET
{{ .Ident "guid" }} = {{ .Arg .GUID }},
{{ .Ident "value" }} = (SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
{{ .Ident "value" }} = {{ .Arg .Value }},
{{ .Ident "action" }} = {{ .Arg .Action }},
{{ .Ident "folder" }} = {{ .Arg .Folder }},
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }}
{{ .Ident "folder" }} = {{ .Arg .Folder }}
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}

View File

@@ -1,5 +0,0 @@
UPDATE {{ .Ident "resource_history" }}
SET
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }},
{{ .Ident "generation" }} = {{ .Arg .Generation }}
WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }};

View File

@@ -12,9 +12,6 @@ import (
"time"
"github.com/grafana/grafana/pkg/apimachinery/validation"
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
"github.com/grafana/grafana/pkg/storage/unified/sql/dbutil"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
gocache "github.com/patrickmn/go-cache"
)
@@ -309,6 +306,10 @@ func (d *dataStore) GetResourceKeyAtRevision(ctx context.Context, key GetRequest
return DataKey{}, fmt.Errorf("invalid get request key: %w", err)
}
if rv == 0 {
rv = math.MaxInt64
}
listKey := ListRequestKey(key)
iter := d.ListResourceKeysAtRevision(ctx, ListRequestOptions{Key: listKey, ResourceVersion: rv})
@@ -597,7 +598,7 @@ func ParseKey(key string) (DataKey, error) {
}, nil
}
// Temporary while we need to support unified/sql/backend compatibility.
// Temporary while we need to support unified/sql/backend compatibility
// Remove once we stop using RvManager in storage_backend.go
func ParseKeyWithGUID(key string) (DataKey, error) {
parts := strings.Split(key, "/")
@@ -814,121 +815,3 @@ func (d *dataStore) getGroupResources(ctx context.Context) ([]GroupResource, err
return results, nil
}
// TODO: remove when backwards compatibility is no longer needed.
var (
sqlKVUpdateLegacyResourceHistory = mustTemplate("sqlkv_update_legacy_resource_history.sql")
sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql")
sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql")
)
// TODO: remove when backwards compatibility is no longer needed.
type sqlKVLegacySaveRequest struct {
sqltemplate.SQLTemplate
GUID string
Group string
Resource string
Namespace string
Name string
Action int64
Folder string
PreviousRV int64
}
func (req sqlKVLegacySaveRequest) Validate() error {
return nil
}
// TODO: remove when backwards compatibility is no longer needed.
type sqlKVLegacyUpdateHistoryRequest struct {
sqltemplate.SQLTemplate
GUID string
PreviousRV int64
Generation int64
}
func (req sqlKVLegacyUpdateHistoryRequest) Validate() error {
return nil
}
// applyBackwardsCompatibleChanges updates the `resource` and `resource_history` tables
// to make sure the sqlkv implementation is backwards-compatible with the existing sql backend.
// Specifically, it will update the `resource_history` table to include the previous resource version
// and generation, which come from the `WriteEvent`, and also make the corresponding change on the
// `resource` table, no longer used in the storage backend.
//
// TODO: remove when backwards compatibility is no longer needed.
func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.Tx, event WriteEvent, key DataKey) error {
kv, isSQLKV := d.kv.(*sqlKV)
if !isSQLKV {
return nil
}
_, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResourceHistory, sqlKVLegacyUpdateHistoryRequest{
SQLTemplate: sqltemplate.New(kv.dialect),
GUID: key.GUID,
PreviousRV: event.PreviousRV,
Generation: event.Object.GetGeneration(),
})
if err != nil {
return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err)
}
var action int64
switch key.Action {
case DataActionCreated:
action = 1
case DataActionUpdated:
action = 2
case DataActionDeleted:
action = 3
}
switch key.Action {
case DataActionCreated:
_, err := dbutil.Exec(ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(kv.dialect),
GUID: key.GUID,
Group: key.Group,
Resource: key.Resource,
Namespace: key.Namespace,
Name: key.Name,
Action: action,
Folder: key.Folder,
PreviousRV: event.PreviousRV,
})
if err != nil {
return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err)
}
case DataActionUpdated:
_, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(kv.dialect),
GUID: key.GUID,
Group: key.Group,
Resource: key.Resource,
Namespace: key.Namespace,
Name: key.Name,
Folder: key.Folder,
PreviousRV: event.PreviousRV,
})
if err != nil {
return fmt.Errorf("compatibility layer: failed to update resource: %w", err)
}
case DataActionDeleted:
_, err := dbutil.Exec(ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(kv.dialect),
Resource: key.Resource,
Namespace: key.Namespace,
Name: key.Name,
})
if err != nil {
return fmt.Errorf("compatibility layer: failed to delete from resource: %w", err)
}
}
return nil
}

View File

@@ -44,6 +44,8 @@ var (
sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql")
sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql")
sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql")
sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql")
sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql")
sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql")
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
@@ -155,6 +157,26 @@ func (req sqlKVSaveRequest) Validate() error {
return req.sqlKVSectionKey.Validate()
}
type sqlKVLegacySaveRequest struct {
sqltemplate.SQLTemplate
Value []byte
GUID string
Group string
Resource string
Namespace string
Name string
Action int64
Folder string
}
func (req sqlKVLegacySaveRequest) Validate() error {
return nil
}
func (req sqlKVLegacySaveRequest) Results() ([]byte, error) {
return req.Value, nil
}
type sqlKVKeysRequest struct {
sqltemplate.SQLTemplate
sqlKVSection
@@ -370,7 +392,7 @@ func (w *sqlWriteCloser) Close() error {
// used to keep backwards compatibility between sql-based kvstore and unified/sql/backend
tx, ok := rvmanager.TxFromCtx(w.ctx)
if !ok {
// temporary save for dataStore without rvmanager (non backwards-compatible)
// temporary save for dataStore without rvmanager
// we can use the same template as the event one after we:
// - move PK from GUID to key_path
// - remove all unnecessary columns (or at least their NOT NULL constraints)
@@ -407,12 +429,11 @@ func (w *sqlWriteCloser) Close() error {
return nil
}
// special, temporary backwards-compatible save that includes all the fields in resource_history that are not relevant
// for the kvstore, as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
// component will be responsible for populating the resource_version and key_path columns.
// For full backwards-compatibility, the `Save` function needs to be called within a callback that updates the resource_history
// table with `previous_resource_version` and `generation` and updates the `resource` table accordingly. See the
// storage_backend for the full implementation.
// special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore,
// as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
// component will be responsible for populating the resource_version and key_path columns
// note that we are not touching resource_version table, neither the resource_version columns or the key_path column
// as the RvManager will be responsible for this
dataKey, err := ParseKeyWithGUID(w.sectionKey.Key)
if err != nil {
return fmt.Errorf("failed to parse key: %w", err)
@@ -427,7 +448,7 @@ func (w *sqlWriteCloser) Close() error {
case DataActionDeleted:
action = 3
default:
return fmt.Errorf("failed to parse key: invalid action")
return fmt.Errorf("failed to parse key: %w", err)
}
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{
@@ -447,6 +468,52 @@ func (w *sqlWriteCloser) Close() error {
return fmt.Errorf("failed to save to resource_history: %w", err)
}
switch dataKey.Action {
case DataActionCreated:
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
Value: w.buf.Bytes(),
GUID: dataKey.GUID,
Group: dataKey.Group,
Resource: dataKey.Resource,
Namespace: dataKey.Namespace,
Name: dataKey.Name,
Action: action,
Folder: dataKey.Folder,
})
if err != nil {
return fmt.Errorf("failed to insert to resource: %w", err)
}
case DataActionUpdated:
_, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
Value: w.buf.Bytes(),
Group: dataKey.Group,
Resource: dataKey.Resource,
Namespace: dataKey.Namespace,
Name: dataKey.Name,
Action: action,
Folder: dataKey.Folder,
})
if err != nil {
return fmt.Errorf("failed to update resource: %w", err)
}
case DataActionDeleted:
_, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{
SQLTemplate: sqltemplate.New(w.kv.dialect),
Group: dataKey.Group,
Resource: dataKey.Resource,
Namespace: dataKey.Namespace,
Name: dataKey.Name,
})
if err != nil {
return fmt.Errorf("failed to delete from resource: %w", err)
}
}
return nil
}

View File

@@ -332,14 +332,11 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
dataKey.GUID = uuid.New().String()
var err error
rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) {
if err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)); err != nil {
err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value))
if err != nil {
return "", fmt.Errorf("failed to write data: %w", err)
}
if err := k.dataStore.applyBackwardsCompatibleChanges(ctx, tx, event, dataKey); err != nil {
return "", fmt.Errorf("failed to apply backwards compatible updates: %w", err)
}
return dataKey.GUID, nil
})
if err != nil {

View File

@@ -0,0 +1,144 @@
package apis
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/util/testutil"
)
const pluginsDiscoveryJSON = `[
{
"version": "v0alpha1",
"freshness": "Current",
"resources": [
{
"resource": "metas",
"responseKind": {
"group": "",
"kind": "Meta",
"version": ""
},
"scope": "Namespaced",
"singularResource": "meta",
"subresources": [
{
"responseKind": {
"group": "",
"kind": "Meta",
"version": ""
},
"subresource": "status",
"verbs": [
"get",
"patch",
"update"
]
}
],
"verbs": [
"get",
"list"
]
},
{
"resource": "plugins",
"responseKind": {
"group": "",
"kind": "Plugin",
"version": ""
},
"scope": "Namespaced",
"singularResource": "plugin",
"subresources": [
{
"responseKind": {
"group": "",
"kind": "Plugin",
"version": ""
},
"subresource": "status",
"verbs": [
"get",
"patch",
"update"
]
}
],
"verbs": [
"create",
"delete",
"deletecollection",
"get",
"list",
"patch",
"update",
"watch"
]
}
]
}
]`
func setupHelper(t *testing.T, openFeatureAPIEnabled bool) *K8sTestHelper {
t.Helper()
helper := NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
OpenFeatureAPIEnabled: openFeatureAPIEnabled,
})
t.Cleanup(func() { helper.Shutdown() })
return helper
}
func TestIntegrationAPIServerRuntimeConfig(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
t.Run("discovery with openfeature api enabled", func(t *testing.T) {
helper := setupHelper(t, true)
disco, err := helper.GetGroupVersionInfoJSON("features.grafana.app")
require.NoError(t, err)
require.JSONEq(t, `[
{
"freshness": "Current",
"resources": [
{
"resource": "noop",
"responseKind": {
"group": "",
"kind": "Status",
"version": ""
},
"scope": "Namespaced",
"singularResource": "noop",
"verbs": [
"get"
]
}
],
"version": "v0alpha1"
}
]`, disco)
// plugins should still be discoverable
disco, err = helper.GetGroupVersionInfoJSON("plugins.grafana.app")
require.NoError(t, err)
require.JSONEq(t, pluginsDiscoveryJSON, disco)
require.NoError(t, err)
})
t.Run("discovery with openfeature api false", func(t *testing.T) {
helper := setupHelper(t, false)
_, err := helper.GetGroupVersionInfoJSON("features.grafana.app")
require.Error(t, err, "expected error when openfeature api is disabled")
// plugins should still be discoverable
disco, err := helper.GetGroupVersionInfoJSON("plugins.grafana.app")
require.NoError(t, err)
require.JSONEq(t, pluginsDiscoveryJSON, disco)
require.NoError(t, err)
})
}

View File

@@ -10,7 +10,6 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
@@ -178,9 +177,6 @@ func setupHelper(t *testing.T) *apis.K8sTestHelper {
AppModeProduction: true,
DisableAnonymous: true,
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
EnableFeatureToggles: []string{
featuremgmt.FlagPluginStoreServiceLoading,
},
})
t.Cleanup(func() { helper.Shutdown() })
return helper

View File

@@ -320,9 +320,8 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
require.NoError(t, err)
_, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled))
require.NoError(t, err)
if opts.OpenFeatureAPIEnabled {
_, err = openFeatureSect.NewKey("provider", "static")
if !opts.OpenFeatureAPIEnabled {
_, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with features-service type, but trying to make tests work
require.NoError(t, err)
_, err = openFeatureSect.NewKey("targetingKey", "grafana")
require.NoError(t, err)

View File

@@ -11,7 +11,6 @@ import { reportInteraction } from '@grafana/runtime';
import { ScrollContainer, useStyles2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { setBookmark } from 'app/core/reducers/navBarTree';
import { shouldUseAlertingNavigationV2 } from 'app/features/alerting/unified/featureToggles';
import { useDispatch, useSelector } from 'app/types/store';
import { MegaMenuExtensionPoint } from './MegaMenuExtensionPoint';
@@ -38,25 +37,9 @@ export const MegaMenu = memo(
const pinnedItems = usePinnedItems();
// Remove profile + help from tree
// For Alerting V2 navigation, flatten the sidebar to show only top-level items (hide nested children/tabs)
const useV2Nav = shouldUseAlertingNavigationV2();
const navItems = navTree
.filter((item) => item.id !== 'profile' && item.id !== 'help')
.map((item) => {
const enriched = enrichWithInteractionTracking(item, state.megaMenuDocked);
// If this is Alerting section and V2 navigation is enabled, flatten children for sidebar display
// Children are still available in navIndex for breadcrumbs and page navigation
if (useV2Nav && item.id === 'alerting' && enriched.children) {
return {
...enriched,
children: enriched.children.map((child) => ({
...child,
children: undefined, // Remove nested children from sidebar, but keep them for page navigation
})),
};
}
return enriched;
});
.map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked));
const bookmarksItem = navItems.find((item) => item.id === 'bookmarks');
if (bookmarksItem) {

View File

@@ -35,18 +35,11 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
if (shouldAddCrumb) {
const activeChildIndex = node.children?.findIndex((child) => child.active) ?? -1;
// Add active tab to breadcrumbs if it exists and its URL is different from the node's URL
// This ensures tabs show in breadcrumbs (including the first tab) while preventing duplication
if (activeChildIndex >= 0) {
// Add tab to breadcrumbs if it's not the first active child
if (activeChildIndex > 0) {
const activeChild = node.children?.[activeChildIndex];
if (activeChild) {
// Only add the active child if its URL doesn't match the node's URL
// This prevents duplication when the pageNav is the active tab
const nodeUrl = node.url?.split('?')[0] ?? '';
const childUrl = activeChild.url?.split('?')[0] ?? '';
if (nodeUrl !== childUrl) {
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
}
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
}
}
crumbs.unshift({ text: node.text, href: node.url ?? '' });

View File

@@ -56,17 +56,6 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
)
),
},
{
path: '/alerting/time-intervals',
roles: evaluateAccess([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
...PERMISSIONS_TIME_INTERVALS_READ,
]),
component: importAlertingComponent(
() => import(/* webpackChunkName: "TimeIntervalsPage" */ 'app/features/alerting/unified/TimeIntervalsPage')
),
},
{
path: '/alerting/routes/mute-timing/new',
roles: evaluateAccess([
@@ -223,13 +212,6 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
)
),
},
{
path: '/alerting/insights',
roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]),
component: importAlertingComponent(
() => import(/* webpackChunkName: "InsightsPage" */ 'app/features/alerting/unified/insights/InsightsPage')
),
},
{
path: '/alerting/recently-deleted/',
roles: () => ['Admin'],

View File

@@ -14,7 +14,6 @@ import { AlertGroupFilter } from './components/alert-groups/AlertGroupFilter';
import { useFilteredAmGroups } from './hooks/useFilteredAmGroups';
import { useGroupedAlerts } from './hooks/useGroupedAlerts';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { useAlertActivityNav } from './navigation/useAlertActivityNav';
import { useAlertmanager } from './state/AlertmanagerContext';
import { fetchAlertGroupsAction } from './state/actions';
import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants';
@@ -114,9 +113,8 @@ const AlertGroups = () => {
};
function AlertGroupsPage() {
const { navId, pageNav } = useAlertActivityNav();
return (
<AlertmanagerPageWrapper navId={navId || 'groups'} pageNav={pageNav} accessType="instance">
<AlertmanagerPageWrapper navId="groups" accessType="instance">
<AlertGroups />
</AlertmanagerPageWrapper>
);

View File

@@ -1,6 +1,6 @@
import { produce } from 'immer';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { render, screen, testWithFeatureToggles, userEvent, within } from 'test/test-utils';
import { render, screen, userEvent, within } from 'test/test-utils';
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
@@ -140,39 +140,6 @@ const getRootRoute = async () => {
};
describe('NotificationPolicies', () => {
describe('V2 Navigation Mode', () => {
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
beforeEach(() => {
setupDataSources(dataSources.am);
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
...PERMISSIONS_NOTIFICATION_POLICIES,
]);
});
it('shows only notification policies without internal tabs', async () => {
renderNotificationPolicies();
// Should show notification policies directly
expect(await ui.rootRouteContainer.find()).toBeInTheDocument();
// Should not have tabs
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
it('does not show time intervals tab in V2 mode', async () => {
renderNotificationPolicies();
// Should show notification policies
expect(await ui.rootRouteContainer.find()).toBeInTheDocument();
// Should not show time intervals tab
expect(screen.queryByText(/time intervals/i)).not.toBeInTheDocument();
});
});
// combobox hack :/
beforeAll(() => {
const mockGetBoundingClientRect = jest.fn(() => ({

View File

@@ -12,8 +12,6 @@ import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alertin
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
import { shouldUseAlertingNavigationV2 } from './featureToggles';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { useAlertmanager } from './state/AlertmanagerContext';
import { withPageErrorBoundary } from './withPageErrorBoundary';
@@ -108,32 +106,9 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap, defaultTab: ActiveTab): Q
};
}
const NotificationPoliciesContent = () => {
const { selectedAlertmanager = '' } = useAlertmanager();
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager} />
<NotificationPoliciesList />
</>
);
};
function NotificationPoliciesPage() {
const useV2Nav = shouldUseAlertingNavigationV2();
const { navId, pageNav } = useNotificationConfigNav();
// In V2 mode, show only notification policies (no internal tabs)
if (useV2Nav) {
return (
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<NotificationPoliciesContent />
</AlertmanagerPageWrapper>
);
}
// Legacy mode: Show internal tabs (backward compatible)
return (
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<AlertmanagerPageWrapper navId="am-routes" accessType="notification">
<NotificationPoliciesTabs />
</AlertmanagerPageWrapper>
);

View File

@@ -1,56 +1,13 @@
import { Route, Routes } from 'react-router-dom-v5-compat';
import { Trans } from '@grafana/i18n';
import { LinkButton, Stack, Text } from '@grafana/ui';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import DuplicateMessageTemplate from './components/contact-points/DuplicateMessageTemplate';
import EditMessageTemplate from './components/contact-points/EditMessageTemplate';
import NewMessageTemplate from './components/contact-points/NewMessageTemplate';
import { NotificationTemplates } from './components/contact-points/NotificationTemplates';
import { shouldUseAlertingNavigationV2 } from './featureToggles';
import { AlertmanagerAction, useAlertmanagerAbility } from './hooks/useAbilities';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { withPageErrorBoundary } from './withPageErrorBoundary';
const TemplatesList = () => {
const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility(
AlertmanagerAction.CreateNotificationTemplate
);
return (
<>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Text variant="body" color="secondary">
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
Create notification templates to customize your notifications.
</Trans>
</Text>
{createTemplateSupported && (
<LinkButton
icon="plus"
variant="primary"
href="/alerting/notifications/templates/new"
disabled={!createTemplateAllowed}
>
<Trans i18nKey="alerting.notification-templates-tab.add-notification-template-group">
Add notification template group
</Trans>
</LinkButton>
)}
</Stack>
<NotificationTemplates />
</>
);
};
function NotificationTemplatesRoutes() {
const useV2Nav = shouldUseAlertingNavigationV2();
function NotificationTemplates() {
return (
<Routes>
{/* In V2 mode, show templates list on base route */}
{useV2Nav && <Route path="" element={<TemplatesList />} />}
<Route path="new" element={<NewMessageTemplate />} />
<Route path=":name/edit" element={<EditMessageTemplate />} />
<Route path=":name/duplicate" element={<DuplicateMessageTemplate />} />
@@ -58,21 +15,4 @@ function NotificationTemplatesRoutes() {
);
}
function NotificationTemplatesPage() {
const useV2Nav = shouldUseAlertingNavigationV2();
const { navId, pageNav } = useNotificationConfigNav();
// In V2 mode, wrap with page wrapper for proper navigation
if (useV2Nav) {
return (
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
<NotificationTemplatesRoutes />
</AlertmanagerPageWrapper>
);
}
// In legacy mode, just render routes (templates are accessed via ContactPoints page tabs)
return <NotificationTemplatesRoutes />;
}
export default withPageErrorBoundary(NotificationTemplatesPage);
export default withPageErrorBoundary(NotificationTemplates);

View File

@@ -1,81 +0,0 @@
import { render, screen, testWithFeatureToggles } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types/accessControl';
import TimeIntervalsPage from './TimeIntervalsPage';
import { defaultConfig } from './components/mute-timings/mocks';
import { setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource } from './mocks';
import { setTimeIntervalsListEmpty } from './mocks/server/configure';
import { setAlertmanagerConfig } from './mocks/server/entities/alertmanagers';
import { setupDataSources } from './testSetup/datasources';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
setupMswServer();
const alertManager = mockDataSource({
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
});
describe('TimeIntervalsPage', () => {
describe('V2 Navigation Mode', () => {
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
beforeEach(() => {
setupDataSources(alertManager);
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, defaultConfig);
setTimeIntervalsListEmpty(); // Mock empty time intervals list so component renders
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingTimeIntervalsRead,
]);
});
it('renders time intervals table', async () => {
const mockNavIndex = {
'notification-config': {
id: 'notification-config',
text: 'Notification configuration',
url: '/alerting/notifications',
},
'notification-config-time-intervals': {
id: 'notification-config-time-intervals',
text: 'Time intervals',
url: '/alerting/time-intervals',
},
};
const store = configureStore({
navIndex: mockNavIndex,
});
render(<TimeIntervalsPage />, {
store,
historyOptions: {
initialEntries: ['/alerting/time-intervals'],
},
});
// Should show time intervals content
// When empty, it shows "You haven't created any time intervals yet"
// When loading, it shows "Loading time intervals..."
// When error, it shows "Error loading time intervals"
// All contain "time intervals" - use getAllByText since there are multiple matches (tab, description, empty state)
const timeIntervalsTexts = await screen.findAllByText(/time intervals/i, {}, { timeout: 5000 });
expect(timeIntervalsTexts.length).toBeGreaterThan(0);
});
it('returns null in legacy mode', () => {
// This test verifies that the component returns null when V2 is disabled
// The feature toggle is controlled by testWithFeatureToggles, so we test it separately
const { container } = render(<TimeIntervalsPage />, {
historyOptions: {
initialEntries: ['/alerting/time-intervals'],
},
});
// In V2 mode (enabled by testWithFeatureToggles), it should render content
expect(container).not.toBeEmptyDOMElement();
});
});
});

View File

@@ -1,40 +0,0 @@
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
import { shouldUseAlertingNavigationV2 } from './featureToggles';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { useAlertmanager } from './state/AlertmanagerContext';
import { withPageErrorBoundary } from './withPageErrorBoundary';
// Content component that uses AlertmanagerContext
// This must be rendered within AlertmanagerPageWrapper
function TimeIntervalsPageContent() {
const { selectedAlertmanager } = useAlertmanager();
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
<TimeIntervalsTable />
</>
);
}
function TimeIntervalsPage() {
const useV2Nav = shouldUseAlertingNavigationV2();
const { navId, pageNav } = useNotificationConfigNav();
// In V2 mode, wrap with page wrapper for proper navigation
// AlertmanagerPageWrapper provides AlertmanagerContext, so TimeIntervalsPageContent can use useAlertmanager
if (useV2Nav) {
return (
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<TimeIntervalsPageContent />
</AlertmanagerPageWrapper>
);
}
// Legacy mode: not used (handled by NotificationPoliciesPage)
return null;
}
export default withPageErrorBoundary(TimeIntervalsPage);

View File

@@ -1,14 +1,6 @@
import { MemoryHistoryBuildOptions } from 'history';
import { ComponentProps, ReactNode } from 'react';
import {
render,
screen,
testWithFeatureToggles,
userEvent,
waitFor,
waitForElementToBeRemoved,
within,
} from 'test/test-utils';
import { render, screen, userEvent, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
import { selectors } from '@grafana/e2e-selectors';
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
@@ -178,30 +170,6 @@ describe('contact points', () => {
});
});
describe('V2 Navigation Mode', () => {
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
test('shows only contact points without internal tabs', async () => {
renderWithProvider(<ContactPointsPageContents />);
// Should show contact points directly
expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
// Should not have tabs
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
test('does not show templates tab in V2 mode', async () => {
renderWithProvider(<ContactPointsPageContents />);
// Should show contact points
expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
// Should not show templates tab
expect(screen.queryByText(/notification templates/i)).not.toBeInTheDocument();
});
});
describe('templates tab', () => {
it('does not show a warning for a "misconfigured" template', async () => {
renderWithProvider(

View File

@@ -1,7 +1,5 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
Alert,
@@ -15,18 +13,15 @@ import {
TabContent,
TabsBar,
Text,
useStyles2,
} from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
import { makeAMLink, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { AccessControlAction } from 'app/types/accessControl';
import { shouldUseAlertingNavigationV2 } from '../../featureToggles';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { usePagination } from '../../hooks/usePagination';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { useNotificationConfigNav } from '../../navigation/useNotificationConfigNav';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { isExtraConfig } from '../../utils/alertmanager/extraConfigs';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
@@ -104,7 +99,7 @@ const ContactPointsTab = () => {
}
return (
<Stack direction="column" gap={1}>
<>
{/* TODO we can add some additional info here with a ToggleTip */}
<Stack direction="row" alignItems="end" justifyContent="space-between">
<ContactPointsFilter />
@@ -153,7 +148,7 @@ const ContactPointsTab = () => {
<GlobalConfigAlert alertManagerName={selectedAlertmanager!} />
)}
{ExportDrawer}
</Stack>
</>
);
};
@@ -163,7 +158,7 @@ const NotificationTemplatesTab = () => {
);
return (
<Stack direction="column" gap={1}>
<>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Text variant="body" color="secondary">
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
@@ -184,7 +179,7 @@ const NotificationTemplatesTab = () => {
)}
</Stack>
<NotificationTemplates />
</Stack>
</>
);
};
@@ -206,10 +201,6 @@ const useTabQueryParam = (defaultTab: ActiveTab) => {
export const ContactPointsPageContents = () => {
const { selectedAlertmanager } = useAlertmanager();
const useV2Nav = shouldUseAlertingNavigationV2();
const styles = useStyles2(getStyles);
// All hooks must be called unconditionally before any early returns
const [, canViewContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
const [, canCreateContactPoints] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
const [, showTemplatesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationTemplate);
@@ -229,19 +220,6 @@ export const ContactPointsPageContents = () => {
alertmanager: selectedAlertmanager!,
});
// In V2 navigation mode, show only contact points (no internal tabs)
// Templates are accessible via the sidebar navigation
if (useV2Nav) {
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
<ContactPointsTab />
</>
);
}
// Legacy mode: Show internal tabs (backward compatible)
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
@@ -266,7 +244,7 @@ export const ContactPointsPageContents = () => {
/>
)}
</TabsBar>
<TabContent className={styles.tabContent}>
<TabContent>
<Stack direction="column">
{showingContactPoints && <ContactPointsTab />}
{showNotificationTemplates && <NotificationTemplatesTab />}
@@ -303,16 +281,9 @@ const ContactPointsList = ({ contactPoints, search, pageSize = DEFAULT_PAGE_SIZE
);
};
const getStyles = (theme: GrafanaTheme2) => ({
tabContent: css({
marginTop: theme.spacing(2),
}),
});
function ContactPointsPage() {
const { navId, pageNav } = useNotificationConfigNav();
return (
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
<AlertmanagerPageWrapper navId="receivers" accessType="notification">
<ContactPointsPageContents />
</AlertmanagerPageWrapper>
);

View File

@@ -1,13 +1,11 @@
import { useInsightsNav } from '../../../navigation/useInsightsNav';
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
import { CentralAlertHistoryScene } from './CentralAlertHistoryScene';
function HistoryPage() {
const { navId, pageNav } = useInsightsNav();
return (
<AlertingPageWrapper navId={navId || 'alerts-history'} pageNav={pageNav} isLoading={false}>
<AlertingPageWrapper navId="alerts-history" isLoading={false}>
<CentralAlertHistoryScene />
</AlertingPageWrapper>
);

View File

@@ -3,7 +3,6 @@ import { Alert } from '@grafana/ui';
import { alertRuleApi } from '../../../api/alertRuleApi';
import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi';
import { useAlertRulesNav } from '../../../navigation/useAlertRulesNav';
import { stringifyErrorLike } from '../../../utils/misc';
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
@@ -19,10 +18,9 @@ function DeletedrulesPage() {
rulerConfig: GRAFANA_RULER_CONFIG,
filter: {}, // todo: add filters, and limit?????
});
const { navId, pageNav } = useAlertRulesNav();
return (
<AlertingPageWrapper navId={navId || 'alerts/recently-deleted'} pageNav={pageNav} isLoading={isLoading}>
<AlertingPageWrapper navId="alerts/recently-deleted" isLoading={isLoading}>
<>
{error && (
<Alert title={t('alerting.deleted-rules.errorloading', 'Failed to load alert deleted rules')}>

View File

@@ -47,7 +47,7 @@ export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields =>
startsAt: interval.start.toISOString(),
endsAt: interval.end.toISOString(),
comment: silence.comment,
createdBy: isExpired ? contextSrv.user.name : silence.createdBy,
createdBy: silence.createdBy,
duration: intervalToAbbreviatedDurationString(interval),
isRegex: false,
matchers: silence.matchers?.map(matcherToMatcherField) || [],

View File

@@ -31,8 +31,3 @@ export const shouldUseFullyCompatibleBackendFilters = () =>
* Saved searches feature - allows users to save and apply search queries on the Alert Rules page.
*/
export const shouldUseSavedSearches = () => config.featureToggles.alertingSavedSearches ?? false;
/**
* New grouped navigation structure for Alerting
*/
export const shouldUseAlertingNavigationV2 = () => config.featureToggles.alertingNavigationV2 ?? false;

View File

@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Box, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
@@ -15,13 +14,10 @@ import { PluginIntegrations } from './PluginIntegrations';
import SyntheticMonitoringCard from './SyntheticMonitoringCard';
function Home() {
// When V2 navigation is enabled, don't show Insights tab on Home page
// (Insights is available via the sidebar Insights menu instead)
const insightsEnabled = (insightsIsAvailable() || isLocalDevEnv()) && !config.featureToggles.alertingNavigationV2;
const insightsEnabled = insightsIsAvailable() || isLocalDevEnv();
const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview');
// Memoize the scene so it's only created once and properly initialized
const insightsScene = useMemo(() => getInsightsScenes(), []);
const insightsScene = getInsightsScenes();
return (
<AlertingPageWrapper subTitle="Learn about problems in your systems moments after they occur" navId="alerting">

View File

@@ -1,44 +0,0 @@
import { useMemo } from 'react';
import { Trans, t } from '@grafana/i18n';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { getInsightsScenes, insightsIsAvailable } from '../home/Insights';
import { useInsightsNav } from '../navigation/useInsightsNav';
import { isLocalDevEnv } from '../utils/misc';
import { withPageErrorBoundary } from '../withPageErrorBoundary';
function InsightsPage() {
const insightsEnabled = insightsIsAvailable() || isLocalDevEnv();
const { navId, pageNav } = useInsightsNav();
// Memoize the scene so it's only created once and properly initialized
const insightsScene = useMemo(() => getInsightsScenes(), []);
if (!insightsEnabled) {
return (
<AlertingPageWrapper
navId={navId || 'insights'}
pageNav={pageNav}
subTitle={t('alerting.insights.subtitle', 'Analytics and history for alerting')}
>
<div>
<Trans i18nKey="alerting.insights.not-available">
Insights are not available. Please configure the required data sources.
</Trans>
</div>
</AlertingPageWrapper>
);
}
return (
<AlertingPageWrapper
navId={navId || 'insights'}
pageNav={pageNav}
subTitle={t('alerting.insights.subtitle', 'Analytics and history for alerting')}
>
<insightsScene.Component model={insightsScene} />
</AlertingPageWrapper>
);
}
export default withPageErrorBoundary(InsightsPage);

View File

@@ -1,187 +0,0 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { useAlertActivityNav } from './useAlertActivityNav';
describe('useAlertActivityNav', () => {
const mockNavIndex = {
'alert-activity': {
id: 'alert-activity',
text: 'Alert activity',
url: '/alerting/alerts',
},
'alert-activity-alerts': {
id: 'alert-activity-alerts',
text: 'Alerts',
url: '/alerting/alerts',
},
'alert-activity-groups': {
id: 'alert-activity-groups',
text: 'Active notifications',
url: '/alerting/groups',
},
groups: {
id: 'groups',
text: 'Alert groups',
url: '/alerting/groups',
},
'alert-alerts': {
id: 'alert-alerts',
text: 'Alerts',
url: '/alerting/alerts',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
beforeEach(() => {
config.featureToggles.alertingNavigationV2 = false;
});
it('should return legacy navId when feature flag is off for /alerting/groups', () => {
const wrapper = getWrapper({
preloadedState: defaultPreloadedState,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('groups');
expect(result.current.pageNav).toBeUndefined();
});
it('should return legacy navId when feature flag is off for /alerting/alerts', () => {
const wrapper = getWrapper({
preloadedState: defaultPreloadedState,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/alerts'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('alert-alerts');
expect(result.current.pageNav).toBeUndefined();
});
it('should return V2 navigation when feature flag is on for Alerts tab', () => {
config.featureToggles.alertingNavigationV2 = true;
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/alerts'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('alert-activity');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
// The pageNav should represent Alert Activity (not the active tab) for consistent title
expect(result.current.pageNav?.text).toBe('Alert activity');
});
it('should return V2 navigation when feature flag is on for Active notifications tab', () => {
config.featureToggles.alertingNavigationV2 = true;
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('alert-activity');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
// The pageNav should represent Alert Activity (not the active tab) for consistent title
expect(result.current.pageNav?.text).toBe('Alert activity');
});
it('should set active tab based on current path', () => {
config.featureToggles.alertingNavigationV2 = true;
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
const activeNotificationsTab = result.current.pageNav?.children?.find((tab) => tab.id === 'alert-activity-groups');
expect(activeNotificationsTab?.active).toBe(true);
// eslint-disable-next-line testing-library/no-node-access
const alertsTab = result.current.pageNav?.children?.find((tab) => tab.id === 'alert-activity-alerts');
expect(alertsTab?.active).toBe(false);
});
it('should filter tabs based on permissions', () => {
config.featureToggles.alertingNavigationV2 = true;
const limitedNavIndex = {
'alert-activity': mockNavIndex['alert-activity'],
'alert-activity-alerts': mockNavIndex['alert-activity-alerts'],
// Missing 'alert-activity-groups' - user doesn't have permission
};
const store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/alerts'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBe(1);
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.[0].id).toBe('alert-activity-alerts');
});
it('should fallback to legacy when alert-activity nav is missing', () => {
config.featureToggles.alertingNavigationV2 = true;
const store = configureStore({
navIndex: {
groups: mockNavIndex.groups,
'alert-alerts': mockNavIndex['alert-alerts'],
},
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('groups');
expect(result.current.pageNav).toBeUndefined();
});
});

View File

@@ -1,93 +0,0 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
export function useAlertActivityNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const useV2Nav = shouldUseAlertingNavigationV2();
// If V2 navigation is not enabled, return legacy navId
if (!useV2Nav) {
if (location.pathname === '/alerting/groups') {
return {
navId: 'groups',
pageNav: undefined,
};
}
if (location.pathname === '/alerting/alerts') {
return {
navId: 'alert-alerts',
pageNav: undefined,
};
}
return {
navId: undefined,
pageNav: undefined,
};
}
const alertActivityNav = navIndex['alert-activity'];
if (!alertActivityNav) {
// Fallback to legacy
if (location.pathname === '/alerting/groups') {
return {
navId: 'groups',
pageNav: undefined,
};
}
if (location.pathname === '/alerting/alerts') {
return {
navId: 'alert-alerts',
pageNav: undefined,
};
}
return {
navId: undefined,
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
id: 'alert-activity-alerts',
text: t('alerting.navigation.alerts', 'Alerts'),
url: '/alerting/alerts',
active: location.pathname === '/alerting/alerts',
icon: 'bell',
parentItem: alertActivityNav,
},
{
id: 'alert-activity-groups',
text: t('alerting.navigation.active-notifications', 'Active notifications'),
url: '/alerting/groups',
active: location.pathname === '/alerting/groups',
icon: 'layer-group',
parentItem: alertActivityNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav structure following the same pattern as useNotificationConfigNav
// Keep "Alert Activity" as the pageNav (not the active tab) so the title and subtitle stay consistent
// The tabs are children, and the breadcrumb utility will add the active tab to breadcrumbs
// (including the first tab, after our fix to the breadcrumb utility)
const pageNav: NavModelItem = {
...alertActivityNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'alert-activity',
pageNav,
};
}

View File

@@ -1,123 +0,0 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { useAlertRulesNav } from './useAlertRulesNav';
describe('useAlertRulesNav', () => {
const mockNavIndex = {
'alert-rules': {
id: 'alert-rules',
text: 'Alert rules',
url: '/alerting/list',
icon: 'list-ul',
},
'alert-rules-list': {
id: 'alert-rules-list',
text: 'Alert rules',
url: '/alerting/list',
},
'alert-rules-recently-deleted': {
id: 'alert-rules-recently-deleted',
text: 'Recently deleted',
url: '/alerting/recently-deleted',
},
'alert-list': {
id: 'alert-list',
text: 'Alert rules',
url: '/alerting/list',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
beforeEach(() => {
config.featureToggles.alertingNavigationV2 = false;
});
it('should return legacy navId when feature flag is off', () => {
const wrapper = getWrapper({
preloadedState: defaultPreloadedState,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/list'],
},
});
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
expect(result.current.navId).toBe('alert-list');
expect(result.current.pageNav).toBeUndefined();
});
it('should return V2 navigation when feature flag is on', () => {
config.featureToggles.alertingNavigationV2 = true;
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/list'],
},
});
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
expect(result.current.navId).toBe('alert-rules');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBeGreaterThan(0);
});
it('should filter tabs based on permissions', () => {
config.featureToggles.alertingNavigationV2 = true;
const limitedNavIndex = {
'alert-rules': mockNavIndex['alert-rules'],
'alert-rules-list': mockNavIndex['alert-rules-list'],
// Missing 'alert-rules-recently-deleted' - user doesn't have permission
};
const store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/list'],
},
});
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBe(1);
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.[0].id).toBe('alert-rules-list');
});
it('should set active tab based on current path', () => {
config.featureToggles.alertingNavigationV2 = true;
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/recently-deleted'],
},
});
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
const recentlyDeletedTab = result.current.pageNav?.children?.find(
(tab) => tab.id === 'alert-rules-recently-deleted'
);
expect(recentlyDeletedTab?.active).toBe(true);
});
});

View File

@@ -1,66 +0,0 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
export function useAlertRulesNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const useV2Nav = shouldUseAlertingNavigationV2();
// If V2 navigation is not enabled, return legacy navId
if (!useV2Nav) {
return {
navId: 'alert-list',
pageNav: undefined,
};
}
const alertRulesNav = navIndex['alert-rules'];
if (!alertRulesNav) {
// Fallback to legacy if V2 nav doesn't exist
return {
navId: 'alert-list',
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
id: 'alert-rules-list',
text: t('alerting.navigation.alert-rules', 'Alert rules'),
url: '/alerting/list',
active: location.pathname === '/alerting/list',
icon: 'list-ul',
parentItem: alertRulesNav,
},
{
id: 'alert-rules-recently-deleted',
text: t('alerting.navigation.recently-deleted', 'Recently deleted'),
url: '/alerting/recently-deleted',
active: location.pathname === '/alerting/recently-deleted',
icon: 'trash-alt',
parentItem: alertRulesNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav that represents the Alert rules page with tabs as children
const pageNav: NavModelItem = {
...alertRulesNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'alert-rules',
pageNav,
};
}

View File

@@ -1,118 +0,0 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { useInsightsNav } from './useInsightsNav';
describe('useInsightsNav', () => {
const mockNavIndex = {
insights: {
id: 'insights',
text: 'Insights',
url: '/alerting/insights',
},
'insights-system': {
id: 'insights-system',
text: 'System Insights',
url: '/alerting/insights',
},
'insights-history': {
id: 'insights-history',
text: 'Alert state history',
url: '/alerting/history',
},
'alerts-history': {
id: 'alerts-history',
text: 'History',
url: '/alerting/history',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
beforeEach(() => {
config.featureToggles.alertingNavigationV2 = false;
});
it('should return legacy navId when feature flag is off', () => {
const wrapper = getWrapper({
preloadedState: defaultPreloadedState,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/history'],
},
});
const { result } = renderHook(() => useInsightsNav(), { wrapper });
expect(result.current.navId).toBe('alerts-history');
expect(result.current.pageNav).toBeUndefined();
});
it('should return V2 navigation when feature flag is on', () => {
config.featureToggles.alertingNavigationV2 = true;
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/insights'],
},
});
const { result } = renderHook(() => useInsightsNav(), { wrapper });
expect(result.current.navId).toBe('insights');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
});
it('should set active tab based on current path', () => {
config.featureToggles.alertingNavigationV2 = true;
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/history'],
},
});
const { result } = renderHook(() => useInsightsNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
const historyTab = result.current.pageNav?.children?.find((tab) => tab.id === 'insights-history');
expect(historyTab?.active).toBe(true);
});
it('should filter tabs based on permissions', () => {
config.featureToggles.alertingNavigationV2 = true;
const limitedNavIndex = {
insights: mockNavIndex.insights,
'insights-system': mockNavIndex['insights-system'],
// Missing 'insights-history' - user doesn't have permission
};
const store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/insights'],
},
});
const { result } = renderHook(() => useInsightsNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBe(1);
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.[0].id).toBe('insights-system');
});
});

View File

@@ -1,79 +0,0 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
export function useInsightsNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const useV2Nav = shouldUseAlertingNavigationV2();
// If V2 navigation is not enabled, return legacy navId
if (!useV2Nav) {
if (location.pathname === '/alerting/history') {
return {
navId: 'alerts-history',
pageNav: undefined,
};
}
// For insights page, it doesn't exist in legacy, so return undefined
return {
navId: undefined,
pageNav: undefined,
};
}
const insightsNav = navIndex.insights;
if (!insightsNav) {
// Fallback to legacy
if (location.pathname === '/alerting/history') {
return {
navId: 'alerts-history',
pageNav: undefined,
};
}
return {
navId: undefined,
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
id: 'insights-system',
text: t('alerting.navigation.system-insights', 'System Insights'),
url: '/alerting/insights',
active: location.pathname === '/alerting/insights',
icon: 'chart-line',
parentItem: insightsNav,
},
{
id: 'insights-history',
text: t('alerting.navigation.alert-state-history', 'Alert state history'),
url: '/alerting/history',
active: location.pathname === '/alerting/history',
icon: 'history',
parentItem: insightsNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav that represents the Insights page with tabs as children
const pageNav: NavModelItem = {
...insightsNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'insights',
pageNav,
};
}

View File

@@ -1,135 +0,0 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { useNotificationConfigNav } from './useNotificationConfigNav';
describe('useNotificationConfigNav', () => {
const mockNavIndex = {
'notification-config': {
id: 'notification-config',
text: 'Notification configuration',
url: '/alerting/notifications',
},
'notification-config-contact-points': {
id: 'notification-config-contact-points',
text: 'Contact points',
url: '/alerting/notifications',
},
'notification-config-policies': {
id: 'notification-config-policies',
text: 'Notification policies',
url: '/alerting/routes',
},
'notification-config-templates': {
id: 'notification-config-templates',
text: 'Notification templates',
url: '/alerting/notifications/templates',
},
'notification-config-time-intervals': {
id: 'notification-config-time-intervals',
text: 'Time intervals',
url: '/alerting/routes?tab=time_intervals',
},
receivers: {
id: 'receivers',
text: 'Contact points',
url: '/alerting/notifications',
},
'am-routes': {
id: 'am-routes',
text: 'Notification policies',
url: '/alerting/routes',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
beforeEach(() => {
config.featureToggles.alertingNavigationV2 = false;
});
it('should return legacy navId when feature flag is off', () => {
const wrapper = getWrapper({
preloadedState: defaultPreloadedState,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/notifications'],
},
});
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
expect(result.current.navId).toBe('receivers');
expect(result.current.pageNav).toBeUndefined();
});
it('should return V2 navigation when feature flag is on', () => {
config.featureToggles.alertingNavigationV2 = true;
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/notifications'],
},
});
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
expect(result.current.navId).toBe('notification-config');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
});
it('should detect time intervals tab from V2 path', () => {
config.featureToggles.alertingNavigationV2 = true;
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/time-intervals'],
},
});
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
const timeIntervalsTab = result.current.pageNav?.children?.find(
(tab) => tab.id === 'notification-config-time-intervals'
);
expect(timeIntervalsTab?.active).toBe(true);
});
it('should filter tabs based on permissions', () => {
config.featureToggles.alertingNavigationV2 = true;
const limitedNavIndex = {
'notification-config': mockNavIndex['notification-config'],
'notification-config-contact-points': mockNavIndex['notification-config-contact-points'],
// Missing other tabs - user doesn't have permission
};
const store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/notifications'],
},
});
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBe(1);
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.[0].id).toBe('notification-config-contact-points');
});
});

View File

@@ -1,112 +0,0 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
export function useNotificationConfigNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const useV2Nav = shouldUseAlertingNavigationV2();
// If V2 navigation is not enabled, return legacy navId based on current path
if (!useV2Nav) {
if (location.pathname.includes('/alerting/notifications/templates')) {
return {
navId: 'receivers',
pageNav: undefined,
};
}
if (location.pathname === '/alerting/routes') {
return {
navId: 'am-routes',
pageNav: undefined,
};
}
return {
navId: 'receivers',
pageNav: undefined,
};
}
const notificationConfigNav = navIndex['notification-config'];
if (!notificationConfigNav) {
// Fallback to legacy navIds
if (location.pathname.includes('/alerting/notifications/templates')) {
return {
navId: 'receivers',
pageNav: undefined,
};
}
if (location.pathname === '/alerting/routes') {
return {
navId: 'am-routes',
pageNav: undefined,
};
}
return {
navId: 'receivers',
pageNav: undefined,
};
}
// Check if we're on the time intervals page
// In V2 mode, check for dedicated route; in legacy mode, check for query param
const isTimeIntervalsTab = useV2Nav
? location.pathname === '/alerting/time-intervals'
: location.pathname === '/alerting/routes' && location.search.includes('tab=time_intervals');
// All available tabs
const allTabs = [
{
id: 'notification-config-contact-points',
text: t('alerting.navigation.contact-points', 'Contact points'),
url: '/alerting/notifications',
active: location.pathname === '/alerting/notifications' && !location.pathname.includes('/templates'),
icon: 'comment-alt-share',
parentItem: notificationConfigNav,
},
{
id: 'notification-config-policies',
text: t('alerting.navigation.notification-policies', 'Notification policies'),
url: '/alerting/routes',
active: location.pathname === '/alerting/routes' && !isTimeIntervalsTab,
icon: 'sitemap',
parentItem: notificationConfigNav,
},
{
id: 'notification-config-templates',
text: t('alerting.navigation.notification-templates', 'Notification templates'),
url: '/alerting/notifications/templates',
active: location.pathname.includes('/alerting/notifications/templates'),
icon: 'file-alt',
parentItem: notificationConfigNav,
},
{
id: 'notification-config-time-intervals',
text: t('alerting.navigation.time-intervals', 'Time intervals'),
url: useV2Nav ? '/alerting/time-intervals' : '/alerting/routes?tab=time_intervals',
active: isTimeIntervalsTab,
icon: 'clock-nine',
parentItem: notificationConfigNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav that represents the Notification configuration page with tabs as children
const pageNav: NavModelItem = {
...notificationConfigNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'notification-config',
pageNav,
};
}

View File

@@ -20,7 +20,6 @@ import { shouldUsePrometheusRulesPrimary } from '../featureToggles';
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces';
import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules';
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector';
import { useAlertRulesNav } from '../navigation/useAlertRulesNav';
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions';
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames } from '../utils/datasource';
@@ -116,14 +115,11 @@ const RuleListV1 = () => {
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
const { navId, pageNav } = useAlertRulesNav();
return (
// We don't want to show the Loading... indicator for the whole page.
// We show separate indicators for Grafana-managed and Cloud rules
<AlertingPageWrapper
navId={navId}
pageNav={pageNav}
navId="alert-list"
isLoading={false}
renderTitle={(title) => <RuleListPageTitle title={title} />}
actions={<RuleListActionButtons hasAlertRulesCreated={hasAlertRulesCreated} />}

View File

@@ -13,7 +13,6 @@ import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelecto
import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton';
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
import { useRulesFilter } from '../hooks/useFilteredRules';
import { useAlertRulesNav } from '../navigation/useAlertRulesNav';
import { FilterView } from './FilterView';
import { GroupedView } from './GroupedView';
@@ -120,12 +119,10 @@ export function RuleListActions() {
export default function RuleListPage() {
const { isApplying } = useApplyDefaultSearch();
const { navId, pageNav } = useAlertRulesNav();
return (
<AlertingPageWrapper
navId={navId}
pageNav={pageNav}
navId="alert-list"
renderTitle={(title) => <RuleListPageTitle title={title} />}
isLoading={isApplying}
actions={<RuleListActions />}

View File

@@ -3,15 +3,21 @@ import { UrlSyncContextProvider } from '@grafana/scenes';
import { withErrorBoundary } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { useAlertActivityNav } from '../navigation/useAlertActivityNav';
import { TriageScene, triageScene } from './scene/TriageScene';
export const TriagePage = () => {
const { navId, pageNav } = useAlertActivityNav();
return (
<AlertingPageWrapper navId={navId || 'alert-alerts'} pageNav={pageNav}>
<AlertingPageWrapper
navId="alert-alerts"
subTitle={t(
'alerting.pages.triage.subtitle',
'See what is currently alerting and explore historical data to investigate current or past issues.'
)}
pageNav={{
text: t('alerting.pages.triage.title', 'Alerts'),
}}
>
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
<TriageScene key={triageScene.state.key} />
</UrlSyncContextProvider>

View File

@@ -39,7 +39,7 @@ export function RecentlyViewedDashboards() {
retry();
};
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
return null;
}
@@ -76,6 +76,10 @@ export function RecentlyViewedDashboards() {
</>
)}
{loading && <Spinner />}
{/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */}
{!loading && recentDashboards.length === 0 && (
<Text>{t('browse-dashboards.recently-viewed.empty', 'Nothing viewed yet')}</Text>
)}
{!loading && recentDashboards.length > 0 && (
<ul className={styles.list}>

View File

@@ -128,7 +128,7 @@ describe('PanelTimeRange', () => {
expect(panelTime.state.value.to.format('Z')).toBe('+00:00'); // UTC
});
it('should handle invalid time reference in timeShift with relative time range', () => {
it('should handle invalid time reference in timeShift', () => {
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
buildAndActivateSceneFor(panelTime);
@@ -139,22 +139,6 @@ describe('PanelTimeRange', () => {
expect(panelTime.state.to).toBe('now');
});
it('should handle invalid time reference in timeShift with absolute time range', () => {
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
const absoluteFrom = '2019-02-11T10:00:00.000Z';
const absoluteTo = '2019-02-11T16:00:00.000Z';
const scene = new SceneFlexLayout({
$timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }),
children: [new SceneFlexItem({ body: panel })],
});
activateFullSceneTree(scene);
expect(panelTime.state.timeInfo).toBe('invalid timeshift');
expect(panelTime.state.from).toBe(absoluteFrom);
expect(panelTime.state.to).toBe(absoluteTo);
});
it('should handle invalid time reference in timeShift combined with timeFrom', () => {
const panelTime = new PanelTimeRange({
timeFrom: 'now-2h',
@@ -169,66 +153,6 @@ describe('PanelTimeRange', () => {
expect(panelTime.state.to).toBe('now');
});
describe('from/to state format for liveNow compatibility', () => {
it('should store relative strings in from/to when timeShift is applied to relative time range', () => {
const panelTime = new PanelTimeRange({ timeShift: '2h' });
buildAndActivateSceneFor(panelTime);
expect(panelTime.state.from).toBe('now-6h-2h');
expect(panelTime.state.to).toBe('now-2h');
expect(panelTime.state.value.raw.from).toBe('now-6h-2h');
expect(panelTime.state.value.raw.to).toBe('now-2h');
});
it('should store relative strings when both timeFrom and timeShift are applied', () => {
const panelTime = new PanelTimeRange({ timeFrom: '2h', timeShift: '1h' });
buildAndActivateSceneFor(panelTime);
expect(panelTime.state.from).toBe('now-2h-1h');
expect(panelTime.state.to).toBe('now-1h');
});
it('should store ISO strings when timeShift is applied to absolute time range', () => {
const panelTime = new PanelTimeRange({ timeShift: '1h' });
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
const absoluteFrom = '2019-02-11T10:00:00.000Z';
const absoluteTo = '2019-02-11T16:00:00.000Z';
const scene = new SceneFlexLayout({
$timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }),
children: [new SceneFlexItem({ body: panel })],
});
activateFullSceneTree(scene);
expect(panelTime.state.from).toBe('2019-02-11T09:00:00.000Z');
expect(panelTime.state.to).toBe('2019-02-11T15:00:00.000Z');
});
it('should update from/to when ancestor time range changes', () => {
const panelTime = new PanelTimeRange({ timeShift: '1h' });
const sceneTimeRange = new SceneTimeRange({ from: 'now-6h', to: 'now' });
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
const scene = new SceneFlexLayout({
$timeRange: sceneTimeRange,
children: [new SceneFlexItem({ body: panel })],
});
activateFullSceneTree(scene);
expect(panelTime.state.from).toBe('now-6h-1h');
expect(panelTime.state.to).toBe('now-1h');
sceneTimeRange.onTimeRangeChange({
from: dateTime('2019-02-11T12:00:00.000Z'),
to: dateTime('2019-02-11T18:00:00.000Z'),
raw: { from: 'now-12h', to: 'now' },
});
expect(panelTime.state.from).toBe('now-12h-1h');
expect(panelTime.state.to).toBe('now-1h');
});
});
describe('onTimeRangeChange', () => {
it('should reverse timeShift when updating time range', () => {
const oneHourShift = '1h';

View File

@@ -81,19 +81,7 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
}
const overrideResult = this.getTimeOverride(timeRange.value);
const { timeRange: overrideTimeRange } = overrideResult;
this.setState({
value: overrideTimeRange,
timeInfo: overrideResult.timeInfo,
from:
typeof overrideTimeRange.raw.from === 'string'
? overrideTimeRange.raw.from
: overrideTimeRange.raw.from.toISOString(),
to:
typeof overrideTimeRange.raw.to === 'string'
? overrideTimeRange.raw.to
: overrideTimeRange.raw.to.toISOString(),
});
this.setState({ value: overrideResult.timeRange, timeInfo: overrideResult.timeInfo });
}
// Get a time shifted request to compare with the primary request.
@@ -165,10 +153,10 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
// Only evaluate if the timeFrom if parent time is relative
if (rangeUtil.isRelativeTimeRange(parentTimeRange.raw)) {
const timezone = this.getTimeZone();
const timeZone = this.getTimeZone();
newTimeData.timeRange = {
from: dateMath.toDateTime(timeFromInfo.from, { timezone })!,
to: dateMath.toDateTime(timeFromInfo.to, { timezone })!,
from: dateMath.parse(timeFromInfo.from, undefined, timeZone)!,
to: dateMath.parse(timeFromInfo.to, undefined, timeZone)!,
raw: { from: timeFromInfo.from, to: timeFromInfo.to },
};
infoBlocks.push(timeFromInfo.display);
@@ -184,39 +172,18 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
return newTimeData;
}
const shift = '-' + timeShiftInterpolated;
infoBlocks.push('timeshift ' + shift);
const timeShift = '-' + timeShiftInterpolated;
infoBlocks.push('timeshift ' + timeShift);
if (rangeUtil.isRelativeTimeRange(newTimeData.timeRange.raw)) {
const timezone = this.getTimeZone();
const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false)!;
const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true)!;
const rawFromShifted = `${newTimeData.timeRange.raw.from}${shift}`;
const rawToShifted = `${newTimeData.timeRange.raw.to}${shift}`;
const from = dateMath.toDateTime(rawFromShifted, { timezone });
const to = dateMath.toDateTime(rawToShifted, { timezone });
if (!from || !to) {
newTimeData.timeInfo = 'invalid timeshift';
return newTimeData;
}
newTimeData.timeRange = {
from,
to,
raw: { from: rawFromShifted, to: rawToShifted },
};
} else {
const from = dateMath.parseDateMath(shift, newTimeData.timeRange.from, false);
const to = dateMath.parseDateMath(shift, newTimeData.timeRange.to, true);
if (!from || !to) {
newTimeData.timeInfo = 'invalid timeshift';
return newTimeData;
}
newTimeData.timeRange = { from, to, raw: { from, to } };
if (!from || !to) {
newTimeData.timeInfo = 'invalid timeshift';
return newTimeData;
}
newTimeData.timeRange = { from, to, raw: { from, to } };
}
if (compareWith) {

View File

@@ -11,6 +11,7 @@ import {
SQLQuery,
SQLSelectableValue,
SqlDatasource,
SQLVariableSupport,
formatSQL,
} from '@grafana/sql';
@@ -25,6 +26,7 @@ export class PostgresDatasource extends SqlDatasource {
constructor(instanceSettings: DataSourceInstanceSettings<PostgresOptions>) {
super(instanceSettings);
this.variables = new SQLVariableSupport(this);
}
getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): PostgresQueryModel {

View File

@@ -3759,6 +3759,7 @@
},
"recently-viewed": {
"clear": "Clear history",
"empty": "Nothing viewed yet",
"error": "Recently viewed dashboards couldnt be loaded.",
"retry": "Retry",
"title": "Recently viewed"