Compare commits

..

16 Commits

Author SHA1 Message Date
Ryan McKinley
4083cf78d4 default sort 2026-01-08 09:31:50 +03:00
grafana-pr-automation[bot]
97af86efb2 I18n: Download translations from Crowdin (#115968)
New Crowdin translations by GitHub Action

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-08 00:43:13 +00:00
Paul Marbach
f58ab2a6a1 Gauge: Fix endpoint rendering for non-gradient cases (#115910)
* Gauge: Fix endpoint rendering for non-gradient cases

* break out the endpoint markers to its own component with tests
2026-01-07 17:17:35 -05:00
Charandas
b96a1ae722 Custom Routes: use existing server's mux container instead of gorilla.Mux (#115605) 2026-01-07 12:46:27 -08:00
Kim Nylander
a53875e621 [DOC] Changed so max_spans_per_span_set can't be changed in Cloud Traces (#115914)
Changed so max_spans_per_span_set can't be changed in Cloud Traces
2026-01-07 15:46:02 -05:00
Cory Forseth
9598ae6434 Datasources: extract data source read methods from service (#115834)
* extra data source read methods

* update tests

* more tests

* fix more tests; actually initialize retriever instead of sending nil

* moving GetAllDataSources isn't strictly required, so keep to minimal changes

* better name for retriever logger

Co-authored-by: Dafydd <72009875+dafydd-t@users.noreply.github.com>

* add compile-time check for DS retriever impl

---------

Co-authored-by: Dafydd <72009875+dafydd-t@users.noreply.github.com>
Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>
2026-01-07 14:29:59 -06:00
owensmallwood
ab0b05550f Unified Storag: Fix readme (#115957)
* fix readme

* spelling
2026-01-07 19:35:33 +00:00
beejeebus
4518add556 Use a different metric name for new config CRUD APIs
Also, make sure to register the metrics with the same prometheus registerer
as the http server, so that metrics will show up.
2026-01-07 14:28:31 -05:00
Kristina Demeshchik
00b89b0d29 Dashboards: Fix liveNow not working for panels with time shift (#115902)
* relative time for timeshifts

* remove extra assertion

* absolute time range
2026-01-07 14:24:20 -05:00
Todd Treece
a3eedfeb73 Plugins: Move fixed role registration behind toggle (#115940) 2026-01-07 13:52:01 -05:00
Renato Costa
1e8f1f74ea unified-storage: apply backwards compatibility changes outside sqlkv (#115954) 2026-01-07 13:51:15 -05:00
owensmallwood
66b05914e2 Tracing: Use service name from config (#115955)
use service name from config
2026-01-07 12:50:11 -06:00
Yunwen Zheng
0c60d356d1 RecentlyViewedDashboards: Hide entire section when there is no recently view item (#115905)
* RecentlyViewedDashboards: Hide entire section when there is no recently view item
2026-01-07 13:31:48 -05:00
Ezequiel Victorero
41d7213d7e Docs: Update dualwrite ini config (#115934) 2026-01-07 17:58:58 +01:00
Todd Treece
efad6c7be0 Chore: Update enterprise imports (#115947) 2026-01-07 16:55:59 +00:00
Paulo Dias
e116254f32 Alerting: Update createdBy field when silence is being Recreated (#115543) 2026-01-07 16:05:53 +00:00
82 changed files with 1299 additions and 842 deletions

View File

@@ -13,7 +13,7 @@ import (
// schema is unexported to prevent accidental overwrites // schema is unexported to prevent accidental overwrites
var ( var (
schemaReceiver = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", NewReceiver(), &ReceiverList{}, resource.WithKind("Receiver"), schemaReceiver = resource.NewSimpleSchema("notifications.alerting.grafana.app", "v0alpha1", NewReceiver(), &ReceiverList{}, resource.WithKind("Receiver"),
resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{resource.SelectableField{ resource.WithPlural("receivers"), resource.WithScope(resource.NamespacedScope), resource.WithSelectableFields([]resource.SelectableField{{
FieldSelector: "spec.title", FieldSelector: "spec.title",
FieldValueFunc: func(o resource.Object) (string, error) { FieldValueFunc: func(o resource.Object) (string, error) {
cast, ok := o.(*Receiver) cast, ok := o.(*Receiver)

View File

@@ -790,8 +790,6 @@ VariableOption: {
text: string | [...string] text: string | [...string]
// Value of the option // Value of the option
value: string | [...string] value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} }
// Query variable specification // Query variable specification

View File

@@ -794,8 +794,6 @@ VariableOption: {
text: string | [...string] text: string | [...string]
// Value of the option // Value of the option
value: string | [...string] value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} }
// Query variable specification // Query variable specification

View File

@@ -301,8 +301,6 @@ var _ resource.ListObject = &DashboardList{}
// Copy methods for all subresource types // Copy methods for all subresource types
// DeepCopy creates a full deep copy of DashboardStatus // DeepCopy creates a full deep copy of DashboardStatus
func (s *DashboardStatus) DeepCopy() *DashboardStatus { func (s *DashboardStatus) DeepCopy() *DashboardStatus {
cpy := &DashboardStatus{} cpy := &DashboardStatus{}

View File

@@ -301,8 +301,6 @@ var _ resource.ListObject = &DashboardList{}
// Copy methods for all subresource types // Copy methods for all subresource types
// DeepCopy creates a full deep copy of DashboardStatus // DeepCopy creates a full deep copy of DashboardStatus
func (s *DashboardStatus) DeepCopy() *DashboardStatus { func (s *DashboardStatus) DeepCopy() *DashboardStatus {
cpy := &DashboardStatus{} cpy := &DashboardStatus{}

View File

@@ -794,8 +794,6 @@ VariableOption: {
text: string | [...string] text: string | [...string]
// Value of the option // Value of the option
value: string | [...string] value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} }
// Query variable specification // Query variable specification

View File

@@ -1411,8 +1411,6 @@ type DashboardVariableOption struct {
Text DashboardStringOrArrayOfString `json:"text"` Text DashboardStringOrArrayOfString `json:"text"`
// Value of the option // Value of the option
Value DashboardStringOrArrayOfString `json:"value"` Value DashboardStringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
} }
// NewDashboardVariableOption creates a new DashboardVariableOption object. // NewDashboardVariableOption creates a new DashboardVariableOption object.

View File

@@ -798,8 +798,6 @@ VariableOption: {
text: string | [...string] text: string | [...string]
// Value of the option // Value of the option
value: string | [...string] value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} }
// Query variable specification // Query variable specification

View File

@@ -1414,8 +1414,6 @@ type DashboardVariableOption struct {
Text DashboardStringOrArrayOfString `json:"text"` Text DashboardStringOrArrayOfString `json:"text"`
// Value of the option // Value of the option
Value DashboardStringOrArrayOfString `json:"value"` Value DashboardStringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
} }
// NewDashboardVariableOption creates a new DashboardVariableOption object. // NewDashboardVariableOption creates a new DashboardVariableOption object.

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,6 @@ import (
v1beta1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" v1beta1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
) )
var ()
var appManifestData = app.ManifestData{ var appManifestData = app.ManifestData{
AppName: "folder", AppName: "folder",
Group: "folder.grafana.app", Group: "folder.grafana.app",

View File

@@ -135,9 +135,12 @@ 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
View File

@@ -33,12 +33,14 @@ 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
@@ -343,7 +345,6 @@ 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
@@ -358,7 +359,6 @@ 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

View File

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

View File

@@ -1,174 +0,0 @@
import { useEffect, useState } from 'react';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import {
CustomVariableSupport,
DataQueryRequest,
DataQueryResponse,
QueryEditorProps,
Field,
DataFrame,
MetricFindValue,
} 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) => {
const frames = d.data || [];
const metricFindValues = convertDataFramesToMetricFindValues(frames, updatedQuery.meta);
return { data: metricFindValues };
})
);
}
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>
);
};
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,
};
};
const convertDataFramesToMetricFindValues = (frames: DataFrame[], meta?: SQLQueryMeta): MetricFindValue[] => {
if (!frames.length) {
throw new Error('no results found');
}
const frame = frames[0];
const fields = frame.fields;
if (fields.length < 1) {
throw new Error('no fields found in the response');
}
let textField = fields.find((f) => f.name === '__text');
let valueField = fields.find((f) => f.name === '__value');
if (meta?.textField) {
textField = fields.find((f) => f.name === meta.textField);
}
if (meta?.valueField) {
valueField = fields.find((f) => f.name === meta.valueField);
}
const resolvedTextField = textField || valueField || fields[0];
const resolvedValueField = valueField || textField || fields[0];
const results: MetricFindValue[] = [];
const rowCount = frame.length;
for (let i = 0; i < rowCount; i++) {
const text = String(resolvedTextField.values[i] ?? '');
const value = String(resolvedValueField.values[i] ?? '');
const properties: Record<string, string> = {};
for (const field of fields) {
properties[field.name] = String(field.values[i] ?? '');
}
results.push({ text, value, properties });
}
return results;
};

View File

@@ -21,7 +21,6 @@ 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';

View File

@@ -69,12 +69,6 @@
"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",

View File

@@ -50,8 +50,6 @@ 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;
@@ -61,7 +59,6 @@ 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 {

View File

@@ -1,8 +1,9 @@
import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react'; import { useId, memo, HTMLAttributes, SVGProps } from 'react';
import { FieldDisplay } from '@grafana/data'; import { FieldDisplay } from '@grafana/data';
import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors'; import { RadialArcPathEndpointMarks } from './RadialArcPathEndpointMarks';
import { getBarEndcapColors, getGradientCss } from './colors';
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
import { drawRadialArcPath, toRad } from './utils'; import { drawRadialArcPath, toRad } from './utils';
@@ -29,11 +30,6 @@ interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase {
type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient; type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient;
const ENDPOINT_MARKER_MIN_ANGLE = 10;
const DOT_OPACITY = 0.5;
const DOT_RADIUS_FACTOR = 0.4;
const MAX_DOT_RADIUS = 8;
export const RadialArcPath = memo( export const RadialArcPath = memo(
({ ({
arcLengthDeg, arcLengthDeg,
@@ -68,67 +64,25 @@ export const RadialArcPath = memo(
const xEnd = centerX + radius * Math.cos(endRadians); const xEnd = centerX + radius * Math.cos(endRadians);
const yEnd = centerY + radius * Math.sin(endRadians); const yEnd = centerY + radius * Math.sin(endRadians);
const dotRadius =
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: boxSize, height: vizHeight, marginLeft: boxX }; const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: boxSize, height: vizHeight, marginLeft: boxX };
const pathProps: SVGProps<SVGPathElement> = {}; const pathProps: SVGProps<SVGPathElement> = {};
let barEndcapColors: [string, string] | undefined;
let endpointMarks: ReactNode = null;
if (isGradient) { if (isGradient) {
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape); bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
if (endpointMarker && (rest.gradient?.length ?? 0) > 0) {
switch (endpointMarker) {
case 'point':
const [pointColorStart, pointColorEnd] = getEndpointMarkerColors(
rest.gradient!,
fieldDisplay.display.percent
);
endpointMarks = (
<>
{arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && (
<circle cx={xStart} cy={yStart} r={dotRadius} fill={pointColorStart} opacity={DOT_OPACITY} />
)}
<circle cx={xEnd} cy={yEnd} r={dotRadius} fill={pointColorEnd} opacity={DOT_OPACITY} />
</>
);
break;
case 'glow':
const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE);
const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle);
const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle);
endpointMarks =
arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? (
<path
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
fill="none"
strokeWidth={barWidth}
stroke={endpointMarkerGlowFilter}
strokeLinecap={roundedBars ? 'round' : 'butt'}
filter={glowFilter}
/>
) : null;
break;
default:
break;
}
}
if (barEndcaps) {
barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent);
}
pathProps.fill = 'none'; pathProps.fill = 'none';
pathProps.stroke = 'white'; pathProps.stroke = 'white';
} else { } else {
bgDivStyle.backgroundColor = rest.color; bgDivStyle.backgroundColor = rest.color;
pathProps.fill = 'none'; pathProps.fill = 'none';
pathProps.stroke = rest.color; pathProps.stroke = rest.color;
} }
let barEndcapColors: [string, string] | undefined;
if (barEndcaps) {
barEndcapColors = isGradient
? getBarEndcapColors(rest.gradient, fieldDisplay.display.percent)
: [rest.color, rest.color];
}
const pathEl = ( const pathEl = (
<path d={path} strokeWidth={barWidth} strokeLinecap={roundedBars ? 'round' : 'butt'} {...pathProps} /> <path d={path} strokeWidth={barWidth} strokeLinecap={roundedBars ? 'round' : 'butt'} {...pathProps} />
); );
@@ -158,7 +112,23 @@ export const RadialArcPath = memo(
)} )}
</g> </g>
{endpointMarks} {endpointMarker && (
<RadialArcPathEndpointMarks
startAngle={angle}
arcLengthDeg={arcLengthDeg}
dimensions={dimensions}
endpointMarker={endpointMarker}
fieldDisplay={fieldDisplay}
xStart={xStart}
xEnd={xEnd}
yStart={yStart}
yEnd={yEnd}
roundedBars={roundedBars}
endpointMarkerGlowFilter={endpointMarkerGlowFilter}
glowFilter={glowFilter}
{...rest}
/>
)}
</> </>
); );
} }

View File

@@ -0,0 +1,143 @@
import { render, RenderResult } from '@testing-library/react';
import { FieldDisplay } from '@grafana/data';
import { RadialArcPathEndpointMarks, RadialArcPathEndpointMarksProps } from './RadialArcPathEndpointMarks';
import { RadialGaugeDimensions } from './types';
const ser = new XMLSerializer();
const expectHTML = (result: RenderResult, expected: string) => {
let actual = ser.serializeToString(result.asFragment()).replace(/xmlns=".*?" /g, '');
expect(actual).toEqual(expected.replace(/^\s*|\n/gm, ''));
};
describe('RadialArcPathEndpointMarks', () => {
const defaultDimensions = Object.freeze({
centerX: 100,
centerY: 100,
radius: 80,
barWidth: 20,
vizWidth: 200,
vizHeight: 200,
margin: 10,
barIndex: 0,
thresholdsBarRadius: 0,
thresholdsBarWidth: 0,
thresholdsBarSpacing: 0,
scaleLabelsFontSize: 0,
scaleLabelsSpacing: 0,
scaleLabelsRadius: 0,
gaugeBottomY: 0,
}) satisfies RadialGaugeDimensions;
const defaultFieldDisplay = Object.freeze({
name: 'Test',
field: {},
display: { text: '50', numeric: 50, color: '#FF0000' },
hasLinks: false,
}) satisfies FieldDisplay;
const defaultProps = Object.freeze({
arcLengthDeg: 90,
dimensions: defaultDimensions,
fieldDisplay: defaultFieldDisplay,
startAngle: 0,
xStart: 100,
xEnd: 150,
yStart: 100,
yEnd: 50,
}) satisfies Omit<RadialArcPathEndpointMarksProps, 'color' | 'gradient' | 'endpointMarker'>;
it('renders the expected marks when endpointMarker is "point" w/ a static color', () => {
expectHTML(
render(
<svg role="img">
<RadialArcPathEndpointMarks {...defaultProps} endpointMarker="point" color="#FF0000" />
</svg>
),
'<svg role=\"img\"><circle cx=\"100\" cy=\"100\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/><circle cx=\"150\" cy=\"50\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/></svg>'
);
});
it('renders the expected marks when endpointMarker is "point" w/ a gradient color', () => {
expectHTML(
render(
<svg role="img">
<RadialArcPathEndpointMarks
{...defaultProps}
endpointMarker="point"
gradient={[
{ color: '#00FF00', percent: 0 },
{ color: '#0000FF', percent: 1 },
]}
/>
</svg>
),
'<svg role=\"img\"><circle cx=\"100\" cy=\"100\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/><circle cx=\"150\" cy=\"50\" r=\"4\" fill=\"#fbfbfb\" opacity=\"0.5\"/></svg>'
);
});
it('renders the expected marks when endpointMarker is "glow" w/ a static color', () => {
expectHTML(
render(
<svg role="img">
<RadialArcPathEndpointMarks {...defaultProps} endpointMarker="glow" color="#FF0000" />
</svg>
),
'<svg role=\"img\"><path d=\"M 113.89185421335443 21.215379759023364 A 80 80 0 0 1 150 50\" fill=\"none\" stroke-width=\"20\" stroke-linecap=\"butt\"/></svg>'
);
});
it('renders the expected marks when endpointMarker is "glow" w/ a gradient color', () => {
expectHTML(
render(
<svg role="img">
<RadialArcPathEndpointMarks
{...defaultProps}
endpointMarker="glow"
gradient={[
{ color: '#00FF00', percent: 0 },
{ color: '#0000FF', percent: 1 },
]}
/>
</svg>
),
'<svg role=\"img\"><path d=\"M 113.89185421335443 21.215379759023364 A 80 80 0 0 1 150 50\" fill=\"none\" stroke-width=\"20\" stroke-linecap=\"butt\"/></svg>'
);
});
it('does not render the start mark when arcLengthDeg is less than the minimum angle for "point" endpointMarker', () => {
expectHTML(
render(
<svg role="img">
<RadialArcPathEndpointMarks {...defaultProps} arcLengthDeg={5} endpointMarker="point" color="#FF0000" />
</svg>
),
'<svg role=\"img\"><circle cx=\"150\" cy=\"50\" r=\"4\" fill=\"#111217\" opacity=\"0.5\"/></svg>'
);
});
it('does not render anything when arcLengthDeg is less than the minimum angle for "glow" endpointMarker', () => {
expectHTML(
render(
<svg role="img">
<RadialArcPathEndpointMarks {...defaultProps} arcLengthDeg={5} endpointMarker="glow" color="#FF0000" />
</svg>
),
'<svg role=\"img\"/>'
);
});
it('does not render anything if endpointMarker is some other value', () => {
expectHTML(
render(
<svg role="img">
{/* @ts-ignore: confirming the component doesn't throw */}
<RadialArcPathEndpointMarks {...defaultProps} endpointMarker="foo" />
</svg>
),
'<svg role=\"img\"/>'
);
});
});

View File

@@ -0,0 +1,98 @@
import { FieldDisplay } from '@grafana/data';
import { getEndpointMarkerColors, getGuideDotColor } from './colors';
import { GradientStop, RadialGaugeDimensions } from './types';
import { toRad } from './utils';
interface RadialArcPathEndpointMarksPropsBase {
arcLengthDeg: number;
dimensions: RadialGaugeDimensions;
fieldDisplay: FieldDisplay;
endpointMarker: 'point' | 'glow';
roundedBars?: boolean;
startAngle: number;
glowFilter?: string;
endpointMarkerGlowFilter?: string;
xStart: number;
xEnd: number;
yStart: number;
yEnd: number;
}
interface RadialArcPathEndpointMarksPropsWithColor extends RadialArcPathEndpointMarksPropsBase {
color: string;
}
interface RadialArcPathEndpointMarksPropsWithGradient extends RadialArcPathEndpointMarksPropsBase {
gradient: GradientStop[];
}
export type RadialArcPathEndpointMarksProps =
| RadialArcPathEndpointMarksPropsWithColor
| RadialArcPathEndpointMarksPropsWithGradient;
const ENDPOINT_MARKER_MIN_ANGLE = 10;
const DOT_OPACITY = 0.5;
const DOT_RADIUS_FACTOR = 0.4;
const MAX_DOT_RADIUS = 8;
export function RadialArcPathEndpointMarks({
startAngle: angle,
arcLengthDeg,
dimensions,
endpointMarker,
fieldDisplay,
xStart,
xEnd,
yStart,
yEnd,
roundedBars,
endpointMarkerGlowFilter,
glowFilter,
...rest
}: RadialArcPathEndpointMarksProps) {
const isGradient = 'gradient' in rest;
const { radius, centerX, centerY, barWidth } = dimensions;
const endRadians = toRad(angle + arcLengthDeg);
switch (endpointMarker) {
case 'point': {
const [pointColorStart, pointColorEnd] = isGradient
? getEndpointMarkerColors(rest.gradient, fieldDisplay.display.percent)
: [getGuideDotColor(rest.color), getGuideDotColor(rest.color)];
const dotRadius =
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
return (
<>
{arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && (
<circle cx={xStart} cy={yStart} r={dotRadius} fill={pointColorStart} opacity={DOT_OPACITY} />
)}
<circle cx={xEnd} cy={yEnd} r={dotRadius} fill={pointColorEnd} opacity={DOT_OPACITY} />
</>
);
}
case 'glow':
const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE);
const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle);
const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle);
if (arcLengthDeg <= ENDPOINT_MARKER_MIN_ANGLE) {
break;
}
return (
<path
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
fill="none"
strokeWidth={barWidth}
stroke={endpointMarkerGlowFilter}
strokeLinecap={roundedBars ? 'round' : 'butt'}
filter={glowFilter}
/>
);
default:
break;
}
return null;
}

View File

@@ -175,7 +175,7 @@ export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape
const GRAY_05 = '#111217'; const GRAY_05 = '#111217';
const GRAY_90 = '#fbfbfb'; const GRAY_90 = '#fbfbfb';
const CONTRAST_THRESHOLD_MAX = 4.5; const CONTRAST_THRESHOLD_MAX = 4.5;
const getGuideDotColor = (color: string): string => { export const getGuideDotColor = (color: string): string => {
const darkColor = GRAY_05; const darkColor = GRAY_05;
const lightColor = GRAY_90; const lightColor = GRAY_90;
return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor;

View File

@@ -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("legacy", "GetDataSourceByUID"), time.Since(start).Seconds()) metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("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("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds()) metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("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("legacy", "AddDataSource"), time.Since(start).Seconds()) metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("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("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds()) metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("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 {

View File

@@ -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{"code_path", "handler"}) }, []string{"handler"})
promRegister.MustRegister(dsConfigHandlerRequestsDuration) promRegister.MustRegister(dsConfigHandlerRequestsDuration)
return promRegister, dsConfigHandlerRequestsDuration return promRegister, dsConfigHandlerRequestsDuration
} }

View File

@@ -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{"code_path", "handler"}), }, []string{"handler"}),
} }
promRegister.MustRegister(hs.htmlHandlerRequestsDuration) promRegister.MustRegister(hs.htmlHandlerRequestsDuration)

