Compare commits

..

6 Commits

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

View File

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

4
go.mod
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import { useId, memo, HTMLAttributes, SVGProps } from 'react';
import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react';
import { FieldDisplay } from '@grafana/data';
import { RadialArcPathEndpointMarks } from './RadialArcPathEndpointMarks';
import { getBarEndcapColors, getGradientCss } from './colors';
import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors';
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
import { drawRadialArcPath, toRad } from './utils';
@@ -30,6 +29,11 @@ interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase {
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(
({
arcLengthDeg,
@@ -64,25 +68,67 @@ export const RadialArcPath = memo(
const xEnd = centerX + radius * Math.cos(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 pathProps: SVGProps<SVGPathElement> = {};
let barEndcapColors: [string, string] | undefined;
let endpointMarks: ReactNode = null;
if (isGradient) {
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.stroke = 'white';
} else {
bgDivStyle.backgroundColor = rest.color;
pathProps.fill = 'none';
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 = (
<path d={path} strokeWidth={barWidth} strokeLinecap={roundedBars ? 'round' : 'butt'} {...pathProps} />
);
@@ -112,23 +158,7 @@ export const RadialArcPath = memo(
)}
</g>
{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}
/>
)}
{endpointMarks}
</>
);
}

View File

@@ -1,143 +0,0 @@
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

@@ -1,98 +0,0 @@
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_90 = '#fbfbfb';
const CONTRAST_THRESHOLD_MAX = 4.5;
export const getGuideDotColor = (color: string): string => {
const getGuideDotColor = (color: string): string => {
const darkColor = GRAY_05;
const lightColor = GRAY_90;
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 {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("GetDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds())
}()
ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID())
@@ -240,7 +240,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re
func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("DeleteDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds())
}()
uid := web.Params(c.Req)[":uid"]
@@ -375,7 +375,7 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error {
func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("AddDataSource"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds())
}()
cmd := datasources.AddDataSourceCommand{}
@@ -497,7 +497,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("UpdateDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds())
}()
cmd := datasources.UpdateDataSourceCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"slices"
"sort"
"strconv"
"strings"
@@ -315,12 +316,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
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)
if err != nil {
errhttp.Write(ctx, err, w)
@@ -337,6 +332,14 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
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)
}
@@ -425,18 +428,6 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
}
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

View File

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

View File

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

View File

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

View File

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

12
pkg/server/wire_gen.go generated

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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