Compare commits
6 Commits
alerting/r
...
sriram/SQL
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f0764d1a0 | ||
|
|
05ad955c7b | ||
|
|
2b82490e88 | ||
|
|
c5bff2df50 | ||
|
|
c621dbc325 | ||
|
|
ecd3f0b490 |
@@ -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.
|
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.
|
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.
|
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.
|
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
|
### Focus on traces or spans
|
||||||
|
|
||||||
Under **Options**, you can choose to display the table as **Traces** or **Spans** focused.
|
Under **Options**, you can choose to display the table as **Traces** or **Spans** focused.
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -33,14 +33,12 @@ require (
|
|||||||
github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad
|
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 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 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/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/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/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/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/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/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/aws/smithy-go v1.23.2 // @grafana/aws-datasources
|
||||||
github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group
|
github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group
|
||||||
github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend
|
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/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/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/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/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/feature/s3/manager v1.17.84 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // 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/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/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/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/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
|
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
|
||||||
|
|||||||
@@ -165,17 +165,9 @@ describe('DateMath', () => {
|
|||||||
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf());
|
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf());
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it('should handle multiple math expressions', () => {
|
||||||
['-2d-6h', [2014, 1, 5], [2014, 1, 2, 18]],
|
const date = dateMath.parseDateMath('-2d-6h', dateTime([2014, 1, 5]));
|
||||||
['-30m-2d', [2014, 1, 5], [2014, 1, 2, 23, 30]],
|
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 2, 18]).valueOf());
|
||||||
['-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 return false when invalid expression', () => {
|
it('should return false when invalid expression', () => {
|
||||||
|
|||||||
@@ -547,11 +547,6 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
alertingCentralAlertHistory?: boolean;
|
alertingCentralAlertHistory?: boolean;
|
||||||
/**
|
/**
|
||||||
* Enable new grouped navigation structure for Alerting
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
alertingNavigationV2?: boolean;
|
|
||||||
/**
|
|
||||||
* Preserve plugin proxy trailing slash.
|
* Preserve plugin proxy trailing slash.
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
|
|||||||
199
packages/grafana-sql/src/SQLVariableSupport.test.tsx
Normal file
199
packages/grafana-sql/src/SQLVariableSupport.test.tsx
Normal 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],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
155
packages/grafana-sql/src/SQLVariableSupport.tsx
Normal file
155
packages/grafana-sql/src/SQLVariableSupport.tsx
Normal 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];
|
||||||
|
};
|
||||||
@@ -21,6 +21,7 @@ export { TLSSecretsConfig } from './components/configuration/TLSSecretsConfig';
|
|||||||
export { useMigrateDatabaseFields } from './components/configuration/useMigrateDatabaseFields';
|
export { useMigrateDatabaseFields } from './components/configuration/useMigrateDatabaseFields';
|
||||||
export { SqlQueryEditorLazy } from './components/QueryEditorLazy';
|
export { SqlQueryEditorLazy } from './components/QueryEditorLazy';
|
||||||
export type { QueryHeaderProps } from './components/QueryHeader';
|
export type { QueryHeaderProps } from './components/QueryHeader';
|
||||||
|
export { SQLVariableSupport } from './SQLVariableSupport';
|
||||||
export { createSelectClause, haveColumns } from './utils/sql.utils';
|
export { createSelectClause, haveColumns } from './utils/sql.utils';
|
||||||
export { applyQueryDefaults } from './defaults';
|
export { applyQueryDefaults } from './defaults';
|
||||||
export { makeVariable } from './utils/testHelpers';
|
export { makeVariable } from './utils/testHelpers';
|
||||||
|
|||||||
@@ -69,6 +69,12 @@
|
|||||||
"placeholder-select-format": "Select format",
|
"placeholder-select-format": "Select format",
|
||||||
"run-query": "Run query"
|
"run-query": "Run query"
|
||||||
},
|
},
|
||||||
|
"query-meta": {
|
||||||
|
"variables": {
|
||||||
|
"textField": "Text Field",
|
||||||
|
"valueField": "Value Field"
|
||||||
|
}
|
||||||
|
},
|
||||||
"query-toolbox": {
|
"query-toolbox": {
|
||||||
"content-hit-ctrlcmdreturn-to-run-query": "Hit CTRL/CMD+Return to run query",
|
"content-hit-ctrlcmdreturn-to-run-query": "Hit CTRL/CMD+Return to run query",
|
||||||
"tooltip-collapse": "Collapse editor",
|
"tooltip-collapse": "Collapse editor",
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export enum QueryFormat {
|
|||||||
Table = 'table',
|
Table = 'table',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SQLQueryMeta = { valueField?: string; textField?: string };
|
||||||
|
|
||||||
export interface SQLQuery extends DataQuery {
|
export interface SQLQuery extends DataQuery {
|
||||||
alias?: string;
|
alias?: string;
|
||||||
format?: QueryFormat;
|
format?: QueryFormat;
|
||||||
@@ -59,6 +61,7 @@ export interface SQLQuery extends DataQuery {
|
|||||||
sql?: SQLExpression;
|
sql?: SQLExpression;
|
||||||
editorMode?: EditorMode;
|
editorMode?: EditorMode;
|
||||||
rawQuery?: boolean;
|
rawQuery?: boolean;
|
||||||
|
meta?: SQLQueryMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NameValue {
|
export interface NameValue {
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response.
|
|||||||
func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
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())
|
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 {
|
func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
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"]
|
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 {
|
func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
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{}
|
cmd := datasources.AddDataSourceCommand{}
|
||||||
@@ -497,7 +497,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
|
|||||||
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
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{}
|
cmd := datasources.UpdateDataSourceCommand{}
|
||||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func setupDsConfigHandlerMetrics() (prometheus.Registerer, *prometheus.Histogram
|
|||||||
Namespace: "grafana",
|
Namespace: "grafana",
|
||||||
Name: "ds_config_handler_requests_duration_seconds",
|
Name: "ds_config_handler_requests_duration_seconds",
|
||||||
Help: "Duration of requests handled by datasource configuration handlers",
|
Help: "Duration of requests handled by datasource configuration handlers",
|
||||||
}, []string{"handler"})
|
}, []string{"code_path", "handler"})
|
||||||
promRegister.MustRegister(dsConfigHandlerRequestsDuration)
|
promRegister.MustRegister(dsConfigHandlerRequestsDuration)
|
||||||
return promRegister, dsConfigHandlerRequestsDuration
|
return promRegister, dsConfigHandlerRequestsDuration
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
|||||||
Namespace: "grafana",
|
Namespace: "grafana",
|
||||||
Name: "ds_config_handler_requests_duration_seconds",
|
Name: "ds_config_handler_requests_duration_seconds",
|
||||||
Help: "Duration of requests handled by datasource configuration handlers",
|
Help: "Duration of requests handled by datasource configuration handlers",
|
||||||
}, []string{"handler"}),
|
}, []string{"code_path", "handler"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
promRegister.MustRegister(hs.htmlHandlerRequestsDuration)
|
promRegister.MustRegister(hs.htmlHandlerRequestsDuration)
|
||||||
|
|||||||
@@ -928,10 +928,9 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *contextmodel.ReqContext, cfg
|
|||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
features := featuremgmt.WithFeatures()
|
features := featuremgmt.WithFeatures()
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := datasourceservice.ProvideService(nil, 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{},
|
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
|
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -1051,11 +1050,9 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secrets
|
|||||||
var routes []*plugins.Route
|
var routes []*plugins.Route
|
||||||
features := featuremgmt.WithFeatures()
|
features := featuremgmt.WithFeatures()
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
var sqlStore db.DB = nil
|
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
||||||
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
|
||||||
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
|
proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -1109,11 +1106,9 @@ func setupDSProxyTest(t *testing.T, ctx *contextmodel.ReqContext, ds *datasource
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger())
|
secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger())
|
||||||
features := featuremgmt.WithFeatures()
|
features := featuremgmt.WithFeatures()
|
||||||
var sqlStore db.DB = nil
|
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
||||||
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
|
||||||
&actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
&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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
tracer := tracing.InitializeTracerForTest()
|
tracer := tracing.InitializeTracerForTest()
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ import (
|
|||||||
_ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault"
|
_ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault"
|
||||||
_ "github.com/Azure/go-autorest/autorest"
|
_ "github.com/Azure/go-autorest/autorest"
|
||||||
_ "github.com/Azure/go-autorest/autorest/adal"
|
_ "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/beevik/etree"
|
||||||
_ "github.com/blugelabs/bluge"
|
_ "github.com/blugelabs/bluge"
|
||||||
_ "github.com/blugelabs/bluge_segment_api"
|
_ "github.com/blugelabs/bluge_segment_api"
|
||||||
@@ -49,6 +46,7 @@ import (
|
|||||||
_ "sigs.k8s.io/randfill"
|
_ "sigs.k8s.io/randfill"
|
||||||
_ "xorm.io/builder"
|
_ "xorm.io/builder"
|
||||||
|
|
||||||
|
_ "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
|
||||||
_ "github.com/grafana/authlib/authn"
|
_ "github.com/grafana/authlib/authn"
|
||||||
_ "github.com/grafana/authlib/authz"
|
_ "github.com/grafana/authlib/authz"
|
||||||
_ "github.com/grafana/authlib/cache"
|
_ "github.com/grafana/authlib/cache"
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ func (ots *TracingService) initSampler() (tracesdk.Sampler, error) {
|
|||||||
case "rateLimiting":
|
case "rateLimiting":
|
||||||
return newRateLimiter(ots.cfg.SamplerParam), nil
|
return newRateLimiter(ots.cfg.SamplerParam), nil
|
||||||
case "remote":
|
case "remote":
|
||||||
return jaegerremote.New(ots.cfg.ServiceName,
|
return jaegerremote.New("grafana",
|
||||||
jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL),
|
jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL),
|
||||||
jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)),
|
jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)),
|
||||||
), nil
|
), nil
|
||||||
|
|||||||
@@ -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) {
|
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)
|
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 {
|
if s.dsConfigHandlerRequestsDuration != nil {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
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 {
|
if s.dsConfigHandlerRequestsDuration != nil {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
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 {
|
if s.dsConfigHandlerRequestsDuration != nil {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
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 {
|
if s.dsConfigHandlerRequestsDuration != nil {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
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.
|
// DeleteCollection implements rest.CollectionDeleter.
|
||||||
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
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)
|
dss, err := s.datasources.ListDataSources(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import (
|
|||||||
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||||
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
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/infra/metrics/metricutil"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/sources"
|
"github.com/grafana/grafana/pkg/plugins/manager/sources"
|
||||||
@@ -70,10 +69,10 @@ func RegisterAPIService(
|
|||||||
|
|
||||||
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
Namespace: "grafana",
|
Namespace: "grafana",
|
||||||
Name: "ds_config_handler_apis_requests_duration_seconds",
|
Name: "ds_config_handler_requests_duration_seconds",
|
||||||
Help: "Duration of requests handled by new k8s style APIs datasource configuration handlers",
|
Help: "Duration of requests handled by datasource configuration handlers",
|
||||||
}, []string{"handler"})
|
}, []string{"code_path", "handler"})
|
||||||
regErr := metrics.ProvideRegisterer().Register(dataSourceCRUDMetric)
|
regErr := reg.Register(dataSourceCRUDMetric)
|
||||||
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
|
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
|
||||||
return nil, regErr
|
return nil, regErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
|
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
|
||||||
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
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/pluginassets"
|
||||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||||
)
|
)
|
||||||
@@ -37,13 +36,9 @@ func ProvideAppInstaller(
|
|||||||
pluginStore pluginstore.Store,
|
pluginStore pluginstore.Store,
|
||||||
pluginAssetsService *pluginassets.Service,
|
pluginAssetsService *pluginassets.Service,
|
||||||
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
|
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
|
||||||
features featuremgmt.FeatureToggles,
|
|
||||||
) (*AppInstaller, error) {
|
) (*AppInstaller, error) {
|
||||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
if err := registerAccessControlRoles(accessControlService); err != nil {
|
||||||
if features.IsEnabledGlobally(featuremgmt.FlagPluginStoreServiceLoading) {
|
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)
|
localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService)
|
||||||
|
|||||||
@@ -330,7 +330,6 @@ var wireBasicSet = wire.NewSet(
|
|||||||
dashsnapstore.ProvideStore,
|
dashsnapstore.ProvideStore,
|
||||||
wire.Bind(new(dashboardsnapshots.Service), new(*dashsnapsvc.ServiceImpl)),
|
wire.Bind(new(dashboardsnapshots.Service), new(*dashsnapsvc.ServiceImpl)),
|
||||||
dashsnapsvc.ProvideService,
|
dashsnapsvc.ProvideService,
|
||||||
datasourceservice.ProvideDataSourceRetriever,
|
|
||||||
datasourceservice.ProvideService,
|
datasourceservice.ProvideService,
|
||||||
wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)),
|
wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)),
|
||||||
datasourceservice.ProvideLegacyDataSourceLookup,
|
datasourceservice.ProvideLegacyDataSourceLookup,
|
||||||
|
|||||||
12
pkg/server/wire_gen.go
generated
12
pkg/server/wire_gen.go
generated
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ package authorizer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
k8suser "k8s.io/apiserver/pkg/authentication/user"
|
k8suser "k8s.io/apiserver/pkg/authentication/user"
|
||||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||||
@@ -28,9 +29,9 @@ type GrafanaAuthorizer struct {
|
|||||||
// 4. We check authorizer that is configured speficially for an api.
|
// 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
|
// 5. As a last fallback we check Role, this will only happen if an api have not configured
|
||||||
// an authorizer or return authorizer.DecisionNoOpinion
|
// an authorizer or return authorizer.DecisionNoOpinion
|
||||||
func NewGrafanaBuiltInSTAuthorizer() *GrafanaAuthorizer {
|
func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer {
|
||||||
authorizers := []authorizer.Authorizer{
|
authorizers := []authorizer.Authorizer{
|
||||||
NewImpersonationAuthorizer(),
|
newImpersonationAuthorizer(),
|
||||||
authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup),
|
authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup),
|
||||||
newNamespaceAuthorizer(),
|
newNamespaceAuthorizer(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil)
|
var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil)
|
||||||
|
|
||||||
func NewImpersonationAuthorizer() *impersonationAuthorizer {
|
func newImpersonationAuthorizer() *impersonationAuthorizer {
|
||||||
return &impersonationAuthorizer{}
|
return &impersonationAuthorizer{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,19 @@ var PathRewriters = []filters.PathRewriter{
|
|||||||
|
|
||||||
func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc {
|
func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc {
|
||||||
return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler {
|
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
|
// filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context
|
||||||
handler = filters.WithRequester(handler)
|
handler = filters.WithRequester(handler)
|
||||||
|
|||||||
@@ -3,306 +3,146 @@ package builder
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/emicklei/go-restful/v3"
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
||||||
|
restclient "k8s.io/client-go/rest"
|
||||||
klog "k8s.io/klog/v2"
|
klog "k8s.io/klog/v2"
|
||||||
"k8s.io/kube-openapi/pkg/spec3"
|
"k8s.io/kube-openapi/pkg/spec3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// convertHandlerToRouteFunction converts an http.HandlerFunc to a restful.RouteFunction
|
type requestHandler struct {
|
||||||
// It extracts path parameters from restful.Request and populates them in the request context
|
router *mux.Router
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AugmentWebServicesWithCustomRoutes adds custom routes from builders to existing WebServices
|
func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer, apiResourceConfig *serverstorage.ResourceConfig) (http.Handler, error) {
|
||||||
// in the container.
|
useful := false // only true if any routes exist anywhere
|
||||||
func AugmentWebServicesWithCustomRoutes(
|
router := mux.NewRouter()
|
||||||
container *restful.Container,
|
|
||||||
builders []APIGroupBuilder,
|
|
||||||
metricsRegistry prometheus.Registerer,
|
|
||||||
apiResourceConfig *serverstorage.ResourceConfig,
|
|
||||||
) error {
|
|
||||||
if container == nil {
|
|
||||||
return fmt.Errorf("container cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics := NewCustomRouteMetrics(metricsRegistry)
|
metrics := NewCustomRouteMetrics(metricsRegistry)
|
||||||
|
|
||||||
// Build a map of existing WebServices by root path
|
for _, builder := range builders {
|
||||||
existingWebServices := make(map[string]*restful.WebService)
|
provider, ok := builder.(APIGroupRouteProvider)
|
||||||
for _, ws := range container.RegisteredWebServices() {
|
|
||||||
existingWebServices[ws.RootPath()] = ws
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, b := range builders {
|
|
||||||
provider, ok := b.(APIGroupRouteProvider)
|
|
||||||
if !ok || provider == nil {
|
if !ok || provider == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, gv := range GetGroupVersions(b) {
|
for _, gv := range GetGroupVersions(builder) {
|
||||||
// Filter out disabled API groups
|
// filter out api groups that are disabled in APIEnablementOptions
|
||||||
gvr := gv.WithResource("")
|
gvr := gv.WithResource("")
|
||||||
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
routes := provider.GetAPIRoutes(gv)
|
routes := provider.GetAPIRoutes(gv)
|
||||||
if routes == nil {
|
if routes == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create WebService for this group version
|
prefix := "/apis/" + gv.String()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add root handlers using OpenAPI specs
|
// Root handlers
|
||||||
|
var sub *mux.Router
|
||||||
for _, route := range routes.Root {
|
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(
|
instrumentedHandler := metrics.InstrumentHandler(
|
||||||
gv.Group,
|
gv.Group,
|
||||||
gv.Version,
|
gv.Version,
|
||||||
route.Path,
|
route.Path, // Use path as resource identifier
|
||||||
route.Handler,
|
route.Handler,
|
||||||
)
|
)
|
||||||
routeFunction := convertHandlerToRouteFunction(instrumentedHandler)
|
|
||||||
|
|
||||||
// Use OpenAPI spec to configure routes properly
|
sub.HandleFunc("/"+route.Path, instrumentedHandler).
|
||||||
if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, false); err != nil {
|
Methods(methods...)
|
||||||
return fmt.Errorf("failed to add root route %s: %w", route.Path, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add namespace handlers using OpenAPI specs
|
// Namespace handlers
|
||||||
|
sub = nil
|
||||||
|
prefix += "/namespaces/{namespace}"
|
||||||
for _, route := range routes.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(
|
instrumentedHandler := metrics.InstrumentHandler(
|
||||||
gv.Group,
|
gv.Group,
|
||||||
gv.Version,
|
gv.Version,
|
||||||
route.Path,
|
route.Path, // Use path as resource identifier
|
||||||
route.Handler,
|
route.Handler,
|
||||||
)
|
)
|
||||||
routeFunction := convertHandlerToRouteFunction(instrumentedHandler)
|
|
||||||
|
|
||||||
// Use OpenAPI spec to configure routes properly
|
sub.HandleFunc("/"+route.Path, instrumentedHandler).
|
||||||
if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, true); err != nil {
|
Methods(methods...)
|
||||||
return fmt.Errorf("failed to add namespace route %s: %w", route.Path, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (h *requestHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
func addRouteFromSpec(ws *restful.WebService, routePath string, pathProps *spec3.PathProps, handler restful.RouteFunction, isNamespaced bool) error {
|
h.router.ServeHTTP(w, req)
|
||||||
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 prefixRouteIDWithK8sVerbIfNotPresent(operationID string, method string) string {
|
func methodsFromSpec(slug string, props *spec3.PathProps) ([]string, error) {
|
||||||
for _, verb := range allowedK8sVerbs {
|
if props == nil {
|
||||||
if len(operationID) > len(verb) && operationID[:len(verb)] == verb {
|
return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, nil
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, part := range parts {
|
methods := make([]string, 0)
|
||||||
if part == "" {
|
if props.Get != nil {
|
||||||
continue
|
methods = append(methods, "GET")
|
||||||
}
|
}
|
||||||
|
if props.Post != nil {
|
||||||
// Extract parameter name from {paramName} format
|
methods = append(methods, "POST")
|
||||||
if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
|
}
|
||||||
paramName := part[1 : len(part)-1]
|
if props.Put != nil {
|
||||||
// Skip generic parameters like {namespace}, but keep specific ones like {flagKey}
|
methods = append(methods, "PUT")
|
||||||
if paramName != "namespace" && paramName != "name" {
|
}
|
||||||
nameParts = append(nameParts, strings.ToUpper(paramName[:1])+paramName[1:])
|
if props.Patch != nil {
|
||||||
}
|
methods = append(methods, "PATCH")
|
||||||
continue
|
}
|
||||||
}
|
if props.Delete != nil {
|
||||||
|
methods = append(methods, "DELETE")
|
||||||
// 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:])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(nameParts) == 0 {
|
if len(methods) == 0 {
|
||||||
return "Route"
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/options"
|
"github.com/grafana/grafana/pkg/services/apiserver/options"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"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")
|
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
|
||||||
|
|
||||||
runtimeConfig := apiserverCfg.Key("runtime_config").String()
|
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 runtimeConfig != "" {
|
||||||
if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil {
|
if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil {
|
||||||
return fmt.Errorf("failed to set runtime config: %w", err)
|
return fmt.Errorf("failed to set runtime config: %w", err)
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func ProvideService(
|
|||||||
features: features,
|
features: features,
|
||||||
rr: rr,
|
rr: rr,
|
||||||
builders: []builder.APIGroupBuilder{},
|
builders: []builder.APIGroupBuilder{},
|
||||||
authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(),
|
authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(cfg),
|
||||||
tracing: tracing,
|
tracing: tracing,
|
||||||
db: db, // For Unified storage
|
db: db, // For Unified storage
|
||||||
metrics: reg,
|
metrics: reg,
|
||||||
@@ -443,19 +443,6 @@ func (s *service) start(ctx context.Context) error {
|
|||||||
return err
|
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
|
// stash the options for later use
|
||||||
s.options = o
|
s.options = o
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ type Service struct {
|
|||||||
pluginStore pluginstore.Store
|
pluginStore pluginstore.Store
|
||||||
pluginClient plugins.Client
|
pluginClient plugins.Client
|
||||||
basePluginContextProvider plugincontext.BasePluginContextProvider
|
basePluginContextProvider plugincontext.BasePluginContextProvider
|
||||||
retriever DataSourceRetriever
|
|
||||||
|
|
||||||
ptc proxyTransportCache
|
ptc proxyTransportCache
|
||||||
}
|
}
|
||||||
@@ -71,7 +70,6 @@ func ProvideService(
|
|||||||
features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService,
|
features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService,
|
||||||
quotaService quota.Service, pluginStore pluginstore.Store, pluginClient plugins.Client,
|
quotaService quota.Service, pluginStore pluginstore.Store, pluginClient plugins.Client,
|
||||||
basePluginContextProvider plugincontext.BasePluginContextProvider,
|
basePluginContextProvider plugincontext.BasePluginContextProvider,
|
||||||
retriever DataSourceRetriever,
|
|
||||||
) (*Service, error) {
|
) (*Service, error) {
|
||||||
dslogger := log.New("datasources")
|
dslogger := log.New("datasources")
|
||||||
store := &SqlStore{db: db, logger: dslogger, features: features}
|
store := &SqlStore{db: db, logger: dslogger, features: features}
|
||||||
@@ -91,7 +89,6 @@ func ProvideService(
|
|||||||
pluginStore: pluginStore,
|
pluginStore: pluginStore,
|
||||||
pluginClient: pluginClient,
|
pluginClient: pluginClient,
|
||||||
basePluginContextProvider: basePluginContextProvider,
|
basePluginContextProvider: basePluginContextProvider,
|
||||||
retriever: retriever,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store))
|
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) {
|
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) {
|
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) {
|
func (s *Service) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) {
|
||||||
|
|||||||
@@ -832,9 +832,8 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) {
|
|||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
permissionSvc := acmock.NewMockedPermissionsService()
|
permissionSvc := acmock.NewMockedPermissionsService()
|
||||||
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
|
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{}, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cmd := &datasources.DeleteDataSourceCommand{
|
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()
|
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||||
cfg := &setting.Cfg{}
|
cfg := &setting.Cfg{}
|
||||||
enableRBACManagedPermissions(t, cfg)
|
enableRBACManagedPermissions(t, cfg)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// First add the datasource
|
// First add the datasource
|
||||||
@@ -1127,9 +1124,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider)
|
rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider)
|
||||||
@@ -1166,9 +1161,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1219,9 +1212,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1269,9 +1260,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1327,9 +1316,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1364,9 +1351,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1435,9 +1420,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1516,9 +1499,7 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1541,9 +1522,7 @@ func TestIntegrationService_getProxySettings(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Run("Should default to disabled", func(t *testing.T) {
|
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())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
@@ -1668,9 +1645,7 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
jsonData := map[string]string{
|
jsonData := map[string]string{
|
||||||
@@ -1698,9 +1673,7 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
jsonData := map[string]string{
|
jsonData := map[string]string{
|
||||||
@@ -1726,9 +1699,7 @@ func TestIntegrationDataSource_CustomHeaders(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
dsService.cfg = setting.NewCfg()
|
dsService.cfg = setting.NewCfg()
|
||||||
@@ -1817,9 +1788,7 @@ func initDSService(t *testing.T) *Service {
|
|||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
mockPermission := acmock.NewMockedPermissionsService()
|
mockPermission := acmock.NewMockedPermissionsService()
|
||||||
mockPermission.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
mockPermission.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||||
features := featuremgmt.WithFeatures()
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{
|
||||||
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{
|
|
||||||
PluginList: []pluginstore.Plugin{{
|
PluginList: []pluginstore.Plugin{{
|
||||||
JSONData: plugins.JSONData{
|
JSONData: plugins.JSONData{
|
||||||
ID: "test",
|
ID: "test",
|
||||||
@@ -1839,7 +1808,7 @@ func initDSService(t *testing.T) *Service {
|
|||||||
ObjectBytes: req.ObjectBytes,
|
ObjectBytes: req.ObjectBytes,
|
||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return dsService
|
return dsService
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -907,14 +907,6 @@ var (
|
|||||||
Owner: grafanaAlertingSquad,
|
Owner: grafanaAlertingSquad,
|
||||||
FrontendOnly: false, // changes navtree from backend
|
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",
|
Name: "pluginProxyPreserveTrailingSlash",
|
||||||
Description: "Preserve plugin proxy trailing slash.",
|
Description: "Preserve plugin proxy trailing slash.",
|
||||||
|
|||||||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -125,7 +125,6 @@ alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true
|
|||||||
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
|
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
|
||||||
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
|
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
|
||||||
alertingCentralAlertHistory,experimental,@grafana/alerting-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
|
pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false
|
||||||
azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false
|
azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false
|
||||||
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false
|
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false
|
||||||
|
|||||||
|
4
pkg/services/featuremgmt/toggles_gen.go
generated
4
pkg/services/featuremgmt/toggles_gen.go
generated
@@ -379,10 +379,6 @@ const (
|
|||||||
// Enables the new central alert history.
|
// Enables the new central alert history.
|
||||||
FlagAlertingCentralAlertHistory = "alertingCentralAlertHistory"
|
FlagAlertingCentralAlertHistory = "alertingCentralAlertHistory"
|
||||||
|
|
||||||
// FlagAlertingNavigationV2
|
|
||||||
// Enable new grouped navigation structure for Alerting
|
|
||||||
FlagAlertingNavigationV2 = "alertingNavigationV2"
|
|
||||||
|
|
||||||
// FlagPluginProxyPreserveTrailingSlash
|
// FlagPluginProxyPreserveTrailingSlash
|
||||||
// Preserve plugin proxy trailing slash.
|
// Preserve plugin proxy trailing slash.
|
||||||
FlagPluginProxyPreserveTrailingSlash = "pluginProxyPreserveTrailingSlash"
|
FlagPluginProxyPreserveTrailingSlash = "pluginProxyPreserveTrailingSlash"
|
||||||
|
|||||||
13
pkg/services/featuremgmt/toggles_gen.json
generated
13
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -348,19 +348,6 @@
|
|||||||
"expression": "true"
|
"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": {
|
"metadata": {
|
||||||
"name": "alertingNotificationHistory",
|
"name": "alertingNotificationHistory",
|
||||||
|
|||||||
@@ -433,214 +433,6 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.NavLink {
|
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)
|
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||||
var alertChildNavs []*navtree.NavLink
|
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 s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
|
||||||
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -542,10 +542,9 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe
|
|||||||
dashService.RegisterDashboardPermissions(acmock.NewMockedPermissionsService())
|
dashService.RegisterDashboardPermissions(acmock.NewMockedPermissionsService())
|
||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
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(),
|
_, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(),
|
||||||
quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, plugincontext.
|
quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, plugincontext.
|
||||||
ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
m := metrics.NewNGAlert(prometheus.NewRegistry())
|
m := metrics.NewNGAlert(prometheus.NewRegistry())
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,9 @@ func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvS
|
|||||||
features := featuremgmt.WithFeatures()
|
features := featuremgmt.WithFeatures()
|
||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, features)
|
|
||||||
dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(),
|
dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(),
|
||||||
acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
migService := ProvideDataSourceMigrationService(dsService, kvStore, features)
|
migService := ProvideDataSourceMigrationService(dsService, kvStore, features)
|
||||||
return migService
|
return migService
|
||||||
|
|||||||
@@ -293,15 +293,15 @@ overrides_path = overrides.yaml
|
|||||||
overrides_reload_period = 5s
|
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
|
```yaml
|
||||||
overrides:
|
overrides:
|
||||||
<NAMESPACE>:
|
<NAMESPACE>:
|
||||||
quotas:
|
quotas:
|
||||||
<GROUP>/<RESOURCE>:
|
<GROUP>.<RESOURCE>:
|
||||||
limit: 10
|
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:
|
To access quotas, use the following API endpoint:
|
||||||
```
|
```
|
||||||
@@ -806,10 +806,8 @@ flowchart TD
|
|||||||
|
|
||||||
#### Setting Dual Writer Mode
|
#### Setting Dual Writer Mode
|
||||||
```ini
|
```ini
|
||||||
; [unified_storage.{resource}.{group}]
|
[unified_storage.{resource}.{kind}.{group}]
|
||||||
[unified_storage.dashboards.dashboard.grafana.app]
|
dualWriterMode = {0-5}
|
||||||
; modes {0-5}
|
|
||||||
dualWriterMode = 0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Background Sync Configuration
|
#### Background Sync Configuration
|
||||||
@@ -1378,3 +1376,4 @@ disable_data_migrations = false
|
|||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
For detailed information about migration architecture, validators, and troubleshooting, refer to [migrations/README.md](./migrations/README.md).
|
For detailed information about migration architecture, validators, and troubleshooting, refer to [migrations/README.md](./migrations/README.md).
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ INSERT INTO {{ .Ident "resource" }}
|
|||||||
{{ .Ident "previous_resource_version" }}
|
{{ .Ident "previous_resource_version" }}
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
(SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
|
COALESCE({{ .Arg .Value }}, ""),
|
||||||
{{ .Arg .GUID }},
|
{{ .Arg .GUID }},
|
||||||
{{ .Arg .Group }},
|
{{ .Arg .Group }},
|
||||||
{{ .Arg .Resource }},
|
{{ .Arg .Resource }},
|
||||||
@@ -19,5 +19,13 @@ VALUES (
|
|||||||
{{ .Arg .Name }},
|
{{ .Arg .Name }},
|
||||||
{{ .Arg .Action }},
|
{{ .Arg .Action }},
|
||||||
{{ .Arg .Folder }},
|
{{ .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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ INSERT INTO {{ .Ident "resource_history" }}
|
|||||||
{{ .Ident "namespace" }},
|
{{ .Ident "namespace" }},
|
||||||
{{ .Ident "name" }},
|
{{ .Ident "name" }},
|
||||||
{{ .Ident "action" }},
|
{{ .Ident "action" }},
|
||||||
{{ .Ident "folder" }}
|
{{ .Ident "folder" }},
|
||||||
|
{{ .Ident "previous_resource_version" }},
|
||||||
|
{{ .Ident "generation" }}
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
COALESCE({{ .Arg .Value }}, ""),
|
COALESCE({{ .Arg .Value }}, ""),
|
||||||
@@ -17,5 +19,26 @@ VALUES (
|
|||||||
{{ .Arg .Namespace }},
|
{{ .Arg .Namespace }},
|
||||||
{{ .Arg .Name }},
|
{{ .Arg .Name }},
|
||||||
{{ .Arg .Action }},
|
{{ .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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
UPDATE {{ .Ident "resource" }}
|
UPDATE {{ .Ident "resource" }}
|
||||||
SET
|
SET
|
||||||
{{ .Ident "guid" }} = {{ .Arg .GUID }},
|
{{ .Ident "value" }} = {{ .Arg .Value }},
|
||||||
{{ .Ident "value" }} = (SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
|
|
||||||
{{ .Ident "action" }} = {{ .Arg .Action }},
|
{{ .Ident "action" }} = {{ .Arg .Action }},
|
||||||
{{ .Ident "folder" }} = {{ .Arg .Folder }},
|
{{ .Ident "folder" }} = {{ .Arg .Folder }}
|
||||||
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }}
|
|
||||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
UPDATE {{ .Ident "resource_history" }}
|
|
||||||
SET
|
|
||||||
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }},
|
|
||||||
{{ .Ident "generation" }} = {{ .Arg .Generation }}
|
|
||||||
WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }};
|
|
||||||
@@ -12,9 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/validation"
|
"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"
|
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)
|
return DataKey{}, fmt.Errorf("invalid get request key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if rv == 0 {
|
||||||
|
rv = math.MaxInt64
|
||||||
|
}
|
||||||
|
|
||||||
listKey := ListRequestKey(key)
|
listKey := ListRequestKey(key)
|
||||||
|
|
||||||
iter := d.ListResourceKeysAtRevision(ctx, ListRequestOptions{Key: listKey, ResourceVersion: rv})
|
iter := d.ListResourceKeysAtRevision(ctx, ListRequestOptions{Key: listKey, ResourceVersion: rv})
|
||||||
@@ -597,7 +598,7 @@ func ParseKey(key string) (DataKey, error) {
|
|||||||
}, nil
|
}, 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
|
// Remove once we stop using RvManager in storage_backend.go
|
||||||
func ParseKeyWithGUID(key string) (DataKey, error) {
|
func ParseKeyWithGUID(key string) (DataKey, error) {
|
||||||
parts := strings.Split(key, "/")
|
parts := strings.Split(key, "/")
|
||||||
@@ -814,121 +815,3 @@ func (d *dataStore) getGroupResources(ctx context.Context) ([]GroupResource, err
|
|||||||
|
|
||||||
return results, nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ var (
|
|||||||
sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql")
|
sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql")
|
||||||
sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql")
|
sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql")
|
||||||
sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.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")
|
sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql")
|
||||||
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
|
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
|
||||||
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
|
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
|
||||||
@@ -155,6 +157,26 @@ func (req sqlKVSaveRequest) Validate() error {
|
|||||||
return req.sqlKVSectionKey.Validate()
|
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 {
|
type sqlKVKeysRequest struct {
|
||||||
sqltemplate.SQLTemplate
|
sqltemplate.SQLTemplate
|
||||||
sqlKVSection
|
sqlKVSection
|
||||||
@@ -370,7 +392,7 @@ func (w *sqlWriteCloser) Close() error {
|
|||||||
// used to keep backwards compatibility between sql-based kvstore and unified/sql/backend
|
// used to keep backwards compatibility between sql-based kvstore and unified/sql/backend
|
||||||
tx, ok := rvmanager.TxFromCtx(w.ctx)
|
tx, ok := rvmanager.TxFromCtx(w.ctx)
|
||||||
if !ok {
|
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:
|
// we can use the same template as the event one after we:
|
||||||
// - move PK from GUID to key_path
|
// - move PK from GUID to key_path
|
||||||
// - remove all unnecessary columns (or at least their NOT NULL constraints)
|
// - remove all unnecessary columns (or at least their NOT NULL constraints)
|
||||||
@@ -407,12 +429,11 @@ func (w *sqlWriteCloser) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// special, temporary backwards-compatible save that includes all the fields in resource_history that are not relevant
|
// special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore,
|
||||||
// for the kvstore, as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
|
// 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.
|
// 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
|
// note that we are not touching resource_version table, neither the resource_version columns or the key_path column
|
||||||
// table with `previous_resource_version` and `generation` and updates the `resource` table accordingly. See the
|
// as the RvManager will be responsible for this
|
||||||
// storage_backend for the full implementation.
|
|
||||||
dataKey, err := ParseKeyWithGUID(w.sectionKey.Key)
|
dataKey, err := ParseKeyWithGUID(w.sectionKey.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse key: %w", err)
|
return fmt.Errorf("failed to parse key: %w", err)
|
||||||
@@ -427,7 +448,7 @@ func (w *sqlWriteCloser) Close() error {
|
|||||||
case DataActionDeleted:
|
case DataActionDeleted:
|
||||||
action = 3
|
action = 3
|
||||||
default:
|
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{
|
_, 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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -332,14 +332,11 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
|||||||
dataKey.GUID = uuid.New().String()
|
dataKey.GUID = uuid.New().String()
|
||||||
var err error
|
var err error
|
||||||
rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, 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)
|
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
|
return dataKey.GUID, nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
144
pkg/tests/apis/config_test.go
Normal file
144
pkg/tests/apis/config_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"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/apis"
|
||||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||||
@@ -178,9 +177,6 @@ func setupHelper(t *testing.T) *apis.K8sTestHelper {
|
|||||||
AppModeProduction: true,
|
AppModeProduction: true,
|
||||||
DisableAnonymous: true,
|
DisableAnonymous: true,
|
||||||
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
|
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
|
||||||
EnableFeatureToggles: []string{
|
|
||||||
featuremgmt.FlagPluginStoreServiceLoading,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
t.Cleanup(func() { helper.Shutdown() })
|
t.Cleanup(func() { helper.Shutdown() })
|
||||||
return helper
|
return helper
|
||||||
|
|||||||
@@ -320,9 +320,8 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled))
|
_, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
if !opts.OpenFeatureAPIEnabled {
|
||||||
if opts.OpenFeatureAPIEnabled {
|
_, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with features-service type, but trying to make tests work
|
||||||
_, err = openFeatureSect.NewKey("provider", "static")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = openFeatureSect.NewKey("targetingKey", "grafana")
|
_, err = openFeatureSect.NewKey("targetingKey", "grafana")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { reportInteraction } from '@grafana/runtime';
|
|||||||
import { ScrollContainer, useStyles2 } from '@grafana/ui';
|
import { ScrollContainer, useStyles2 } from '@grafana/ui';
|
||||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
import { setBookmark } from 'app/core/reducers/navBarTree';
|
import { setBookmark } from 'app/core/reducers/navBarTree';
|
||||||
import { shouldUseAlertingNavigationV2 } from 'app/features/alerting/unified/featureToggles';
|
|
||||||
import { useDispatch, useSelector } from 'app/types/store';
|
import { useDispatch, useSelector } from 'app/types/store';
|
||||||
|
|
||||||
import { MegaMenuExtensionPoint } from './MegaMenuExtensionPoint';
|
import { MegaMenuExtensionPoint } from './MegaMenuExtensionPoint';
|
||||||
@@ -38,25 +37,9 @@ export const MegaMenu = memo(
|
|||||||
const pinnedItems = usePinnedItems();
|
const pinnedItems = usePinnedItems();
|
||||||
|
|
||||||
// Remove profile + help from tree
|
// 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
|
const navItems = navTree
|
||||||
.filter((item) => item.id !== 'profile' && item.id !== 'help')
|
.filter((item) => item.id !== 'profile' && item.id !== 'help')
|
||||||
.map((item) => {
|
.map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked));
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
const bookmarksItem = navItems.find((item) => item.id === 'bookmarks');
|
const bookmarksItem = navItems.find((item) => item.id === 'bookmarks');
|
||||||
if (bookmarksItem) {
|
if (bookmarksItem) {
|
||||||
|
|||||||
@@ -35,18 +35,11 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
|
|||||||
|
|
||||||
if (shouldAddCrumb) {
|
if (shouldAddCrumb) {
|
||||||
const activeChildIndex = node.children?.findIndex((child) => child.active) ?? -1;
|
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
|
// Add tab to breadcrumbs if it's not the first active child
|
||||||
// This ensures tabs show in breadcrumbs (including the first tab) while preventing duplication
|
if (activeChildIndex > 0) {
|
||||||
if (activeChildIndex >= 0) {
|
|
||||||
const activeChild = node.children?.[activeChildIndex];
|
const activeChild = node.children?.[activeChildIndex];
|
||||||
if (activeChild) {
|
if (activeChild) {
|
||||||
// Only add the active child if its URL doesn't match the node's URL
|
crumbs.unshift({ text: activeChild.text, href: activeChild.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: node.text, href: node.url ?? '' });
|
crumbs.unshift({ text: node.text, href: node.url ?? '' });
|
||||||
|
|||||||
@@ -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',
|
path: '/alerting/routes/mute-timing/new',
|
||||||
roles: evaluateAccess([
|
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/',
|
path: '/alerting/recently-deleted/',
|
||||||
roles: () => ['Admin'],
|
roles: () => ['Admin'],
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { AlertGroupFilter } from './components/alert-groups/AlertGroupFilter';
|
|||||||
import { useFilteredAmGroups } from './hooks/useFilteredAmGroups';
|
import { useFilteredAmGroups } from './hooks/useFilteredAmGroups';
|
||||||
import { useGroupedAlerts } from './hooks/useGroupedAlerts';
|
import { useGroupedAlerts } from './hooks/useGroupedAlerts';
|
||||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||||
import { useAlertActivityNav } from './navigation/useAlertActivityNav';
|
|
||||||
import { useAlertmanager } from './state/AlertmanagerContext';
|
import { useAlertmanager } from './state/AlertmanagerContext';
|
||||||
import { fetchAlertGroupsAction } from './state/actions';
|
import { fetchAlertGroupsAction } from './state/actions';
|
||||||
import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants';
|
import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants';
|
||||||
@@ -114,9 +113,8 @@ const AlertGroups = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function AlertGroupsPage() {
|
function AlertGroupsPage() {
|
||||||
const { navId, pageNav } = useAlertActivityNav();
|
|
||||||
return (
|
return (
|
||||||
<AlertmanagerPageWrapper navId={navId || 'groups'} pageNav={pageNav} accessType="instance">
|
<AlertmanagerPageWrapper navId="groups" accessType="instance">
|
||||||
<AlertGroups />
|
<AlertGroups />
|
||||||
</AlertmanagerPageWrapper>
|
</AlertmanagerPageWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
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 { byLabelText, byRole, byTestId } from 'testing-library-selector';
|
||||||
|
|
||||||
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
|
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
|
||||||
@@ -140,39 +140,6 @@ const getRootRoute = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('NotificationPolicies', () => {
|
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 :/
|
// combobox hack :/
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const mockGetBoundingClientRect = jest.fn(() => ({
|
const mockGetBoundingClientRect = jest.fn(() => ({
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alertin
|
|||||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
|
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
|
||||||
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
|
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
|
||||||
import { shouldUseAlertingNavigationV2 } from './featureToggles';
|
|
||||||
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
|
|
||||||
import { useAlertmanager } from './state/AlertmanagerContext';
|
import { useAlertmanager } from './state/AlertmanagerContext';
|
||||||
import { withPageErrorBoundary } from './withPageErrorBoundary';
|
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() {
|
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 (
|
return (
|
||||||
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
|
<AlertmanagerPageWrapper navId="am-routes" accessType="notification">
|
||||||
<NotificationPoliciesTabs />
|
<NotificationPoliciesTabs />
|
||||||
</AlertmanagerPageWrapper>
|
</AlertmanagerPageWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,56 +1,13 @@
|
|||||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
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 DuplicateMessageTemplate from './components/contact-points/DuplicateMessageTemplate';
|
||||||
import EditMessageTemplate from './components/contact-points/EditMessageTemplate';
|
import EditMessageTemplate from './components/contact-points/EditMessageTemplate';
|
||||||
import NewMessageTemplate from './components/contact-points/NewMessageTemplate';
|
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';
|
import { withPageErrorBoundary } from './withPageErrorBoundary';
|
||||||
|
|
||||||
const TemplatesList = () => {
|
function NotificationTemplates() {
|
||||||
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();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* In V2 mode, show templates list on base route */}
|
|
||||||
{useV2Nav && <Route path="" element={<TemplatesList />} />}
|
|
||||||
<Route path="new" element={<NewMessageTemplate />} />
|
<Route path="new" element={<NewMessageTemplate />} />
|
||||||
<Route path=":name/edit" element={<EditMessageTemplate />} />
|
<Route path=":name/edit" element={<EditMessageTemplate />} />
|
||||||
<Route path=":name/duplicate" element={<DuplicateMessageTemplate />} />
|
<Route path=":name/duplicate" element={<DuplicateMessageTemplate />} />
|
||||||
@@ -58,21 +15,4 @@ function NotificationTemplatesRoutes() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationTemplatesPage() {
|
export default withPageErrorBoundary(NotificationTemplates);
|
||||||
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);
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
import { MemoryHistoryBuildOptions } from 'history';
|
import { MemoryHistoryBuildOptions } from 'history';
|
||||||
import { ComponentProps, ReactNode } from 'react';
|
import { ComponentProps, ReactNode } from 'react';
|
||||||
import {
|
import { render, screen, userEvent, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
testWithFeatureToggles,
|
|
||||||
userEvent,
|
|
||||||
waitFor,
|
|
||||||
waitForElementToBeRemoved,
|
|
||||||
within,
|
|
||||||
} from 'test/test-utils';
|
|
||||||
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
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', () => {
|
describe('templates tab', () => {
|
||||||
it('does not show a warning for a "misconfigured" template', async () => {
|
it('does not show a warning for a "misconfigured" template', async () => {
|
||||||
renderWithProvider(
|
renderWithProvider(
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -15,18 +13,15 @@ import {
|
|||||||
TabContent,
|
TabContent,
|
||||||
TabsBar,
|
TabsBar,
|
||||||
Text,
|
Text,
|
||||||
useStyles2,
|
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
|
import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
|
||||||
import { makeAMLink, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
import { makeAMLink, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
||||||
import { AccessControlAction } from 'app/types/accessControl';
|
import { AccessControlAction } from 'app/types/accessControl';
|
||||||
|
|
||||||
import { shouldUseAlertingNavigationV2 } from '../../featureToggles';
|
|
||||||
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||||
import { usePagination } from '../../hooks/usePagination';
|
import { usePagination } from '../../hooks/usePagination';
|
||||||
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
|
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
|
||||||
import { useNotificationConfigNav } from '../../navigation/useNotificationConfigNav';
|
|
||||||
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||||
import { isExtraConfig } from '../../utils/alertmanager/extraConfigs';
|
import { isExtraConfig } from '../../utils/alertmanager/extraConfigs';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||||
@@ -104,7 +99,7 @@ const ContactPointsTab = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="column" gap={1}>
|
<>
|
||||||
{/* TODO we can add some additional info here with a ToggleTip */}
|
{/* TODO we can add some additional info here with a ToggleTip */}
|
||||||
<Stack direction="row" alignItems="end" justifyContent="space-between">
|
<Stack direction="row" alignItems="end" justifyContent="space-between">
|
||||||
<ContactPointsFilter />
|
<ContactPointsFilter />
|
||||||
@@ -153,7 +148,7 @@ const ContactPointsTab = () => {
|
|||||||
<GlobalConfigAlert alertManagerName={selectedAlertmanager!} />
|
<GlobalConfigAlert alertManagerName={selectedAlertmanager!} />
|
||||||
)}
|
)}
|
||||||
{ExportDrawer}
|
{ExportDrawer}
|
||||||
</Stack>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,7 +158,7 @@ const NotificationTemplatesTab = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="column" gap={1}>
|
<>
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
<Text variant="body" color="secondary">
|
<Text variant="body" color="secondary">
|
||||||
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
|
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
|
||||||
@@ -184,7 +179,7 @@ const NotificationTemplatesTab = () => {
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<NotificationTemplates />
|
<NotificationTemplates />
|
||||||
</Stack>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,10 +201,6 @@ const useTabQueryParam = (defaultTab: ActiveTab) => {
|
|||||||
|
|
||||||
export const ContactPointsPageContents = () => {
|
export const ContactPointsPageContents = () => {
|
||||||
const { selectedAlertmanager } = useAlertmanager();
|
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 [, canViewContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
|
||||||
const [, canCreateContactPoints] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
|
const [, canCreateContactPoints] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
|
||||||
const [, showTemplatesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationTemplate);
|
const [, showTemplatesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationTemplate);
|
||||||
@@ -229,19 +220,6 @@ export const ContactPointsPageContents = () => {
|
|||||||
alertmanager: selectedAlertmanager!,
|
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 showingContactPoints = activeTab === ActiveTab.ContactPoints;
|
||||||
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
|
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
|
||||||
|
|
||||||
@@ -266,7 +244,7 @@ export const ContactPointsPageContents = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsBar>
|
</TabsBar>
|
||||||
<TabContent className={styles.tabContent}>
|
<TabContent>
|
||||||
<Stack direction="column">
|
<Stack direction="column">
|
||||||
{showingContactPoints && <ContactPointsTab />}
|
{showingContactPoints && <ContactPointsTab />}
|
||||||
{showNotificationTemplates && <NotificationTemplatesTab />}
|
{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() {
|
function ContactPointsPage() {
|
||||||
const { navId, pageNav } = useNotificationConfigNav();
|
|
||||||
return (
|
return (
|
||||||
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
|
<AlertmanagerPageWrapper navId="receivers" accessType="notification">
|
||||||
<ContactPointsPageContents />
|
<ContactPointsPageContents />
|
||||||
</AlertmanagerPageWrapper>
|
</AlertmanagerPageWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useInsightsNav } from '../../../navigation/useInsightsNav';
|
|
||||||
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
|
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
|
||||||
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
||||||
|
|
||||||
import { CentralAlertHistoryScene } from './CentralAlertHistoryScene';
|
import { CentralAlertHistoryScene } from './CentralAlertHistoryScene';
|
||||||
|
|
||||||
function HistoryPage() {
|
function HistoryPage() {
|
||||||
const { navId, pageNav } = useInsightsNav();
|
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper navId={navId || 'alerts-history'} pageNav={pageNav} isLoading={false}>
|
<AlertingPageWrapper navId="alerts-history" isLoading={false}>
|
||||||
<CentralAlertHistoryScene />
|
<CentralAlertHistoryScene />
|
||||||
</AlertingPageWrapper>
|
</AlertingPageWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Alert } from '@grafana/ui';
|
|||||||
|
|
||||||
import { alertRuleApi } from '../../../api/alertRuleApi';
|
import { alertRuleApi } from '../../../api/alertRuleApi';
|
||||||
import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi';
|
import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi';
|
||||||
import { useAlertRulesNav } from '../../../navigation/useAlertRulesNav';
|
|
||||||
import { stringifyErrorLike } from '../../../utils/misc';
|
import { stringifyErrorLike } from '../../../utils/misc';
|
||||||
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
|
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
|
||||||
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
||||||
@@ -19,10 +18,9 @@ function DeletedrulesPage() {
|
|||||||
rulerConfig: GRAFANA_RULER_CONFIG,
|
rulerConfig: GRAFANA_RULER_CONFIG,
|
||||||
filter: {}, // todo: add filters, and limit?????
|
filter: {}, // todo: add filters, and limit?????
|
||||||
});
|
});
|
||||||
const { navId, pageNav } = useAlertRulesNav();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper navId={navId || 'alerts/recently-deleted'} pageNav={pageNav} isLoading={isLoading}>
|
<AlertingPageWrapper navId="alerts/recently-deleted" isLoading={isLoading}>
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert title={t('alerting.deleted-rules.errorloading', 'Failed to load alert deleted rules')}>
|
<Alert title={t('alerting.deleted-rules.errorloading', 'Failed to load alert deleted rules')}>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields =>
|
|||||||
startsAt: interval.start.toISOString(),
|
startsAt: interval.start.toISOString(),
|
||||||
endsAt: interval.end.toISOString(),
|
endsAt: interval.end.toISOString(),
|
||||||
comment: silence.comment,
|
comment: silence.comment,
|
||||||
createdBy: isExpired ? contextSrv.user.name : silence.createdBy,
|
createdBy: silence.createdBy,
|
||||||
duration: intervalToAbbreviatedDurationString(interval),
|
duration: intervalToAbbreviatedDurationString(interval),
|
||||||
isRegex: false,
|
isRegex: false,
|
||||||
matchers: silence.matchers?.map(matcherToMatcherField) || [],
|
matchers: silence.matchers?.map(matcherToMatcherField) || [],
|
||||||
|
|||||||
@@ -31,8 +31,3 @@ export const shouldUseFullyCompatibleBackendFilters = () =>
|
|||||||
* Saved searches feature - allows users to save and apply search queries on the Alert Rules page.
|
* Saved searches feature - allows users to save and apply search queries on the Alert Rules page.
|
||||||
*/
|
*/
|
||||||
export const shouldUseSavedSearches = () => config.featureToggles.alertingSavedSearches ?? false;
|
export const shouldUseSavedSearches = () => config.featureToggles.alertingSavedSearches ?? false;
|
||||||
|
|
||||||
/**
|
|
||||||
* New grouped navigation structure for Alerting
|
|
||||||
*/
|
|
||||||
export const shouldUseAlertingNavigationV2 = () => config.featureToggles.alertingNavigationV2 ?? false;
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { config } from '@grafana/runtime';
|
|
||||||
import { Box, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
|
import { Box, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
|
||||||
|
|
||||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||||
@@ -15,13 +14,10 @@ import { PluginIntegrations } from './PluginIntegrations';
|
|||||||
import SyntheticMonitoringCard from './SyntheticMonitoringCard';
|
import SyntheticMonitoringCard from './SyntheticMonitoringCard';
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
// When V2 navigation is enabled, don't show Insights tab on Home page
|
const insightsEnabled = insightsIsAvailable() || isLocalDevEnv();
|
||||||
// (Insights is available via the sidebar Insights menu instead)
|
|
||||||
const insightsEnabled = (insightsIsAvailable() || isLocalDevEnv()) && !config.featureToggles.alertingNavigationV2;
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview');
|
const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview');
|
||||||
// Memoize the scene so it's only created once and properly initialized
|
const insightsScene = getInsightsScenes();
|
||||||
const insightsScene = useMemo(() => getInsightsScenes(), []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper subTitle="Learn about problems in your systems moments after they occur" navId="alerting">
|
<AlertingPageWrapper subTitle="Learn about problems in your systems moments after they occur" navId="alerting">
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ import { shouldUsePrometheusRulesPrimary } from '../featureToggles';
|
|||||||
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces';
|
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces';
|
||||||
import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules';
|
import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules';
|
||||||
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector';
|
||||||
import { useAlertRulesNav } from '../navigation/useAlertRulesNav';
|
|
||||||
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions';
|
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions';
|
||||||
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
|
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames } from '../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames } from '../utils/datasource';
|
||||||
@@ -116,14 +115,11 @@ const RuleListV1 = () => {
|
|||||||
|
|
||||||
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
|
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
|
||||||
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
|
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
|
||||||
const { navId, pageNav } = useAlertRulesNav();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// We don't want to show the Loading... indicator for the whole page.
|
// We don't want to show the Loading... indicator for the whole page.
|
||||||
// We show separate indicators for Grafana-managed and Cloud rules
|
// We show separate indicators for Grafana-managed and Cloud rules
|
||||||
<AlertingPageWrapper
|
<AlertingPageWrapper
|
||||||
navId={navId}
|
navId="alert-list"
|
||||||
pageNav={pageNav}
|
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
||||||
actions={<RuleListActionButtons hasAlertRulesCreated={hasAlertRulesCreated} />}
|
actions={<RuleListActionButtons hasAlertRulesCreated={hasAlertRulesCreated} />}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelecto
|
|||||||
import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton';
|
import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton';
|
||||||
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
|
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
|
||||||
import { useRulesFilter } from '../hooks/useFilteredRules';
|
import { useRulesFilter } from '../hooks/useFilteredRules';
|
||||||
import { useAlertRulesNav } from '../navigation/useAlertRulesNav';
|
|
||||||
|
|
||||||
import { FilterView } from './FilterView';
|
import { FilterView } from './FilterView';
|
||||||
import { GroupedView } from './GroupedView';
|
import { GroupedView } from './GroupedView';
|
||||||
@@ -120,12 +119,10 @@ export function RuleListActions() {
|
|||||||
|
|
||||||
export default function RuleListPage() {
|
export default function RuleListPage() {
|
||||||
const { isApplying } = useApplyDefaultSearch();
|
const { isApplying } = useApplyDefaultSearch();
|
||||||
const { navId, pageNav } = useAlertRulesNav();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper
|
<AlertingPageWrapper
|
||||||
navId={navId}
|
navId="alert-list"
|
||||||
pageNav={pageNav}
|
|
||||||
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
||||||
isLoading={isApplying}
|
isLoading={isApplying}
|
||||||
actions={<RuleListActions />}
|
actions={<RuleListActions />}
|
||||||
|
|||||||
@@ -3,15 +3,21 @@ import { UrlSyncContextProvider } from '@grafana/scenes';
|
|||||||
import { withErrorBoundary } from '@grafana/ui';
|
import { withErrorBoundary } from '@grafana/ui';
|
||||||
|
|
||||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||||
import { useAlertActivityNav } from '../navigation/useAlertActivityNav';
|
|
||||||
|
|
||||||
import { TriageScene, triageScene } from './scene/TriageScene';
|
import { TriageScene, triageScene } from './scene/TriageScene';
|
||||||
|
|
||||||
export const TriagePage = () => {
|
export const TriagePage = () => {
|
||||||
const { navId, pageNav } = useAlertActivityNav();
|
|
||||||
|
|
||||||
return (
|
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}>
|
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
|
||||||
<TriageScene key={triageScene.state.key} />
|
<TriageScene key={triageScene.state.key} />
|
||||||
</UrlSyncContextProvider>
|
</UrlSyncContextProvider>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function RecentlyViewedDashboards() {
|
|||||||
retry();
|
retry();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
|
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +76,10 @@ export function RecentlyViewedDashboards() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{loading && <Spinner />}
|
{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 && (
|
{!loading && recentDashboards.length > 0 && (
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ describe('PanelTimeRange', () => {
|
|||||||
expect(panelTime.state.value.to.format('Z')).toBe('+00:00'); // UTC
|
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' });
|
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
|
||||||
|
|
||||||
buildAndActivateSceneFor(panelTime);
|
buildAndActivateSceneFor(panelTime);
|
||||||
@@ -139,22 +139,6 @@ describe('PanelTimeRange', () => {
|
|||||||
expect(panelTime.state.to).toBe('now');
|
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', () => {
|
it('should handle invalid time reference in timeShift combined with timeFrom', () => {
|
||||||
const panelTime = new PanelTimeRange({
|
const panelTime = new PanelTimeRange({
|
||||||
timeFrom: 'now-2h',
|
timeFrom: 'now-2h',
|
||||||
@@ -169,66 +153,6 @@ describe('PanelTimeRange', () => {
|
|||||||
expect(panelTime.state.to).toBe('now');
|
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', () => {
|
describe('onTimeRangeChange', () => {
|
||||||
it('should reverse timeShift when updating time range', () => {
|
it('should reverse timeShift when updating time range', () => {
|
||||||
const oneHourShift = '1h';
|
const oneHourShift = '1h';
|
||||||
|
|||||||
@@ -81,19 +81,7 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
|||||||
}
|
}
|
||||||
|
|
||||||
const overrideResult = this.getTimeOverride(timeRange.value);
|
const overrideResult = this.getTimeOverride(timeRange.value);
|
||||||
const { timeRange: overrideTimeRange } = overrideResult;
|
this.setState({ value: overrideResult.timeRange, timeInfo: overrideResult.timeInfo });
|
||||||
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(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get a time shifted request to compare with the primary request.
|
// 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
|
// Only evaluate if the timeFrom if parent time is relative
|
||||||
if (rangeUtil.isRelativeTimeRange(parentTimeRange.raw)) {
|
if (rangeUtil.isRelativeTimeRange(parentTimeRange.raw)) {
|
||||||
const timezone = this.getTimeZone();
|
const timeZone = this.getTimeZone();
|
||||||
newTimeData.timeRange = {
|
newTimeData.timeRange = {
|
||||||
from: dateMath.toDateTime(timeFromInfo.from, { timezone })!,
|
from: dateMath.parse(timeFromInfo.from, undefined, timeZone)!,
|
||||||
to: dateMath.toDateTime(timeFromInfo.to, { timezone })!,
|
to: dateMath.parse(timeFromInfo.to, undefined, timeZone)!,
|
||||||
raw: { from: timeFromInfo.from, to: timeFromInfo.to },
|
raw: { from: timeFromInfo.from, to: timeFromInfo.to },
|
||||||
};
|
};
|
||||||
infoBlocks.push(timeFromInfo.display);
|
infoBlocks.push(timeFromInfo.display);
|
||||||
@@ -184,39 +172,18 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
|||||||
return newTimeData;
|
return newTimeData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shift = '-' + timeShiftInterpolated;
|
const timeShift = '-' + timeShiftInterpolated;
|
||||||
infoBlocks.push('timeshift ' + shift);
|
infoBlocks.push('timeshift ' + timeShift);
|
||||||
|
|
||||||
if (rangeUtil.isRelativeTimeRange(newTimeData.timeRange.raw)) {
|
const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false)!;
|
||||||
const timezone = this.getTimeZone();
|
const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true)!;
|
||||||
|
|
||||||
const rawFromShifted = `${newTimeData.timeRange.raw.from}${shift}`;
|
if (!from || !to) {
|
||||||
const rawToShifted = `${newTimeData.timeRange.raw.to}${shift}`;
|
newTimeData.timeInfo = 'invalid timeshift';
|
||||||
|
return newTimeData;
|
||||||
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 } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newTimeData.timeRange = { from, to, raw: { from, to } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (compareWith) {
|
if (compareWith) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
SQLQuery,
|
SQLQuery,
|
||||||
SQLSelectableValue,
|
SQLSelectableValue,
|
||||||
SqlDatasource,
|
SqlDatasource,
|
||||||
|
SQLVariableSupport,
|
||||||
formatSQL,
|
formatSQL,
|
||||||
} from '@grafana/sql';
|
} from '@grafana/sql';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export class PostgresDatasource extends SqlDatasource {
|
|||||||
|
|
||||||
constructor(instanceSettings: DataSourceInstanceSettings<PostgresOptions>) {
|
constructor(instanceSettings: DataSourceInstanceSettings<PostgresOptions>) {
|
||||||
super(instanceSettings);
|
super(instanceSettings);
|
||||||
|
this.variables = new SQLVariableSupport(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): PostgresQueryModel {
|
getQueryModel(target?: SQLQuery, templateSrv?: TemplateSrv, scopedVars?: ScopedVars): PostgresQueryModel {
|
||||||
|
|||||||
@@ -3759,6 +3759,7 @@
|
|||||||
},
|
},
|
||||||
"recently-viewed": {
|
"recently-viewed": {
|
||||||
"clear": "Clear history",
|
"clear": "Clear history",
|
||||||
|
"empty": "Nothing viewed yet",
|
||||||
"error": "Recently viewed dashboards couldn’t be loaded.",
|
"error": "Recently viewed dashboards couldn’t be loaded.",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"title": "Recently viewed"
|
"title": "Recently viewed"
|
||||||
|
|||||||
Reference in New Issue
Block a user