View File

@@ -928,9 +928,10 @@ 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())) plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
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)
@@ -1050,9 +1051,11 @@ 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)
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), var sqlStore db.DB = nil
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())) plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
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)
@@ -1106,9 +1109,11 @@ 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()
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), var sqlStore db.DB = nil
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())) plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
require.NoError(t, err) require.NoError(t, err)
tracer := tracing.InitializeTracerForTest() tracer := tracing.InitializeTracerForTest()

View File

@@ -11,6 +11,9 @@ 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"
@@ -46,7 +49,6 @@ 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"

View File

@@ -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("grafana", return jaegerremote.New(ots.cfg.ServiceName,
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

View File

@@ -8,7 +8,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -316,6 +315,12 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
return return
} }
// sort.Slice(parsedResults.Hits, func(i, j int) bool {
// // Just sorting by resource for now. The rest should be sorted by search score already
// return parsedResults.Hits[i].Resource > parsedResults.Hits[j].Resource
// })
// }
result, err := s.client.Search(ctx, searchRequest) result, err := s.client.Search(ctx, searchRequest)
if err != nil { if err != nil {
errhttp.Write(ctx, err, w) errhttp.Write(ctx, err, w)
@@ -332,14 +337,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
return return
} }
if len(searchRequest.SortBy) == 0 {
// default sort by resource descending ( folders then dashboards ) then title
sort.Slice(parsedResults.Hits, func(i, j int) bool {
// Just sorting by resource for now. The rest should be sorted by search score already
return parsedResults.Hits[i].Resource > parsedResults.Hits[j].Resource
})
}
s.write(w, parsedResults) s.write(w, parsedResults)
} }
@@ -428,6 +425,18 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
} }
searchRequest.SortBy = append(searchRequest.SortBy, s) searchRequest.SortBy = append(searchRequest.SortBy, s)
} }
} else if searchRequest.Query == "" {
// When no query exists, return the results in a predictable order
searchRequest.SortBy = []*resourcepb.ResourceSearchRequest_Sort{
{
Field: resource.SEARCH_FIELD_GROUP_RESOURCE, // folders then dashboards
Desc: true,
},
{
Field: resource.SEARCH_FIELD_TITLE, // then title
Desc: false,
},
}
} }
// The facet term fields // The facet term fields

