Compare commits

...

22 Commits

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

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

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

* update tests

* more tests

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

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

* better name for retriever logger

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

* add compile-time check for DS retriever impl

---------

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

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

* remove extra assertion

* absolute time range
2026-01-07 14:24:20 -05:00
Todd Treece
a3eedfeb73 Plugins: Move fixed role registration behind toggle (#115940) 2026-01-07 13:52:01 -05:00
Renato Costa
1e8f1f74ea unified-storage: apply backwards compatibility changes outside sqlkv (#115954) 2026-01-07 13:51:15 -05:00
owensmallwood
66b05914e2 Tracing: Use service name from config (#115955)
use service name from config
2026-01-07 12:50:11 -06:00
Yunwen Zheng
0c60d356d1 RecentlyViewedDashboards: Hide entire section when there is no recently view item (#115905)
* RecentlyViewedDashboards: Hide entire section when there is no recently view item
2026-01-07 13:31:48 -05:00
Ezequiel Victorero
41d7213d7e Docs: Update dualwrite ini config (#115934) 2026-01-07 17:58:58 +01:00
Todd Treece
efad6c7be0 Chore: Update enterprise imports (#115947) 2026-01-07 16:55:59 +00:00
Paulo Dias
e116254f32 Alerting: Update createdBy field when silence is being Recreated (#115543) 2026-01-07 16:05:53 +00:00
Matheus Macabu
2efcc88e62 FeatureToggles: Remove unused kubernetesFeatureToggles (#115933) 2026-01-07 15:53:58 +01:00
Galen Kistler
6fea614106 LogsTable: Inspect button fix (#115912)
* fix: inspect button

* chore: memoize component
2026-01-07 14:31:04 +00:00
antonio
c0c05a65fd docs/alerting: add video to tutorial (#115675) 2026-01-07 15:11:41 +01:00
Alexander Akhmetov
41ed2aeb23 Alerting: Display change message next to the rule version when exists (#115664)
* Alerting: Display change message next to the rule version when exists

* Alerting: Update version history tests for message field

Updates test mocks and assertions to include message fields in version
history data. Adds three message examples to the mock handler and updates
test expectations to verify the Notes column displays correctly when
messages are present or absent.

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
2026-01-07 15:06:41 +01:00
Johnny Kartheiser
9e9233051e alerting docs: saved searches (#115524)
* alerting docs: saved searches

adds paragraph about saved searches functionality

* typo and explainer

details on default search option

* image update
2026-01-07 08:03:38 -06:00
Ricardo Galeno
a5faedbe68 Explore: escape character of break-line in Traceql in Search tab fixing an issue when filtering by a multi line span tag value (#114672)
* Explore: escape character of break-line in Traceql in Search tab

* Explore: fix test for escape character of break-line in Traceql in Search tab
2026-01-07 13:31:29 +01:00
79 changed files with 1476 additions and 707 deletions

View File

@@ -41,9 +41,13 @@ Select a group to expand it and view the list of alert rules within that group.
The list view includes a number of filters to simplify managing large volumes of alerts.
## Filter and save searches
Click the **Filter** button to open the filter popup. You can filter by name, label, folder/namespace, evaluation group, data source, contact point, rule source, rule state, rule type, and the health of the alert rule from the popup menu. Click **Apply** at the bottom of the filter popup to enact the filters as you search.
{{< figure src="/media/docs/alerting/alerting-list-view-filter.png" max-width="750px" alt="Alert rule filter options" >}}
Click the **Saved searches** button to open the list of previously saved searches, or click **+ Save current search** to add your current search to the saved searches list. You can also rename a saved search or set it as a default search. When you set a saved search as the default search, the Alert rules page opens with the search applied.
{{< figure src="/media/docs/alerting/alerting-saved-searches.png" max-width="750px" alt="Alert rule filter options" >}}
## Change alert rules list view

View File

@@ -135,9 +135,12 @@ You can use the **Span Limit** field in **Options** section of the TraceQL query
This field sets the maximum number of spans to return for each span set.
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.

View File

@@ -23,6 +23,8 @@ killercoda:
This tutorial is a continuation of the [Get started with Grafana Alerting - Route alerts using dynamic labels](http://www.grafana.com/tutorials/alerting-get-started-pt5/) tutorial.
{{< youtube id="mqj_hN24zLU" >}}
<!-- USE CASE -->
In this tutorial you will learn how to:

4
go.mod
View File

@@ -33,12 +33,14 @@ require (
github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad
github.com/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
@@ -343,7 +345,6 @@ 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
@@ -358,7 +359,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect
github.com/aws/aws-sdk-go-v2/service/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,9 +165,17 @@ describe('DateMath', () => {
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).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.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 return false when invalid expression', () => {

View File

@@ -400,10 +400,6 @@ export interface FeatureToggles {
*/
tableSharedCrosshair?: boolean;
/**
* Use the kubernetes API for feature toggle management in the frontend
*/
kubernetesFeatureToggles?: boolean;
/**
* Enabled grafana cloud specific RBAC roles
*/
cloudRBACRoles?: boolean;

View File

@@ -1,8 +1,9 @@
import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react';
import { useId, memo, HTMLAttributes, SVGProps } from 'react';
import { FieldDisplay } from '@grafana/data';
import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors';
import { RadialArcPathEndpointMarks } from './RadialArcPathEndpointMarks';
import { getBarEndcapColors, getGradientCss } from './colors';
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
import { drawRadialArcPath, toRad } from './utils';
@@ -29,11 +30,6 @@ 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,
@@ -68,67 +64,25 @@ 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} />
);
@@ -158,7 +112,23 @@ export const RadialArcPath = memo(
)}
</g>
{endpointMarks}
{endpointMarker && (
<RadialArcPathEndpointMarks
startAngle={angle}
arcLengthDeg={arcLengthDeg}
dimensions={dimensions}
endpointMarker={endpointMarker}
fieldDisplay={fieldDisplay}
xStart={xStart}
xEnd={xEnd}
yStart={yStart}
yEnd={yEnd}
roundedBars={roundedBars}
endpointMarkerGlowFilter={endpointMarkerGlowFilter}
glowFilter={glowFilter}
{...rest}
/>
)}
</>
);
}

View File

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

View File

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

View File

@@ -175,7 +175,7 @@ export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape
const GRAY_05 = '#111217';
const GRAY_90 = '#fbfbfb';
const CONTRAST_THRESHOLD_MAX = 4.5;
const getGuideDotColor = (color: string): string => {
export 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("legacy", "GetDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("GetDataSourceByUID"), time.Since(start).Seconds())
}()
ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID())
@@ -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("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("DeleteDataSourceByUID"), time.Since(start).Seconds())
}()
uid := web.Params(c.Req)[":uid"]
@@ -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("legacy", "AddDataSource"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("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("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("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{"code_path", "handler"})
}, []string{"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{"code_path", "handler"}),
}, []string{"handler"}),
}
promRegister.MustRegister(hs.htmlHandlerRequestsDuration)

View File

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

View File

@@ -11,6 +11,9 @@ import (
_ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault"
_ "github.com/Azure/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"
@@ -46,7 +49,6 @@ 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("grafana",
return jaegerremote.New(ots.cfg.ServiceName,
jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL),
jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)),
), nil

View File

@@ -8,7 +8,6 @@ import (
"net/http"
"net/url"
"slices"
"sort"
"strconv"
"strings"
@@ -316,6 +315,12 @@ 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)
@@ -332,14 +337,6 @@ 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)
}
@@ -428,6 +425,18 @@ 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,6 +57,12 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec
}
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
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)
}
@@ -64,7 +70,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("new", "Get"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Get"), time.Since(start).Seconds())
}()
}
@@ -76,7 +82,7 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Create"), time.Since(start).Seconds())
}()
}
@@ -92,7 +98,7 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Update"), time.Since(start).Seconds())
}()
}
@@ -135,7 +141,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
if s.dsConfigHandlerRequestsDuration != nil {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Delete"), time.Since(start).Seconds())
}()
}
@@ -145,6 +151,13 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
// DeleteCollection implements rest.CollectionDeleter.
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,6 +21,7 @@ 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"
@@ -69,10 +70,10 @@ func RegisterAPIService(
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "grafana",
Name: "ds_config_handler_requests_duration_seconds",
Help: "Duration of requests handled by datasource configuration handlers",
}, []string{"code_path", "handler"})
regErr := reg.Register(dataSourceCRUDMetric)
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)
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
return nil, regErr
}

