Compare commits

..

15 Commits

Author SHA1 Message Date
Johnny K.
b8e7d3b91a prettier 2026-01-09 14:12:31 -06:00
Johnny K.
9bd5cfe0c1 alerting docs: update docs for slack 2026-01-08 15:32:19 -06:00
Charandas
b96a1ae722 Custom Routes: use existing server's mux container instead of gorilla.Mux (#115605) 2026-01-07 12:46:27 -08:00
Kim Nylander
a53875e621 [DOC] Changed so max_spans_per_span_set can't be changed in Cloud Traces (#115914)
Changed so max_spans_per_span_set can't be changed in Cloud Traces
2026-01-07 15:46:02 -05:00
Cory Forseth
9598ae6434 Datasources: extract data source read methods from service (#115834)
* extra data source read methods

* update tests

* more tests

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

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

* better name for retriever logger

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

* add compile-time check for DS retriever impl

---------

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

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

* remove extra assertion

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -43,7 +43,7 @@ There are two ways of integrating Slack into Grafana Alerting.
Note that you can only setup one Slack channel per contact point.
You can customize the `title` and `body` of the Slack message using [notification templates](ref:notification-templates); however, you cannot modify its visual appearance with custom blocks.
You can customize the `title` and `body` of the Slack message using [notification templates](ref:notification-templates); however, you cannot modify its visual appearance with custom blocks. Additional optional settings are available to customize bot appearance, mentions, and message formatting. Refer to the [Optional settings](#optional-settings) section for more details.
## Before you begin
@@ -83,6 +83,47 @@ To create your Slack integration in Grafana Alerting, complete the following ste
1. Click **Save contact point**.
## Optional settings
You can configure additional settings to customize your Slack notifications beyond the required fields.
### Customize bot appearance
You can customize how the bot appears in Slack channels:
- **Username**: Override the default bot username that appears in Slack. Set the `username` field to customize the display name.
- **Icon emoji**: Provide an emoji to use as the icon for the message. Set the `icon_emoji` field (for example, `:rocket:`). This overrides the icon URL.
- **Icon URL**: Provide a URL to an image to use as the icon for the message. Set the `icon_url` field with the image URL.
### Configure mentions
You can automatically mention users, groups, or the entire channel when notifications are sent:
- **Mention users**: Mention one or more specific users in the notification. Set the `mentionUsers` field with a comma-separated list of user IDs (for example, `U024BE7LH,U024BE7LJ`). To find a user's ID, refer to [Slack's documentation on finding member IDs](https://api.slack.com/methods/users.list).
- **Mention groups**: Mention one or more user groups in the notification. Set the `mentionGroups` field with a comma-separated list of group IDs. You can copy group IDs from the group's Slack profile URL.
- **Mention channel**: Mention the entire channel or active members. Set the `mentionChannel` field to one of the following:
- Empty or omit the field to disable channel mentions
- `here` to mention every active channel member
- `channel` to mention every channel member
### Customize message content
You can customize the notification message using [notification templates](ref:notification-templates):
- **Title**: Set a custom title for the Slack message. Use the `title` field with template variables (for example, `{{ template "slack.default.title" . }}`).
- **Text body**: Set a custom body text for the Slack message. Use the `text` field with template variables (for example, `{{ template "slack.default.text" . }}`).
- **Color**: Set the color of the vertical bar on the left side of the message attachment. Use the `color` field with either:
### Advanced configuration
For specialized environments, you can override the default Slack API endpoint:
- **Endpoint URL**: Specify a custom Slack API endpoint for non-webhook requests. Set the `endpointUrl` field to your custom endpoint. The default is `https://slack.com/api/chat.postMessage`.
## Next steps
The Slack contact point is ready to receive alert notifications.

View File

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

4
go.mod
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -204,7 +204,7 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response.
func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("GetDataSourceByUID"), time.Since(start).Seconds())
}()
ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID())
@@ -240,7 +240,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re
func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("DeleteDataSourceByUID"), time.Since(start).Seconds())
}()
uid := web.Params(c.Req)[":uid"]
@@ -375,7 +375,7 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error {
func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("AddDataSource"), time.Since(start).Seconds())
}()
cmd := datasources.AddDataSourceCommand{}
@@ -497,7 +497,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
start := time.Now()
defer func() {
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds())
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("UpdateDataSourceByUID"), time.Since(start).Seconds())
}()
cmd := datasources.UpdateDataSourceCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
pkg/server/wire_gen.go generated

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"