View File

@@ -57,6 +57,12 @@ 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)
} }
@@ -64,7 +70,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("new", "Get"), time.Since(start).Seconds()) metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Get"), time.Since(start).Seconds())
}() }()
} }
@@ -76,7 +82,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("new", "Create"), time.Since(start).Seconds()) metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Create"), time.Since(start).Seconds())
}() }()
} }
@@ -92,7 +98,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("new", "Create"), time.Since(start).Seconds()) metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Update"), time.Since(start).Seconds())
}() }()
} }
@@ -135,7 +141,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("new", "Create"), time.Since(start).Seconds()) metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Delete"), time.Since(start).Seconds())
}() }()
} }
@@ -145,6 +151,13 @@ 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

View File

@@ -21,6 +21,7 @@ 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"
@@ -69,10 +70,10 @@ func RegisterAPIService(
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{ dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "grafana", Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds", Name: "ds_config_handler_apis_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers", Help: "Duration of requests handled by new k8s style APIs datasource configuration handlers",
}, []string{"code_path", "handler"}) }, []string{"handler"})
regErr := reg.Register(dataSourceCRUDMetric) regErr := metrics.ProvideRegisterer().Register(dataSourceCRUDMetric)
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) { if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
return nil, regErr return nil, regErr
} }

View File