View File

@@ -13,6 +13,7 @@ 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"
)
@@ -36,9 +37,13 @@ func ProvideAppInstaller(
pluginStore pluginstore.Store,
pluginAssetsService *pluginassets.Service,
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
features featuremgmt.FeatureToggles,
) (*AppInstaller, error) {
if err := registerAccessControlRoles(accessControlService); err != nil {
return nil, fmt.Errorf("registering access control roles: %w", err)
//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)
}
}
localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService)

View File

@@ -330,6 +330,7 @@ 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,7 +3,6 @@ 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"
@@ -29,9 +28,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(cfg *setting.Cfg) *GrafanaAuthorizer {
func NewGrafanaBuiltInSTAuthorizer() *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,19 +76,7 @@ var PathRewriters = []filters.PathRewriter{
func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc {
return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler {
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)
handler := filters.WithTracingHTTPLoggingAttributes(delegateHandler)
// 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,146 +3,306 @@ 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"
)
type requestHandler struct {
router *mux.Router
// 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)
}
}
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()
// 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")
}
metrics := NewCustomRouteMetrics(metricsRegistry)
for _, builder := range builders {
provider, ok := builder.(APIGroupRouteProvider)
// 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)
if !ok || provider == nil {
continue
}
for _, gv := range GetGroupVersions(builder) {
// filter out api groups that are disabled in APIEnablementOptions
for _, gv := range GetGroupVersions(b) {
// Filter out disabled API groups
gvr := gv.WithResource("")
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) {
klog.InfoS("Skipping custom route handler for disabled group version", "gv", gv.String())
klog.InfoS("Skipping custom routes for disabled group version", "gv", gv.String())
continue
}
routes := provider.GetAPIRoutes(gv)
if routes == nil {
continue
}
prefix := "/apis/" + gv.String()
// 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, // Use path as resource identifier
route.Handler,
)
sub.HandleFunc("/"+route.Path, instrumentedHandler).
Methods(methods...)
// 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
}
// 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
}
// Add root handlers using OpenAPI specs
for _, route := range routes.Root {
instrumentedHandler := metrics.InstrumentHandler(
gv.Group,
gv.Version,
route.Path, // Use path as resource identifier
route.Path,
route.Handler,
)
routeFunction := convertHandlerToRouteFunction(instrumentedHandler)
sub.HandleFunc("/"+route.Path, instrumentedHandler).
Methods(methods...)
// 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)
}
}
// Add namespace handlers using OpenAPI specs
for _, route := range routes.Namespace {
instrumentedHandler := metrics.InstrumentHandler(
gv.Group,
gv.Version,
route.Path,
route.Handler,
)
routeFunction := convertHandlerToRouteFunction(instrumentedHandler)
// Use OpenAPI spec to configure routes properly
if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, true); err != nil {
return fmt.Errorf("failed to add namespace route %s: %w", route.Path, err)
}
}
}
}
if !useful {
return 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
return nil
}
func (h *requestHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
h.router.ServeHTTP(w, req)
// 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 methodsFromSpec(slug string, props *spec3.PathProps) ([]string, error) {
if props == nil {
return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, nil
func prefixRouteIDWithK8sVerbIfNotPresent(operationID string, method string) string {
for _, verb := range allowedK8sVerbs {
if len(operationID) > len(verb) && operationID[:len(verb)] == verb {
return operationID
}
}
methods := make([]string, 0)
if props.Get != nil {
methods = append(methods, "GET")
}
if props.Post != nil {
methods = append(methods, "POST")
}
if props.Put != nil {
methods = append(methods, "PUT")
}
if props.Patch != nil {
methods = append(methods, "PATCH")
}
if props.Delete != nil {
methods = append(methods, "DELETE")
}
if len(methods) == 0 {
return nil, fmt.Errorf("invalid OpenAPI Spec for slug=%s without any methods in PathProps", slug)
}
return methods, nil
return fmt.Sprintf("%s%s", httpMethodToK8sVerb[strings.ToUpper(method)], operationID)
}
type methodNotAllowedHandler struct{}
func (h *methodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(405) // method not allowed
var allowedK8sVerbs = []string{
"get", "log", "read", "replace", "patch", "delete", "deletecollection", "watch", "connect", "proxy", "list", "create", "patch",
}
var httpMethodToK8sVerb = map[string]string{
http.MethodGet: "get",
http.MethodPost: "create",
http.MethodPut: "replace",
http.MethodPatch: "patch",
http.MethodDelete: "delete",
http.MethodConnect: "connect",
http.MethodOptions: "connect", // No real equivalent to options and head
http.MethodHead: "connect",
}
// generateOperationNameFromPath creates an operation name from a route path.
// The operation name is used by the OpenAPI generator and should be descriptive.
// It uses meaningful path segments to create readable yet unique operation names.
// Examples:
// - "/search" -> "Search"
// - "/snapshots/create" -> "SnapshotsCreate"
// - "ofrep/v1/evaluate/flags" -> "OfrepEvaluateFlags"
// - "ofrep/v1/evaluate/flags/{flagKey}" -> "OfrepEvaluateFlagsFlagKey"
func generateOperationNameFromPath(routePath string) string {
// Remove leading slash and split by path segments
parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
// Filter to keep meaningful segments and path parameters
var nameParts []string
skipPrefixes := map[string]bool{
"namespaces": true,
"apis": true,
}
for _, part := range parts {
if part == "" {
continue
}
// Extract parameter name from {paramName} format
if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
paramName := part[1 : len(part)-1]
// Skip generic parameters like {namespace}, but keep specific ones like {flagKey}
if paramName != "namespace" && paramName != "name" {
nameParts = append(nameParts, strings.ToUpper(paramName[:1])+paramName[1:])
}
continue
}
// Skip common prefixes
if skipPrefixes[strings.ToLower(part)] {
continue
}
// Skip version segments like v1, v0alpha1, v2beta1, etc.
if strings.HasPrefix(strings.ToLower(part), "v") &&
(len(part) <= 3 || strings.Contains(strings.ToLower(part), "alpha") || strings.Contains(strings.ToLower(part), "beta")) {
continue
}
// Capitalize first letter and add to parts
if len(part) > 0 {
nameParts = append(nameParts, strings.ToUpper(part[:1])+part[1:])
}
}
if len(nameParts) == 0 {
return "Route"
}
return strings.Join(nameParts, "")
}

View File

@@ -5,7 +5,6 @@ import (
"net"
"path/filepath"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/services/apiserver/options"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -41,15 +40,6 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
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(cfg),
authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(),
tracing: tracing,
db: db, // For Unified storage
metrics: reg,
@@ -443,6 +443,19 @@ 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,6 +51,7 @@ type Service struct {
pluginStore pluginstore.Store
pluginClient plugins.Client
basePluginContextProvider plugincontext.BasePluginContextProvider
retriever DataSourceRetriever
ptc proxyTransportCache
}
@@ -70,6 +71,7 @@ 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}
@@ -89,6 +91,7 @@ func ProvideService(
pluginStore: pluginStore,
pluginClient: pluginClient,
basePluginContextProvider: basePluginContextProvider,
retriever: retriever,
}
ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store))
@@ -175,11 +178,11 @@ func NewIDScopeResolver(db DataSourceRetriever) (string, accesscontrol.ScopeAttr
}
func (s *Service) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
return s.SQLStore.GetDataSource(ctx, query)
return s.retriever.GetDataSource(ctx, query)
}
func (s *Service) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) {
return s.SQLStore.GetDataSourceInNamespace(ctx, namespace, name, group)
return s.retriever.GetDataSourceInNamespace(ctx, namespace, name, group)
}
func (s *Service) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) {

View File

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

View File

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

View File

@@ -650,13 +650,6 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaDatavizSquad,
},
{
Name: "kubernetesFeatureToggles",
Description: "Use the kubernetes API for feature toggle management in the frontend",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaOperatorExperienceSquad,
},
{
Name: "cloudRBACRoles",
Description: "Enabled grafana cloud specific RBAC roles",

View File

@@ -90,7 +90,6 @@ pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
timeComparison,experimental,@grafana/dataviz-squad,false,false,true
tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true
kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true
cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false
alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
90 canvasPanelPanZoom preview @grafana/dataviz-squad false false true
91 timeComparison experimental @grafana/dataviz-squad false false true
92 tableSharedCrosshair experimental @grafana/dataviz-squad false false true
kubernetesFeatureToggles experimental @grafana/grafana-operator-experience-squad false false true
93 cloudRBACRoles preview @grafana/identity-access-team false true false
94 alertingQueryOptimization GA @grafana/alerting-squad false false false
95 jitterAlertRulesWithinGroups preview @grafana/alerting-squad false true false

View File

@@ -2044,7 +2044,8 @@
"metadata": {
"name": "kubernetesFeatureToggles",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-01-18T05:32:44Z"
"creationTimestamp": "2024-01-18T05:32:44Z",
"deletionTimestamp": "2026-01-07T12:02:51Z"
},
"spec": {
"description": "Use the kubernetes API for feature toggle management in the frontend",

View File

@@ -542,9 +542,10 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe
dashService.RegisterDashboardPermissions(acmock.NewMockedPermissionsService())
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()))
ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
require.NoError(t, err)
m := metrics.NewNGAlert(prometheus.NewRegistry())

