Compare commits

..
Author SHA1 Message Date
Ryan McKinley 4083cf78d4 default sort 2026-01-08 09:31:50 +03:00
grafana-pr-automation[bot]andgithub-actions[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
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
86 changed files with 1424 additions and 1657 deletions
@@ -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.
+2 -2
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
@@ -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', () => {
@@ -78,6 +78,7 @@ export interface ExploreTracePanelState {
export interface ExploreLogsPanelState {
id?: string;
columns?: Record<number, string>;
visualisationType?: 'table' | 'logs';
labelFieldName?: string;
// Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized
@@ -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}
/>
)}
</>
);
}
@@ -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\"/>'
);
});
});
@@ -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;
}
@@ -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;
+4 -4
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 {
+1 -1
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
}
+1 -1
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)
+10 -5
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()
+3 -1
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"
+1 -1
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
+18 -9
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
+17 -4
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
+5 -4
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
}
+7 -2
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)
+1
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,
+7 -5
View File
File diff suppressed because one or more lines are too long
@@ -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(),
}
@@ -8,7 +8,7 @@ import (
var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil)
func newImpersonationAuthorizer() *impersonationAuthorizer {
func NewImpersonationAuthorizer() *impersonationAuthorizer {
return &impersonationAuthorizer{}
}
+1 -13
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)
+256 -96
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{}
var allowedK8sVerbs = []string{
"get", "log", "read", "replace", "patch", "delete", "deletecollection", "watch", "connect", "proxy", "list", "create", "patch",
}
func (h *methodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(405) // method not allowed
var httpMethodToK8sVerb = map[string]string{
http.MethodGet: "get",
http.MethodPost: "create",
http.MethodPut: "replace",
http.MethodPatch: "patch",
http.MethodDelete: "delete",
http.MethodConnect: "connect",
http.MethodOptions: "connect", // No real equivalent to options and head
http.MethodHead: "connect",
}
// generateOperationNameFromPath creates an operation name from a route path.
// The operation name is used by the OpenAPI generator and should be descriptive.
// It uses meaningful path segments to create readable yet unique operation names.
// Examples:
// - "/search" -> "Search"
// - "/snapshots/create" -> "SnapshotsCreate"
// - "ofrep/v1/evaluate/flags" -> "OfrepEvaluateFlags"
// - "ofrep/v1/evaluate/flags/{flagKey}" -> "OfrepEvaluateFlagsFlagKey"
func generateOperationNameFromPath(routePath string) string {
// Remove leading slash and split by path segments
parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
// Filter to keep meaningful segments and path parameters
var nameParts []string
skipPrefixes := map[string]bool{
"namespaces": true,
"apis": true,
}
for _, part := range parts {
if part == "" {
continue
}
// Extract parameter name from {paramName} format
if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
paramName := part[1 : len(part)-1]
// Skip generic parameters like {namespace}, but keep specific ones like {flagKey}
if paramName != "namespace" && paramName != "name" {
nameParts = append(nameParts, strings.ToUpper(paramName[:1])+paramName[1:])
}
continue
}
// Skip common prefixes
if skipPrefixes[strings.ToLower(part)] {
continue
}
// Skip version segments like v1, v0alpha1, v2beta1, etc.
if strings.HasPrefix(strings.ToLower(part), "v") &&
(len(part) <= 3 || strings.Contains(strings.ToLower(part), "alpha") || strings.Contains(strings.ToLower(part), "beta")) {
continue
}
// Capitalize first letter and add to parts
if len(part) > 0 {
nameParts = append(nameParts, strings.ToUpper(part[:1])+part[1:])
}
}
if len(nameParts) == 0 {
return "Route"
}
return strings.Join(nameParts, "")
}
-10
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)
+14 -1
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
@@ -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) {
@@ -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
@@ -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)
}
+2 -1
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())
@@ -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
+7 -6
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).
@@ -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 }}
);
@@ -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 }}
);
@@ -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 }}
@@ -0,0 +1,5 @@
UPDATE {{ .Ident "resource_history" }}
SET
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }},
{{ .Ident "generation" }} = {{ .Arg .Generation }}
WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }};
+122 -5
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
}
+8 -75
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
}
@@ -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 {
-144
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)
})
}
+4
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
+3 -2
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)
@@ -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) || [],
@@ -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}>
@@ -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';
@@ -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) {
@@ -61,16 +61,6 @@ jest.mock('../state/query', () => ({
},
}));
jest.mock('app/core/context/GrafanaContext', () => ({
...jest.requireActual('app/core/context/GrafanaContext'),
useGrafana: () => ({
location: {
getSearchObject: jest.fn().mockReturnValue({}),
partial: jest.fn(),
},
}),
}));
describe('Logs', () => {
let originalHref = window.location.href;
+20 -66
View File
@@ -30,6 +30,7 @@ import {
serializeStateToUrlParam,
urlUtil,
LogLevel,
shallowCompare,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
@@ -46,7 +47,6 @@ import {
Themeable2,
withTheme2,
} from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
import store from 'app/core/store';
import { createAndCopyShortLink, getLogsPermalinkRange } from 'app/core/utils/shortLinks';
import { ControlledLogRows } from 'app/features/logs/components/ControlledLogRows';
@@ -74,7 +74,6 @@ import {
} from '../ContentOutline/ContentOutlineAnalyticEvents';
import { useContentOutlineContext } from '../ContentOutline/ContentOutlineContext';
import { getUrlStateFromPaneState } from '../hooks/useStateSync';
import { parseURL } from '../hooks/useStateSync/parseURL';
import { changePanelState } from '../state/explorePane';
import { changeQueries, runQueries } from '../state/query';
@@ -83,7 +82,6 @@ import { LogsMetaRow } from './LogsMetaRow';
import LogsNavigation from './LogsNavigation';
import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap';
import { LogsVolumePanelList } from './LogsVolumePanelList';
import { migrateLegacyColumns } from './utils/columnMigration';
import { SETTING_KEY_ROOT, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
import { getExploreBaseUrl } from './utils/url';
@@ -203,9 +201,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
panelState?.logs?.sortOrder ?? store.get(SETTINGS_KEYS.logsSortOrder) ?? LogsSortOrder.Descending
);
const [isFlipping, setIsFlipping] = useState<boolean>(false);
const [displayedFields, setDisplayedFields] = useState<string[]>(panelState?.logs?.displayedFields ?? []);
const [defaultDisplayedFields, setDefaultDisplayedFields] = useState<string[]>([]);
// Use Redux state as single source of truth
const displayedFields = useMemo(() => panelState?.logs?.displayedFields ?? [], [panelState?.logs?.displayedFields]);
const [contextOpen, setContextOpen] = useState<boolean>(false);
const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined);
const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>(PINNED_LOGS_MESSAGE);
@@ -215,7 +212,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const logsContainerRef = useRef<HTMLDivElement | null>(null);
const dispatch = useDispatch();
const previousLoading = usePrevious(loading);
const { location } = useGrafana();
const logsVolumeEventBus = eventBus.newScopedBus('logsvolume', { onlyLocal: false });
const { register, unregister, outlineItems, updateItem } = useContentOutlineContext() ?? {};
@@ -326,6 +322,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
dispatch(
changePanelState(exploreId, 'logs', {
...state.panelsState.logs,
columns: logsPanelState.columns ?? panelState?.logs?.columns,
visualisationType: logsPanelState.visualisationType ?? visualisationType,
labelFieldName: logsPanelState.labelFieldName,
refId: logsPanelState.refId ?? panelState?.logs?.refId,
@@ -339,6 +336,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
[
dispatch,
exploreId,
panelState?.logs?.columns,
panelState?.logs?.displayedFields,
panelState?.logs?.refId,
panelState?.logs?.tableSortBy,
@@ -347,38 +345,14 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
]
);
// Migration: Convert legacy 'columns' parameter from URL to 'displayedFields'
useEffect(() => {
// Parse URL to check for legacy columns
const urlParams = location.getSearchObject();
const [urlState] = parseURL(urlParams);
// Find the pane - exploreId might not match the URL pane key directly
const urlPane = urlState.panes[exploreId] ?? Object.values(urlState.panes)[0];
if (!urlPane?.panelsState?.logs) {
return;
}
// Get current displayedFields to use as defaults for merge
const currentDisplayedFields = displayedFields;
// Use migration utility to parse and transform legacy columns
const mergedFields = migrateLegacyColumns(urlPane.panelsState.logs, currentDisplayedFields, visualisationType);
if (!mergedFields) {
return;
}
// Update displayedFields in Redux state - URL sync will handle URL update
dispatch(
changePanelState(exploreId, 'logs', {
if (!shallowCompare(displayedFields, panelState?.logs?.displayedFields ?? [])) {
updatePanelState({
...panelState?.logs,
columns: undefined, // Remove columns from URL
displayedFields: mergedFields,
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only on mount
displayedFields,
});
}
}, [displayedFields, panelState?.logs, updatePanelState]);
// actions
const onLogRowHover = useCallback(
@@ -567,48 +541,30 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const showField = useCallback(
(key: string) => {
const currentFields = panelState?.logs?.displayedFields ?? [];
const index = currentFields.indexOf(key);
const index = displayedFields.indexOf(key);
if (index === -1) {
const updatedDisplayedFields = currentFields.concat(key);
updatePanelState({
displayedFields: updatedDisplayedFields,
});
const updatedDisplayedFields = displayedFields.concat(key);
setDisplayedFields(updatedDisplayedFields);
}
},
[panelState?.logs?.displayedFields, updatePanelState]
[displayedFields]
);
const hideField = useCallback(
(key: string) => {
const currentFields = panelState?.logs?.displayedFields ?? [];
const index = currentFields.indexOf(key);
const index = displayedFields.indexOf(key);
if (index > -1) {
const updatedDisplayedFields = currentFields.filter((k) => key !== k);
updatePanelState({
displayedFields: updatedDisplayedFields,
});
const updatedDisplayedFields = displayedFields.filter((k) => key !== k);
setDisplayedFields(updatedDisplayedFields);
}
},
[panelState?.logs?.displayedFields, updatePanelState]
[displayedFields]
);
const clearDisplayedFields = useCallback(() => {
updatePanelState({
displayedFields: defaultDisplayedFields,
});
}, [defaultDisplayedFields, updatePanelState]);
// Wrapper function for setDisplayedFields prop - updates Redux directly
const setDisplayedFields = useCallback(
(fields: string[]) => {
updatePanelState({
displayedFields: fields,
});
},
[updatePanelState]
);
setDisplayedFields([]);
}, []);
const onCloseCallbackRef = useRef<() => void>(() => {});
@@ -1047,7 +1003,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
updatePanelState={updatePanelState}
datasourceType={props.datasourceType}
displayedFields={displayedFields}
defaultDisplayedFields={defaultDisplayedFields}
exploreId={props.exploreId}
absoluteRange={props.absoluteRange}
logRows={props.logRows}
@@ -1087,7 +1042,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
getFieldLinks={getFieldLinks}
logsSortOrder={logsSortOrder}
displayedFields={displayedFields}
defaultDisplayedFields={defaultDisplayedFields}
onClickShowField={showField}
onClickHideField={hideField}
app={CoreApp.Explore}
@@ -48,7 +48,7 @@ describe('LogsMetaRow', () => {
});
it('renders the show original line button', () => {
setup({ displayedFields: ['test'], defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'] });
setup({ displayedFields: ['test'] });
expect(
screen.getByRole('button', {
name: 'Show original line',
@@ -66,20 +66,13 @@ describe('LogsMetaRow', () => {
});
it('renders the displayed fields', async () => {
setup({
displayedFields: ['testField1234'],
defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'],
});
setup({ displayedFields: ['testField1234'] });
expect(await screen.findByText('testField1234')).toBeInTheDocument();
});
it('renders a button to clear displayedfields', () => {
const clearSpy = jest.fn();
setup({
displayedFields: ['testField1234'],
defaultDisplayedFields: ['Time', 'detected_level', '___LOG_LINE_BODY___'],
clearDisplayedFields: clearSpy,
});
setup({ displayedFields: ['testField1234'], clearDisplayedFields: clearSpy });
fireEvent(
screen.getByRole('button', {
name: 'Show original line',
@@ -1,7 +1,16 @@
import { css } from '@emotion/css';
import { memo, useMemo } from 'react';
import { memo } from 'react';
import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data';
import {
LogsDedupStrategy,
LogsMetaItem,
LogsMetaKind,
LogRowModel,
CoreApp,
Labels,
store,
shallowCompare,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
@@ -49,14 +58,6 @@ export const LogsMetaRow = memo(
}: Props) => {
const style = useStyles2(getStyles);
// Filter out default fields from displayedFields to show only user-added fields
const nonDefaultFields = useMemo(() => {
if (!displayedFields?.length || !defaultDisplayedFields?.length) {
return [];
}
return displayedFields.filter((field) => !defaultDisplayedFields.includes(field));
}, [displayedFields, defaultDisplayedFields]);
const logsMetaItem: Array<LogsMetaItem | MetaItemProps> = [...meta];
// Add deduplication info
@@ -68,12 +69,16 @@ export const LogsMetaRow = memo(
});
}
// Add detected fields info - only show when user has added fields beyond defaults
if (visualisationType === 'logs' && nonDefaultFields.length > 0) {
// Add detected fields info
if (
visualisationType === 'logs' &&
displayedFields?.length > 0 &&
shallowCompare(displayedFields, defaultDisplayedFields) === false
) {
logsMetaItem.push(
{
label: t('explore.logs-meta-row.label.showing-only-selected-fields', 'Showing only selected fields'),
value: <LogLabelsList labels={nonDefaultFields} />,
value: <LogLabelsList labels={displayedFields} />,
},
{
label: '',
@@ -33,8 +33,6 @@ import {
useStyles2,
} from '@grafana/ui';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal';
import { TABLE_DETECTED_LEVEL_FIELD_NAME } from 'app/features/logs/components/LogDetailsBody';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from 'app/features/logs/components/otel/formats';
import { DATAPLANE_ID_NAME, LogsFrame } from 'app/features/logs/logsFrame';
import { getFieldLinksForExplore } from '../utils/links';
@@ -398,10 +396,9 @@ export function getLogsExtractFields(dataFrame: DataFrame) {
function buildLabelFilters(columnsWithMeta: Record<string, FieldNameMeta>) {
// Create object of label filters to include columns selected by the user
// Exclude OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME from table view
let labelFilters: Record<string, number> = {};
Object.keys(columnsWithMeta)
.filter((key) => columnsWithMeta[key].active && key !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
.filter((key) => columnsWithMeta[key].active)
.forEach((key) => {
const index = columnsWithMeta[key].index;
// Index should always be defined for any active column
@@ -436,11 +433,6 @@ function getInitialFieldWidth(field: Field): number | undefined {
if (field.type === FieldType.time) {
return 230;
}
// Set constrained width for detected_level
if (field.name === TABLE_DETECTED_LEVEL_FIELD_NAME) {
return 190;
}
// All other fields (including body field) will auto-expand
return undefined;
}
@@ -43,11 +43,6 @@ export const LogsTableActionButtons = memo((props: Props) => {
return logRowById?.raw ?? '';
};
const lineValue = getLineValue();
// Check if line value is available
const isLineValueAvailable = lineValue !== undefined && lineValue !== null && lineValue !== '';
const styles = getStyles(theme);
// Generate link to the log line
@@ -99,9 +94,7 @@ export const LogsTableActionButtons = memo((props: Props) => {
}, [absoluteRange, displayedFields, exploreId, logId, logRows, rowIndex, panelState]);
const handleViewClick = () => {
if (isLineValueAvailable) {
setIsInspecting(true);
}
setIsInspecting(true);
};
return (
@@ -117,7 +110,6 @@ export const LogsTableActionButtons = memo((props: Props) => {
name="eye"
onClick={handleViewClick}
tabIndex={0}
disabled={!isLineValueAvailable}
/>
<ClipboardButton
className={styles.icon}
@@ -130,7 +122,6 @@ export const LogsTableActionButtons = memo((props: Props) => {
tabIndex={0}
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
getText={getText}
disabled={!isLineValueAvailable}
/>
</div>
{isInspecting && (
@@ -67,7 +67,7 @@ describe('LogsTableWrap', () => {
setup({
panelState: {
visualisationType: 'table',
displayedFields: undefined,
columns: undefined,
},
updatePanelState: updatePanelState,
});
@@ -84,7 +84,7 @@ describe('LogsTableWrap', () => {
await waitFor(() => {
expect(updatePanelState).toBeCalledWith({
visualisationType: 'table',
displayedFields: ['app', '___LOG_LINE_BODY___', 'Time'],
columns: { 0: 'app', 1: 'Line', 2: 'Time' },
labelFieldName: 'labels',
});
});
@@ -97,7 +97,7 @@ describe('LogsTableWrap', () => {
await waitFor(() => {
expect(updatePanelState).toBeCalledWith({
visualisationType: 'table',
displayedFields: ['___LOG_LINE_BODY___', 'Time'],
columns: { 0: 'Line', 1: 'Time' },
labelFieldName: 'labels',
});
});
@@ -109,7 +109,7 @@ describe('LogsTableWrap', () => {
setup({
panelState: {
visualisationType: 'table',
displayedFields: undefined,
columns: undefined,
},
updatePanelState: updatePanelState,
});
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { Resizable, ResizeCallback } from 're-resizable';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
DataFrame,
@@ -14,23 +14,15 @@ import {
store,
TimeRange,
AbsoluteTimeRange,
shallowCompare,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { getDragStyles, InlineField, Select, useStyles2 } from '@grafana/ui';
import {
TABLE_TIME_FIELD_NAME,
TABLE_LINE_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
} from 'app/features/logs/components/LogDetailsBody';
import {
getFieldSelectorWidth,
LogsTableFieldSelector,
MIN_WIDTH,
} from 'app/features/logs/components/fieldSelector/FieldSelector';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from 'app/features/logs/components/otel/formats';
import { reportInteractionOnce } from 'app/features/logs/components/panel/analytics';
import { parseLogsFrame } from '../../logs/logsFrame';
@@ -52,7 +44,6 @@ interface Props {
datasourceType?: string;
exploreId?: string;
displayedFields?: string[];
defaultDisplayedFields?: string[];
absoluteRange?: AbsoluteTimeRange;
logRows?: LogRowModel[];
}
@@ -78,15 +69,10 @@ type FieldName = string;
export type FieldNameMetaStore = Record<FieldName, FieldNameMeta>;
export function LogsTableWrap(props: Props) {
const { logsFrames, updatePanelState, panelState, defaultDisplayedFields } = props;
const propsColumns = panelState?.displayedFields;
const { logsFrames, updatePanelState, panelState } = props;
const propsColumns = panelState?.columns;
// Save the normalized cardinality of each label
const [columnsWithMeta, setColumnsWithMeta] = useState<FieldNameMetaStore | undefined>(undefined);
// Use ref to access columnsWithMeta in useEffect without causing infinite loops
const columnsWithMetaRef = useRef(columnsWithMeta);
useEffect(() => {
columnsWithMetaRef.current = columnsWithMeta;
}, [columnsWithMeta]);
const dragStyles = useStyles2(getDragStyles);
// Filtered copy of columnsWithMeta that only includes matching results
@@ -100,41 +86,34 @@ export function LogsTableWrap(props: Props) {
logsFrames.find((f) => f.refId === panelStateRefId) ?? logsFrames[0]
);
const logsFrame = useMemo(() => parseLogsFrame(currentDataFrame), [currentDataFrame]);
const getColumnsFromProps = useCallback(
(fieldNames: FieldNameMetaStore) => {
const previouslySelected = props.panelState?.displayedFields;
const previouslySelected = props.panelState?.columns;
if (previouslySelected) {
Object.values(previouslySelected).forEach((key, index) => {
// Map LOG_LINE_BODY_FIELD_NAME to actual body field name
const mappedKey =
key === LOG_LINE_BODY_FIELD_NAME ? (logsFrame?.bodyField?.name ?? TABLE_LINE_FIELD_NAME) : key;
if (fieldNames[mappedKey]) {
fieldNames[mappedKey].active = true;
fieldNames[mappedKey].index = index;
if (fieldNames[key]) {
fieldNames[key].active = true;
fieldNames[key].index = index;
}
});
}
return fieldNames;
},
[props.panelState?.displayedFields, logsFrame?.bodyField?.name]
[props.panelState?.columns]
);
const logsFrame = useMemo(() => parseLogsFrame(currentDataFrame), [currentDataFrame]);
useEffect(() => {
if (logsFrame?.timeField.name && logsFrame?.bodyField.name && !propsColumns) {
// Use defaultDisplayedFields if available, otherwise fall back to basic defaults
const columns = defaultDisplayedFields?.length
? defaultDisplayedFields
: [logsFrame?.timeField.name, logsFrame?.bodyField.name];
const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' };
updatePanelState({
displayedFields: columns,
columns: Object.values(defaultColumns),
visualisationType: 'table',
labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
});
}
}, [logsFrame, propsColumns, updatePanelState, defaultDisplayedFields]);
}, [logsFrame, propsColumns, updatePanelState]);
/**
* When logs frame updates (e.g. query|range changes), we need to set the selected frame to state
@@ -208,7 +187,6 @@ export function LogsTableWrap(props: Props) {
// If we have labels and log lines
if (labels?.length && numberOfLogLines) {
const displayedFields = props.panelState?.displayedFields ?? [];
// Iterate through all of Labels
labels.forEach((labels: Labels) => {
const labelsArray = Object.keys(labels);
@@ -218,19 +196,11 @@ export function LogsTableWrap(props: Props) {
if (labelCardinality.has(label)) {
const value = labelCardinality.get(label);
if (value) {
// Check displayedFields first, then fall back to current value
const isActiveInDisplayedFields = displayedFields.includes(label);
const currentMeta = columnsWithMetaRef.current?.[label];
const shouldBeActive = isActiveInDisplayedFields || currentMeta?.active || value.active;
const index = isActiveInDisplayedFields
? displayedFields.indexOf(label)
: (currentMeta?.index ?? value.index);
if (shouldBeActive && index !== undefined) {
if (value?.active) {
labelCardinality.set(label, {
percentOfLinesWithLabel: value.percentOfLinesWithLabel + 1,
active: true,
index: index,
index: value.index,
});
} else {
labelCardinality.set(label, {
@@ -242,25 +212,7 @@ export function LogsTableWrap(props: Props) {
}
// Otherwise add it
} else {
// Check if this label is in displayedFields
const isActiveInDisplayedFields = displayedFields.includes(label);
const currentMeta = columnsWithMetaRef.current?.[label];
const shouldBeActive = isActiveInDisplayedFields || currentMeta?.active || false;
const index = isActiveInDisplayedFields ? displayedFields.indexOf(label) : currentMeta?.index;
if (shouldBeActive && index !== undefined) {
labelCardinality.set(label, {
percentOfLinesWithLabel: 1,
active: true,
index: index,
});
} else {
labelCardinality.set(label, {
percentOfLinesWithLabel: 1,
active: false,
index: undefined,
});
}
labelCardinality.set(label, { percentOfLinesWithLabel: 1, active: false, index: undefined });
}
});
});
@@ -278,14 +230,9 @@ export function LogsTableWrap(props: Props) {
}
// Normalize the other fields
const displayedFields = props.panelState?.displayedFields ?? [];
otherFields.forEach((field) => {
// Check displayedFields first, then fall back to current columnsWithMeta
const isActiveInDisplayedFields = displayedFields.includes(field.name);
const currentMeta = columnsWithMetaRef.current?.[field.name];
const isActive = isActiveInDisplayedFields || currentMeta?.active || false;
const index = isActiveInDisplayedFields ? displayedFields.indexOf(field.name) : currentMeta?.index;
const isActive = pendingLabelState[field.name]?.active;
const index = pendingLabelState[field.name]?.index;
if (isActive && index !== undefined) {
pendingLabelState[field.name] = {
percentOfLinesWithLabel: normalize(
@@ -327,13 +274,10 @@ export function LogsTableWrap(props: Props) {
pendingLabelState[logsFrame.timeField.name].type = 'TIME_FIELD';
}
// Only update if the state actually changed to prevent infinite loops
if (!columnsWithMetaRef.current || !shallowCompare(columnsWithMetaRef.current, pendingLabelState)) {
setColumnsWithMeta(pendingLabelState);
}
setColumnsWithMeta(pendingLabelState);
// The panel state is updated when the user interacts with the multi-select sidebar
}, [currentDataFrame, getColumnsFromProps, props.panelState?.displayedFields]);
}, [currentDataFrame, getColumnsFromProps]);
const [sidebarWidth, setSidebarWidth] = useState(getFieldSelectorWidth(SETTING_KEY_ROOT));
const tableWidth = props.width - sidebarWidth;
@@ -379,33 +323,17 @@ export function LogsTableWrap(props: Props) {
const clearSelection = () => {
const pendingLabelState = { ...columnsWithMeta };
Object.keys(pendingLabelState).forEach((key) => {
const field = pendingLabelState[key];
const isTimeField = field.type === 'TIME_FIELD' || key === TABLE_TIME_FIELD_NAME;
const isBodyField = field.type === 'BODY_FIELD' || key === TABLE_LINE_FIELD_NAME;
const isDetectedLevel = key === TABLE_DETECTED_LEVEL_FIELD_NAME;
// After reset, only active fields are Time, detected_level, and Line
if (isTimeField || isBodyField || isDetectedLevel) {
pendingLabelState[key].active = true;
// Set indices: Time at 0, detected_level at 1, Line at 2
if (isTimeField) {
pendingLabelState[key].index = 0;
} else if (isDetectedLevel) {
pendingLabelState[key].index = 1;
} else if (isBodyField) {
pendingLabelState[key].index = 2;
}
const isDefaultField = !!pendingLabelState[key].type;
// after reset the only active fields are the special time and body fields
pendingLabelState[key].active = isDefaultField ? true : false;
// reset the index
if (pendingLabelState[key].type === 'TIME_FIELD') {
pendingLabelState[key].index = 0;
} else {
pendingLabelState[key].active = false;
pendingLabelState[key].index = undefined;
pendingLabelState[key].index = pendingLabelState[key].type === 'BODY_FIELD' ? 1 : undefined;
}
});
setColumnsWithMeta(pendingLabelState);
// Reset displayedFields to defaults
updatePanelState({
displayedFields: defaultDisplayedFields?.length ? defaultDisplayedFields : [],
});
};
const reorderColumn = (newColumns: string[]) => {
@@ -436,29 +364,17 @@ export function LogsTableWrap(props: Props) {
return 0;
});
// Map body field name to LOG_LINE_BODY_FIELD_NAME
const bodyFieldName = logsFrame?.bodyField?.name ?? TABLE_LINE_FIELD_NAME;
const bodyFieldIndex = newColumnsArray.indexOf(bodyFieldName);
if (bodyFieldIndex !== -1) {
// Replace body field name with LOG_LINE_BODY_FIELD_NAME
newColumnsArray[bodyFieldIndex] = LOG_LINE_BODY_FIELD_NAME;
}
// Preserve ___OTEL_LOG_ATTRIBUTES___ from displayedFields if it exists
const currentDisplayedFields = props.panelState?.displayedFields ?? [];
const otelAttributesIndex = currentDisplayedFields.indexOf(OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME);
if (otelAttributesIndex !== -1 && !newColumnsArray.includes(OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)) {
// Insert at original position if it was in displayedFields
newColumnsArray.splice(otelAttributesIndex, 0, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME);
}
const defaultColumns: string[] = [logsFrame?.timeField.name, logsFrame?.bodyField.name].filter(
(name): name is string => name !== undefined
const newColumns: Record<number, string> = Object.assign(
{},
// Get the keys of the object as an array
newColumnsArray
);
const defaultColumns = { 0: logsFrame?.timeField.name ?? '', 1: logsFrame?.bodyField.name ?? '' };
const newPanelState: ExploreLogsPanelState = {
...props.panelState,
// URL format requires our array of values be an object, so we convert it using object.assign
displayedFields: newColumnsArray.length ? newColumnsArray : defaultColumns,
columns: Object.keys(newColumns).length ? newColumns : defaultColumns,
refId: currentDataFrame.refId,
visualisationType: 'table',
labelFieldName: logsFrame?.getLabelFieldName() ?? undefined,
@@ -531,6 +447,7 @@ export function LogsTableWrap(props: Props) {
setFilteredColumnsWithMeta(pendingFilteredLabelState);
}
updateExploreState(pendingLabelState);
};
@@ -1,380 +0,0 @@
import { LOG_LINE_BODY_FIELD_NAME, TABLE_LINE_FIELD_NAME } from 'app/features/logs/components/LogDetailsBody';
import {
parseLegacyColumns,
mapLegacyFieldNames,
mergeWithDefaults,
hasLegacyColumns,
extractColumnsValue,
extractDisplayedFields,
migrateLegacyColumns,
} from './columnMigration';
describe('columnMigration', () => {
describe('parseLegacyColumns', () => {
it('should return null for null input', () => {
expect(parseLegacyColumns(null)).toBeNull();
});
it('should return null for undefined input', () => {
expect(parseLegacyColumns(undefined)).toBeNull();
});
it('should return null for empty array', () => {
expect(parseLegacyColumns([])).toBeNull();
});
it('should return null for empty object', () => {
expect(parseLegacyColumns({})).toBeNull();
});
it('should parse array format correctly', () => {
const input = ['Time', 'Line', 'level'];
expect(parseLegacyColumns(input)).toEqual(['Time', 'Line', 'level']);
});
it('should parse object format correctly', () => {
const input = { 0: 'Time', 1: 'Line', 2: 'level' };
expect(parseLegacyColumns(input)).toEqual(['Time', 'Line', 'level']);
});
it('should return null for array with non-string elements', () => {
const input = ['Time', 123, 'level'];
expect(parseLegacyColumns(input)).toBeNull();
});
it('should return null for object with non-string values', () => {
const input = { 0: 'Time', 1: 123, 2: 'level' };
expect(parseLegacyColumns(input)).toBeNull();
});
it('should return null for primitive types', () => {
expect(parseLegacyColumns('string')).toBeNull();
expect(parseLegacyColumns(123)).toBeNull();
expect(parseLegacyColumns(true)).toBeNull();
});
it('should handle single element array', () => {
expect(parseLegacyColumns(['Time'])).toEqual(['Time']);
});
it('should handle single property object', () => {
expect(parseLegacyColumns({ 0: 'Time' })).toEqual(['Time']);
});
it('should parse real URL format with string numeric keys', () => {
// Real format from URL: columns%22:%7B%220%22:%22cluster%22,%221%22:%22Line%22,%222%22:%22Time%22%7D
// Decoded: {"0":"cluster","1":"Line","2":"Time"}
const input = { '0': 'cluster', '1': 'Line', '2': 'Time' };
expect(parseLegacyColumns(input)).toEqual(['cluster', 'Line', 'Time']);
});
});
describe('mapLegacyFieldNames', () => {
it('should map Line to LOG_LINE_BODY_FIELD_NAME', () => {
const input = [TABLE_LINE_FIELD_NAME];
expect(mapLegacyFieldNames(input)).toEqual([LOG_LINE_BODY_FIELD_NAME]);
});
it('should preserve other field names', () => {
const input = ['Time', 'level', 'host'];
expect(mapLegacyFieldNames(input)).toEqual(['Time', 'level', 'host']);
});
it('should map Line while preserving other fields', () => {
const input = ['Time', TABLE_LINE_FIELD_NAME, 'level'];
expect(mapLegacyFieldNames(input)).toEqual(['Time', LOG_LINE_BODY_FIELD_NAME, 'level']);
});
it('should handle empty array', () => {
expect(mapLegacyFieldNames([])).toEqual([]);
});
it('should handle multiple Line fields', () => {
const input = [TABLE_LINE_FIELD_NAME, TABLE_LINE_FIELD_NAME];
expect(mapLegacyFieldNames(input)).toEqual([LOG_LINE_BODY_FIELD_NAME, LOG_LINE_BODY_FIELD_NAME]);
});
});
describe('mergeWithDefaults', () => {
it('should return defaults when migrated columns is empty', () => {
const defaults = ['Time', 'body'];
expect(mergeWithDefaults([], defaults)).toEqual(['Time', 'body']);
});
it('should return migrated columns when defaults is empty', () => {
const migrated = ['Time', 'level'];
expect(mergeWithDefaults(migrated, [])).toEqual(['Time', 'level']);
});
it('should place defaults first', () => {
const migrated = ['level', 'host'];
const defaults = ['Time', 'body'];
const result = mergeWithDefaults(migrated, defaults);
expect(result).toEqual(['Time', 'body', 'level', 'host']);
});
it('should not duplicate fields', () => {
const migrated = ['Time', 'level'];
const defaults = ['Time', 'body'];
const result = mergeWithDefaults(migrated, defaults);
expect(result).toEqual(['Time', 'body', 'level']);
});
it('should handle all duplicates', () => {
const migrated = ['Time', 'body'];
const defaults = ['Time', 'body'];
const result = mergeWithDefaults(migrated, defaults);
expect(result).toEqual(['Time', 'body']);
});
it('should preserve order of defaults', () => {
const migrated = ['host'];
const defaults = ['body', 'Time', 'level'];
const result = mergeWithDefaults(migrated, defaults);
expect(result[0]).toBe('body');
expect(result[1]).toBe('Time');
expect(result[2]).toBe('level');
expect(result[3]).toBe('host');
});
});
describe('hasLegacyColumns', () => {
it('should return false for null', () => {
expect(hasLegacyColumns(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(hasLegacyColumns(undefined)).toBe(false);
});
it('should return false for non-object', () => {
expect(hasLegacyColumns('string')).toBe(false);
expect(hasLegacyColumns(123)).toBe(false);
});
it('should return false for object without columns property', () => {
expect(hasLegacyColumns({ displayedFields: ['Time'] })).toBe(false);
});
it('should return true for object with columns property', () => {
expect(hasLegacyColumns({ columns: ['Time', 'Line'] })).toBe(true);
});
it('should return true even if columns is null', () => {
expect(hasLegacyColumns({ columns: null })).toBe(true);
});
it('should return true even if columns is empty', () => {
expect(hasLegacyColumns({ columns: [] })).toBe(true);
});
});
describe('extractColumnsValue', () => {
it('should extract columns array', () => {
const state = { columns: ['Time', 'Line'] };
expect(extractColumnsValue(state)).toEqual(['Time', 'Line']);
});
it('should extract columns object', () => {
const state = { columns: { 0: 'Time', 1: 'Line' } };
expect(extractColumnsValue(state)).toEqual({ 0: 'Time', 1: 'Line' });
});
it('should return undefined when columns not present', () => {
const state = { displayedFields: ['Time'] };
expect(extractColumnsValue(state)).toBeUndefined();
});
});
describe('extractDisplayedFields', () => {
it('should extract displayedFields array', () => {
const state = { displayedFields: ['Time', 'level', 'host'] };
expect(extractDisplayedFields(state)).toEqual(['Time', 'level', 'host']);
});
it('should return undefined when displayedFields not present', () => {
const state = { columns: ['Time'] };
expect(extractDisplayedFields(state)).toBeUndefined();
});
it('should extract empty displayedFields array', () => {
const state = { displayedFields: [] };
expect(extractDisplayedFields(state)).toEqual([]);
});
it('should handle state with both columns and displayedFields', () => {
const state = {
columns: { '0': 'cluster', '1': 'Line' },
displayedFields: ['service_name', 'component'],
};
expect(extractDisplayedFields(state)).toEqual(['service_name', 'component']);
});
});
describe('migrateLegacyColumns', () => {
const defaultDisplayedFields = ['Time', LOG_LINE_BODY_FIELD_NAME];
describe('general behavior', () => {
it('should return null when logsState is null', () => {
expect(migrateLegacyColumns(null, defaultDisplayedFields, 'table')).toBeNull();
});
it('should return null when logsState is undefined', () => {
expect(migrateLegacyColumns(undefined, defaultDisplayedFields, 'table')).toBeNull();
});
it('should return null when no columns property exists', () => {
const logsState = { displayedFields: ['Time'] };
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'table')).toBeNull();
});
it('should return null when visualisationType is not provided', () => {
const logsState = { columns: ['Time', 'level'] };
expect(migrateLegacyColumns(logsState, defaultDisplayedFields)).toBeNull();
});
it('should return null when visualisationType is unknown', () => {
const logsState = { columns: ['Time', 'level'] };
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'unknown')).toBeNull();
});
});
describe('visualisationType: table', () => {
it('should return null when columns is empty array', () => {
const logsState = { columns: [] };
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'table')).toBeNull();
});
it('should return null when columns is invalid', () => {
const logsState = { columns: 'invalid' };
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'table')).toBeNull();
});
it('should migrate array format columns', () => {
const logsState = { columns: ['Time', TABLE_LINE_FIELD_NAME, 'level'] };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual(['Time', LOG_LINE_BODY_FIELD_NAME, 'level']);
});
it('should migrate object format columns', () => {
const logsState = { columns: { 0: 'Time', 1: TABLE_LINE_FIELD_NAME, 2: 'level' } };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual(['Time', LOG_LINE_BODY_FIELD_NAME, 'level']);
});
it('should return only mapped columns without merging with defaults', () => {
const logsState = { columns: ['level', 'host'] };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
// Table visualization returns only the columns, not merged with defaults
expect(result).toEqual(['level', 'host']);
});
it('should map Line to body field name', () => {
const logsState = { columns: [TABLE_LINE_FIELD_NAME] };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual([LOG_LINE_BODY_FIELD_NAME]);
});
it('should map timestamp to Time', () => {
const logsState = { columns: ['timestamp', 'level'] };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual(['Time', 'level']);
});
it('should map body to LOG_LINE_BODY_FIELD_NAME', () => {
const logsState = { columns: ['body', 'level'] };
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual([LOG_LINE_BODY_FIELD_NAME, 'level']);
});
it('should ignore displayedFields and only use columns for table', () => {
const logsState = {
columns: ['level'],
displayedFields: ['existing', 'fields'],
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
// Should only return mapped columns, ignoring displayedFields
expect(result).toEqual(['level']);
});
it('should handle real URL format with full logsState structure', () => {
const logsState = {
columns: { '0': 'cluster', '1': 'Line', '2': 'Time' },
visualisationType: 'table',
labelFieldName: 'labels',
refId: 'A',
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
// Returns mapped columns in order (Line -> LOG_LINE_BODY_FIELD_NAME)
expect(result).toEqual(['cluster', LOG_LINE_BODY_FIELD_NAME, 'Time']);
});
it('should migrate columns from real Grafana Explore URL', () => {
const logsState = {
sortOrder: 'Ascending',
columns: { '0': 'cluster', '1': 'Line', '2': 'Time' },
visualisationType: 'table',
labelFieldName: 'labels',
refId: 'A',
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toContain('cluster');
expect(result).toContain(LOG_LINE_BODY_FIELD_NAME);
expect(result).toContain('Time');
expect(result).not.toContain('Line'); // Line should be mapped
});
it('should map legacy field names correctly', () => {
const logsState = {
columns: { '0': 'timestamp', '1': 'body', '2': 'env', '3': 'namespace' },
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'table');
expect(result).toEqual(['Time', LOG_LINE_BODY_FIELD_NAME, 'env', 'namespace']);
});
});
describe('visualisationType: logs', () => {
it('should return null when no columns property exists (required for migration)', () => {
const logsState = { displayedFields: ['Time', 'level'] };
// logs visualization requires legacy columns to exist for migration to run
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'logs')).toBeNull();
});
it('should return displayedFields directly when columns exist', () => {
const logsState = {
columns: { '0': 'old', '1': 'columns' }, // Legacy columns must exist
displayedFields: ['Time', 'level', 'host'],
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'logs');
expect(result).toEqual(['Time', 'level', 'host']);
});
it('should return null when displayedFields is empty', () => {
const logsState = {
columns: { '0': 'old' },
displayedFields: [],
};
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'logs')).toBeNull();
});
it('should return null when displayedFields is not an array', () => {
const logsState = {
columns: { '0': 'old' },
displayedFields: 'not-an-array',
};
expect(migrateLegacyColumns(logsState, defaultDisplayedFields, 'logs')).toBeNull();
});
it('should ignore columns and use displayedFields for logs visualization', () => {
const logsState = {
columns: { '0': 'cluster', '1': 'Line', '2': 'Time' },
displayedFields: ['service_name', 'component'],
};
const result = migrateLegacyColumns(logsState, defaultDisplayedFields, 'logs');
// Should return displayedFields, ignoring columns
expect(result).toEqual(['service_name', 'component']);
});
});
});
});
@@ -1,183 +0,0 @@
import {
LOG_LINE_BODY_FIELD_NAME,
TABLE_LINE_FIELD_NAME,
TABLE_TIME_FIELD_NAME,
} from 'app/features/logs/components/LogDetailsBody';
/**
* Migration utility for converting legacy 'columns' URL parameter to 'displayedFields'.
*/
/**
* Parses legacy columns value from URL.
* Handles both array format and object format (e.g., {0: 'Time', 1: 'Line'}).
*
* @param columnsValue - The raw columns value from URL state
* @returns Array of column names, or null if invalid/empty
*/
export function parseLegacyColumns(columnsValue: unknown): string[] | null {
if (columnsValue === null || columnsValue === undefined) {
return null;
}
// Handle array format
if (Array.isArray(columnsValue)) {
if (columnsValue.length === 0) {
return null;
}
// Validate all elements are strings
if (columnsValue.every((v) => typeof v === 'string')) {
return columnsValue;
}
return null;
}
// Handle object format (e.g., {0: 'Time', 1: 'Line'})
if (typeof columnsValue === 'object') {
const values = Object.values(columnsValue);
if (values.length === 0) {
return null;
}
// Validate all values are strings and filter to string array
if (values.every((v): v is string => typeof v === 'string')) {
return values;
}
}
return null;
}
/**
* Maps legacy field names to their new equivalents.
* Maps: 'Line' -> LOG_LINE_BODY_FIELD_NAME, 'timestamp' -> 'Time', 'body' -> LOG_LINE_BODY_FIELD_NAME
*
* @param columns - Array of column names
* @returns Array with mapped column names
*/
export function mapLegacyFieldNames(columns: string[]): string[] {
return columns.map((column) => {
// Map 'Line' to LOG_LINE_BODY_FIELD_NAME
if (column === TABLE_LINE_FIELD_NAME) {
return LOG_LINE_BODY_FIELD_NAME;
}
// Map 'timestamp' to TABLE_TIME_FIELD_NAME ('Time')
if (column === 'timestamp') {
return TABLE_TIME_FIELD_NAME;
}
// Map 'body' to LOG_LINE_BODY_FIELD_NAME
if (column === 'body') {
return LOG_LINE_BODY_FIELD_NAME;
}
return column;
});
}
/**
* Merges migrated columns with default displayed fields.
* Default fields come first, then migrated columns (avoiding duplicates).
*
* @param migratedColumns - Columns from the legacy format (already mapped)
* @param defaultFields - Default fields to display
* @returns Merged array with defaults first, no duplicates
*/
export function mergeWithDefaults(migratedColumns: string[], defaultFields: string[]): string[] {
const mergedFields = [...defaultFields];
migratedColumns.forEach((column) => {
if (!mergedFields.includes(column)) {
mergedFields.push(column);
}
});
return mergedFields;
}
/**
* Checks if a logs state object contains legacy columns that need migration.
* Acts as a type guard to narrow the type to an object with columns property.
*
* @param logsState - The logs panel state from URL
* @returns True if legacy columns exist
*/
export function hasLegacyColumns(logsState: unknown): logsState is object & { columns: unknown } {
if (!logsState || typeof logsState !== 'object') {
return false;
}
return 'columns' in logsState;
}
/**
* Extracts the columns value from logs state using safe property access.
*
* @param logsState - The logs panel state from URL
* @returns The columns value, or undefined if not present
*/
export function extractColumnsValue(logsState: object): unknown {
const descriptor = Object.getOwnPropertyDescriptor(logsState, 'columns');
return descriptor?.value;
}
/**
* Extracts the displayedFields value from logs state using safe property access.
*
* @param logsState - The logs panel state from URL
* @returns The displayedFields value, or undefined if not present
*/
export function extractDisplayedFields(logsState: object): unknown {
const descriptor = Object.getOwnPropertyDescriptor(logsState, 'displayedFields');
return descriptor?.value;
}
/**
* Main migration function - orchestrates the full migration process.
* Returns the migrated and merged fields, or null if no migration is needed.
*
* For table visualization: merges defaults with legacy 'columns' from URL
* For logs visualization: merges defaults with 'displayedFields' from URL
*
* @param logsState - The logs panel state from URL
* @param defaultDisplayedFields - Default fields to merge with
* @param visualisationType - The current visualization type ('table' or 'logs')
* @returns Merged displayed fields array, or null if no migration needed
*/
export function migrateLegacyColumns(
logsState: unknown,
defaultDisplayedFields: string[],
visualisationType?: string
): string[] | null {
// Ensure logsState is an object
// Only run this migration if legacy columns are present
if (!logsState || typeof logsState !== 'object' || !hasLegacyColumns(logsState)) {
return null;
}
// For table visualization: only use columns from URL and map the old field names to the new ones
if (visualisationType === 'table') {
const columnsValue = extractColumnsValue(logsState);
const parsedColumns = parseLegacyColumns(columnsValue);
if (!parsedColumns) {
return null;
}
// Map legacy field names to new names
const mappedColumns = mapLegacyFieldNames(parsedColumns);
return mappedColumns;
}
// For logs visualization only use displayedFields from URL
if (visualisationType === 'logs') {
const displayedFieldsValue = extractDisplayedFields(logsState);
// displayedFields should already be an array of strings
if (!Array.isArray(displayedFieldsValue) || displayedFieldsValue.length === 0) {
return null;
}
return displayedFieldsValue;
}
// No visualisationType specified or unknown type - return null
return null;
}
@@ -2,8 +2,6 @@ import { DataFrame, ExplorePanelsState } from '@grafana/data';
import { t } from '@grafana/i18n';
import { DataQuery, DataSourceRef, Panel } from '@grafana/schema';
import { DataTransformerConfig } from '@grafana/schema/dist/esm/raw/dashboard/x/dashboard_types.gen';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_TIME_FIELD_NAME } from 'app/features/logs/components/LogDetailsBody';
import { parseLogsFrame } from 'app/features/logs/logsFrame';
import { ExplorePanelData } from 'app/types/explore';
interface ExploreToDashboardPanelOptions {
@@ -26,7 +24,7 @@ function getLogsTableTransformations(
options: ExploreToDashboardPanelOptions
): DataTransformerConfig[] {
let transformations: DataTransformerConfig[] = [];
if (panelType === 'table' && options.panelState?.logs?.displayedFields) {
if (panelType === 'table' && options.panelState?.logs?.columns) {
// If we have a labels column, we need to extract the fields from it
if (options.panelState.logs?.labelFieldName) {
transformations.push({
@@ -37,37 +35,18 @@ function getLogsTableTransformations(
});
}
// Map constant field names to actual field names from the data frame
// Find the first logs frame to get the actual field names
const logsFrame = options.queryResponse.logsFrames.find((frame) => frame.refId === options.panelState?.logs?.refId);
const parsedLogsFrame = logsFrame ? parseLogsFrame(logsFrame) : null;
// Map displayedFields from constant names to actual field names
const mappedDisplayedFields = options.panelState.logs.displayedFields.map((fieldName) => {
// Map LOG_LINE_BODY_FIELD_NAME to actual body field name
if (fieldName === LOG_LINE_BODY_FIELD_NAME) {
return parsedLogsFrame?.bodyField?.name ?? fieldName;
}
// Map TABLE_TIME_FIELD_NAME to actual time field name
if (fieldName === TABLE_TIME_FIELD_NAME) {
return parsedLogsFrame?.timeField?.name ?? fieldName;
}
// Return as-is for other fields (including extracted labels)
return fieldName;
});
// Show the columns that the user selected in explore
transformations.push({
id: 'organize',
options: {
indexByName: mappedDisplayedFields.reduce(
indexByName: Object.values(options.panelState.logs.columns).reduce(
(acc: Record<string, number>, value: string, idx) => ({
...acc,
[value]: idx,
}),
{}
),
includeByName: mappedDisplayedFields.reduce(
includeByName: Object.values(options.panelState.logs.columns).reduce(
(acc: Record<string, boolean>, value: string) => ({
...acc,
[value]: true,
@@ -43,7 +43,6 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
width?: number;
logsTableFrames?: DataFrame[];
displayedFields?: string[];
defaultDisplayedFields?: string[];
exploreId?: string;
absoluteRange?: AbsoluteTimeRange;
logRows?: LogRowModel[];
@@ -26,7 +26,6 @@ export const ControlledLogsTable = ({
logsTableFrames,
visualisationType,
displayedFields,
defaultDisplayedFields,
exploreId,
absoluteRange,
logRows,
@@ -64,7 +63,6 @@ export const ControlledLogsTable = ({
updatePanelState={updatePanelState}
datasourceType={datasourceType}
displayedFields={displayedFields}
defaultDisplayedFields={defaultDisplayedFields}
exploreId={exploreId}
absoluteRange={absoluteRange}
logRows={logRows}
@@ -31,12 +31,6 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => {
export const LOG_LINE_BODY_FIELD_NAME = '___LOG_LINE_BODY___';
// Table view field constants
export const TABLE_TIME_FIELD_NAME = 'Time';
export const TABLE_LINE_FIELD_NAME = 'Line';
export const TABLE_DETECTED_LEVEL_FIELD_NAME = 'detected_level';
export const TABLE_LEVEL_FIELD_NAME = 'level';
export const LogDetailsBody = (props: Props) => {
const showField = () => {
const { onClickShowField, row } = props;
@@ -21,6 +21,22 @@ interface Props {
export const ActiveFields = ({ activeFields, clear, fields, reorder, suggestedFields, toggle }: Props) => {
const styles = useStyles2(getLogsFieldsStyles);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) {
return;
}
const newActiveFields = [...activeFields];
const element = activeFields[result.source.index];
newActiveFields.splice(result.source.index, 1);
newActiveFields.splice(result.destination.index, 0, element);
reorder(newActiveFields);
},
[activeFields, reorder]
);
const active = useMemo(
() => [
...activeFields
@@ -32,47 +48,6 @@ export const ActiveFields = ({ activeFields, clear, fields, reorder, suggestedFi
[activeFields, fields, suggestedFields]
);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) {
return;
}
// Get the field names from the active array and use that instead of the index
// This is needed because in the table and logs view some fields are not rendered, so the index is not the same as the index in the activeFields array
const sourceFieldName = active[result.source.index]?.name;
if (!sourceFieldName) {
return;
}
const newActiveFields = [...activeFields];
const sourceIndexInActiveFields = newActiveFields.indexOf(sourceFieldName);
if (sourceIndexInActiveFields === -1) {
return;
}
const [movedField] = newActiveFields.splice(sourceIndexInActiveFields, 1);
const destFieldName = active[result.destination.index]?.name;
if (destFieldName) {
const destIndexInActiveFields = newActiveFields.indexOf(destFieldName);
if (destIndexInActiveFields !== -1) {
const insertIndex =
result.source.index < result.destination.index ? destIndexInActiveFields + 1 : destIndexInActiveFields;
newActiveFields.splice(insertIndex, 0, movedField);
} else {
newActiveFields.push(movedField);
}
} else {
newActiveFields.push(movedField);
}
reorder(newActiveFields);
},
[activeFields, active, reorder]
);
const suggested = useMemo(
() => suggestedFields.filter((suggestedField) => !activeFields.includes(suggestedField.name)),
[activeFields, suggestedFields]
@@ -10,8 +10,8 @@ import { FieldNameMetaStore } from 'app/features/explore/Logs/LogsTableWrap';
import { SETTING_KEY_ROOT } from 'app/features/explore/Logs/utils/logs';
import { parseLogsFrame } from 'app/features/logs/logsFrame';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_DETECTED_LEVEL_FIELD_NAME } from '../LogDetailsBody';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, getSuggestedFieldsForLogs } from '../otel/formats';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { getSuggestedFieldsForLogs } from '../otel/formats';
import { useLogListContext } from '../panel/LogListContext';
import { reportInteractionOnce } from '../panel/analytics';
import { LogListModel } from '../panel/processing';
@@ -103,10 +103,7 @@ export const LogListFieldSelector = ({ containerElement, dataFrames, logs }: Log
);
const suggestedFields = useMemo(() => getSuggestedFields(logs, displayedFields), [displayedFields, logs]);
const fields = useMemo(
() => getFieldsWithStats(dataFrames).filter((field) => field.name !== TABLE_DETECTED_LEVEL_FIELD_NAME),
[dataFrames]
);
const fields = useMemo(() => getFieldsWithStats(dataFrames), [dataFrames]);
if (!onClickShowField || !onClickHideField || !setDisplayedFields) {
console.warn(
@@ -218,7 +215,7 @@ export const LogsTableFieldSelector = ({
const displayedColumns = useMemo(
() =>
Object.keys(columnsWithMeta)
.filter((column) => columnsWithMeta[column].active && column !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
.filter((column) => columnsWithMeta[column].active)
.sort((a, b) =>
columnsWithMeta[a].index !== undefined && columnsWithMeta[b].index !== undefined
? columnsWithMeta[a].index - columnsWithMeta[b].index
@@ -253,10 +250,7 @@ export const LogsTableFieldSelector = ({
() => getSuggestedFields(logs, displayedColumns, defaultColumns),
[defaultColumns, displayedColumns, logs]
);
const fields = useMemo(
() => getFieldsWithStats(dataFrames).filter((field) => field.name !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME),
[dataFrames]
);
const fields = useMemo(() => getFieldsWithStats(dataFrames), [dataFrames]);
return sidebarWidth > MIN_WIDTH * 2 ? (
<FieldSelector
@@ -17,7 +17,7 @@ import { findHighlightChunksInText, GrafanaTheme2, LogsDedupStrategy, TimeRange
import { t } from '@grafana/i18n';
import { Button, Icon, Tooltip } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_TIME_FIELD_NAME } from '../LogDetailsBody';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogLabels } from '../LogLabels';
import { LogMessageAnsi } from '../LogMessageAnsi';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
@@ -392,10 +392,6 @@ const DisplayedFields = ({
if (field === LOG_LINE_BODY_FIELD_NAME) {
return <LogLineBody log={log} key={field} styles={styles} />;
}
// Hide Time field - it's already rendered via showTime in the parent Log component
if (field === TABLE_TIME_FIELD_NAME) {
return null;
}
if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME && syntaxHighlighting) {
return (
<span className="field log-syntax-highlight" title={getNormalizedFieldName(field)} key={field}>
@@ -15,7 +15,7 @@ import {
import { config, reportInteraction } from '@grafana/runtime';
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
import { LOG_LINE_BODY_FIELD_NAME, TABLE_TIME_FIELD_NAME, TABLE_DETECTED_LEVEL_FIELD_NAME } from '../LogDetailsBody';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine, createLogRow } from '../mocks/logRow';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats';
@@ -125,32 +125,12 @@ describe('LogList', () => {
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
const logsWithDetectedLevel = [
createLogRow({ uid: '1', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'info' } }),
createLogRow({ uid: '2', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'debug' } }),
];
render(
<LogList
{...defaultProps}
logs={logsWithDetectedLevel}
onLogOptionsChange={onLogOptionsChange}
setDisplayedFields={setDisplayedFields}
/>
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
// Even when OTel is disabled, we still report table defaults
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
// setDisplayedFields is called with the default fields
expect(setDisplayedFields).toHaveBeenCalledWith([
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
expect(onLogOptionsChange).not.toHaveBeenCalled();
expect(setDisplayedFields).not.toHaveBeenCalled();
config.featureToggles.otelLogsFormatting = originalState;
});
@@ -160,33 +140,14 @@ describe('LogList', () => {
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
const logsWithDetectedLevel = [
createLogRow({ uid: '1', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'info' } }),
createLogRow({ uid: '2', labels: { [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'debug' } }),
];
render(
<LogList
{...defaultProps}
logs={logsWithDetectedLevel}
onLogOptionsChange={onLogOptionsChange}
setDisplayedFields={setDisplayedFields}
/>
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
// For non-OTel logs, we report table defaults only (no OTel attributes field)
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', []);
// setDisplayedFields is called with the default fields
expect(setDisplayedFields).toHaveBeenCalledWith([
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
]);
// No fields to display, no call
expect(setDisplayedFields).not.toHaveBeenCalled();
config.featureToggles.otelLogsFormatting = originalState;
});
@@ -196,12 +157,7 @@ describe('LogList', () => {
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
const logs = [
createLogRow({
uid: '1',
labels: { [OTEL_PROBE_FIELD]: '1', [TABLE_DETECTED_LEVEL_FIELD_NAME]: 'info' },
}),
];
const logs = [createLogRow({ uid: '1', labels: { [OTEL_PROBE_FIELD]: '1' } })];
render(
<LogList
@@ -212,19 +168,11 @@ describe('LogList', () => {
/>
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
// For OTel logs, we report table defaults + OTel fields
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
]);
expect(setDisplayedFields).toHaveBeenCalledWith([
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
LOG_LINE_BODY_FIELD_NAME,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
]);
expect(setDisplayedFields).toHaveBeenCalledWith([LOG_LINE_BODY_FIELD_NAME, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]);
config.featureToggles.otelLogsFormatting = originalState;
});
@@ -27,12 +27,6 @@ import { config, getDataSourceSrv } from '@grafana/runtime';
import { PopoverContent } from '@grafana/ui';
import { checkLogsError, checkLogsSampled, downloadLogs as download, DownloadFormat } from '../../utils';
import {
LOG_LINE_BODY_FIELD_NAME,
TABLE_TIME_FIELD_NAME,
TABLE_DETECTED_LEVEL_FIELD_NAME,
TABLE_LEVEL_FIELD_NAME,
} from '../LogDetailsBody';
import { getFieldSelectorState } from '../fieldSelector/FieldSelector';
import { getDisplayedFieldsForLogs } from '../otel/formats';
@@ -124,27 +118,6 @@ export const useLogIsPermalinked = (log: LogListModel) => {
return permalinkedLogId && permalinkedLogId === log.uid;
};
/**
* Get default table fields.
* Always returns Time, and detected_level if it exists in the logs (excluding Line).
*/
function getTableDefaultFields(logs: LogRowModel[]): string[] {
const fields: string[] = [TABLE_TIME_FIELD_NAME];
// Check if detected_level exists in any log's labels, fall back to level if not found
const hasDetectedLevel = logs.some((log) => log.labels?.[TABLE_DETECTED_LEVEL_FIELD_NAME] !== undefined);
const hasLevel = !hasDetectedLevel && logs.some((log) => log.labels?.[TABLE_LEVEL_FIELD_NAME] !== undefined);
if (hasDetectedLevel) {
fields.push(TABLE_DETECTED_LEVEL_FIELD_NAME);
} else if (hasLevel) {
// Fall back to level if detected_level is not present
fields.push(TABLE_LEVEL_FIELD_NAME);
}
return fields;
}
export type LogListState = Pick<
LogListContextData,
| 'dedupStrategy'
@@ -290,52 +263,27 @@ export const LogListContextProvider = ({
}, []);
const otelDisplayedFields = useMemo(() => {
if (!config.featureToggles.otelLogsFormatting) {
return [];
}
if (showLogAttributes === false) {
if (!config.featureToggles.otelLogsFormatting || !setDisplayedFields || showLogAttributes === false) {
return [];
}
return getDisplayedFieldsForLogs(logs);
}, [logs, showLogAttributes]);
}, [logs, setDisplayedFields, showLogAttributes]);
// Get table default fields
const tableDefaultFields = useMemo(() => {
return getTableDefaultFields(logs);
}, [logs]);
// Combine table defaults with OTel defaults in specific order:
// ['Time', 'detected_level', '___LOG_LINE_BODY___', '___OTEL_LOG_ATTRIBUTES___']
const defaultDisplayedFields = useMemo(() => {
const orderedFields: string[] = tableDefaultFields;
// Always add LOG_LINE_BODY before OTel fields
orderedFields.push(LOG_LINE_BODY_FIELD_NAME);
// Add OTel fields, excluding LOG_LINE_BODY_FIELD_NAME if it's already there to avoid duplicates
const otelFieldsWithoutBody = otelDisplayedFields.filter((field) => field !== LOG_LINE_BODY_FIELD_NAME);
orderedFields.push(...otelFieldsWithoutBody);
return orderedFields;
}, [tableDefaultFields, otelDisplayedFields]);
// Pass default displayed fields (table defaults + OTel defaults) to parent
// OTel displayed fields
useEffect(() => {
if (defaultDisplayedFields.length > 0) {
onLogOptionsChange?.('defaultDisplayedFields', defaultDisplayedFields);
if (config.featureToggles.otelLogsFormatting && showLogAttributes !== false) {
onLogOptionsChange?.('defaultDisplayedFields', otelDisplayedFields);
}
}, [onLogOptionsChange, defaultDisplayedFields]);
}, [onLogOptionsChange, otelDisplayedFields, showLogAttributes]);
// Set default displayed fields (table defaults + OTel defaults) when displayedFields is empty or missing table defaults
useEffect(() => {
if (!setDisplayedFields || defaultDisplayedFields.length === 0) {
if (displayedFields.length > 0 || !setDisplayedFields) {
return;
}
if (displayedFields.length === 0) {
setDisplayedFields(defaultDisplayedFields);
if (otelDisplayedFields.length) {
setDisplayedFields(otelDisplayedFields);
}
}, [displayedFields, defaultDisplayedFields, tableDefaultFields, setDisplayedFields]);
}, [displayedFields.length, otelDisplayedFields, setDisplayedFields]);
// Sync state
useEffect(() => {
@@ -223,8 +223,6 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending);
@@ -237,8 +235,6 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(DEDUPE_LABEL_COPY));
await userEvent.click(screen.getByText('Numbers'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
@@ -290,8 +286,6 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
@@ -304,8 +298,6 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(WRAP_LINES_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
@@ -327,8 +319,6 @@ describe('LogListControls', () => {
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText('Wrap disabled'));
await userEvent.click(screen.getByText('Enable line wrapping'));
@@ -364,8 +354,6 @@ describe('LogListControls', () => {
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
await userEvent.click(screen.getByText('Show millisecond timestamps'));
@@ -393,8 +381,6 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
onLogOptionsChange.mockClear();
await userEvent.click(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true);
+11 -15
View File
@@ -156,34 +156,30 @@ export const sortInDescendingOrder = (a: LogRowModel, b: LogRowModel) => {
return 0;
};
export function sortLogRows(logRows: LogRowModel[], sortOrder: LogsSortOrder) {
return sortOrder === LogsSortOrder.Ascending
? logRows.sort(sortInAscendingOrder)
: logRows.sort(sortInDescendingOrder);
}
export const sortLogsResult = (logsResult: LogsModel | null, sortOrder: LogsSortOrder): LogsModel => {
const rows = logsResult ? sortLogRows(logsResult.rows, sortOrder) : [];
return logsResult ? { ...logsResult, rows } : { hasUniqueLabels: false, rows };
};
// Currently supports only error condition in Loki logs
export function checkLogsError(logRow: LogRowModel): string | undefined {
return logRow.labels.__error__;
}
export const sortLogRows = (logRows: LogRowModel[], sortOrder: LogsSortOrder) =>
sortOrder === LogsSortOrder.Ascending ? logRows.sort(sortInAscendingOrder) : logRows.sort(sortInDescendingOrder);
export function checkLogsSampled(logRow: LogRowModel): string | undefined {
// Currently supports only error condition in Loki logs
export const checkLogsError = (logRow: LogRowModel): string | undefined => {
return logRow.labels.__error__;
};
export const checkLogsSampled = (logRow: LogRowModel): string | undefined => {
if (!logRow.labels.__adaptive_logs_sampled__) {
return undefined;
}
return logRow.labels.__adaptive_logs_sampled__ === 'true'
? 'Logs like this one have been dropped by Adaptive Logs'
: `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`;
}
};
export function escapeUnescapedString(string: string) {
return string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n'));
}
export const escapeUnescapedString = (string: string) =>
string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n'));
export function logRowsToReadableJson(logs: LogRowModel[], pickFields: string[] = []) {
return logs.map((log) => {
+17 -4
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": {
+15 -4
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": {
-1
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"
+15 -4
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": {
+15 -4
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": {
+15 -4
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": {
+14 -4
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": {
+15 -4
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": {
+14 -4
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": {
+14 -4
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": {
+15 -4
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": {
+17 -4
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": {
+15 -4
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": {
+15 -4
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": {
+17 -4
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": {
+15 -4
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": {
+15 -4
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": {
+14 -4
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": {
+14 -4
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": {