@@ -13,6 +13,7 @@ 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"
) )
@@ -36,9 +37,13 @@ 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) {
if err := registerAccessControlRoles(accessControlService); err != nil { //nolint:staticcheck // not yet migrated to OpenFeature
return nil, fmt.Errorf("registering access control roles: %w", err) if features.IsEnabledGlobally(featuremgmt.FlagPluginStoreServiceLoading) {
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)

View File

@@ -330,6 +330,7 @@ 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

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,6 @@ 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"
@@ -29,9 +28,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(cfg *setting.Cfg) *GrafanaAuthorizer { func NewGrafanaBuiltInSTAuthorizer() *GrafanaAuthorizer {
authorizers := []authorizer.Authorizer{ authorizers := []authorizer.Authorizer{
newImpersonationAuthorizer(), NewImpersonationAuthorizer(),
authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup), authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup),
newNamespaceAuthorizer(), newNamespaceAuthorizer(),
} }

View File

@@ -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{}
} }

View File

@@ -76,19 +76,7 @@ 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 {
requestHandler, err := GetCustomRoutesHandler( handler := filters.WithTracingHTTPLoggingAttributes(delegateHandler)
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)

View File

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

View File

@@ -5,7 +5,6 @@ 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"
@@ -41,15 +40,6 @@ 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)

View File

@@ -155,7 +155,7 @@ func ProvideService(
features: features, features: features,
rr: rr, rr: rr,
builders: []builder.APIGroupBuilder{}, builders: []builder.APIGroupBuilder{},
authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(cfg), authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(),
tracing: tracing, tracing: tracing,
db: db, // For Unified storage db: db, // For Unified storage
metrics: reg, metrics: reg,
@@ -443,6 +443,19 @@ 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

View File

@@ -51,6 +51,7 @@ 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
} }
@@ -70,6 +71,7 @@ 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}
@@ -89,6 +91,7 @@ func ProvideService(
pluginStore: pluginStore, pluginStore: pluginStore,
pluginClient: pluginClient, pluginClient: pluginClient,
basePluginContextProvider: basePluginContextProvider, basePluginContextProvider: basePluginContextProvider,
retriever: retriever,
} }
ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store)) ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store))
@@ -175,11 +178,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.SQLStore.GetDataSource(ctx, query) return s.retriever.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.SQLStore.GetDataSourceInNamespace(ctx, namespace, name, group) return s.retriever.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) {

View File

@@ -832,8 +832,9 @@ 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()
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
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{
@@ -857,7 +858,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
// First add the datasource // First add the datasource
@@ -1124,7 +1127,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider) rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider)
@@ -1161,7 +1166,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
ds := datasources.DataSource{ ds := datasources.DataSource{
@@ -1212,7 +1219,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
ds := datasources.DataSource{ ds := datasources.DataSource{
@@ -1260,7 +1269,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
ds := datasources.DataSource{ ds := datasources.DataSource{
@@ -1316,7 +1327,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
ds := datasources.DataSource{ ds := datasources.DataSource{
@@ -1351,7 +1364,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
ds := datasources.DataSource{ ds := datasources.DataSource{
@@ -1420,7 +1435,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
ds := datasources.DataSource{ ds := datasources.DataSource{
@@ -1499,7 +1516,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
ds := datasources.DataSource{ ds := datasources.DataSource{
@@ -1522,7 +1541,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
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) {
@@ -1620,7 +1641,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
for _, tc := range testCases { for _, tc := range testCases {
@@ -1645,7 +1668,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
jsonData := map[string]string{ jsonData := map[string]string{
@@ -1673,7 +1698,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
jsonData := map[string]string{ jsonData := map[string]string{
@@ -1699,7 +1726,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) features := featuremgmt.WithFeatures()
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
require.NoError(t, err) require.NoError(t, err)
dsService.cfg = setting.NewCfg() dsService.cfg = setting.NewCfg()
@@ -1788,7 +1817,9 @@ 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)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{ features := featuremgmt.WithFeatures()
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",
@@ -1808,7 +1839,7 @@ func initDSService(t *testing.T) *Service {
ObjectBytes: req.ObjectBytes, ObjectBytes: req.ObjectBytes,
}, nil }, nil
}, },
}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) }, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
require.NoError(t, err) require.NoError(t, err)
return dsService return dsService

View File

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

View File

@@ -542,9 +542,10 @@ 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())) ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
require.NoError(t, err) require.NoError(t, err)
m := metrics.NewNGAlert(prometheus.NewRegistry()) m := metrics.NewNGAlert(prometheus.NewRegistry())

View File

@@ -37,9 +37,10 @@ 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())) plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
require.NoError(t, err) require.NoError(t, err)
migService := ProvideDataSourceMigrationService(dsService, kvStore, features) migService := ProvideDataSourceMigrationService(dsService, kvStore, features)
return migService return migService

View File

@@ -293,15 +293,15 @@ overrides_path = overrides.yaml
overrides_reload_period = 5s overrides_reload_period = 5s
``` ```
To overrides the default quota for a tenant, add the following to the overrides.yaml file: To override 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,8 +806,10 @@ flowchart TD
#### Setting Dual Writer Mode #### Setting Dual Writer Mode
```ini ```ini
[unified_storage.{resource}.{kind}.{group}] ; [unified_storage.{resource}.{group}]
dualWriterMode = {0-5} [unified_storage.dashboards.dashboard.grafana.app]
; modes {0-5}
dualWriterMode = 0
``` ```
#### Background Sync Configuration #### Background Sync Configuration
@@ -1376,4 +1378,3 @@ 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).

View File

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

View File

@@ -7,9 +7,7 @@ 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 }}, ""),
@@ -19,26 +17,5 @@ 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
); );

View File

@@ -1,8 +1,10 @@
UPDATE {{ .Ident "resource" }} UPDATE {{ .Ident "resource" }}
SET SET
{{ .Ident "value" }} = {{ .Arg .Value }}, {{ .Ident "guid" }} = {{ .Arg .GUID }},
{{ .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 }}

View File

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

View File

@@ -12,6 +12,9 @@ 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"
) )
@@ -306,10 +309,6 @@ 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})
@@ -598,7 +597,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, "/")
@@ -815,3 +814,121 @@ 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
}