View File

@@ -37,9 +37,10 @@ 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()))
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
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 overrides the default quota for a tenant, add the following to the overrides.yaml file:
To override the default quota for a tenant, add the following to the `overrides.yaml` file:
```yaml
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,8 +806,10 @@ flowchart TD
#### Setting Dual Writer Mode
```ini
[unified_storage.{resource}.{kind}.{group}]
dualWriterMode = {0-5}
; [unified_storage.{resource}.{group}]
[unified_storage.dashboards.dashboard.grafana.app]
; modes {0-5}
dualWriterMode = 0
```
#### Background Sync Configuration
@@ -1376,4 +1378,3 @@ 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 (
COALESCE({{ .Arg .Value }}, ""),
(SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
{{ .Arg .GUID }},
{{ .Arg .Group }},
{{ .Arg .Resource }},
@@ -19,13 +19,5 @@ VALUES (
{{ .Arg .Name }},
{{ .Arg .Action }},
{{ .Arg .Folder }},
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
{{ .Arg .PreviousRV }}
);

View File

@@ -7,9 +7,7 @@ INSERT INTO {{ .Ident "resource_history" }}
{{ .Ident "namespace" }},
{{ .Ident "name" }},
{{ .Ident "action" }},
{{ .Ident "folder" }},
{{ .Ident "previous_resource_version" }},
{{ .Ident "generation" }}
{{ .Ident "folder" }}
)
VALUES (
COALESCE({{ .Arg .Value }}, ""),
@@ -19,26 +17,5 @@ VALUES (
{{ .Arg .Namespace }},
{{ .Arg .Name }},
{{ .Arg .Action }},
{{ .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
{{ .Arg .Folder }}
);

View File

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

View File

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

View File

@@ -12,6 +12,9 @@ import (
"time"
"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"
)
@@ -306,10 +309,6 @@ 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})
@@ -598,7 +597,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, "/")
@@ -815,3 +814,121 @@ 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,8 +44,6 @@ 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")
@@ -157,26 +155,6 @@ 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
@@ -392,7 +370,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
// temporary save for dataStore without rvmanager (non backwards-compatible)
// 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)
@@ -429,11 +407,12 @@ func (w *sqlWriteCloser) Close() error {
return nil
}
// 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
// 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.
dataKey, err := ParseKeyWithGUID(w.sectionKey.Key)
if err != nil {
return fmt.Errorf("failed to parse key: %w", err)
@@ -448,7 +427,7 @@ func (w *sqlWriteCloser) Close() error {
case DataActionDeleted:
action = 3
default:
return fmt.Errorf("failed to parse key: %w", err)
return fmt.Errorf("failed to parse key: invalid action")
}
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{
@@ -468,52 +447,6 @@ 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,11 +332,14 @@ 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) {
err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value))
if err != nil {
if err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)); 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

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

View File

@@ -10,6 +10,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/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"
@@ -177,6 +178,9 @@ 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,8 +320,9 @@ 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") // in practice, APIEnabled being false goes with features-service type, but trying to make tests work
if opts.OpenFeatureAPIEnabled {
_, err = openFeatureSect.NewKey("provider", "static")
require.NoError(t, err)
_, err = openFeatureSect.NewKey("targetingKey", "grafana")
require.NoError(t, err)

View File

@@ -3,7 +3,8 @@ import { render, screen, userEvent, waitFor } from 'test/test-utils';
import { byLabelText, byRole, byText } from 'testing-library-selector';
import { setPluginLinksHook } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import server from '@grafana/test-utils/server';
import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types/accessControl';
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
@@ -22,6 +23,7 @@ import {
mockPluginLinkExtension,
mockPromAlertingRule,
mockRulerGrafanaRecordingRule,
mockRulerGrafanaRule,
} from '../../mocks';
import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
import { grantPermissionsHelper } from '../../test/test-utils';
@@ -130,6 +132,8 @@ const dataSources = {
};
describe('RuleViewer', () => {
const api = mockAlertRuleApi(server);
beforeEach(() => {
setupDataSources(...Object.values(dataSources));
});
@@ -249,19 +253,22 @@ describe('RuleViewer', () => {
expect(screen.getAllByRole('row')).toHaveLength(7);
expect(screen.getAllByRole('row')[1]).toHaveTextContent(/6Provisioning2025-01-18 04:35:17/i);
expect(screen.getAllByRole('row')[1]).toHaveTextContent('+3-3Latest');
expect(screen.getAllByRole('row')[1]).toHaveTextContent('Updated by provisioning service');
expect(screen.getAllByRole('row')[1]).toHaveTextContent('+4-3Latest');
expect(screen.getAllByRole('row')[2]).toHaveTextContent(/5Alerting2025-01-17 04:35:17/i);
expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-5');
expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-6');
expect(screen.getAllByRole('row')[3]).toHaveTextContent(/4different user2025-01-16 04:35:17/i);
expect(screen.getAllByRole('row')[3]).toHaveTextContent('+5-5');
expect(screen.getAllByRole('row')[3]).toHaveTextContent('Changed alert title and thresholds');
expect(screen.getAllByRole('row')[3]).toHaveTextContent('+6-5');
expect(screen.getAllByRole('row')[4]).toHaveTextContent(/3user12025-01-15 04:35:17/i);
expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-9');
expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-10');
expect(screen.getAllByRole('row')[5]).toHaveTextContent(/2User ID foo2025-01-14 04:35:17/i);
expect(screen.getAllByRole('row')[5]).toHaveTextContent('+11-7');
expect(screen.getAllByRole('row')[5]).toHaveTextContent('Updated evaluation interval and routing');
expect(screen.getAllByRole('row')[5]).toHaveTextContent('+12-7');
expect(screen.getAllByRole('row')[6]).toHaveTextContent(/1Unknown 2025-01-13 04:35:17/i);
@@ -275,9 +282,10 @@ describe('RuleViewer', () => {
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled();
expect(screen.getByRole('cell', { name: /provisioning/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /alerting/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /Unknown/i })).toBeInTheDocument();
// Check for special updated_by values - use getAllByRole since some text appears in multiple columns
expect(screen.getAllByRole('cell', { name: /provisioning/i }).length).toBeGreaterThan(0);
expect(screen.getByRole('cell', { name: /^alerting$/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /^Unknown$/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /user id foo/i })).toBeInTheDocument();
});
@@ -321,6 +329,47 @@ describe('RuleViewer', () => {
await renderRuleViewer(rule, ruleIdentifier);
expect(screen.queryByText('Labels')).not.toBeInTheDocument();
});
it('shows Notes column when versions have messages', async () => {
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
expect(await screen.findByRole('columnheader', { name: /Notes/i })).toBeInTheDocument();
expect(screen.getAllByRole('row')).toHaveLength(7); // 1 header + 6 data rows
expect(screen.getByRole('cell', { name: /Updated by provisioning service/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /Changed alert title and thresholds/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /Updated evaluation interval and routing/i })).toBeInTheDocument();
});
it('does not show Notes column when no versions have messages', async () => {
const versionsWithoutMessages = [
mockRulerGrafanaRule(
{},
{
uid: grafanaRulerRule.grafana_alert.uid,
version: 2,
updated: '2025-01-14T09:35:17.000Z',
updated_by: { uid: 'foo', name: '' },
}
),
mockRulerGrafanaRule(
{},
{
uid: grafanaRulerRule.grafana_alert.uid,
version: 1,
updated: '2025-01-13T09:35:17.000Z',
updated_by: null,
}
),
];
api.getAlertRuleVersionHistory(grafanaRulerRule.grafana_alert.uid, versionsWithoutMessages);
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
await screen.findByRole('button', { name: /Compare versions/i });
expect(screen.getAllByRole('row')).toHaveLength(3); // 1 header + 2 data rows
expect(screen.queryByRole('columnheader', { name: /Notes/i })).not.toBeInTheDocument();
});
});
});

View File

@@ -1,8 +1,9 @@
import { css } from '@emotion/css';
import { useMemo, useState } from 'react';
import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text } from '@grafana/ui';
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text, useStyles2 } from '@grafana/ui';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { computeVersionDiff } from 'app/features/alerting/unified/utils/diff';
import { RuleIdentifier } from 'app/types/unified-alerting';
@@ -33,6 +34,7 @@ export function VersionHistoryTable({
onRestoreError,
canRestore,
}: VersionHistoryTableProps) {
const styles = useStyles2(getStyles);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [ruleToRestore, setRuleToRestore] = useState<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>();
const ruleToRestoreUid = ruleToRestore?.grafana_alert?.uid ?? '';
@@ -41,6 +43,8 @@ export function VersionHistoryTable({
[ruleToRestoreUid]
);
const hasAnyNotes = useMemo(() => ruleVersions.some((v) => v.grafana_alert.message), [ruleVersions]);
const showConfirmation = (ruleToRestore: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
setShowConfirmModal(true);
setRuleToRestore(ruleToRestore);
@@ -52,6 +56,15 @@ export function VersionHistoryTable({
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
const notesColumn: Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>> = {
id: 'notes',
header: t('core.versionHistory.table.notes', 'Notes'),
cell: ({ row }) => {
const message = row.original.grafana_alert.message;
return message || null;
},
};
const columns: Array<Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>> = [
{
disableGrow: true,
@@ -91,9 +104,12 @@ export function VersionHistoryTable({
if (!value) {
return unknown;
}
return dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')';
return (
<span className={styles.nowrap}>{dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'}</span>
);
},
},
...(hasAnyNotes ? [notesColumn] : []),
{
id: 'diff',
disableGrow: true,
@@ -179,3 +195,9 @@ export function VersionHistoryTable({
</>
);
}
const getStyles = () => ({
nowrap: css({
whiteSpace: 'nowrap',
}),
});

View File

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

View File

@@ -154,6 +154,7 @@ export const rulerRuleVersionHistoryHandler = () => {
uid: 'service',
name: '',
};
draft.grafana_alert.message = 'Updated by provisioning service';
}),
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
draft.grafana_alert.version = 5;
@@ -171,6 +172,7 @@ export const rulerRuleVersionHistoryHandler = () => {
uid: 'different',
name: 'different user',
};
draft.grafana_alert.message = 'Changed alert title and thresholds';
}),
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
draft.grafana_alert.version = 3;
@@ -193,6 +195,7 @@ export const rulerRuleVersionHistoryHandler = () => {
uid: 'foo',
name: '',
};
draft.grafana_alert.message = 'Updated evaluation interval and routing';
}),
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
draft.grafana_alert.version = 1;

View File

@@ -39,7 +39,7 @@ export function RecentlyViewedDashboards() {
retry();
};
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
return null;
}
@@ -76,10 +76,6 @@ 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', () => {
it('should handle invalid time reference in timeShift with relative time range', () => {
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
buildAndActivateSceneFor(panelTime);
@@ -139,6 +139,22 @@ 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',
@@ -153,6 +169,66 @@ 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,7 +81,19 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
}
const overrideResult = this.getTimeOverride(timeRange.value);
this.setState({ value: overrideResult.timeRange, timeInfo: overrideResult.timeInfo });
const { timeRange: overrideTimeRange } = overrideResult;
this.setState({
value: overrideTimeRange,
timeInfo: overrideResult.timeInfo,
from:
typeof overrideTimeRange.raw.from === 'string'
? overrideTimeRange.raw.from
: overrideTimeRange.raw.from.toISOString(),
to:
typeof overrideTimeRange.raw.to === 'string'
? overrideTimeRange.raw.to
: overrideTimeRange.raw.to.toISOString(),
});
}
// Get a time shifted request to compare with the primary request.
@@ -153,10 +165,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.parse(timeFromInfo.from, undefined, timeZone)!,
to: dateMath.parse(timeFromInfo.to, undefined, timeZone)!,
from: dateMath.toDateTime(timeFromInfo.from, { timezone })!,
to: dateMath.toDateTime(timeFromInfo.to, { timezone })!,
raw: { from: timeFromInfo.from, to: timeFromInfo.to },
};
infoBlocks.push(timeFromInfo.display);
@@ -172,18 +184,39 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
return newTimeData;
}
const timeShift = '-' + timeShiftInterpolated;
infoBlocks.push('timeshift ' + timeShift);
const shift = '-' + timeShiftInterpolated;
infoBlocks.push('timeshift ' + shift);
const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false)!;
const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true)!;
if (rangeUtil.isRelativeTimeRange(newTimeData.timeRange.raw)) {
const timezone = this.getTimeZone();
if (!from || !to) {
newTimeData.timeInfo = 'invalid timeshift';
return newTimeData;
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 } };
}
newTimeData.timeRange = { from, to, raw: { from, to } };
}
if (compareWith) {

View File

@@ -33,7 +33,7 @@ import {
useStyles2,
} from '@grafana/ui';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal';
import { LogsFrame } from 'app/features/logs/logsFrame';
import { DATAPLANE_ID_NAME, LogsFrame } from 'app/features/logs/logsFrame';
import { getFieldLinksForExplore } from '../utils/links';
@@ -154,9 +154,9 @@ export function LogsTable(props: Props) {
},
});
// `getLinks` and `applyFieldOverrides` are taken from TableContainer.tsx
for (const [index, field] of frameWithOverrides.fields.entries()) {
for (const [fieldIdx, field] of frameWithOverrides.fields.entries()) {
// Hide ID field from visualization (it's only needed for row matching)
if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === 'id')) {
if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === DATAPLANE_ID_NAME)) {
field.config = {
...field.config,
custom: {
@@ -180,7 +180,7 @@ export function LogsTable(props: Props) {
};
// For the first field (time), wrap the cell to include action buttons
const isFirstField = index === 0;
const isFirstField = fieldIdx === 0;
field.config = {
...field.config,
@@ -202,7 +202,6 @@ export function LogsTable(props: Props) {
panelState={props.panelState}
absoluteRange={props.absoluteRange}
logRows={props.logRows}
rowIndex={cellProps.rowIndex}
/>
<span className={styles.firstColumnCell}>
{cellProps.field.display?.(cellProps.value).text ?? String(cellProps.value)}

View File

@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useCallback, useState } from 'react';
import { useCallback, useState, memo } from 'react';
import {
AbsoluteTimeRange,
@@ -13,7 +13,7 @@ import { t } from '@grafana/i18n';
import { ClipboardButton, CustomCellRendererProps, IconButton, Modal, useTheme2 } from '@grafana/ui';
import { getLogsPermalinkRange } from 'app/core/utils/shortLinks';
import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync';
import { LogsFrame } from 'app/features/logs/logsFrame';
import { LogsFrame, DATAPLANE_ID_NAME } from 'app/features/logs/logsFrame';
import { getState } from 'app/store/store';
import { getExploreBaseUrl } from './utils/url';
@@ -28,25 +28,20 @@ interface Props extends CustomCellRendererProps {
index?: number;
}
export function LogsTableActionButtons(props: Props) {
export const LogsTableActionButtons = memo((props: Props) => {
const { exploreId, absoluteRange, logRows, rowIndex, panelState, displayedFields, logsFrame, frame } = props;
const theme = useTheme2();
const [isInspecting, setIsInspecting] = useState(false);
// Get logId from the table frame (frame), not the original logsFrame, because
// the table frame is sorted/transformed and rowIndex refers to the table frame
const idFieldName = logsFrame?.idField?.name ?? 'id';
const idField = frame.fields.find((field) => field.name === idFieldName || field.name === 'id');
const idFieldName = logsFrame?.idField?.name ?? DATAPLANE_ID_NAME;
const idField = frame.fields.find((field) => field.name === idFieldName || field.name === DATAPLANE_ID_NAME);
const logId = idField?.values[rowIndex];
const getLineValue = () => {
const bodyFieldName = logsFrame?.bodyField?.name;
const bodyField = bodyFieldName
? frame.fields.find((field) => field.name === bodyFieldName)
: frame.fields.find((field) => field.type === 'string');
return bodyField?.values[rowIndex];
};
const lineValue = getLineValue();
const getLineValue = () => {
const logRowById = logRows?.find((row) => row.rowId === logId);
return logRowById?.raw ?? '';
};
const styles = getStyles(theme);
@@ -105,33 +100,29 @@ export function LogsTableActionButtons(props: Props) {
return (
<>
<div className={styles.iconWrapper}>
<div className={styles.inspect}>
<IconButton
className={styles.inspectButton}
tooltip={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
variant="secondary"
aria-label={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
tooltipPlacement="top"
size="md"
name="eye"
onClick={handleViewClick}
tabIndex={0}
/>
</div>
<div className={styles.inspect}>
<ClipboardButton
className={styles.clipboardButton}
icon="share-alt"
variant="secondary"
fill="text"
size="md"
tooltip={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
tooltipPlacement="top"
tabIndex={0}
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
getText={getText}
/>
</div>
<IconButton
className={styles.icon}
tooltip={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
variant="secondary"
aria-label={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
tooltipPlacement="top"
size="md"
name="eye"
onClick={handleViewClick}
tabIndex={0}
/>
<ClipboardButton
className={styles.icon}
icon="share-alt"
variant="secondary"
fill="text"
size="md"
tooltip={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
tooltipPlacement="top"
tabIndex={0}
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
getText={getText}
/>
</div>
{isInspecting && (
<Modal
@@ -139,9 +130,9 @@ export function LogsTableActionButtons(props: Props) {
isOpen={true}
title={t('explore.logs-table.action-buttons.inspect-value', 'Inspect value')}
>
<pre>{lineValue}</pre>
<pre>{getLineValue()}</pre>
<Modal.ButtonRow>
<ClipboardButton icon="copy" getText={() => lineValue}>
<ClipboardButton icon="copy" getText={() => getLineValue()}>
{t('explore.logs-table.action-buttons.copy-to-clipboard', 'Copy to Clipboard')}
</ClipboardButton>
</Modal.ButtonRow>
@@ -149,15 +140,11 @@ export function LogsTableActionButtons(props: Props) {
)}
</>
);
}
});
export const getStyles = (theme: GrafanaTheme2) => ({
clipboardButton: css({
height: '100%',
lineHeight: '1',
padding: 0,
width: '20px',
}),
LogsTableActionButtons.displayName = 'LogsTableActionButtons';
const getStyles = (theme: GrafanaTheme2) => ({
iconWrapper: css({
background: theme.colors.background.secondary,
boxShadow: theme.shadows.z2,
@@ -166,25 +153,50 @@ export const getStyles = (theme: GrafanaTheme2) => ({
height: '35px',
left: 0,
top: 0,
padding: `0 ${theme.spacing(0.5)}`,
padding: 0,
position: 'absolute',
zIndex: 1,
alignItems: 'center',
// Fix switching icon direction when cell is numeric (rtl)
direction: 'ltr',
}),
inspect: css({
'& button svg': {
marginRight: 'auto',
icon: css({
gap: 0,
margin: 0,
padding: 0,
borderRadius: theme.shape.radius.default,
width: '28px',
height: '32px',
display: 'inline-flex',
justifyContent: 'center',
'&:before': {
content: '""',
position: 'absolute',
width: 24,
height: 24,
top: 0,
bottom: 0,
left: 0,
right: 0,
margin: 'auto',
borderRadius: theme.shape.radius.default,
backgroundColor: theme.colors.background.primary,
zIndex: -1,
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transitionDuration: '0.2s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionProperty: 'opacity',
},
},
'&:hover': {
color: theme.colors.text.link,
cursor: 'pointer',
background: 'none',
'&:before': {
opacity: 1,
},
},
padding: '5px 3px',
}),
inspectButton: css({
borderRadius: theme.shape.radius.default,
display: 'inline-flex',
margin: 0,
overflow: 'hidden',
verticalAlign: 'middle',
}),
});

View File

@@ -32,7 +32,7 @@ function getField(cache: FieldCache, name: string, fieldType: FieldType): FieldW
const DATAPLANE_TIMESTAMP_NAME = 'timestamp';
const DATAPLANE_BODY_NAME = 'body';
const DATAPLANE_SEVERITY_NAME = 'severity';
const DATAPLANE_ID_NAME = 'id';
export const DATAPLANE_ID_NAME = 'id';
const DATAPLANE_LABELS_NAME = 'labels';
// NOTE: this is a hot fn, we need to avoid allocating new objects here

View File

@@ -762,6 +762,19 @@ describe('Tempo service graph view', () => {
]);
});
it('should escape span with multi line content correctly', () => {
const spanContent = [
`
SELECT * from "my_table"
WHERE "data_enabled" = 1
ORDER BY "name" ASC`,
];
let escaped = getEscapedRegexValues(getEscapedValues(spanContent));
expect(escaped).toEqual([
'\\n SELECT \\\\* from \\"my_table\\"\\n WHERE \\"data_enabled\\" = 1\\n ORDER BY \\"name\\" ASC',
]);
});
it('should get field config correctly', () => {
let datasourceUid = 's4Jvz8Qnk';
let tempoDatasourceUid = 'EbPO1fYnz';

View File

@@ -1168,7 +1168,7 @@ export function getEscapedRegexValues(values: string[]) {
}
export function getEscapedValues(values: string[]) {
return values.map((value: string) => value.replace(/["\\]/g, '\\$&'));
return values.map((value: string) => value.replace(/["\\]/g, '\\$&').replace(/[\n]/g, '\\n'));
}
export function getFieldConfig(

View File

@@ -293,6 +293,7 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
updated?: string;
updated_by?: UpdatedBy | null;
version?: number;
message?: string;
}
// types for Grafana-managed recording and alerting rules

View File

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

View File

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

View File

@@ -3759,7 +3759,6 @@
},
"recently-viewed": {
"clear": "Clear history",
"empty": "Nothing viewed yet",
"error": "Recently viewed dashboards couldnt be loaded.",
"retry": "Retry",
"title": "Recently viewed"
@@ -4416,6 +4415,7 @@
},
"no-properties-changed": "No relevant properties changed",
"table": {
"notes": "Notes",
"updated": "Date",
"updatedBy": "Updated By",
"version": "Version"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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