View File

@@ -44,8 +44,6 @@ 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")
@@ -157,26 +155,6 @@ 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
@@ -392,7 +370,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 // temporary save for dataStore without rvmanager (non backwards-compatible)
// 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)
@@ -429,11 +407,12 @@ func (w *sqlWriteCloser) Close() error {
return nil return nil
} }
// special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore, // special, temporary backwards-compatible save that includes all the fields in resource_history that are not relevant
// as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that // for the kvstore, as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
// component will be responsible for populating the resource_version and key_path columns // component will be responsible for populating the resource_version and key_path columns.
// note that we are not touching resource_version table, neither the resource_version columns or the key_path column // For full backwards-compatibility, the `Save` function needs to be called within a callback that updates the resource_history
// as the RvManager will be responsible for this // table with `previous_resource_version` and `generation` and updates the `resource` table accordingly. See the
// 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)
@@ -448,7 +427,7 @@ func (w *sqlWriteCloser) Close() error {
case DataActionDeleted: case DataActionDeleted:
action = 3 action = 3
default: default:
return fmt.Errorf("failed to parse key: %w", err) return fmt.Errorf("failed to parse key: invalid action")
} }
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{ _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{
@@ -468,52 +447,6 @@ 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
} }

View File

@@ -332,11 +332,14 @@ 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) {
err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)) if err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)); err != nil {
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 {

View File

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

View File

@@ -10,6 +10,7 @@ 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"
@@ -177,6 +178,9 @@ 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

View File

@@ -320,8 +320,9 @@ 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 {
_, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with features-service type, but trying to make tests work if opts.OpenFeatureAPIEnabled {
_, 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)

View File

@@ -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: silence.createdBy, createdBy: isExpired ? contextSrv.user.name : silence.createdBy,
duration: intervalToAbbreviatedDurationString(interval), duration: intervalToAbbreviatedDurationString(interval),
isRegex: false, isRegex: false,
matchers: silence.matchers?.map(matcherToMatcherField) || [], matchers: silence.matchers?.map(matcherToMatcherField) || [],

View File

@@ -39,7 +39,7 @@ export function RecentlyViewedDashboards() {
retry(); retry();
}; };
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) { if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
return null; return null;
} }
@@ -76,10 +76,6 @@ 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}>

View File

@@ -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', () => { it('should handle invalid time reference in timeShift with relative time range', () => {
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' }); const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
buildAndActivateSceneFor(panelTime); buildAndActivateSceneFor(panelTime);
@@ -139,6 +139,22 @@ 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',
@@ -153,6 +169,66 @@ 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';

View File

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

View File

@@ -284,7 +284,6 @@ function variableValueOptionsToVariableOptions(varState: MultiValueVariable['sta
value: String(o.value), value: String(o.value),
text: o.label, text: o.label,
selected: Array.isArray(varState.value) ? varState.value.includes(o.value) : varState.value === o.value, selected: Array.isArray(varState.value) ? varState.value.includes(o.value) : varState.value === o.value,
...(o.properties && { properties: o.properties }),
})); }));
} }

View File

@@ -80,18 +80,18 @@ const buildLabelPath = (label: string) => {
return label.includes('.') || label.trim().includes(' ') ? `["${label}"]` : `.${label}`; return label.includes('.') || label.trim().includes(' ') ? `["${label}"]` : `.${label}`;
}; };
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null && !Array.isArray(value);
};
const getVariableValueProperties = (variable: TypedVariableModel): string[] => { const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
function collectFieldPaths(option: Record<string, unknown>, currentPath: string): string[] { if (!('valuesFormat' in variable) || variable.valuesFormat !== 'json') {
return [];
}
function collectFieldPaths(option: Record<string, string>, currentPath: string) {
let paths: string[] = []; let paths: string[] = [];
for (const field in option) { for (const field in option) {
if (option.hasOwnProperty(field)) { if (option.hasOwnProperty(field)) {
const newPath = `${currentPath}.${field}`; const newPath = `${currentPath}.${field}`;
const value = option[field]; const value = option[field];
if (isRecord(value)) { if (typeof value === 'object' && value !== null) {
paths = [...paths, ...collectFieldPaths(value, newPath)]; paths = [...paths, ...collectFieldPaths(value, newPath)];
} }
paths.push(newPath); paths.push(newPath);
@@ -100,23 +100,11 @@ const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
return paths; return paths;
} }
if ('valuesFormat' in variable && variable.valuesFormat === 'json') { try {
try { return collectFieldPaths(JSON.parse(variable.query)[0], variable.name);
return collectFieldPaths(JSON.parse(variable.query)[0], variable.name); } catch {
} catch { return [];
return [];
}
} }
if ('options' in variable && Array.isArray(variable.options) && variable.options.length > 0) {
for (const opt of variable.options) {
if ('properties' in opt && isRecord(opt.properties) && Object.keys(opt.properties).length > 0) {
return collectFieldPaths(opt.properties, variable.name);
}
}
}
return [];
}; };
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [ export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [

View File

@@ -11,7 +11,6 @@ import {
SQLQuery, SQLQuery,
SQLSelectableValue, SQLSelectableValue,
SqlDatasource, SqlDatasource,
SQLVariableSupport,
formatSQL, formatSQL,
} from '@grafana/sql'; } from '@grafana/sql';
@@ -26,7 +25,6 @@ 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 {

View File

@@ -3791,7 +3791,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4454,6 +4453,7 @@
}, },
"no-properties-changed": "Žádné relevantní vlastnosti se nezměnily", "no-properties-changed": "Žádné relevantní vlastnosti se nezměnily",
"table": { "table": {
"notes": "",
"updated": "Datum", "updated": "Datum",
"updatedBy": "Aktualizoval uživatel", "updatedBy": "Aktualizoval uživatel",
"version": "Verze" "version": "Verze"
@@ -4912,7 +4912,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Hodnoty oddělené čárkou"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Filtr názvu", "name-filter": "Filtr názvu",
@@ -6010,6 +6011,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Vlastní možnosti", "custom-options": "Vlastní možnosti",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Hodnoty oddělené čárkou", "name-values-separated-comma": "Hodnoty oddělené čárkou",
"selection-options": "Možnosti výběru" "selection-options": "Možnosti výběru"
}, },
@@ -6601,6 +6605,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Nástěnka byla uložena" "message-dashboard-saved": "Nástěnka byla uložena"
}, },
@@ -6624,6 +6633,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6683,8 +6693,11 @@
"tooltip-show-usages": "Zobrazit použití" "tooltip-show-usages": "Zobrazit použití"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Náhled hodnot", "show-more": "Zobrazit více",
"show-more": "Zobrazit více" "preview-of-values_one": "",
"preview-of-values_few": "",
"preview-of-values_many": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3759,7 +3759,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4416,6 +4415,7 @@
}, },
"no-properties-changed": "Keine relevanten Eigenschaften geändert", "no-properties-changed": "Keine relevanten Eigenschaften geändert",
"table": { "table": {
"notes": "",
"updated": "Datum", "updated": "Datum",
"updatedBy": "Aktualisiert von", "updatedBy": "Aktualisiert von",
"version": "Version" "version": "Version"
@@ -4874,7 +4874,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Werte werden durch Komma getrennt"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Namensfilter", "name-filter": "Namensfilter",
@@ -5968,6 +5969,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Benutzerdefinierte Optionen", "custom-options": "Benutzerdefinierte Optionen",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Werte werden durch Komma getrennt", "name-values-separated-comma": "Werte werden durch Komma getrennt",
"selection-options": "Auswahloptionen" "selection-options": "Auswahloptionen"
}, },
@@ -6555,6 +6559,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Dashboard gespeichert" "message-dashboard-saved": "Dashboard gespeichert"
}, },
@@ -6578,6 +6587,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6637,8 +6647,9 @@
"tooltip-show-usages": "Nutzungen anzeigen" "tooltip-show-usages": "Nutzungen anzeigen"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Vorschau der Werte", "show-more": "Mehr anzeigen",
"show-more": "Mehr anzeigen" "preview-of-values_one": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

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

View File

@@ -3759,7 +3759,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4416,6 +4415,7 @@
}, },
"no-properties-changed": "No se ha cambiado ninguna propiedad relevante", "no-properties-changed": "No se ha cambiado ninguna propiedad relevante",
"table": { "table": {
"notes": "",
"updated": "Fecha", "updated": "Fecha",
"updatedBy": "Actualizada por", "updatedBy": "Actualizada por",
"version": "Versión" "version": "Versión"
@@ -4874,7 +4874,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Valores separados por coma"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Nombrar filtro", "name-filter": "Nombrar filtro",
@@ -5968,6 +5969,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Opciones personalizadas", "custom-options": "Opciones personalizadas",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Valores separados por comas", "name-values-separated-comma": "Valores separados por comas",
"selection-options": "Opciones de selección" "selection-options": "Opciones de selección"
}, },
@@ -6555,6 +6559,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Dashboard guardado" "message-dashboard-saved": "Dashboard guardado"
}, },
@@ -6578,6 +6587,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6637,8 +6647,9 @@
"tooltip-show-usages": "Mostrar usos" "tooltip-show-usages": "Mostrar usos"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Vista previa de los valores", "show-more": "Mostrar más",
"show-more": "Mostrar más" "preview-of-values_one": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3759,7 +3759,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4416,6 +4415,7 @@
}, },
"no-properties-changed": "Aucune propriété pertinente na été modifiée", "no-properties-changed": "Aucune propriété pertinente na été modifiée",
"table": { "table": {
"notes": "",
"updated": "Date", "updated": "Date",
"updatedBy": "Mis à jour par", "updatedBy": "Mis à jour par",
"version": "Version" "version": "Version"
@@ -4874,7 +4874,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Valeurs séparées par une virgule"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Nom du filtre", "name-filter": "Nom du filtre",
@@ -5968,6 +5969,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Personnaliser les options", "custom-options": "Personnaliser les options",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Valeurs séparées par des virgules", "name-values-separated-comma": "Valeurs séparées par des virgules",
"selection-options": "Options de sélection" "selection-options": "Options de sélection"
}, },
@@ -6555,6 +6559,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Tableau de bord enregistré" "message-dashboard-saved": "Tableau de bord enregistré"
}, },
@@ -6578,6 +6587,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6637,8 +6647,9 @@
"tooltip-show-usages": "Afficher les usages" "tooltip-show-usages": "Afficher les usages"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Aperçu des valeurs", "show-more": "Afficher plus",
"show-more": "Afficher plus" "preview-of-values_one": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3759,7 +3759,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4416,6 +4415,7 @@
}, },
"no-properties-changed": "Nem változtak meg a releváns tulajdonságok", "no-properties-changed": "Nem változtak meg a releváns tulajdonságok",
"table": { "table": {
"notes": "",
"updated": "Dátum", "updated": "Dátum",
"updatedBy": "Frissítette:", "updatedBy": "Frissítette:",
"version": "Verzió" "version": "Verzió"
@@ -4874,7 +4874,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Értékek vesszővel elválasztva"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Névszűrő", "name-filter": "Névszűrő",
@@ -5968,6 +5969,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Egyéni opciók", "custom-options": "Egyéni opciók",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Értékek vesszővel elválasztva", "name-values-separated-comma": "Értékek vesszővel elválasztva",
"selection-options": "Kijelölés beállításai" "selection-options": "Kijelölés beállításai"
}, },
@@ -6555,6 +6559,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Irányítópult elmentve" "message-dashboard-saved": "Irányítópult elmentve"
}, },
@@ -6578,6 +6587,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6637,8 +6647,9 @@
"tooltip-show-usages": "Használatok megjelenítése" "tooltip-show-usages": "Használatok megjelenítése"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Értékek előnézete", "show-more": "Több megjelenítése",
"show-more": "Több megjelenítése" "preview-of-values_one": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3743,7 +3743,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4397,6 +4396,7 @@
}, },
"no-properties-changed": "Tidak ada properti yang relevan yang diubah", "no-properties-changed": "Tidak ada properti yang relevan yang diubah",
"table": { "table": {
"notes": "",
"updated": "Tanggal", "updated": "Tanggal",
"updatedBy": "Diperbarui Oleh", "updatedBy": "Diperbarui Oleh",
"version": "Versi" "version": "Versi"
@@ -4855,7 +4855,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Nilai dipisahkan dengan koma"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Filter nama", "name-filter": "Filter nama",
@@ -5947,6 +5948,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Opsi kustom", "custom-options": "Opsi kustom",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Nilai dipisahkan dengan koma", "name-values-separated-comma": "Nilai dipisahkan dengan koma",
"selection-options": "Opsi pemilihan" "selection-options": "Opsi pemilihan"
}, },
@@ -6532,6 +6536,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Dasbor disimpan" "message-dashboard-saved": "Dasbor disimpan"
}, },
@@ -6555,6 +6564,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6614,8 +6624,8 @@
"tooltip-show-usages": "Tampilkan penggunaan" "tooltip-show-usages": "Tampilkan penggunaan"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Pratinjau nilai", "show-more": "Tampilkan lebih banyak",
"show-more": "Tampilkan lebih banyak" "preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3759,7 +3759,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4416,6 +4415,7 @@
}, },
"no-properties-changed": "Nessuna proprietà rilevante modificata", "no-properties-changed": "Nessuna proprietà rilevante modificata",
"table": { "table": {
"notes": "",
"updated": "Data", "updated": "Data",
"updatedBy": "Aggiornato da", "updatedBy": "Aggiornato da",
"version": "Versione" "version": "Versione"
@@ -4874,7 +4874,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Valori separati da virgola"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Filtro nome", "name-filter": "Filtro nome",
@@ -5968,6 +5969,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Opzioni personalizzate", "custom-options": "Opzioni personalizzate",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Valori separati da virgola", "name-values-separated-comma": "Valori separati da virgola",
"selection-options": "Seleziona opzioni" "selection-options": "Seleziona opzioni"
}, },
@@ -6555,6 +6559,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Dashboard salvata" "message-dashboard-saved": "Dashboard salvata"
}, },
@@ -6578,6 +6587,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6637,8 +6647,9 @@
"tooltip-show-usages": "Mostra utilizzi" "tooltip-show-usages": "Mostra utilizzi"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Anteprima dei valori", "show-more": "Mostra di più",
"show-more": "Mostra di più" "preview-of-values_one": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3743,7 +3743,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4397,6 +4396,7 @@
}, },
"no-properties-changed": "関連するプロパティは変更されていません", "no-properties-changed": "関連するプロパティは変更されていません",
"table": { "table": {
"notes": "",
"updated": "日付", "updated": "日付",
"updatedBy": "更新者", "updatedBy": "更新者",
"version": "バージョン" "version": "バージョン"
@@ -4855,7 +4855,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "カンマで区切った値"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "名前フィルター", "name-filter": "名前フィルター",
@@ -5947,6 +5948,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "カスタムオプション", "custom-options": "カスタムオプション",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "カンマ区切りの値", "name-values-separated-comma": "カンマ区切りの値",
"selection-options": "選択オプション" "selection-options": "選択オプション"
}, },
@@ -6532,6 +6536,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "ダッシュボードが保存されました" "message-dashboard-saved": "ダッシュボードが保存されました"
}, },
@@ -6555,6 +6564,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6614,8 +6624,8 @@
"tooltip-show-usages": "使用状況を表示" "tooltip-show-usages": "使用状況を表示"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "値のプレビュー", "show-more": "さらに表示",
"show-more": "さらに表示" "preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3743,7 +3743,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4397,6 +4396,7 @@
}, },
"no-properties-changed": "변경된 관련 속성 없음", "no-properties-changed": "변경된 관련 속성 없음",
"table": { "table": {
"notes": "",
"updated": "날짜", "updated": "날짜",
"updatedBy": "업데이트한 사용자", "updatedBy": "업데이트한 사용자",
"version": "버전" "version": "버전"
@@ -4855,7 +4855,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "쉼표로 구분된 값"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "이름 필터", "name-filter": "이름 필터",
@@ -5947,6 +5948,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "사용자 지정 옵션", "custom-options": "사용자 지정 옵션",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "쉼표로 구분된 값", "name-values-separated-comma": "쉼표로 구분된 값",
"selection-options": "선택 옵션" "selection-options": "선택 옵션"
}, },
@@ -6532,6 +6536,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "대시보드가 저장되었습니다" "message-dashboard-saved": "대시보드가 저장되었습니다"
}, },
@@ -6555,6 +6564,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6614,8 +6624,8 @@
"tooltip-show-usages": "사용처 표시" "tooltip-show-usages": "사용처 표시"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "값 미리 보기", "show-more": " 보기",
"show-more": "더 보기" "preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3759,7 +3759,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4416,6 +4415,7 @@
}, },
"no-properties-changed": "Geen relevante eigenschappen gewijzigd", "no-properties-changed": "Geen relevante eigenschappen gewijzigd",
"table": { "table": {
"notes": "",
"updated": "Datum", "updated": "Datum",
"updatedBy": "Bijgewerkt door", "updatedBy": "Bijgewerkt door",
"version": "Versie" "version": "Versie"
@@ -4874,7 +4874,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Waarden gescheiden door komma"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Filter een naam geven", "name-filter": "Filter een naam geven",
@@ -5968,6 +5969,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Aangepaste opties", "custom-options": "Aangepaste opties",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Waarden gescheiden door komma", "name-values-separated-comma": "Waarden gescheiden door komma",
"selection-options": "Selectiemogelijkheden" "selection-options": "Selectiemogelijkheden"
}, },
@@ -6555,6 +6559,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Dashboard opgeslagen" "message-dashboard-saved": "Dashboard opgeslagen"
}, },
@@ -6578,6 +6587,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6637,8 +6647,9 @@
"tooltip-show-usages": "Gebruik weergeven" "tooltip-show-usages": "Gebruik weergeven"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Voorbeeldweergave van waarden", "show-more": "Meer weergeven",
"show-more": "Meer weergeven" "preview-of-values_one": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3791,7 +3791,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4454,6 +4453,7 @@
}, },
"no-properties-changed": "Nie zmieniono istotnych właściwości", "no-properties-changed": "Nie zmieniono istotnych właściwości",
"table": { "table": {
"notes": "",
"updated": "Data", "updated": "Data",
"updatedBy": "Zaktualizowane przez", "updatedBy": "Zaktualizowane przez",
"version": "Wersja" "version": "Wersja"
@@ -4912,7 +4912,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Wartości rozdzielone przecinkami"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Filtr nazwy", "name-filter": "Filtr nazwy",
@@ -6010,6 +6011,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Opcje niestandardowe", "custom-options": "Opcje niestandardowe",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Wartości rozdzielone przecinkami", "name-values-separated-comma": "Wartości rozdzielone przecinkami",
"selection-options": "Opcje wyboru" "selection-options": "Opcje wyboru"
}, },
@@ -6601,6 +6605,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Pulpit został zapisany" "message-dashboard-saved": "Pulpit został zapisany"
}, },
@@ -6624,6 +6633,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6683,8 +6693,11 @@
"tooltip-show-usages": "Wyświetl użycie" "tooltip-show-usages": "Wyświetl użycie"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Podgląd wartości", "show-more": "Pokaż więcej",
"show-more": "Pokaż więcej" "preview-of-values_one": "",
"preview-of-values_few": "",
"preview-of-values_many": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3759,7 +3759,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4416,6 +4415,7 @@
}, },
"no-properties-changed": "Nenhuma propriedade relevante alterada", "no-properties-changed": "Nenhuma propriedade relevante alterada",
"table": { "table": {
"notes": "",
"updated": "Data", "updated": "Data",
"updatedBy": "Atualizada por", "updatedBy": "Atualizada por",
"version": "Versão" "version": "Versão"
@@ -4874,7 +4874,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Valores separados por vírgula"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Filtro de nome", "name-filter": "Filtro de nome",
@@ -5968,6 +5969,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Opções personalizadas", "custom-options": "Opções personalizadas",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Valores separados por vírgula", "name-values-separated-comma": "Valores separados por vírgula",
"selection-options": "Opções de seleção" "selection-options": "Opções de seleção"
}, },
@@ -6555,6 +6559,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Painel de controle salvo" "message-dashboard-saved": "Painel de controle salvo"
}, },
@@ -6578,6 +6587,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6637,8 +6647,9 @@
"tooltip-show-usages": "Exibir usos" "tooltip-show-usages": "Exibir usos"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Pré-visualização de valores", "show-more": "Exibir mais",
"show-more": "Exibir mais" "preview-of-values_one": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3759,7 +3759,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4416,6 +4415,7 @@
}, },
"no-properties-changed": "Nenhuma propriedade relevante alterada", "no-properties-changed": "Nenhuma propriedade relevante alterada",
"table": { "table": {
"notes": "",
"updated": "Data", "updated": "Data",
"updatedBy": "Atualizado por", "updatedBy": "Atualizado por",
"version": "Versão" "version": "Versão"
@@ -4874,7 +4874,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Valores separados por vírgulas"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Filtro de nome", "name-filter": "Filtro de nome",
@@ -5968,6 +5969,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Opções personalizadas", "custom-options": "Opções personalizadas",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Valores separados por vírgulas", "name-values-separated-comma": "Valores separados por vírgulas",
"selection-options": "Opções de seleção" "selection-options": "Opções de seleção"
}, },
@@ -6555,6 +6559,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Painel de controlo guardado" "message-dashboard-saved": "Painel de controlo guardado"
}, },
@@ -6578,6 +6587,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6637,8 +6647,9 @@
"tooltip-show-usages": "Mostrar utilizações" "tooltip-show-usages": "Mostrar utilizações"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Pré-visualização de valores", "show-more": "Mostrar mais",
"show-more": "Mostrar mais" "preview-of-values_one": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3791,7 +3791,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4454,6 +4453,7 @@
}, },
"no-properties-changed": "Нет изменений соответствующих свойств", "no-properties-changed": "Нет изменений соответствующих свойств",
"table": { "table": {
"notes": "",
"updated": "Дата", "updated": "Дата",
"updatedBy": "Обновлено", "updatedBy": "Обновлено",
"version": "Версия" "version": "Версия"
@@ -4912,7 +4912,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Значения, разделенные запятыми"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Фильтр по названию", "name-filter": "Фильтр по названию",
@@ -6010,6 +6011,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Пользовательские параметры", "custom-options": "Пользовательские параметры",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Значения, разделенные запятыми", "name-values-separated-comma": "Значения, разделенные запятыми",
"selection-options": "Параметры выбора" "selection-options": "Параметры выбора"
}, },
@@ -6601,6 +6605,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Дашборд сохранен" "message-dashboard-saved": "Дашборд сохранен"
}, },
@@ -6624,6 +6633,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6683,8 +6693,11 @@
"tooltip-show-usages": "Показать варианты использования" "tooltip-show-usages": "Показать варианты использования"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Просмотр значений", "show-more": "Показать еще",
"show-more": "Показать еще" "preview-of-values_one": "",
"preview-of-values_few": "",
"preview-of-values_many": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3759,7 +3759,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4416,6 +4415,7 @@
}, },
"no-properties-changed": "Inga relevanta egenskaper har ändrats", "no-properties-changed": "Inga relevanta egenskaper har ändrats",
"table": { "table": {
"notes": "",
"updated": "Datum", "updated": "Datum",
"updatedBy": "Uppdaterad per", "updatedBy": "Uppdaterad per",
"version": "Version" "version": "Version"
@@ -4874,7 +4874,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Värden åtskilda med kommatecken"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Namnfilter", "name-filter": "Namnfilter",
@@ -5968,6 +5969,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Anpassade alternativ", "custom-options": "Anpassade alternativ",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Värden åtskilda med kommatecken", "name-values-separated-comma": "Värden åtskilda med kommatecken",
"selection-options": "Urvalsalternativ" "selection-options": "Urvalsalternativ"
}, },
@@ -6555,6 +6559,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Kontrollpanelen sparades" "message-dashboard-saved": "Kontrollpanelen sparades"
}, },
@@ -6578,6 +6587,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6637,8 +6647,9 @@
"tooltip-show-usages": "Visa användningar" "tooltip-show-usages": "Visa användningar"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Förhandsgranska värden", "show-more": "Visa mer",
"show-more": "Visa mer" "preview-of-values_one": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3759,7 +3759,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4416,6 +4415,7 @@
}, },
"no-properties-changed": "İlgili hiçbir özellik değiştirilmedi", "no-properties-changed": "İlgili hiçbir özellik değiştirilmedi",
"table": { "table": {
"notes": "",
"updated": "Tarih", "updated": "Tarih",
"updatedBy": "Güncelleyen:", "updatedBy": "Güncelleyen:",
"version": "Sürüm" "version": "Sürüm"
@@ -4874,7 +4874,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "Virgülle ayrılmış değerler"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "Ad filtresi", "name-filter": "Ad filtresi",
@@ -5968,6 +5969,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "Özel seçenekler", "custom-options": "Özel seçenekler",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "Virgülle ayrılmış değerler", "name-values-separated-comma": "Virgülle ayrılmış değerler",
"selection-options": "Seçim ayarları" "selection-options": "Seçim ayarları"
}, },
@@ -6555,6 +6559,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "Pano kaydedildi" "message-dashboard-saved": "Pano kaydedildi"
}, },
@@ -6578,6 +6587,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6637,8 +6647,9 @@
"tooltip-show-usages": "Kullanımları göster" "tooltip-show-usages": "Kullanımları göster"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "Değerlerin ön izlemesi", "show-more": "Daha fazla göster",
"show-more": "Daha fazla göster" "preview-of-values_one": "",
"preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3743,7 +3743,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4397,6 +4396,7 @@
}, },
"no-properties-changed": "没有相关属性更改", "no-properties-changed": "没有相关属性更改",
"table": { "table": {
"notes": "",
"updated": "日期", "updated": "日期",
"updatedBy": "更新者", "updatedBy": "更新者",
"version": "版本" "version": "版本"
@@ -4855,7 +4855,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "以逗号分隔的值"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "名称筛选器", "name-filter": "名称筛选器",
@@ -5947,6 +5948,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "自定义选项", "custom-options": "自定义选项",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "以逗号分隔的值", "name-values-separated-comma": "以逗号分隔的值",
"selection-options": "选择内容选项" "selection-options": "选择内容选项"
}, },
@@ -6532,6 +6536,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "数据面板已保存" "message-dashboard-saved": "数据面板已保存"
}, },
@@ -6555,6 +6564,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6614,8 +6624,8 @@
"tooltip-show-usages": "显示使用情况" "tooltip-show-usages": "显示使用情况"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "值预览", "show-more": "显示更多",
"show-more": "显示更多" "preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {

View File

@@ -3743,7 +3743,6 @@
}, },
"recently-viewed": { "recently-viewed": {
"clear": "", "clear": "",
"empty": "",
"error": "", "error": "",
"retry": "", "retry": "",
"title": "" "title": ""
@@ -4397,6 +4396,7 @@
}, },
"no-properties-changed": "沒有相關的屬性變更", "no-properties-changed": "沒有相關的屬性變更",
"table": { "table": {
"notes": "",
"updated": "日期", "updated": "日期",
"updatedBy": "更新者", "updatedBy": "更新者",
"version": "版本" "version": "版本"
@@ -4855,7 +4855,8 @@
"apply": "", "apply": "",
"change-value": "", "change-value": "",
"discard": "", "discard": "",
"modal-title": "" "modal-title": "",
"values": "以逗號分隔的值"
}, },
"datasource-options": { "datasource-options": {
"name-filter": "名稱篩選", "name-filter": "名稱篩選",
@@ -5947,6 +5948,9 @@
}, },
"custom-variable-form": { "custom-variable-form": {
"custom-options": "自訂選項", "custom-options": "自訂選項",
"json-values-tooltip": "",
"name-csv-values": "",
"name-json-values": "",
"name-values-separated-comma": "以逗號分隔的值", "name-values-separated-comma": "以逗號分隔的值",
"selection-options": "選擇選項" "selection-options": "選擇選項"
}, },
@@ -6532,6 +6536,11 @@
} }
} }
}, },
"use-modal-editor": {
"description": {
"change-variable-query": ""
}
},
"use-save-dashboard": { "use-save-dashboard": {
"message-dashboard-saved": "儀表板已儲存" "message-dashboard-saved": "儀表板已儲存"
}, },
@@ -6555,6 +6564,7 @@
"label": "" "label": ""
}, },
"hidden": { "hidden": {
"description": "",
"label": "" "label": ""
}, },
"hidden-label": { "hidden-label": {
@@ -6614,8 +6624,8 @@
"tooltip-show-usages": "顯示使用情況" "tooltip-show-usages": "顯示使用情況"
}, },
"variable-values-preview": { "variable-values-preview": {
"preview-of-values": "數值預覽", "show-more": "顯示更多",
"show-more": "顯示更多" "preview-of-values_other": ""
}, },
"version-history": { "version-history": {
"comparison": { "comparison": {